1use 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#[derive(Clone, Debug)]
98pub struct PostgresConfig {
99 pub url: String,
101 pub max_connections: u32,
104 pub acquire_timeout: Duration,
107 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#[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 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 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 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 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
243fn 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#[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 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(¤t_str)?;
380 if !crate::jobs::allowed_transition(¤t, &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 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 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 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
694async 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 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
788fn 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 #[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 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}