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