feat(db-tool): hectic-inheritance: add hectic.immutable + diff coverage

Add a hectic.immutable parent table. Tables inheriting it get auto-attached
BEFORE INSERT/UPDATE/DELETE/TRUNCATE row+statement triggers that block DML
unless the session sets hectic.migration_mode='on' (intended use: SET LOCAL
inside a migration transaction). Same exemptions as the rest of the bundle
apply (hectic schema, partitions, temp tables, GUC-excluded schemas).

database diff now appends an --- IMMUTABLE TABLE DATA --- section to its
output, with per-table unified row diffs of every table inheriting
hectic.immutable, surfacing drift in 'frozen' reference data alongside schema
drift. Subcommand exits non-zero when either schema or data differs.

Test postgres-init-hectic-inheritance extended to 10 cases covering
immutable triggers, DML blocked outside migration_mode, SET LOCAL allowing
DML inside a transaction, GUC not leaking past COMMIT, and TRUNCATE under
migration_mode.
This commit is contained in:
2026-04-30 16:10:38 +00:00
parent 31d2994997
commit 2eaa568f5b
4 changed files with 266 additions and 5 deletions

View File

@@ -112,7 +112,7 @@ To use `db-tool` in a Nix development shell, add the following to your `flake.ni
## hectic Inheritance Bundle
`pkgs.hectic.hectic-inheritance` ships a SQL artifact that bootstraps a `hectic`
schema with two parent tables and DDL event triggers:
schema with three parent tables and DDL event triggers:
- `hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — every
user table must `INHERITS (hectic.created_at)`. The event trigger
@@ -122,6 +122,20 @@ schema with two parent tables and DDL event triggers:
Any table that inherits from it automatically gets a `BEFORE UPDATE FOR EACH
ROW` trigger calling `hectic.set_updated_at()` attached by
`hectic_attach_updated_at_trigger`.
- `hectic.immutable()` — pure marker. Tables inheriting it are blocked from
`INSERT`/`UPDATE`/`DELETE`/`TRUNCATE` outside migration mode by triggers
attached by `hectic_attach_immutable_triggers`. Useful for reference data
that must only change via migrations. To allow DML inside a migration, wrap
it in a transaction:
```sql
BEGIN;
SET LOCAL hectic.migration_mode = 'on';
INSERT INTO public.frozen (id, label) VALUES (1, 'x');
COMMIT;
```
`SET LOCAL` is required so the permission cannot leak past `COMMIT`.
Always-exempt schemas: `hectic`, `information_schema`, anything matching
`pg_*`. Declarative partitions (`relispartition = true`) and temporary tables
@@ -134,6 +148,16 @@ Per-database opt-out for additional schemas via the
ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl';
```
### `db-tool diff` and immutable tables
`database diff` already includes immutable tables in its schema-level
comparison (via `pg_dump --schema-only`). On top of that, when a `hectic`
schema is present in either side, it appends an
`--- IMMUTABLE TABLE DATA ---` section to the diff with a per-table unified
diff of the rows of every table inheriting `hectic.immutable`. Drift in
"frozen" reference data therefore surfaces in the same pager view as schema
drift, and the subcommand exits non-zero when either differs.
### Apply via `postgres-init`
Applied automatically. Set `PG_HECTIC_INHERITANCE=0` to opt out.

View File

@@ -1197,6 +1197,78 @@ ___diff_dump_schema() {
fi
}
___diff_immutable_tables() {
local socket_dir="$1"
local port="$2"
psql -h "$socket_dir" -p "$port" -d testdb -tAv ON_ERROR_STOP=1 -c "$(cat <<'SQL'
SELECT n.nspname || '.' || c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE i.inhparent = 'hectic.immutable'::regclass
AND c.relkind = 'r'
ORDER BY 1
SQL
)" 2>/dev/null
}
___diff_immutable_data() {
local sock1="$1"
local port1="$2"
local sock2="$3"
local port2="$4"
local out_file="$5"
if ! psql -h "$sock1" -p "$port1" -d testdb -tAc \
"SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname='immutable';" \
>/dev/null 2>&1
then
return 0
fi
local tables1 tables2 tables
tables1=$(___diff_immutable_tables "$sock1" "$port1" || true)
tables2=$(___diff_immutable_tables "$sock2" "$port2" || true)
tables=$(printf '%s\n%s\n' "$tables1" "$tables2" | sort -u | sed '/^$/d')
if [ -z "$tables" ]; then
return 0
fi
log notice "diffing data of tables inheriting hectic.immutable"
printf '\n--- IMMUTABLE TABLE DATA ---\n' >> "$out_file"
local data1 data2 differs=0
data1=$(mktemp)
data2=$(mktemp)
trap 'rm -f "$data1" "$data2"' EXIT INT HUP
for tbl in $tables; do
log info " $tbl"
: > "$data1"
: > "$data2"
pg_dump -h "$sock1" -p "$port1" testdb \
--data-only --no-owner --no-privileges --column-inserts -t "$tbl" \
> "$data1" 2>/dev/null || :
pg_dump -h "$sock2" -p "$port2" testdb \
--data-only --no-owner --no-privileges --column-inserts -t "$tbl" \
> "$data2" 2>/dev/null || :
{
printf '\n=== %s ===\n' "$tbl"
if diff --color=always -u "$data1" "$data2"; then
printf '(no differences)\n'
else
differs=1
fi
} >> "$out_file"
done
rm -f "$data1" "$data2"
trap - EXIT INT HUP
return $differs
}
help_check() {
# shellcheck disable=SC2059
printf "$(cat <<EOF
@@ -1451,9 +1523,22 @@ subcommand_diff() {
if diff --color=always -u "$DIFF_DUMP1" "$DIFF_DUMP2" \
> "$DIFF_TMPDIR/diff"
then
log notice "no schema differences found"
schema_differs=0
else
log notice "schema differences found"
schema_differs=1
fi
___diff_immutable_data \
"$DIFF_PGDATA1/sock" "5432" \
"$DIFF_PGDATA2/sock" "5432" \
"$DIFF_TMPDIR/diff"
data_status=$?
if [ "$schema_differs" = 0 ] && [ "$data_status" = 0 ]; then
log notice "no differences found"
else
log notice "differences found"
"$PAGER_OR_CAT" "$DIFF_TMPDIR/diff"
fi

View File

@@ -1,17 +1,28 @@
-- hectic.created_at / hectic.updated_at inheritance machinery.
-- hectic.created_at / hectic.updated_at / hectic.immutable inheritance machinery.
--
-- Provides:
-- * schema hectic
-- * tables hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())
-- hectic.updated_at(updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())
-- hectic.immutable() -- pure marker
-- * function hectic.set_updated_at() -- BEFORE UPDATE row trigger function
-- * function hectic.block_immutable_dml()
-- BEFORE INSERT/UPDATE/DELETE/TRUNCATE row+statement trigger function;
-- allows DML iff current_setting('hectic.migration_mode', true) = 'on'.
-- * GUC hectic.inheritance_extra_excluded_schemas
-- (text, comma-separated list of schemas the enforcement trigger skips)
-- * GUC hectic.migration_mode
-- (text, 'on' enables DML on tables inheriting hectic.immutable.
-- Intended use: SET LOCAL inside a migration transaction.)
-- * event trigger hectic_enforce_created_at_inheritance
-- RAISE EXCEPTION on CREATE TABLE that does not inherit hectic.created_at
-- * event trigger hectic_attach_updated_at_trigger
-- auto-attaches BEFORE UPDATE row trigger calling hectic.set_updated_at()
-- on any new table that inherits hectic.updated_at and lacks one.
-- * event trigger hectic_attach_immutable_triggers
-- auto-attaches BEFORE INSERT/UPDATE/DELETE FOR EACH ROW and BEFORE
-- TRUNCATE FOR EACH STATEMENT triggers calling hectic.block_immutable_dml()
-- on any new table that inherits hectic.immutable and lacks them.
--
-- Idempotent: safe to run on an already-bootstrapped database.
@@ -25,6 +36,8 @@ CREATE TABLE IF NOT EXISTS "hectic"."updated_at" (
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "hectic"."immutable" ();
DO $bootstrap$
BEGIN
PERFORM set_config('hectic.inheritance_extra_excluded_schemas',
@@ -35,6 +48,16 @@ EXCEPTION WHEN undefined_object THEN
END
$bootstrap$;
DO $bootstrap_mm$
BEGIN
PERFORM set_config('hectic.migration_mode',
current_setting('hectic.migration_mode', true),
false);
EXCEPTION WHEN undefined_object THEN
PERFORM set_config('hectic.migration_mode', '', false);
END
$bootstrap_mm$;
CREATE OR REPLACE FUNCTION "hectic"."set_updated_at"() RETURNS trigger
LANGUAGE plpgsql AS $fn$
BEGIN
@@ -43,6 +66,29 @@ BEGIN
END
$fn$;
CREATE OR REPLACE FUNCTION "hectic"."block_immutable_dml"() RETURNS trigger
LANGUAGE plpgsql AS $fn$
DECLARE
mm text;
BEGIN
BEGIN
mm := current_setting('hectic.migration_mode', true);
EXCEPTION WHEN OTHERS THEN
mm := '';
END;
IF mm = 'on' THEN
IF TG_LEVEL = 'STATEMENT' THEN RETURN NULL; END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION
'hectic: table %.% inherits hectic.immutable; % blocked outside migration_mode',
quote_ident(TG_TABLE_SCHEMA), quote_ident(TG_TABLE_NAME), TG_OP
USING HINT = 'wrap the statement in a migration transaction with '
|| 'SET LOCAL hectic.migration_mode = ''on''';
END
$fn$;
CREATE OR REPLACE FUNCTION "hectic"."_is_excluded_schema"(p_schema text) RETURNS boolean
LANGUAGE plpgsql STABLE AS $fn$
DECLARE
@@ -153,3 +199,59 @@ CREATE EVENT TRIGGER "hectic_attach_updated_at_trigger"
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE')
EXECUTE FUNCTION "hectic"."attach_updated_at_trigger"();
CREATE OR REPLACE FUNCTION "hectic"."attach_immutable_triggers"() RETURNS event_trigger
LANGUAGE plpgsql AS $fn$
DECLARE
obj record;
rel pg_class;
schema_name text;
parent_oid oid;
has_row boolean;
has_trunc boolean;
BEGIN
parent_oid := 'hectic.immutable'::regclass;
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() WHERE command_tag = 'CREATE TABLE'
LOOP
SELECT * INTO rel FROM pg_class WHERE oid = obj.objid;
IF NOT FOUND THEN CONTINUE; END IF;
IF rel.relpersistence = 't' THEN CONTINUE; END IF;
IF rel.relispartition THEN CONTINUE; END IF;
SELECT nspname INTO schema_name FROM pg_namespace WHERE oid = rel.relnamespace;
IF schema_name = 'hectic' THEN CONTINUE; END IF;
IF NOT "hectic"."_table_inherits"(rel.oid, parent_oid) THEN CONTINUE; END IF;
SELECT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = rel.oid
AND tgname = 'hectic_block_immutable_dml'
AND NOT tgisinternal
) INTO has_row;
SELECT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = rel.oid
AND tgname = 'hectic_block_immutable_truncate'
AND NOT tgisinternal
) INTO has_trunc;
IF NOT has_row THEN
EXECUTE format(
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON %I.%I '
|| 'FOR EACH ROW EXECUTE FUNCTION "hectic"."block_immutable_dml"()',
'hectic_block_immutable_dml', schema_name, rel.relname
);
END IF;
IF NOT has_trunc THEN
EXECUTE format(
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON %I.%I '
|| 'FOR EACH STATEMENT EXECUTE FUNCTION "hectic"."block_immutable_dml"()',
'hectic_block_immutable_truncate', schema_name, rel.relname
);
END IF;
END LOOP;
END
$fn$;
DROP EVENT TRIGGER IF EXISTS "hectic_attach_immutable_triggers";
CREATE EVENT TRIGGER "hectic_attach_immutable_triggers"
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE')
EXECUTE FUNCTION "hectic"."attach_immutable_triggers"();