1use 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#[derive(Clone, Debug)]
97pub struct PostgresConfig {
98 pub url: String,
100 pub max_connections: u32,
103 pub acquire_timeout: Duration,
106 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#[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 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 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 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 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
242fn 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 {
282 chrono::Utc::now()
283 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
284 .to_string()
285}
286
287#[async_trait]
292impl AuditStore for PostgresStore {
293 async fn record(
294 &self,
295 transaction: NewTransaction,
296 ) -> Result<TransactionRecord, TransactionStoreError> {
297 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
298 let transaction_id = Uuid::new_v4().to_string();
299 let inserted =
300 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
301 tx.commit().await.map_err(map_sqlx_err)?;
302 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
303 Ok(inserted.record)
304 }
305
306 async fn record_previewed(
307 &self,
308 transaction: NewTransaction,
309 preview: PreviewEnvelope,
310 ) -> Result<RecordedPreviewedTransaction, TransactionStoreError> {
311 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
312 let transaction_id = Uuid::new_v4().to_string();
313 let inserted =
314 insert_transaction(&mut tx, &self.audit_key, &transaction_id, transaction).await?;
315
316 sqlx_core::query::query(
317 "INSERT INTO transaction_previews (transaction_id, preview_json) VALUES ($1, $2)",
318 )
319 .bind(&inserted.record.transaction_id)
320 .bind(serialize(&preview)?)
321 .execute(&mut *tx)
322 .await
323 .map_err(map_sqlx_err)?;
324
325 tx.commit().await.map_err(map_sqlx_err)?;
326 emit_chain_tip_watermark(inserted.seq, &inserted.chain_hash);
327 Ok(RecordedPreviewedTransaction {
328 transaction: inserted.record,
329 preview,
330 })
331 }
332
333 async fn get(
334 &self,
335 transaction_id: &str,
336 ) -> Result<Option<TransactionRecord>, TransactionStoreError> {
337 let row = sqlx_core::query::query(
338 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
339 status, approval_id, summary, warnings_json \
340 FROM transactions WHERE transaction_id = $1",
341 )
342 .bind(transaction_id)
343 .fetch_optional(&self.pool)
344 .await
345 .map_err(map_sqlx_err)?;
346 row.map(row_to_record).transpose()
347 }
348
349 async fn get_preview(
350 &self,
351 transaction_id: &str,
352 ) -> Result<Option<PreviewEnvelope>, TransactionStoreError> {
353 let row = sqlx_core::query::query(
354 "SELECT preview_json FROM transaction_previews WHERE transaction_id = $1",
355 )
356 .bind(transaction_id)
357 .fetch_optional(&self.pool)
358 .await
359 .map_err(map_sqlx_err)?;
360 match row {
361 Some(r) => {
362 let s: String = r.try_get("preview_json").map_err(map_sqlx_err)?;
363 Ok(Some(deserialize(&s)?))
364 }
365 None => Ok(None),
366 }
367 }
368
369 async fn update_status(
370 &self,
371 transaction_id: &str,
372 new_status: JobState,
373 ) -> Result<(), TransactionStoreError> {
374 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
383 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
384 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
385 )
386 .bind(transaction_id)
387 .fetch_optional(&mut *tx)
388 .await
389 .map_err(map_sqlx_err)?;
390 let current_str = current_str
391 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
392 let current: JobState = deserialize(¤t_str)?;
393 if !crate::jobs::allowed_transition(¤t, &new_status) {
394 return Err(TransactionStoreError::InvalidTransition {
395 from: current,
396 to: new_status,
397 });
398 }
399 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
400 .bind(serialize(&new_status)?)
401 .bind(transaction_id)
402 .execute(&mut *tx)
403 .await
404 .map_err(map_sqlx_err)?;
405 tx.commit().await.map_err(map_sqlx_err)?;
406 Ok(())
407 }
408
409 async fn approve_transaction(
410 &self,
411 transaction_id: &str,
412 ) -> Result<Option<String>, TransactionStoreError> {
413 let row = sqlx_core::query::query(
414 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
415 )
416 .bind(transaction_id)
417 .fetch_optional(&self.pool)
418 .await
419 .map_err(map_sqlx_err)?;
420 let Some(row) = row else {
421 return Ok(None);
422 };
423 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
424 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
425 let receipt = self
426 .audit_key
427 .approval_receipt(transaction_id, &request_hash);
428 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
429 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
430 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
431 });
432 if !commitment_matches {
433 return Err(TransactionStoreError::DatabaseInvariant(format!(
434 "transaction {transaction_id} approval commitment does not match its signed preview"
435 )));
436 }
437 let queued = serialize(&JobState::Queued)?;
438 let result = sqlx_core::query::query(
439 sqlx_core::sql_str::AssertSqlSafe(format!(
440 "INSERT INTO transaction_approvals \
441 (transaction_id, receipt_digest, approved_at) \
442 SELECT transaction_id, $1, $2 FROM transactions \
443 WHERE transaction_id = $3 \
444 AND status = $4 \
445 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
446 ON CONFLICT (transaction_id) DO NOTHING"
447 )),
448 )
449 .bind(receipt_digest)
450 .bind(now_iso())
451 .bind(transaction_id)
452 .bind(&queued)
453 .execute(&self.pool)
454 .await
455 .map_err(map_sqlx_err)?;
456 Ok((result.rows_affected() > 0).then_some(receipt))
457 }
458
459 async fn revoke_unconsumed_approval(
460 &self,
461 transaction_id: &str,
462 ) -> Result<bool, TransactionStoreError> {
463 let result = sqlx_core::query::query(
464 "DELETE FROM transaction_approvals \
465 WHERE transaction_id = $1 AND consumed_at IS NULL",
466 )
467 .bind(transaction_id)
468 .execute(&self.pool)
469 .await
470 .map_err(map_sqlx_err)?;
471 Ok(result.rows_affected() > 0)
472 }
473
474 async fn claim_approved_for_execution(
475 &self,
476 transaction_id: &str,
477 receipt_digest: &str,
478 ) -> Result<bool, TransactionStoreError> {
479 let queued = serialize(&JobState::Queued)?;
480 let running = serialize(&JobState::Running)?;
481 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
482 let result = sqlx_core::query::query(
483 sqlx_core::sql_str::AssertSqlSafe(format!(
484 "UPDATE transactions SET status = $1 \
485 WHERE transaction_id = $2 \
486 AND status = $3 \
487 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
488 AND EXISTS ( \
489 SELECT 1 FROM transaction_approvals \
490 WHERE transaction_id = $2 \
491 AND receipt_digest = $4 \
492 AND consumed_at IS NULL \
493 )"
494 )),
495 )
496 .bind(&running)
497 .bind(transaction_id)
498 .bind(&queued)
499 .bind(receipt_digest)
500 .execute(&mut *tx)
501 .await
502 .map_err(map_sqlx_err)?;
503 if result.rows_affected() > 0 {
504 sqlx_core::query::query(
505 "UPDATE transaction_approvals SET consumed_at = $1 \
506 WHERE transaction_id = $2 AND consumed_at IS NULL",
507 )
508 .bind(now_iso())
509 .bind(transaction_id)
510 .execute(&mut *tx)
511 .await
512 .map_err(map_sqlx_err)?;
513 }
514 tx.commit().await.map_err(map_sqlx_err)?;
515 Ok(result.rows_affected() > 0)
516 }
517
518 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
519 let queued = serialize(&JobState::Queued)?;
520 let canceled = serialize(&JobState::Canceled)?;
521 let result = sqlx_core::query::query(
522 sqlx_core::sql_str::AssertSqlSafe(format!(
523 "UPDATE transactions SET status = $1 \
524 WHERE status = $2 \
525 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
526 )),
527 )
528 .bind(&canceled)
529 .bind(&queued)
530 .execute(&self.pool)
531 .await
532 .map_err(map_sqlx_err)?;
533 Ok(result.rows_affected())
534 }
535
536 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
537 let queued = serialize(&JobState::Queued)?;
540 let canceled = serialize(&JobState::Canceled)?;
541 let result = sqlx_core::query::query(
542 "UPDATE transactions SET status = $1 \
543 WHERE transaction_id = $2 AND status = $3",
544 )
545 .bind(&canceled)
546 .bind(transaction_id)
547 .bind(&queued)
548 .execute(&self.pool)
549 .await
550 .map_err(map_sqlx_err)?;
551 Ok(result.rows_affected() > 0)
552 }
553
554 async fn list_transactions(
555 &self,
556 limit: u32,
557 status_filter: Option<&str>,
558 action_filter: Option<&str>,
559 since_hours: Option<u32>,
560 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
561 let limit = limit.min(100) as i64;
562
563 let validated_status: Option<String> = status_filter
566 .map(|s| -> Result<String, TransactionStoreError> {
567 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
568 serialize(&parsed)
569 })
570 .transpose()?;
571
572 let mut sql = String::from(
573 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
574 status, approval_id, summary, warnings_json \
575 FROM transactions WHERE TRUE",
576 );
577 let mut idx = 1;
578 if validated_status.is_some() {
579 sql.push_str(&format!(" AND status = ${idx}"));
580 idx += 1;
581 }
582 if action_filter.is_some() {
583 sql.push_str(&format!(" AND action_name = ${idx}"));
584 idx += 1;
585 }
586 if since_hours.is_some() {
587 sql.push_str(&format!(
588 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
589 ));
590 idx += 1;
591 }
592 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
593
594 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
595 if let Some(s) = &validated_status {
596 q = q.bind(s);
597 }
598 if let Some(a) = action_filter {
599 q = q.bind(a);
600 }
601 if let Some(h) = since_hours {
602 q = q.bind(h.to_string());
603 }
604 q = q.bind(limit);
605
606 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
607 rows.into_iter().map(row_to_record).collect()
608 }
609
610 async fn list_history(
611 &self,
612 limit: u32,
613 status_filter: Option<&str>,
614 action_filter: Option<&str>,
615 since_hours: Option<u32>,
616 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
617 let limit = limit.min(100) as i64;
620 let validated_status: Option<String> = status_filter
621 .map(|s| -> Result<String, TransactionStoreError> {
622 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
623 serialize(&parsed)
624 })
625 .transpose()?;
626
627 let mut sql = String::from(
628 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
629 FROM transactions WHERE TRUE",
630 );
631 let mut idx = 1;
632 if validated_status.is_some() {
633 sql.push_str(&format!(" AND status = ${idx}"));
634 idx += 1;
635 }
636 if action_filter.is_some() {
637 sql.push_str(&format!(" AND action_name = ${idx}"));
638 idx += 1;
639 }
640 if since_hours.is_some() {
641 sql.push_str(&format!(
642 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
643 ));
644 idx += 1;
645 }
646 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
647
648 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
649 if let Some(s) = &validated_status {
650 q = q.bind(s);
651 }
652 if let Some(a) = action_filter {
653 q = q.bind(a);
654 }
655 if let Some(h) = since_hours {
656 q = q.bind(h.to_string());
657 }
658 q = q.bind(limit);
659
660 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
661 rows.into_iter().map(row_to_history_entry).collect()
662 }
663
664 async fn fetch_chain_row(
665 &self,
666 transaction_id: &str,
667 ) -> Result<Option<ChainRow>, TransactionStoreError> {
668 let row = sqlx_core::query::query(
669 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
670 action_name, risk_level, summary, approval_id, warnings_json, \
671 created_at, prev_chain_hash, chain_hash \
672 FROM transactions WHERE transaction_id = $1",
673 )
674 .bind(transaction_id)
675 .fetch_optional(&self.pool)
676 .await
677 .map_err(map_sqlx_err)?;
678 row.map(row_to_chain_row).transpose()
679 }
680
681 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
682 fetch_chain_rows_from_pool(&self.pool).await
683 }
684
685 async fn verify_audit_chain(
686 &self,
687 key: &AuditKey,
688 ) -> Result<VerifyOutcome, TransactionStoreError> {
689 let rows = self.fetch_chain_rows().await?;
690 Ok(crate::audit_chain::verify_chain(key, &rows))
691 }
692}
693
694async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
695 let rows = sqlx_core::query::query(
696 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
697 action_name, risk_level, summary, approval_id, warnings_json, \
698 created_at, prev_chain_hash, chain_hash \
699 FROM transactions ORDER BY seq ASC",
700 )
701 .fetch_all(pool)
702 .await
703 .map_err(map_sqlx_err)?;
704 rows.into_iter().map(row_to_chain_row).collect()
705}
706
707async fn insert_transaction(
712 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
713 key: &AuditKey,
714 transaction_id: &str,
715 transaction: NewTransaction,
716) -> Result<InsertedTransaction, TransactionStoreError> {
717 let warnings_json = serialize(&transaction.warnings)?;
718 let status = JobState::Queued;
719 let created_at = now_iso();
720 let key_id = CURRENT_KEY_ID.to_string();
721 let approval_id = Some(key.approval_commitment(transaction_id, &transaction.request_hash));
724
725 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
731 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
732 )
733 .fetch_optional(&mut **tx)
734 .await
735 .map_err(map_sqlx_err)?;
736 let (seq, prev_chain_hash) = match prev {
737 Some((s, h)) => ((s as u64) + 1, h),
738 None => (1, String::new()),
739 };
740
741 let chain_hash = key.chain_hash(
742 &ChainContent {
743 seq,
744 key_id: &key_id,
745 transaction_id,
746 request_id: &transaction.request_id,
747 request_hash: &transaction.request_hash,
748 action_name: &transaction.action_name,
749 risk_level: transaction.risk_level,
750 summary: &transaction.summary,
751 approval_id: approval_id.as_deref(),
752 warnings_json: &warnings_json,
753 created_at: &created_at,
754 },
755 &prev_chain_hash,
756 );
757
758 sqlx_core::query::query(
759 "INSERT INTO transactions ( \
760 transaction_id, request_id, request_hash, action_name, risk_level, \
761 status, approval_id, summary, warnings_json, created_at, \
762 seq, key_id, chain_hash, prev_chain_hash \
763 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
764 )
765 .bind(transaction_id)
766 .bind(&transaction.request_id)
767 .bind(&transaction.request_hash)
768 .bind(&transaction.action_name)
769 .bind(serialize(&transaction.risk_level)?)
770 .bind(serialize(&status)?)
771 .bind(&approval_id)
772 .bind(&transaction.summary)
773 .bind(&warnings_json)
774 .bind(&created_at)
775 .bind(seq as i64)
776 .bind(&key_id)
777 .bind(&chain_hash)
778 .bind(&prev_chain_hash)
779 .execute(&mut **tx)
780 .await
781 .map_err(map_sqlx_err)?;
782
783 Ok(InsertedTransaction {
784 record: TransactionRecord {
785 transaction_id: transaction_id.to_string(),
786 request_id: transaction.request_id,
787 request_hash: transaction.request_hash,
788 action_name: transaction.action_name,
789 risk_level: transaction.risk_level,
790 status,
791 approval_id,
792 summary: transaction.summary,
793 warnings: transaction.warnings,
794 },
795 seq,
796 chain_hash,
797 })
798}
799
800fn row_to_history_entry(
805 row: sqlx_postgres::PgRow,
806) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
807 Ok(crate::transactions::JobHistoryEntry {
808 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
809 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
810 risk_level: deserialize(
811 &row.try_get::<String, _>("risk_level")
812 .map_err(map_sqlx_err)?,
813 )?,
814 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
815 summary: row.try_get("summary").map_err(map_sqlx_err)?,
816 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
817 })
818}
819
820fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
821 Ok(TransactionRecord {
822 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
823 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
824 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
825 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
826 risk_level: deserialize(
827 &row.try_get::<String, _>("risk_level")
828 .map_err(map_sqlx_err)?,
829 )?,
830 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
831 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
832 summary: row.try_get("summary").map_err(map_sqlx_err)?,
833 warnings: deserialize(
834 &row.try_get::<String, _>("warnings_json")
835 .map_err(map_sqlx_err)?,
836 )?,
837 })
838}
839
840fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
841 Ok(ChainRow {
842 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
843 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
844 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
845 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
846 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
847 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
848 risk_level: deserialize(
849 &row.try_get::<String, _>("risk_level")
850 .map_err(map_sqlx_err)?,
851 )?,
852 summary: row.try_get("summary").map_err(map_sqlx_err)?,
853 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
854 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
855 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
856 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
857 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
858 })
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 #[test]
868 fn default_config_keeps_statement_cache_enabled() {
869 let c = PostgresConfig::default();
870 assert!(c.statement_cache_capacity > 0);
871 assert!(c.max_connections >= 4);
872 assert!(c.acquire_timeout >= Duration::from_secs(5));
873 }
874
875 #[test]
876 fn url_parsing_accepts_standard_postgres_url() {
877 let opts = PgConnectOptions::from_str(
878 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
879 )
880 .expect("standard URL parses");
881 let _ = opts.statement_cache_capacity(0);
883 }
884
885 #[test]
886 fn now_iso_produces_a_parseable_rfc3339_timestamp() {
887 let ts = now_iso();
894 let parsed = chrono::DateTime::parse_from_rfc3339(&ts);
895 assert!(
896 parsed.is_ok(),
897 "now_iso() must emit a parseable RFC 3339 timestamp, got {ts:?} ({parsed:?})"
898 );
899 assert_eq!(
900 ts.len(),
901 "2026-07-27T12:34:56.789Z".len(),
902 "timestamp shape changed unexpectedly: {ts}"
903 );
904 }
905
906 #[test]
907 fn now_iso_matches_the_sqlite_backends_timestamp_shape() {
908 let ts = now_iso();
914 let (date, rest) = ts.split_once('T').expect("timestamp has a T separator");
915 assert_eq!(date.len(), "2026-07-27".len(), "date part: {ts}");
916 assert!(rest.ends_with('Z'), "must be UTC with a Z suffix: {ts}");
917 let time = rest.trim_end_matches('Z');
918 let fields: Vec<&str> = time.split(':').collect();
919 assert_eq!(fields.len(), 3, "expected HH:MM:SS.mmm, got {time:?}");
920 assert!(
921 fields[2].contains('.'),
922 "seconds field must carry fractional milliseconds: {time:?}"
923 );
924 let (secs, millis) = fields[2].split_once('.').expect("checked above");
925 assert_eq!(secs.len(), 2, "seconds must be two digits: {time:?}");
926 assert_eq!(millis.len(), 3, "expected millisecond precision: {time:?}");
927 }
928
929 #[test]
930 fn schema_decode_errors_are_classified_as_invariant_failures() {
931 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
932 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
933 }
934}