Skip to main content

cratestack_sqlx/audit/
schema.rs

1//! Audit-log table DDL + idempotent bootstrap.
2
3use cratestack_core::CoolError;
4
5use crate::sqlx;
6
7/// DDL for the audit log table. Banks typically run migrations
8/// through their own tooling — this DDL is exposed so the
9/// [`crate::SqlxRuntime`] can idempotently ensure the table exists
10/// during bootstrap.
11pub const AUDIT_TABLE_DDL: &str = r#"
12CREATE TABLE IF NOT EXISTS cratestack_audit (
13    event_id UUID PRIMARY KEY,
14    schema_name TEXT NOT NULL,
15    model TEXT NOT NULL,
16    operation TEXT NOT NULL,
17    primary_key JSONB NOT NULL,
18    actor JSONB NOT NULL,
19    tenant TEXT,
20    before JSONB,
21    after JSONB,
22    request_id TEXT,
23    occurred_at TIMESTAMPTZ NOT NULL,
24    delivered_at TIMESTAMPTZ,
25    attempts BIGINT NOT NULL DEFAULT 0,
26    last_error TEXT
27);
28
29CREATE INDEX IF NOT EXISTS cratestack_audit_model_idx
30    ON cratestack_audit (schema_name, model, occurred_at DESC);
31
32CREATE INDEX IF NOT EXISTS cratestack_audit_tenant_idx
33    ON cratestack_audit (tenant, occurred_at DESC)
34    WHERE tenant IS NOT NULL;
35
36CREATE INDEX IF NOT EXISTS cratestack_audit_undelivered_idx
37    ON cratestack_audit (occurred_at)
38    WHERE delivered_at IS NULL;
39"#;
40
41pub(crate) async fn ensure_audit_table(pool: &sqlx::PgPool) -> Result<(), CoolError> {
42    // sqlx prepared statements accept only one statement per query;
43    // multi-statement DDL is split on `;`. Sub-statements are
44    // idempotent (`CREATE ... IF NOT EXISTS`), so this stays safe
45    // under concurrent first-runs.
46    for statement in AUDIT_TABLE_DDL
47        .split(';')
48        .map(str::trim)
49        .filter(|s| !s.is_empty())
50    {
51        sqlx::query(statement)
52            .execute(pool)
53            .await
54            .map_err(|error| CoolError::Database(error.to_string()))?;
55    }
56    Ok(())
57}