feat(db): schema bootstrap with append-only audit_log table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 07:46:39 +02:00
parent 4c6f77b999
commit 152fc30116
5 changed files with 61 additions and 1 deletions
+28
View File
@@ -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();
+9
View File
@@ -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
}
}
+20
View File
@@ -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<String> =
sqlx::query_scalar("SELECT to_regclass('public.audit_log')::text")
.fetch_one(db.pool())
.await
.unwrap();
assert_eq!(regclass.as_deref(), Some("audit_log"));
}