Skip to main content

sysknife_daemon/store/
postgres.rs

1//! Postgres backend for the audit log.
2//!
3//! Wire-compatible with AWS RDS / Aurora, GCP Cloud SQL + AlloyDB, Azure
4//! Database for PostgreSQL Flexible Server, Supabase (direct + pooler),
5//! Neon (direct + pooler), and self-hosted Postgres.
6//!
7//! ## Connection lifecycle
8//!
9//! [`PostgresStore::connect`] takes a [`PostgresConfig`] (URL plus pool +
10//! statement-cache tuning) and returns a configured `sqlx::PgPool`. The
11//! same pool is used for every request — sqlx handles connection reuse,
12//! retry on broken connections, and TLS via rustls.
13//!
14//! On connect, `PostgresStore::initialize` runs ordered, transactional schema
15//! migrations under a database advisory lock. The schema mirrors SQLite
16//! field-for-field; the only dialect
17//! difference is `BIGINT NOT NULL UNIQUE` for `seq`. `created_at` remains
18//! `TEXT` (RFC 3339 with `Z` suffix) in both backends and is cast to
19//! `timestamptz` for interval arithmetic.
20//!
21//! ## Concurrency
22//!
23//! Like the SQLite path, the chain-hash + seq computation is wrapped in a
24//! `BEGIN ... COMMIT` block. Postgres serialises this naturally via
25//! row-level locks on the `seq` write; we use a `SELECT … FOR UPDATE` on
26//! the most-recent row so concurrent writers see the same predecessor and
27//! the second commit fails atomically rather than corrupting the chain.
28
29use async_trait::async_trait;
30use sqlx_core::row::Row;
31use sqlx_postgres::{PgConnectOptions, PgPool, PgPoolOptions};
32use std::str::FromStr;
33use std::time::Duration;
34use subtle::ConstantTimeEq;
35use sysknife_types::{JobState, PreviewEnvelope, TransactionRecord};
36use uuid::Uuid;
37
38use crate::audit_chain::{AuditKey, ChainContent, ChainRow, VerifyOutcome, CURRENT_KEY_ID};
39use crate::audit_watermark::emit_chain_tip_watermark;
40use crate::store::AuditStore;
41use crate::transactions::{
42    NewTransaction, RecordedPreviewedTransaction, TransactionStoreError,
43    APPROVAL_RECEIPT_TTL_MINUTES,
44};
45
46const MIGRATION_LOCK_ID: i64 = 0x5359_534b_4e49_4645;
47
48struct Migration {
49    version: i64,
50    name: &'static str,
51    statements: &'static [&'static str],
52}
53
54const MIGRATIONS: &[Migration] = &[Migration {
55    version: 1,
56    name: "initial_audit_schema",
57    statements: &[
58        r#"
59        CREATE TABLE IF NOT EXISTS transactions (
60            transaction_id TEXT PRIMARY KEY,
61            request_id TEXT NOT NULL,
62            request_hash TEXT NOT NULL,
63            action_name TEXT NOT NULL,
64            risk_level TEXT NOT NULL,
65            status TEXT NOT NULL,
66            approval_id TEXT,
67            summary TEXT NOT NULL,
68            warnings_json TEXT NOT NULL,
69            created_at TEXT NOT NULL,
70            seq BIGINT NOT NULL UNIQUE,
71            key_id TEXT NOT NULL,
72            chain_hash TEXT NOT NULL,
73            prev_chain_hash TEXT NOT NULL DEFAULT ''
74        )
75        "#,
76        r#"
77        CREATE TABLE IF NOT EXISTS transaction_approvals (
78            transaction_id TEXT PRIMARY KEY,
79            receipt_digest TEXT NOT NULL,
80            approved_at TEXT NOT NULL,
81            consumed_at TEXT
82        )
83        "#,
84        r#"
85        CREATE TABLE IF NOT EXISTS transaction_previews (
86            transaction_id TEXT PRIMARY KEY,
87            preview_json TEXT NOT NULL
88        )
89        "#,
90        "CREATE INDEX IF NOT EXISTS transactions_seq_idx ON transactions(seq)",
91    ],
92}];
93
94/// Configuration for the Postgres backend. Built by `main.rs` from
95/// `[storage]` in `config.toml`.
96#[derive(Clone, Debug)]
97pub struct PostgresConfig {
98    /// `postgres://...` URL. SSL mode and other knobs are encoded here.
99    pub url: String,
100    /// Maximum pool size. 8 is comfortable for a single SysKnife daemon;
101    /// raise if multiple shells run concurrently.
102    pub max_connections: u32,
103    /// `acquire_timeout` for getting a connection from the pool. Raise above
104    /// the default 5s if you hit Neon cold starts (~600 ms first call).
105    pub acquire_timeout: Duration,
106    /// Set to 0 for transaction-mode PgBouncer (including some hosted
107    /// poolers), which cannot use sqlx's per-connection prepared-statement
108    /// cache. Default 100 covers direct Postgres connections.
109    pub statement_cache_capacity: usize,
110}
111
112impl Default for PostgresConfig {
113    fn default() -> Self {
114        Self {
115            url: String::new(),
116            max_connections: 8,
117            acquire_timeout: Duration::from_secs(10),
118            statement_cache_capacity: 100,
119        }
120    }
121}
122
123/// Postgres-backed [`AuditStore`].
124#[derive(Debug, Clone)]
125pub struct PostgresStore {
126    pool: PgPool,
127    audit_key: std::sync::Arc<AuditKey>,
128}
129
130struct InsertedTransaction {
131    record: TransactionRecord,
132    seq: u64,
133    chain_hash: String,
134}
135
136impl PostgresStore {
137    async fn connect_pool(config: &PostgresConfig) -> Result<PgPool, TransactionStoreError> {
138        let mut connect_opts = PgConnectOptions::from_str(&config.url).map_err(|e| {
139            TransactionStoreError::Io(std::io::Error::new(
140                std::io::ErrorKind::InvalidInput,
141                format!("invalid postgres URL: {e}"),
142            ))
143        })?;
144        connect_opts = connect_opts.statement_cache_capacity(config.statement_cache_capacity);
145        PgPoolOptions::new()
146            .max_connections(config.max_connections)
147            .acquire_timeout(config.acquire_timeout)
148            .connect_with(connect_opts)
149            .await
150            .map_err(map_sqlx_err)
151    }
152
153    /// Connect to the configured Postgres database, run the migration,
154    /// and bind an audit key for chain computation.
155    pub async fn connect(
156        config: &PostgresConfig,
157        audit_key: std::sync::Arc<AuditKey>,
158    ) -> Result<Self, TransactionStoreError> {
159        let pool = Self::connect_pool(config).await?;
160
161        let store = Self { pool, audit_key };
162        store.initialize().await?;
163        Ok(store)
164    }
165
166    /// Verify an existing Postgres chain with only the exported public key.
167    /// This path performs no migrations and never loads the signing key.
168    pub async fn verify_with_pubkey(
169        config: &PostgresConfig,
170        verifying_key_hex: &str,
171    ) -> Result<VerifyOutcome, TransactionStoreError> {
172        let pool = Self::connect_pool(config).await?;
173        let rows = fetch_chain_rows_from_pool(&pool).await?;
174        Ok(crate::audit_chain::verify_chain_with_pubkey(
175            verifying_key_hex,
176            &rows,
177        ))
178    }
179
180    /// Apply pending schema migrations atomically. The advisory transaction
181    /// lock serializes concurrent daemon starts against the same database.
182    async fn initialize(&self) -> Result<(), TransactionStoreError> {
183        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
184        sqlx_core::query::query("SELECT pg_advisory_xact_lock($1)")
185            .bind(MIGRATION_LOCK_ID)
186            .execute(&mut *tx)
187            .await
188            .map_err(map_sqlx_err)?;
189
190        sqlx_core::query::query(
191            r#"
192            CREATE TABLE IF NOT EXISTS schema_migrations (
193                version BIGINT PRIMARY KEY,
194                name TEXT NOT NULL,
195                applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
196            )
197            "#,
198        )
199        .execute(&mut *tx)
200        .await
201        .map_err(map_sqlx_err)?;
202
203        let current: i64 = sqlx_core::query_scalar::query_scalar(
204            "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
205        )
206        .fetch_one(&mut *tx)
207        .await
208        .map_err(map_sqlx_err)?;
209        let latest = MIGRATIONS.last().map_or(0, |migration| migration.version);
210        if current > latest {
211            return Err(TransactionStoreError::DatabaseInvariant(format!(
212                "postgres schema version {current} is newer than this binary supports ({latest})"
213            )));
214        }
215
216        for migration in MIGRATIONS
217            .iter()
218            .filter(|migration| migration.version > current)
219        {
220            // Run statements separately for transaction-mode poolers that
221            // reject multi-statement query strings.
222            for statement in migration.statements {
223                sqlx_core::query::query(*statement)
224                    .execute(&mut *tx)
225                    .await
226                    .map_err(map_sqlx_err)?;
227            }
228            sqlx_core::query::query(
229                "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
230            )
231            .bind(migration.version)
232            .bind(migration.name)
233            .execute(&mut *tx)
234            .await
235            .map_err(map_sqlx_err)?;
236        }
237
238        tx.commit().await.map_err(map_sqlx_err)
239    }
240}
241
242// ---------------------------------------------------------------------------
243// Helpers
244// ---------------------------------------------------------------------------
245
246fn map_sqlx_err(e: sqlx_core::Error) -> TransactionStoreError {
247    let message = format!("postgres: {e}");
248    let invariant = match &e {
249        sqlx_core::Error::Database(db) => db
250            .code()
251            .is_some_and(|code| code.starts_with("22") || code.starts_with("23")),
252        sqlx_core::Error::TypeNotFound { .. }
253        | sqlx_core::Error::ColumnIndexOutOfBounds { .. }
254        | sqlx_core::Error::ColumnNotFound(_)
255        | sqlx_core::Error::ColumnDecode { .. }
256        | sqlx_core::Error::Decode(_) => true,
257        _ => false,
258    };
259    if invariant {
260        TransactionStoreError::DatabaseInvariant(message)
261    } else {
262        TransactionStoreError::Io(std::io::Error::other(message))
263    }
264}
265
266fn serialize<T: serde::Serialize>(v: &T) -> Result<String, TransactionStoreError> {
267    serde_json::to_string(v).map_err(TransactionStoreError::Json)
268}
269
270fn deserialize<T: serde::de::DeserializeOwned>(s: &str) -> Result<T, TransactionStoreError> {
271    serde_json::from_str(s).map_err(TransactionStoreError::Json)
272}
273
274fn now_iso() -> String {
275    chrono::Utc::now()
276        .format("%Y-%m-%dT%H:%M:%.3fZ")
277        .to_string()
278}
279
280// ---------------------------------------------------------------------------
281// AuditStore impl
282// ---------------------------------------------------------------------------
283
284#[async_trait]
285impl AuditStore for PostgresStore {
286    async fn record(
287        &self,
288        transaction: NewTransaction,
289    ) -> Result<TransactionRecord, TransactionStoreError> {
290        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
291        let transaction_id = Uuid::new_v4().to_string();
292        let inserted =
293            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
294        tx.commit().await.map_err(map_sqlx_err)?;
295        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
296        Ok(inserted.record)
297    }
298
299    async fn record_previewed(
300        &self,
301        transaction: NewTransaction,
302        preview: PreviewEnvelope,
303    ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
304        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
305        let transaction_id = Uuid::new_v4().to_string();
306        let inserted =
307            insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
308
309        sqlx_core::query::query(
310            "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
311        )
312        .bind(&inserted.record.transaction_id)
313        .bind(serialize(&preview)?)
314        .execute(&mut *tx)
315        .await
316        .map_err(map_sqlx_err)?;
317
318        tx.commit().await.map_err(map_sqlx_err)?;
319        emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
320        Ok(RecordedPreviewedTransaction {
321            transaction: inserted.record,
322            preview,
323        })
324    }
325
326    async fn get(
327        &self,
328        transaction_id: &str,
329    ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
330        let row = sqlx_core::query::query(
331            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
332                    status, approval_id, summary, warnings_json \
333             FROM transactions WHERE transaction_id = $1",
334        )
335        .bind(transaction_id)
336        .fetch_optional(&self.pool)
337        .await
338        .map_err(map_sqlx_err)?;
339        row.map(row_to_record).transpose()
340    }
341
342    async fn get_preview(
343        &self,
344        transaction_id: &str,
345    ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
346        let row = sqlx_core::query::query(
347            "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
348        )
349        .bind(transaction_id)
350        .fetch_optional(&self.pool)
351        .await
352        .map_err(map_sqlx_err)?;
353        match row {
354            Some(r) => {
355                let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
356                Ok(Some(deserialize(&s)?))
357            }
358            None => Ok(None),
359        }
360    }
361
362    async fn update_status(
363        &self,
364        transaction_id: &str,
365        new_status: JobState,
366    ) -> Result<(), TransactionStoreError> {
367        // Read-validate-write with a state-machine guard, same shape as the
368        // SQLite path but strictly stronger: `SELECT ... FOR UPDATE` takes a
369        // row lock for the lifetime of this transaction, so no concurrent
370        // writer can change `status` between our read and our `UPDATE` — the
371        // read-modify-write here is atomic by construction. SQLite has no
372        // equivalent row-level lock, so its `update_status` instead uses an
373        // explicit compare-and-swap (`UPDATE ... WHERE status = <observed>`,
374        // checking `rows_affected`) to get the same guarantee.
375        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
376        let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
377            "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
378        )
379        .bind(transaction_id)
380        .fetch_optional(&mut *tx)
381        .await
382        .map_err(map_sqlx_err)?;
383        let current_str = current_str
384            .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
385        let current: JobState = deserialize(&current_str)?;
386        if !crate::jobs::allowed_transition(&current, &new_status) {
387            return Err(TransactionStoreError::InvalidTransition {
388                from: current,
389                to: new_status,
390            });
391        }
392        sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
393            .bind(serialize(&new_status)?)
394            .bind(transaction_id)
395            .execute(&mut *tx)
396            .await
397            .map_err(map_sqlx_err)?;
398        tx.commit().await.map_err(map_sqlx_err)?;
399        Ok(())
400    }
401
402    async fn approve_transaction(
403        &self,
404        transaction_id: &str,
405    ) -> Result<Option<String>, TransactionStoreError> {
406        let row = sqlx_core::query::query(
407            "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
408        )
409        .bind(transaction_id)
410        .fetch_optional(&self.pool)
411        .await
412        .map_err(map_sqlx_err)?;
413        let Some(row) = row else {
414            return Ok(None);
415        };
416        let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
417        let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
418        let receipt = self
419            .audit_key
420            .approval_receipt(transaction_id, &request_hash);
421        let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
422        let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
423            bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
424        });
425        if !commitment_matches {
426            return Err(TransactionStoreError::DatabaseInvariant(format!(
427                "transaction {transaction_id} approval commitment does not match its signed preview"
428            )));
429        }
430        let queued = serialize(&JobState::Queued)?;
431        let result = sqlx_core::query::query(
432            sqlx_core::sql_str::AssertSqlSafe(format!(
433                "INSERT INTO transaction_approvals \
434                     (transaction_id, receipt_digest, approved_at) \
435                 SELECT transaction_id, $1, $2 FROM transactions \
436                 WHERE transaction_id = $3 \
437                   AND status = $4 \
438                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
439                 ON CONFLICT (transaction_id) DO NOTHING"
440            )),
441        )
442        .bind(receipt_digest)
443        .bind(now_iso())
444        .bind(transaction_id)
445        .bind(&queued)
446        .execute(&self.pool)
447        .await
448        .map_err(map_sqlx_err)?;
449        Ok((result.rows_affected() > 0).then_some(receipt))
450    }
451
452    async fn revoke_unconsumed_approval(
453        &self,
454        transaction_id: &str,
455    ) -> Result<bool, TransactionStoreError> {
456        let result = sqlx_core::query::query(
457            "DELETE FROM transaction_approvals \
458             WHERE transaction_id = $1 AND consumed_at IS NULL",
459        )
460        .bind(transaction_id)
461        .execute(&self.pool)
462        .await
463        .map_err(map_sqlx_err)?;
464        Ok(result.rows_affected() > 0)
465    }
466
467    async fn claim_approved_for_execution(
468        &self,
469        transaction_id: &str,
470        receipt_digest: &str,
471    ) -> Result<bool, TransactionStoreError> {
472        let queued = serialize(&JobState::Queued)?;
473        let running = serialize(&JobState::Running)?;
474        let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
475        let result = sqlx_core::query::query(
476            sqlx_core::sql_str::AssertSqlSafe(format!(
477                "UPDATE transactions SET status = $1 \
478                 WHERE transaction_id = $2 \
479                   AND status = $3 \
480                   AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
481                   AND EXISTS ( \
482                       SELECT 1 FROM transaction_approvals \
483                       WHERE transaction_id = $2 \
484                         AND receipt_digest = $4 \
485                         AND consumed_at IS NULL \
486                   )"
487            )),
488        )
489        .bind(&running)
490        .bind(transaction_id)
491        .bind(&queued)
492        .bind(receipt_digest)
493        .execute(&mut *tx)
494        .await
495        .map_err(map_sqlx_err)?;
496        if result.rows_affected() > 0 {
497            sqlx_core::query::query(
498                "UPDATE transaction_approvals SET consumed_at = $1 \
499                 WHERE transaction_id = $2 AND consumed_at IS NULL",
500            )
501            .bind(now_iso())
502            .bind(transaction_id)
503            .execute(&mut *tx)
504            .await
505            .map_err(map_sqlx_err)?;
506        }
507        tx.commit().await.map_err(map_sqlx_err)?;
508        Ok(result.rows_affected() > 0)
509    }
510
511    async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
512        let queued = serialize(&JobState::Queued)?;
513        let canceled = serialize(&JobState::Canceled)?;
514        let result = sqlx_core::query::query(
515            sqlx_core::sql_str::AssertSqlSafe(format!(
516                "UPDATE transactions SET status = $1 \
517                 WHERE status = $2 \
518                   AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
519            )),
520        )
521        .bind(&canceled)
522        .bind(&queued)
523        .execute(&self.pool)
524        .await
525        .map_err(map_sqlx_err)?;
526        Ok(result.rows_affected())
527    }
528
529    async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
530        // Option A: the `status = $3` (Queued) guard means a Running (in-flight)
531        // transaction is never cancelled.
532        let queued = serialize(&JobState::Queued)?;
533        let canceled = serialize(&JobState::Canceled)?;
534        let result = sqlx_core::query::query(
535            "UPDATE transactions SET status = $1 \
536             WHERE transaction_id = $2 AND status = $3",
537        )
538        .bind(&canceled)
539        .bind(transaction_id)
540        .bind(&queued)
541        .execute(&self.pool)
542        .await
543        .map_err(map_sqlx_err)?;
544        Ok(result.rows_affected() > 0)
545    }
546
547    async fn list_transactions(
548        &self,
549        limit: u32,
550        status_filter: Option<&str>,
551        action_filter: Option<&str>,
552        since_hours: Option<u32>,
553    ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
554        let limit = limit.min(100) as i64;
555
556        // Validate the status filter against the JobState enum so a typo
557        // produces an error instead of silent empty results.
558        let validated_status: Option<String> = status_filter
559            .map(|s| -> Result<String, TransactionStoreError> {
560                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
561                serialize(&parsed)
562            })
563            .transpose()?;
564
565        let mut sql = String::from(
566            "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
567                    status, approval_id, summary, warnings_json \
568             FROM transactions WHERE TRUE",
569        );
570        let mut idx = 1;
571        if validated_status.is_some() {
572            sql.push_str(&format!(" AND status = ${idx}"));
573            idx += 1;
574        }
575        if action_filter.is_some() {
576            sql.push_str(&format!(" AND action_name = ${idx}"));
577            idx += 1;
578        }
579        if since_hours.is_some() {
580            sql.push_str(&format!(
581                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
582            ));
583            idx += 1;
584        }
585        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
586
587        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
588        if let Some(s) = &validated_status {
589            q = q.bind(s);
590        }
591        if let Some(a) = action_filter {
592            q = q.bind(a);
593        }
594        if let Some(h) = since_hours {
595            q = q.bind(h.to_string());
596        }
597        q = q.bind(limit);
598
599        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
600        rows.into_iter().map(row_to_record).collect()
601    }
602
603    async fn list_history(
604        &self,
605        limit: u32,
606        status_filter: Option<&str>,
607        action_filter: Option<&str>,
608        since_hours: Option<u32>,
609    ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
610        // Parallels list_transactions but selects created_at and returns the
611        // structured JobHistoryEntry DTO. Same filter/validation semantics.
612        let limit = limit.min(100) as i64;
613        let validated_status: Option<String> = status_filter
614            .map(|s| -> Result<String, TransactionStoreError> {
615                let parsed: JobState = deserialize(&format!("\"{s}\""))?;
616                serialize(&parsed)
617            })
618            .transpose()?;
619
620        let mut sql = String::from(
621            "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
622             FROM transactions WHERE TRUE",
623        );
624        let mut idx = 1;
625        if validated_status.is_some() {
626            sql.push_str(&format!(" AND status = ${idx}"));
627            idx += 1;
628        }
629        if action_filter.is_some() {
630            sql.push_str(&format!(" AND action_name = ${idx}"));
631            idx += 1;
632        }
633        if since_hours.is_some() {
634            sql.push_str(&format!(
635                " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
636            ));
637            idx += 1;
638        }
639        sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
640
641        let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
642        if let Some(s) = &validated_status {
643            q = q.bind(s);
644        }
645        if let Some(a) = action_filter {
646            q = q.bind(a);
647        }
648        if let Some(h) = since_hours {
649            q = q.bind(h.to_string());
650        }
651        q = q.bind(limit);
652
653        let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
654        rows.into_iter().map(row_to_history_entry).collect()
655    }
656
657    async fn fetch_chain_row(
658        &self,
659        transaction_id: &str,
660    ) -> Result<Option<ChainRow>, TransactionStoreError> {
661        let row = sqlx_core::query::query(
662            "SELECT seq, key_id, transaction_id, request_id, request_hash, \
663                    action_name, risk_level, summary, approval_id, warnings_json, \
664                    created_at, prev_chain_hash, chain_hash \
665             FROM transactions WHERE transaction_id = $1",
666        )
667        .bind(transaction_id)
668        .fetch_optional(&self.pool)
669        .await
670        .map_err(map_sqlx_err)?;
671        row.map(row_to_chain_row).transpose()
672    }
673
674    async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
675        fetch_chain_rows_from_pool(&self.pool).await
676    }
677
678    async fn verify_audit_chain(
679        &self,
680        key: &AuditKey,
681    ) -> Result<VerifyOutcome, TransactionStoreError> {
682        let rows = self.fetch_chain_rows().await?;
683        Ok(crate::audit_chain::verify_chain(key, &rows))
684    }
685}
686
687async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
688    let rows = sqlx_core::query::query(
689        "SELECT seq, key_id, transaction_id, request_id, request_hash, \
690                    action_name, risk_level, summary, approval_id, warnings_json, \
691                    created_at, prev_chain_hash, chain_hash \
692             FROM transactions ORDER BY seq ASC",
693    )
694    .fetch_all(pool)
695    .await
696    .map_err(map_sqlx_err)?;
697    rows.into_iter().map(row_to_chain_row).collect()
698}
699
700// ---------------------------------------------------------------------------
701// Insert path — chain-aware
702// ---------------------------------------------------------------------------
703
704async fn insert_transaction(
705    tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
706    key: &AuditKey,
707    transaction_id: &str,
708    transaction: NewTransaction,
709) -> Result<InsertedTransaction, TransactionStoreError> {
710    let warnings_json = serialize(&transaction.warnings)?;
711    let status = JobState::Queued;
712    let created_at = now_iso();
713    let key_id = CURRENT_KEY_ID.to_string();
714    let approval_id = transaction
715        .approval_id
716        .clone()
717        .or_else(|| Some(key.approval_commitment(transaction_id, &transaction.request_hash)));
718
719    // SELECT … FOR UPDATE on the most-recent row (if any) so concurrent
720    // writers serialise on the chain tip. Without this, two parallel
721    // record() calls could both compute seq=N+1 and then race on the
722    // UNIQUE(seq) constraint — the loser would get a unique-violation
723    // error.
724    let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
725        "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
726    )
727    .fetch_optional(&mut **tx)
728    .await
729    .map_err(map_sqlx_err)?;
730    let (seq, prev_chain_hash) = match prev {
731        Some((s, h)) => ((s as u64) + 1, h),
732        None => (1, String::new()),
733    };
734
735    let chain_hash = key.chain_hash(
736        &ChainContent {
737            seq,
738            key_id: &key_id,
739            transaction_id,
740            request_id: &transaction.request_id,
741            request_hash: &transaction.request_hash,
742            action_name: &transaction.action_name,
743            risk_level: transaction.risk_level,
744            summary: &transaction.summary,
745            approval_id: approval_id.as_deref(),
746            warnings_json: &warnings_json,
747            created_at: &created_at,
748        },
749        &prev_chain_hash,
750    );
751
752    sqlx_core::query::query(
753        "INSERT INTO transactions ( \
754            transaction_id, request_id, request_hash, action_name, risk_level, \
755            status, approval_id, summary, warnings_json, created_at, \
756            seq, key_id, chain_hash, prev_chain_hash \
757         ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
758    )
759    .bind(transaction_id)
760    .bind(&transaction.request_id)
761    .bind(&transaction.request_hash)
762    .bind(&transaction.action_name)
763    .bind(serialize(&transaction.risk_level)?)
764    .bind(serialize(&status)?)
765    .bind(&approval_id)
766    .bind(&transaction.summary)
767    .bind(&warnings_json)
768    .bind(&created_at)
769    .bind(seq as i64)
770    .bind(&key_id)
771    .bind(&chain_hash)
772    .bind(&prev_chain_hash)
773    .execute(&mut **tx)
774    .await
775    .map_err(map_sqlx_err)?;
776
777    Ok(InsertedTransaction {
778        record: TransactionRecord {
779            transaction_id: transaction_id.to_string(),
780            request_id: transaction.request_id,
781            request_hash: transaction.request_hash,
782            action_name: transaction.action_name,
783            risk_level: transaction.risk_level,
784            status,
785            approval_id,
786            summary: transaction.summary,
787            warnings: transaction.warnings,
788        },
789        seq,
790        chain_hash,
791    })
792}
793
794// ---------------------------------------------------------------------------
795// Row mappers
796// ---------------------------------------------------------------------------
797
798fn row_to_history_entry(
799    row: sqlx_postgres::PgRow,
800) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
801    Ok(crate::transactions::JobHistoryEntry {
802        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
803        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
804        risk_level: deserialize(
805            &row.try_get::<String, _>("risk_level")
806                .map_err(map_sqlx_err)?,
807        )?,
808        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
809        summary: row.try_get("summary").map_err(map_sqlx_err)?,
810        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
811    })
812}
813
814fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
815    Ok(TransactionRecord {
816        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
817        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
818        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
819        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
820        risk_level: deserialize(
821            &row.try_get::<String, _>("risk_level")
822                .map_err(map_sqlx_err)?,
823        )?,
824        status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
825        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
826        summary: row.try_get("summary").map_err(map_sqlx_err)?,
827        warnings: deserialize(
828            &row.try_get::<String, _>("warnings_json")
829                .map_err(map_sqlx_err)?,
830        )?,
831    })
832}
833
834fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
835    Ok(ChainRow {
836        seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
837        key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
838        transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
839        request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
840        request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
841        action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
842        risk_level: deserialize(
843            &row.try_get::<String, _>("risk_level")
844                .map_err(map_sqlx_err)?,
845        )?,
846        summary: row.try_get("summary").map_err(map_sqlx_err)?,
847        approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
848        warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
849        created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
850        prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
851        chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
852    })
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858
859    /// Verifies that PostgresConfig defaults are sensible and don't accidentally
860    /// disable the statement cache.
861    #[test]
862    fn default_config_keeps_statement_cache_enabled() {
863        let c = PostgresConfig::default();
864        assert!(c.statement_cache_capacity > 0);
865        assert!(c.max_connections >= 4);
866        assert!(c.acquire_timeout >= Duration::from_secs(5));
867    }
868
869    #[test]
870    fn url_parsing_accepts_standard_postgres_url() {
871        let opts = PgConnectOptions::from_str(
872            "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
873        )
874        .expect("standard URL parses");
875        // Ensure binding via PgPoolOptions wouldn't reject it (just smoke-check).
876        let _ = opts.statement_cache_capacity(0);
877    }
878
879    #[test]
880    fn schema_decode_errors_are_classified_as_invariant_failures() {
881        let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
882        assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
883    }
884}