Files
siddharthd 2251060ac6
ci / lint-test (push) Successful in 57s
db: append-only change log for the ledger (migration 0037)
`transaction_audit` records every INSERT/UPDATE/DELETE on transactions,
transaction_splits, transaction_tags, transaction_overrides and
expense_metadata, via AFTER row triggers.

WHY: on 2026-08-23 the n8n CC Statement Processor stamped `statement_id` onto
seven statementless wallet/pantry rows without setting `reconciled_with_id`,
double-counting $245.90 and hiding those rows from the reconcile queue
(`WHERE statement_id IS NULL`). Answering "was anything lost?" required
restoring the 02:30 pg_dump into a scratch database and diffing it column by
column, because nothing in the estate recorded what changed.

Trigger rather than application code because the ledger has at least five
independent writers -- this app (Prisma), n8n (raw SQL), pantry-app, the
ingestion runner (psycopg) and ad-hoc `docker exec psql`. That bug was an n8n
raw-SQL UPDATE; an app-level audit would not have seen it.

Not Postgres' own logs: wal_level=replica, wal_keep_size=0, archive_mode=off,
log_statement=none, track_commit_timestamp=off, no pgaudit -- verified live.
Postgres was recording nothing usable, and has no built-in audit tables.

UPDATEs store only the columns that changed (a whole-row copy buries a
one-column edit, and line_items can be kilobytes); no-op UPDATEs write nothing;
txid groups the rows of one logical edit, which matters because
applyReconcileMatch moves four children in a single transaction. No FK to
transactions -- the log must outlive the row. TRUNCATE does not fire row
triggers, so the test suite's wipes are silent.

Rehearsed against a scratch restore first: changed-column-only diffing, no-op
suppression, composite PK (transaction_tags), and the
matched_transaction_id fallback (a card-settled order has only that) all
verified before applying. Applied to personal and personal_test. Board #259.
2026-08-23 15:57:44 +10:00

153 lines
6.9 KiB
PL/PgSQL

