feat: add Postgres store with append-only schema and migrations
internal/store connects via pgx and runs golang-migrate migrations embedded in the binary (go:embed), so Deklarix stays a single binary despite the move to Postgres. Schema covers the five MVP tables (submission, asset, extraction, finding, evidence_package, participant). extraction, finding and evidence_package are append-only by design: a Postgres trigger rejects UPDATE/DELETE outright, since a corrigible evidence archive isn't an evidence archive. Corrections to a finding are new rows whose supersedes column points at the row they replace (set at INSERT time on the new row, since the trigger blocks UPDATE on the old one) — "currently valid" findings are the ones no other row supersedes. scripts/test.sh now spins up a disposable Postgres container so the store's integration tests (including the append-only guarantee) actually run on every test.sh/release.sh invocation instead of silently skipping for lack of DATABASE_URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
11
internal/store/migrations/0001_init.down.sql
Normal file
11
internal/store/migrations/0001_init.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
DROP TRIGGER IF EXISTS evidence_package_append_only ON evidence_package;
|
||||
DROP TRIGGER IF EXISTS finding_append_only ON finding;
|
||||
DROP TRIGGER IF EXISTS extraction_append_only ON extraction;
|
||||
DROP FUNCTION IF EXISTS forbid_update_delete();
|
||||
|
||||
DROP TABLE IF EXISTS participant;
|
||||
DROP TABLE IF EXISTS evidence_package;
|
||||
DROP TABLE IF EXISTS finding;
|
||||
DROP TABLE IF EXISTS extraction;
|
||||
DROP TABLE IF EXISTS asset;
|
||||
DROP TABLE IF EXISTS submission;
|
||||
89
internal/store/migrations/0001_init.up.sql
Normal file
89
internal/store/migrations/0001_init.up.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE submission (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
platform TEXT NOT NULL CHECK (platform IN ('instagram', 'tiktok', 'youtube', 'linkedin')),
|
||||
post_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'checked', 'published', 'archived')),
|
||||
caption TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE asset (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('image', 'video', 'file')),
|
||||
path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stufe 1: striktes JSON aus der Claude-Extraktion. Append-only, siehe Trigger unten.
|
||||
CREATE TABLE extraction (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
payload JSONB NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Stufe 2: Ergebnis pro Regel. Append-only, Korrektur = neue Zeile.
|
||||
-- supersedes zeigt auf die alte Zeile, die diese Zeile ersetzt (gesetzt
|
||||
-- beim INSERT der Korrektur, nie per UPDATE — der Trigger würde das
|
||||
-- verbieten). "Aktuell gültig" = Zeilen, auf die kein supersedes zeigt,
|
||||
-- siehe Index unten für den Anti-Join.
|
||||
CREATE TABLE finding (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
extraction_id UUID REFERENCES extraction (id),
|
||||
rule_id TEXT NOT NULL,
|
||||
rule_version INTEGER NOT NULL,
|
||||
severity TEXT NOT NULL CHECK (severity IN ('niedrig', 'mittel', 'hoch')),
|
||||
message TEXT NOT NULL,
|
||||
supersedes UUID REFERENCES finding (id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX finding_supersedes_idx ON finding (supersedes) WHERE supersedes IS NOT NULL;
|
||||
|
||||
-- Dossier + Beweiskette (Hash, RFC-3161-Token). Append-only.
|
||||
CREATE TABLE evidence_package (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
dossier_path TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
timestamp_token BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE participant (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
submission_id UUID NOT NULL REFERENCES submission (id),
|
||||
role TEXT NOT NULL CHECK (role IN ('creator', 'agentur', 'marke', 'kanzlei')),
|
||||
name TEXT NOT NULL,
|
||||
vorgegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
freigegeben BOOLEAN NOT NULL DEFAULT false,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Ein Beweisarchiv, in dem man Zeilen ändern kann, ist kein Beweisarchiv.
|
||||
CREATE FUNCTION forbid_update_delete() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'append-only table: % on % is not allowed', TG_OP, TG_TABLE_NAME;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER extraction_append_only
|
||||
BEFORE UPDATE OR DELETE ON extraction
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TRIGGER finding_append_only
|
||||
BEFORE UPDATE OR DELETE ON finding
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
|
||||
CREATE TRIGGER evidence_package_append_only
|
||||
BEFORE UPDATE OR DELETE ON evidence_package
|
||||
FOR EACH ROW EXECUTE FUNCTION forbid_update_delete();
|
||||
Reference in New Issue
Block a user