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)?;
369 let current_str: Option<String> = sqlx_core::query_scalar::query_scalar(
370 "SELECT status FROM transactions WHERE transaction_id = $1 FOR UPDATE",
371 )
372 .bind(transaction_id)
373 .fetch_optional(&mut *tx)
374 .await
375 .map_err(map_sqlx_err)?;
376 let current_str = current_str
377 .ok_or_else(|| TransactionStoreError::NotFound(transaction_id.to_string()))?;
378 let current: JobState = deserialize(¤t_str)?;
379 if !crate::jobs::allowed_transition(¤t, &new_status) {
380 return Err(TransactionStoreError::InvalidTransition {
381 from: current,
382 to: new_status,
383 });
384 }
385 sqlx_core::query::query("UPDATE transactions SET status = $1 WHERE transaction_id = $2")
386 .bind(serialize(&new_status)?)
387 .bind(transaction_id)
388 .execute(&mut *tx)
389 .await
390 .map_err(map_sqlx_err)?;
391 tx.commit().await.map_err(map_sqlx_err)?;
392 Ok(())
393 }
394
395 async fn approve_transaction(
396 &self,
397 transaction_id: &str,
398 ) -> Result<Option<String>, TransactionStoreError> {
399 let row = sqlx_core::query::query(
400 "SELECT request_hash, approval_id FROM transactions WHERE transaction_id = $1",
401 )
402 .bind(transaction_id)
403 .fetch_optional(&self.pool)
404 .await
405 .map_err(map_sqlx_err)?;
406 let Some(row) = row else {
407 return Ok(None);
408 };
409 let request_hash: String = row.try_get("request_hash").map_err(map_sqlx_err)?;
410 let committed_digest: Option<String> = row.try_get("approval_id").map_err(map_sqlx_err)?;
411 let receipt = self
412 .audit_key
413 .approval_receipt(transaction_id, &request_hash);
414 let receipt_digest = crate::audit_chain::approval_receipt_digest(&receipt);
415 let commitment_matches = committed_digest.as_deref().is_some_and(|committed| {
416 bool::from(receipt_digest.as_bytes().ct_eq(committed.as_bytes()))
417 });
418 if !commitment_matches {
419 return Err(TransactionStoreError::DatabaseInvariant(format!(
420 "transaction {transaction_id} approval commitment does not match its signed preview"
421 )));
422 }
423 let queued = serialize(&JobState::Queued)?;
424 let result = sqlx_core::query::query(
425 sqlx_core::sql_str::AssertSqlSafe(format!(
426 "INSERT INTO transaction_approvals \
427 (transaction_id, receipt_digest, approved_at) \
428 SELECT transaction_id, $1, $2 FROM transactions \
429 WHERE transaction_id = $3 \
430 AND status = $4 \
431 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
432 ON CONFLICT (transaction_id) DO NOTHING"
433 )),
434 )
435 .bind(receipt_digest)
436 .bind(now_iso())
437 .bind(transaction_id)
438 .bind(&queued)
439 .execute(&self.pool)
440 .await
441 .map_err(map_sqlx_err)?;
442 Ok((result.rows_affected() > 0).then_some(receipt))
443 }
444
445 async fn revoke_unconsumed_approval(
446 &self,
447 transaction_id: &str,
448 ) -> Result<bool, TransactionStoreError> {
449 let result = sqlx_core::query::query(
450 "DELETE FROM transaction_approvals \
451 WHERE transaction_id = $1 AND consumed_at IS NULL",
452 )
453 .bind(transaction_id)
454 .execute(&self.pool)
455 .await
456 .map_err(map_sqlx_err)?;
457 Ok(result.rows_affected() > 0)
458 }
459
460 async fn claim_approved_for_execution(
461 &self,
462 transaction_id: &str,
463 receipt_digest: &str,
464 ) -> Result<bool, TransactionStoreError> {
465 let queued = serialize(&JobState::Queued)?;
466 let running = serialize(&JobState::Running)?;
467 let mut tx = self.pool.begin().await.map_err(map_sqlx_err)?;
468 let result = sqlx_core::query::query(
469 sqlx_core::sql_str::AssertSqlSafe(format!(
470 "UPDATE transactions SET status = $1 \
471 WHERE transaction_id = $2 \
472 AND status = $3 \
473 AND created_at::timestamptz > now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes' \
474 AND EXISTS ( \
475 SELECT 1 FROM transaction_approvals \
476 WHERE transaction_id = $2 \
477 AND receipt_digest = $4 \
478 AND consumed_at IS NULL \
479 )"
480 )),
481 )
482 .bind(&running)
483 .bind(transaction_id)
484 .bind(&queued)
485 .bind(receipt_digest)
486 .execute(&mut *tx)
487 .await
488 .map_err(map_sqlx_err)?;
489 if result.rows_affected() > 0 {
490 sqlx_core::query::query(
491 "UPDATE transaction_approvals SET consumed_at = $1 \
492 WHERE transaction_id = $2 AND consumed_at IS NULL",
493 )
494 .bind(now_iso())
495 .bind(transaction_id)
496 .execute(&mut *tx)
497 .await
498 .map_err(map_sqlx_err)?;
499 }
500 tx.commit().await.map_err(map_sqlx_err)?;
501 Ok(result.rows_affected() > 0)
502 }
503
504 async fn cleanup_stale_queued(&self) -> Result<u64, TransactionStoreError> {
505 let queued = serialize(&JobState::Queued)?;
506 let canceled = serialize(&JobState::Canceled)?;
507 let result = sqlx_core::query::query(
508 sqlx_core::sql_str::AssertSqlSafe(format!(
509 "UPDATE transactions SET status = $1 \
510 WHERE status = $2 \
511 AND created_at::timestamptz <= now() - INTERVAL '{APPROVAL_RECEIPT_TTL_MINUTES} minutes'"
512 )),
513 )
514 .bind(&canceled)
515 .bind(&queued)
516 .execute(&self.pool)
517 .await
518 .map_err(map_sqlx_err)?;
519 Ok(result.rows_affected())
520 }
521
522 async fn cancel_queued(&self, transaction_id: &str) -> Result<bool, TransactionStoreError> {
523 let queued = serialize(&JobState::Queued)?;
526 let canceled = serialize(&JobState::Canceled)?;
527 let result = sqlx_core::query::query(
528 "UPDATE transactions SET status = $1 \
529 WHERE transaction_id = $2 AND status = $3",
530 )
531 .bind(&canceled)
532 .bind(transaction_id)
533 .bind(&queued)
534 .execute(&self.pool)
535 .await
536 .map_err(map_sqlx_err)?;
537 Ok(result.rows_affected() > 0)
538 }
539
540 async fn list_transactions(
541 &self,
542 limit: u32,
543 status_filter: Option<&str>,
544 action_filter: Option<&str>,
545 since_hours: Option<u32>,
546 ) -> Result<Vec<TransactionRecord>, TransactionStoreError> {
547 let limit = limit.min(100) as i64;
548
549 let validated_status: Option<String> = status_filter
552 .map(|s| -> Result<String, TransactionStoreError> {
553 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
554 serialize(&parsed)
555 })
556 .transpose()?;
557
558 let mut sql = String::from(
559 "SELECT transaction_id, request_id, request_hash, action_name, risk_level, \
560 status, approval_id, summary, warnings_json \
561 FROM transactions WHERE TRUE",
562 );
563 let mut idx = 1;
564 if validated_status.is_some() {
565 sql.push_str(&format!(" AND status = ${idx}"));
566 idx += 1;
567 }
568 if action_filter.is_some() {
569 sql.push_str(&format!(" AND action_name = ${idx}"));
570 idx += 1;
571 }
572 if since_hours.is_some() {
573 sql.push_str(&format!(
574 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
575 ));
576 idx += 1;
577 }
578 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
579
580 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
581 if let Some(s) = &validated_status {
582 q = q.bind(s);
583 }
584 if let Some(a) = action_filter {
585 q = q.bind(a);
586 }
587 if let Some(h) = since_hours {
588 q = q.bind(h.to_string());
589 }
590 q = q.bind(limit);
591
592 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
593 rows.into_iter().map(row_to_record).collect()
594 }
595
596 async fn list_history(
597 &self,
598 limit: u32,
599 status_filter: Option<&str>,
600 action_filter: Option<&str>,
601 since_hours: Option<u32>,
602 ) -> Result<Vec<crate::transactions::JobHistoryEntry>, TransactionStoreError> {
603 let limit = limit.min(100) as i64;
606 let validated_status: Option<String> = status_filter
607 .map(|s| -> Result<String, TransactionStoreError> {
608 let parsed: JobState = deserialize(&format!("\"{s}\""))?;
609 serialize(&parsed)
610 })
611 .transpose()?;
612
613 let mut sql = String::from(
614 "SELECT transaction_id, action_name, risk_level, status, summary, created_at \
615 FROM transactions WHERE TRUE",
616 );
617 let mut idx = 1;
618 if validated_status.is_some() {
619 sql.push_str(&format!(" AND status = ${idx}"));
620 idx += 1;
621 }
622 if action_filter.is_some() {
623 sql.push_str(&format!(" AND action_name = ${idx}"));
624 idx += 1;
625 }
626 if since_hours.is_some() {
627 sql.push_str(&format!(
628 " AND created_at::timestamptz > now() - (${idx} || ' hours')::INTERVAL"
629 ));
630 idx += 1;
631 }
632 sql.push_str(&format!(" ORDER BY seq DESC LIMIT ${idx}"));
633
634 let mut q = sqlx_core::query::query(sqlx_core::sql_str::AssertSqlSafe(sql.as_str()));
635 if let Some(s) = &validated_status {
636 q = q.bind(s);
637 }
638 if let Some(a) = action_filter {
639 q = q.bind(a);
640 }
641 if let Some(h) = since_hours {
642 q = q.bind(h.to_string());
643 }
644 q = q.bind(limit);
645
646 let rows = q.fetch_all(&self.pool).await.map_err(map_sqlx_err)?;
647 rows.into_iter().map(row_to_history_entry).collect()
648 }
649
650 async fn fetch_chain_row(
651 &self,
652 transaction_id: &str,
653 ) -> Result<Option<ChainRow>, TransactionStoreError> {
654 let row = sqlx_core::query::query(
655 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
656 action_name, risk_level, summary, approval_id, warnings_json, \
657 created_at, prev_chain_hash, chain_hash \
658 FROM transactions WHERE transaction_id = $1",
659 )
660 .bind(transaction_id)
661 .fetch_optional(&self.pool)
662 .await
663 .map_err(map_sqlx_err)?;
664 row.map(row_to_chain_row).transpose()
665 }
666
667 async fn fetch_chain_rows(&self) -> Result<Vec<ChainRow>, TransactionStoreError> {
668 fetch_chain_rows_from_pool(&self.pool).await
669 }
670
671 async fn verify_audit_chain(
672 &self,
673 key: &AuditKey,
674 ) -> Result<VerifyOutcome, TransactionStoreError> {
675 let rows = self.fetch_chain_rows().await?;
676 Ok(crate::audit_chain::verify_chain(key, &rows))
677 }
678}
679
680async fn fetch_chain_rows_from_pool(pool: &PgPool) -> Result<Vec<ChainRow>, TransactionStoreError> {
681 let rows = sqlx_core::query::query(
682 "SELECT seq, key_id, transaction_id, request_id, request_hash, \
683 action_name, risk_level, summary, approval_id, warnings_json, \
684 created_at, prev_chain_hash, chain_hash \
685 FROM transactions ORDER BY seq ASC",
686 )
687 .fetch_all(pool)
688 .await
689 .map_err(map_sqlx_err)?;
690 rows.into_iter().map(row_to_chain_row).collect()
691}
692
693async fn insert_transaction(
698 tx: &mut sqlx_core::transaction::Transaction<'_, sqlx_postgres::Postgres>,
699 key: &AuditKey,
700 transaction_id: &str,
701 transaction: NewTransaction,
702) -> Result<InsertedTransaction, TransactionStoreError> {
703 let warnings_json = serialize(&transaction.warnings)?;
704 let status = JobState::Queued;
705 let created_at = now_iso();
706 let key_id = CURRENT_KEY_ID.to_string();
707 let approval_id = transaction
708 .approval_id
709 .clone()
710 .or_else(|| Some(key.approval_commitment(transaction_id, &transaction.request_hash)));
711
712 let prev: Option<(i64, String)> = sqlx_core::query_as::query_as(
718 "SELECT seq, chain_hash FROM transactions ORDER BY seq DESC LIMIT 1 FOR UPDATE",
719 )
720 .fetch_optional(&mut **tx)
721 .await
722 .map_err(map_sqlx_err)?;
723 let (seq, prev_chain_hash) = match prev {
724 Some((s, h)) => ((s as u64) + 1, h),
725 None => (1, String::new()),
726 };
727
728 let chain_hash = key.chain_hash(
729 &ChainContent {
730 seq,
731 key_id: &key_id,
732 transaction_id,
733 request_id: &transaction.request_id,
734 request_hash: &transaction.request_hash,
735 action_name: &transaction.action_name,
736 risk_level: transaction.risk_level,
737 summary: &transaction.summary,
738 approval_id: approval_id.as_deref(),
739 warnings_json: &warnings_json,
740 created_at: &created_at,
741 },
742 &prev_chain_hash,
743 );
744
745 sqlx_core::query::query(
746 "INSERT INTO transactions ( \
747 transaction_id, request_id, request_hash, action_name, risk_level, \
748 status, approval_id, summary, warnings_json, created_at, \
749 seq, key_id, chain_hash, prev_chain_hash \
750 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
751 )
752 .bind(transaction_id)
753 .bind(&transaction.request_id)
754 .bind(&transaction.request_hash)
755 .bind(&transaction.action_name)
756 .bind(serialize(&transaction.risk_level)?)
757 .bind(serialize(&status)?)
758 .bind(&approval_id)
759 .bind(&transaction.summary)
760 .bind(&warnings_json)
761 .bind(&created_at)
762 .bind(seq as i64)
763 .bind(&key_id)
764 .bind(&chain_hash)
765 .bind(&prev_chain_hash)
766 .execute(&mut **tx)
767 .await
768 .map_err(map_sqlx_err)?;
769
770 Ok(InsertedTransaction {
771 record: TransactionRecord {
772 transaction_id: transaction_id.to_string(),
773 request_id: transaction.request_id,
774 request_hash: transaction.request_hash,
775 action_name: transaction.action_name,
776 risk_level: transaction.risk_level,
777 status,
778 approval_id,
779 summary: transaction.summary,
780 warnings: transaction.warnings,
781 },
782 seq,
783 chain_hash,
784 })
785}
786
787fn row_to_history_entry(
792 row: sqlx_postgres::PgRow,
793) -> Result<crate::transactions::JobHistoryEntry, TransactionStoreError> {
794 Ok(crate::transactions::JobHistoryEntry {
795 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
796 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
797 risk_level: deserialize(
798 &row.try_get::<String, _>("risk_level")
799 .map_err(map_sqlx_err)?,
800 )?,
801 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
802 summary: row.try_get("summary").map_err(map_sqlx_err)?,
803 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
804 })
805}
806
807fn row_to_record(row: sqlx_postgres::PgRow) -> Result<TransactionRecord, TransactionStoreError> {
808 Ok(TransactionRecord {
809 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
810 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
811 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
812 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
813 risk_level: deserialize(
814 &row.try_get::<String, _>("risk_level")
815 .map_err(map_sqlx_err)?,
816 )?,
817 status: deserialize(&row.try_get::<String, _>("status").map_err(map_sqlx_err)?)?,
818 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
819 summary: row.try_get("summary").map_err(map_sqlx_err)?,
820 warnings: deserialize(
821 &row.try_get::<String, _>("warnings_json")
822 .map_err(map_sqlx_err)?,
823 )?,
824 })
825}
826
827fn row_to_chain_row(row: sqlx_postgres::PgRow) -> Result<ChainRow, TransactionStoreError> {
828 Ok(ChainRow {
829 seq: row.try_get::<i64, _>("seq").map_err(map_sqlx_err)? as u64,
830 key_id: row.try_get("key_id").map_err(map_sqlx_err)?,
831 transaction_id: row.try_get("transaction_id").map_err(map_sqlx_err)?,
832 request_id: row.try_get("request_id").map_err(map_sqlx_err)?,
833 request_hash: row.try_get("request_hash").map_err(map_sqlx_err)?,
834 action_name: row.try_get("action_name").map_err(map_sqlx_err)?,
835 risk_level: deserialize(
836 &row.try_get::<String, _>("risk_level")
837 .map_err(map_sqlx_err)?,
838 )?,
839 summary: row.try_get("summary").map_err(map_sqlx_err)?,
840 approval_id: row.try_get("approval_id").map_err(map_sqlx_err)?,
841 warnings_json: row.try_get("warnings_json").map_err(map_sqlx_err)?,
842 created_at: row.try_get("created_at").map_err(map_sqlx_err)?,
843 prev_chain_hash: row.try_get("prev_chain_hash").map_err(map_sqlx_err)?,
844 chain_hash: row.try_get("chain_hash").map_err(map_sqlx_err)?,
845 })
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851
852 #[test]
855 fn default_config_keeps_statement_cache_enabled() {
856 let c = PostgresConfig::default();
857 assert!(c.statement_cache_capacity > 0);
858 assert!(c.max_connections >= 4);
859 assert!(c.acquire_timeout >= Duration::from_secs(5));
860 }
861
862 #[test]
863 fn url_parsing_accepts_standard_postgres_url() {
864 let opts = PgConnectOptions::from_str(
865 "postgres://user:pass@host.example.com:5432/audit?sslmode=require",
866 )
867 .expect("standard URL parses");
868 let _ = opts.statement_cache_capacity(0);
870 }
871
872 #[test]
873 fn schema_decode_errors_are_classified_as_invariant_failures() {
874 let error = map_sqlx_err(sqlx_core::Error::ColumnNotFound("chain_hash".to_string()));
875 assert!(matches!(error, TransactionStoreError::DatabaseInvariant(_)));
876 }
877}