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 {
275 chrono::Utc::now()
276 .format("%Y-%m-%dT%H:%M:%.3fZ")
277 .to_string()
278}
279
280#[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 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(¤t_str)?;
386 if !crate::jobs::allowed_transition(¤t, &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 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 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 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
700async 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 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
794fn 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 #[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 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}