From 152fc3011636871cf9fc5bded4ca350c9d8e68d1 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 07:46:39 +0200 Subject: [PATCH] feat(db): schema bootstrap with append-only audit_log table Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 +- crates/db/migrations/0001_audit_log.sql | 28 +++++++++++++++++++++++++ crates/db/src/lib.rs | 9 ++++++++ crates/db/tests/migrate.rs | 20 ++++++++++++++++++ crates/server/src/lib.rs | 3 +++ 5 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 crates/db/migrations/0001_audit_log.sql create mode 100644 crates/db/tests/migrate.rs diff --git a/Cargo.toml b/Cargo.toml index 4580caa..1522aa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.85" [workspace.dependencies] tokio = { version = "1", features = ["full"] } axum = "0.8" -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros", "time", "json"] } uuid = { version = "1", features = ["v4", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/db/migrations/0001_audit_log.sql b/crates/db/migrations/0001_audit_log.sql new file mode 100644 index 0000000..8533bc2 --- /dev/null +++ b/crates/db/migrations/0001_audit_log.sql @@ -0,0 +1,28 @@ +-- Append-only audit log. One database == one organization, so there is no org_id. +CREATE TABLE audit_log ( + seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + at TIMESTAMPTZ NOT NULL DEFAULT now(), + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user', 'system')), + actor_id UUID, + action TEXT NOT NULL CHECK (action IN ('created', 'updated', 'deleted')), + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + changes JSONB NOT NULL DEFAULT '[]'::jsonb, + CONSTRAINT actor_id_matches_kind CHECK ( + (actor_kind = 'user' AND actor_id IS NOT NULL) OR + (actor_kind = 'system' AND actor_id IS NULL) + ) +); + +CREATE INDEX audit_log_entity_idx ON audit_log (entity_type, entity_id, seq); + +-- Enforce append-only at the database level: reject any UPDATE or DELETE. +CREATE OR REPLACE FUNCTION audit_log_reject_mutation() RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'audit_log is append-only; % is not permitted', TG_OP; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER audit_log_immutable + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION audit_log_reject_mutation(); diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 27ceab9..c741b63 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -37,4 +37,13 @@ impl Db { Ok(()) } + + /// Apply all pending schema migrations (embedded at compile time). + /// + /// Pre-1.0 the migration files are rewritten freely and dev databases are + /// recreated; this is the schema-bootstrap mechanism, not forward-migration + /// discipline. + pub async fn migrate(&self) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::migrate!().run(&self.pool).await + } } diff --git a/crates/db/tests/migrate.rs b/crates/db/tests/migrate.rs new file mode 100644 index 0000000..c1f4d47 --- /dev/null +++ b/crates/db/tests/migrate.rs @@ -0,0 +1,20 @@ +use db::Db; +use sqlx::PgPool; + +#[sqlx::test] +async fn migrate_is_idempotent_and_creates_audit_log(pool: PgPool) { + let db = Db::from_pool(pool); + + // sqlx::test already applied migrations to this temp DB; re-running must be a + // no-op success (idempotent). + db.migrate() + .await + .expect("re-running migrate is idempotent"); + + let regclass: Option = + sqlx::query_scalar("SELECT to_regclass('public.audit_log')::text") + .fetch_one(db.pool()) + .await + .unwrap(); + assert_eq!(regclass.as_deref(), Some("audit_log")); +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 7d37361..cad5f8d 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -14,6 +14,9 @@ pub async fn run(config: Config) -> anyhow::Result<()> { let db = Db::connect(&config.database_url) .await .context("connecting to the database")?; + + db.migrate().await.context("running database migrations")?; + let state = AppState { db, app_name: config.app_name.clone(),