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    // `raw_sql` sends the whole DDL block as one batch over PG's
59    // simple-query protocol instead of splitting on `;` client-side
60    // (which would corrupt any dollar-quoted body). Sub-statements are
61    // idempotent (`CREATE ... IF NOT EXISTS`), so this stays safe under
62    // concurrent first-runs.
63    sqlx::raw_sql(AUDIT_TABLE_DDL)
64        .execute(runtime.pool())
65        .await
66        .map_err(|error| CoolError::Database(error.to_string()))?;
67
68    runtime.audit_table_ensured().store(true, Ordering::Release);
69    Ok(())
70}