-- Append-only change log for the ledger and its curation children.
--
-- WHY THIS EXISTS: on 2026-08-23 the CC Statement Processor's `Run Reconciliation`
-- node stamped `statement_id` onto seven statementless wallet/pantry rows without
-- setting `reconciled_with_id` or superseding the statement's own extracted line.
-- The purchase counted twice, and because finance-app's reconcile queue is
-- `WHERE statement_id IS NULL`, the stamp also hid those rows from the only thing
-- that could repair them. Answering "was anything lost?" required restoring the
-- 02:30 pg_dump into a scratch database and diffing it column by column, because
-- NOTHING in this estate records what changed. That is the gap this closes.
--
-- WHY AT THE DATABASE AND NOT IN THE APP: the ledger has at least five independent
-- writers -- finance-app (Prisma), n8n (raw SQL in a Postgres node), pantry-app,
-- the ingestion-engine runner (psycopg), and ad-hoc `docker exec psql`. The 4a bug
-- was an n8n raw-SQL UPDATE; an application-level audit would not have seen it at
-- all. A trigger cannot be bypassed by choice of client.
--
-- WHY NOT POSTGRES' OWN LOGS: checked live on this instance -- wal_level=replica
-- (so WAL cannot be logically decoded into row changes), wal_keep_size=0 and
-- archive_mode=off (so WAL is recycled almost immediately), log_statement=none,
-- logging_collector=off, track_commit_timestamp=off, and pgaudit is not installed.
-- Postgres was recording nothing usable. It has no built-in audit or temporal
-- tables; this always has to be built.
CREATE TABLE IF NOT EXISTS transaction_audit (
id bigserial PRIMARY KEY,
changed_at timestamptz NOT NULL DEFAULT now(),
-- Groups every row touched by one logical edit. `applyReconcileMatch` moves
-- overrides, tags, splits and metadata in a single transaction; without this
-- they read as four unrelated events.
txid bigint NOT NULL DEFAULT txid_current(),
table_name text NOT NULL,
op text NOT NULL CHECK (op IN ('INSERT','UPDATE','DELETE')),
-- Full primary key as an object, because transaction_tags is composite
-- (transaction_id, tag_id) and the others are a bare `id`.
row_key jsonb NOT NULL,
-- Denormalised so "everything that ever happened to transaction N" is one
-- index scan rather than a five-way union.
transaction_id bigint,
-- On UPDATE these hold ONLY the columns that actually changed. Storing whole
-- rows would bury a one-column edit in forty unchanged fields, and
-- expense_metadata.line_items alone can be kilobytes.
old_data jsonb,
new_data jsonb,
-- Attribution is partial and that is understood: every client connects as the
-- same `personal` role. app_name is only as good as what each client sets;
-- client_addr distinguishes containers and is NULL for `docker exec` over the
-- unix socket, which is itself a useful signal.
db_user text NOT NULL DEFAULT current_user,
app_name text,
client_addr inet
);
COMMENT ON TABLE transaction_audit IS
'Append-only row-level change log for transactions + curation children. Never UPDATE or DELETE from this table.';
CREATE INDEX IF NOT EXISTS transaction_audit_txn_time
ON transaction_audit (transaction_id, changed_at DESC);
CREATE INDEX IF NOT EXISTS transaction_audit_time
ON transaction_audit (changed_at DESC);
CREATE INDEX IF NOT EXISTS transaction_audit_txid
ON transaction_audit (txid);
CREATE INDEX IF NOT EXISTS transaction_audit_table_time
ON transaction_audit (table_name, changed_at DESC);
-- TG_ARGV[0]: comma-separated candidate columns for the owning transaction id;
-- the first non-null wins (expense_metadata carries the id in
-- transaction_id OR matched_transaction_id -- a card-settled order
-- only ever has the latter).
-- TG_ARGV[1]: comma-separated primary key columns.
CREATE OR REPLACE FUNCTION log_transaction_change() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public, pg_temp
AS $fn$
DECLARE
v_full jsonb;
v_old jsonb;
v_new jsonb;
v_key jsonb := '{}'::jsonb;
v_txn bigint;
k text;
BEGIN
IF TG_OP = 'INSERT' THEN
v_new := to_jsonb(NEW);
v_full := v_new;
ELSIF TG_OP = 'DELETE' THEN
v_old := to_jsonb(OLD);
v_full := v_old;
ELSE
v_old := to_jsonb(OLD);
v_new := to_jsonb(NEW);
v_full := v_new;
-- Reduce to the changed columns only. IS DISTINCT FROM so a NULL->NULL
-- comparison counts as unchanged rather than evaluating to NULL.
FOR k IN SELECT jsonb_object_keys(v_old) LOOP
IF (v_old -> k) IS NOT DISTINCT FROM (v_new -> k) THEN
v_old := v_old - k;
v_new := v_new - k;
END IF;
END LOOP;
-- A no-op UPDATE is noise, and finance-app's upserts issue plenty of them.
IF v_old = '{}'::jsonb THEN
RETURN NULL;
END IF;
END IF;
FOREACH k IN ARRAY string_to_array(TG_ARGV[1], ',') LOOP
v_key := v_key || jsonb_build_object(k, v_full -> k);
END LOOP;
FOREACH k IN ARRAY string_to_array(TG_ARGV[0], ',') LOOP
IF v_txn IS NULL AND (v_full ->> k) IS NOT NULL THEN
v_txn := (v_full ->> k)::bigint;
END IF;
END LOOP;
INSERT INTO transaction_audit
(table_name, op, row_key, transaction_id, old_data, new_data, app_name, client_addr)
VALUES
(TG_TABLE_NAME, TG_OP, v_key, v_txn, v_old, v_new,
NULLIF(current_setting('application_name', true), ''), inet_client_addr());
RETURN NULL; -- AFTER trigger; return value is ignored
END
$fn$;
-- AFTER, so a statement that raises never leaves a phantom audit row; the audit
-- write is in the same transaction as the change and rolls back with it.
DROP TRIGGER IF EXISTS trg_audit_transactions ON transactions;
CREATE TRIGGER trg_audit_transactions
AFTER INSERT OR UPDATE OR DELETE ON transactions
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('id', 'id');
DROP TRIGGER IF EXISTS trg_audit_transaction_splits ON transaction_splits;
CREATE TRIGGER trg_audit_transaction_splits
AFTER INSERT OR UPDATE OR DELETE ON transaction_splits
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id', 'id');
DROP TRIGGER IF EXISTS trg_audit_transaction_tags ON transaction_tags;
CREATE TRIGGER trg_audit_transaction_tags
AFTER INSERT OR UPDATE OR DELETE ON transaction_tags
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id', 'transaction_id,tag_id');
DROP TRIGGER IF EXISTS trg_audit_transaction_overrides ON transaction_overrides;
CREATE TRIGGER trg_audit_transaction_overrides
AFTER INSERT OR UPDATE OR DELETE ON transaction_overrides
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id', 'id');
DROP TRIGGER IF EXISTS trg_audit_expense_metadata ON expense_metadata;
CREATE TRIGGER trg_audit_expense_metadata
AFTER INSERT OR UPDATE OR DELETE ON expense_metadata
FOR EACH ROW EXECUTE FUNCTION log_transaction_change('transaction_id,matched_transaction_id', 'id');