Skip to main content

cratestack_sqlx/audit/
schema.rs

1//! Audit-log table DDL + idempotent bootstrap.
2
3use std::sync::atomic::Ordering;
4
5use cratestack_core::CoolError;
6
7use crate::SqlxRuntime;
8use crate::sqlx;
9
10/// DDL for the audit log table. Banks typically run migrations
11/// through their own tooling — this DDL is exposed so the
12/// [`crate::SqlxRuntime`] can idempotently ensure the table exists
13/// during bootstrap.
14pub const AUDIT_TABLE_DDL: &str = r#"
15CREATE TABLE IF NOT EXISTS cratestack_audit (
16    event_id UUID PRIMARY KEY,
17    schema_name TEXT NOT NULL,
18    model TEXT NOT NULL,
19    operation TEXT NOT NULL,
20    primary_key JSONB NOT NULL,
21    actor JSONB NOT NULL,
22    tenant TEXT,
23    before JSONB,
24    after JSONB,
25    request_id TEXT,
26    occurred_at TIMESTAMPTZ NOT NULL,
27    delivered_at TIMESTAMPTZ,
28    attempts BIGINT NOT NULL DEFAULT 0,
29    last_error TEXT
30);
31
32CREATE INDEX IF NOT EXISTS cratestack_audit_model_idx
33    ON cratestack_audit (schema_name, model, occurred_at DESC);
34
35CREATE INDEX IF NOT EXISTS cratestack_audit_tenant_idx
36    ON cratestack_audit (tenant, occurred_at DESC)
37    WHERE tenant IS NOT NULL;
38
39CREATE INDEX IF NOT EXISTS cratestack_audit_undelivered_idx
40    ON cratestack_audit (occurred_at)
41    WHERE delivered_at IS NULL;
42"#;
43
44/// Idempotently bootstraps `cratestack_audit`, but only actually runs
45/// the DDL once per [`SqlxRuntime`] (cached on a shared flag, so every
46/// clone of the same runtime agrees). This is load-bearing, not just
47/// an optimization: `CREATE INDEX IF NOT EXISTS` still takes a
48/// `ShareLock` on the table even when it's a no-op, which self-
49/// deadlocks against a `RowExclusiveLock` a prior audited write in the
50/// same caller-managed transaction is already holding. Skipping the
51/// DDL entirely after the first successful run avoids taking that
52/// lock at all on every subsequent call.
53pub(crate) async fn ensure_audit_table(runtime: &SqlxRuntime) -> Result<(), CoolError> {
54    if runtime.audit_table_ensured().load(Ordering::Acquire) {
55        return Ok(());
56    }
57
58    // sqlx prepared statements accept only one statement per query;
59    // multi-statement DDL is split on `;`. Sub-statements are
60    // idempotent (`CREATE ... IF NOT EXISTS`), so this stays safe
61    // under concurrent first-runs.
62    for statement in AUDIT_TABLE_DDL
63        .split(';')
64        .map(str::trim)
65        .filter(|s| !s.is_empty())
66    {
67        sqlx::query(statement)
68            .execute(runtime.pool())
69            .await
70            .map_err(|error| CoolError::Database(error.to_string()))?;
71    }
72
73    runtime.audit_table_ensured().store(true, Ordering::Release);
74    Ok(())
75}