feat(db-tool): hectic-inheritance: bootstrap hectic.created_at / hectic.updated_at inheritance hook

Adds a SQL bundle plus event triggers that enforce `INHERITS (hectic.created_at)`
on every user CREATE TABLE and auto-attach a BEFORE UPDATE row trigger when a
table inherits `hectic.updated_at`. Always-exempt: `hectic`, `information_schema`,
`pg_*`, declarative partitions, temp tables. Per-DB opt-out via the GUC
`hectic.inheritance_extra_excluded_schemas`.

Exposed three ways:
  * `pkgs.hectic.hectic-inheritance` — derivation with the SQL at
    $out/share/hectic/hectic-inheritance.sql
  * `self.lib.hecticInheritance.{sql,path}` — pkgs-free Nix surface
  * `postgres-init` opt-in via `PG_HECTIC_INHERITANCE=1` (HECTIC_INHERITANCE_SQL
    overrides the default)

Test postgres-init-hectic-inheritance covers all six branches: bootstrap,
non-inheriting reject, accepting inheritance, auto updated_at trigger fires,
GUC exclusion, declarative partition exemption.
This commit is contained in:
2026-04-30 15:16:57 +00:00
parent 7d5300853b
commit 58e9aa8fad
7 changed files with 339 additions and 5 deletions

View File

@@ -100,6 +100,19 @@ in {
# -- Cargo.toml -- # -- Cargo.toml --
cargoToml = src: (builtins.fromTOML (builtins.readFile "${src}/Cargo.toml")); cargoToml = src: (builtins.fromTOML (builtins.readFile "${src}/Cargo.toml"));
# SQL bundle bootstrapping `hectic.created_at` / `hectic.updated_at` inheritance enforcement.
# Consumers can either:
# * read the SQL string for inline pipelines: `self.lib.hecticInheritance.sql`
# * reference the source path: `self.lib.hecticInheritance.path`
# * use the per-system package: `pkgs.hectic.hectic-inheritance` (provides
# `$out/share/hectic/hectic-inheritance.sql`)
hecticInheritance = let
path = ../package/db-tool/sql/hectic-inheritance.sql;
in {
inherit path;
sql = builtins.readFile path;
};
ssh.keys = { ssh.keys = {
hetzner-test = { hetzner-test = {
yukkop = ''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8scy1tv6zfXX6xyaukhO/fsZwif5rC89DvXNc6XxOf''; yukkop = ''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8scy1tv6zfXX6xyaukhO/fsZwif5rC89DvXNc6XxOf'';

View File

@@ -32,6 +32,8 @@ These variables must be set for `db-tool` to function.
| `PG_CONF_FILE` | (unset) | Path to a `postgresql.conf` file. When set, replaces the script-generated config entirely on fresh init. `port` and `unix_socket_directories` are still appended at runtime (always overridden). When set, `PG_DISABLE_LOGGING` and `PG_SHARED_PRELOAD_LIBRARIES` are ignored. | | `PG_CONF_FILE` | (unset) | Path to a `postgresql.conf` file. When set, replaces the script-generated config entirely on fresh init. `port` and `unix_socket_directories` are still appended at runtime (always overridden). When set, `PG_DISABLE_LOGGING` and `PG_SHARED_PRELOAD_LIBRARIES` are ignored. |
| `PG_SHARED_PRELOAD_LIBRARIES` | `pg_cron` | Comma-separated `shared_preload_libraries` value. Set to empty string to disable. Ignored when `PG_CONF_FILE` is set. | | `PG_SHARED_PRELOAD_LIBRARIES` | `pg_cron` | Comma-separated `shared_preload_libraries` value. Set to empty string to disable. Ignored when `PG_CONF_FILE` is set. |
| `PG_DISABLE_LOGGING` | `0` | Set to `1` to disable PostgreSQL logging collector. Ignored when `PG_CONF_FILE` is set. | | `PG_DISABLE_LOGGING` | `0` | Set to `1` to disable PostgreSQL logging collector. Ignored when `PG_CONF_FILE` is set. |
| `PG_HECTIC_INHERITANCE` | `0` | Set to `1` to apply the [`hectic` inheritance bundle](#hectic-inheritance-bundle) to the target database after init. |
| `HECTIC_INHERITANCE_SQL` | (auto) | Override path to the SQL file applied by `PG_HECTIC_INHERITANCE=1`. Defaults to the SQL shipped with `postgres-init`. |
| `PATCH_LOG` | (stdout) | Path to log the output of database patches. | | `PATCH_LOG` | (stdout) | Path to log the output of database patches. |
| `HYDRATE_LOG` | (stdout) | Path to log the output of database hydration. | | `HYDRATE_LOG` | (stdout) | Path to log the output of database hydration. |
@@ -107,6 +109,54 @@ 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:
- `hectic.created_at(created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — every
user table must `INHERITS (hectic.created_at)`. The event trigger
`hectic_enforce_created_at_inheritance` raises an exception on `CREATE TABLE`
otherwise.
- `hectic.updated_at(updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` — optional.
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`.
Always-exempt schemas: `hectic`, `information_schema`, anything matching
`pg_*`. Declarative partitions (`relispartition = true`) and temporary tables
are also auto-exempt.
Per-database opt-out for additional schemas via the
`hectic.inheritance_extra_excluded_schemas` GUC (comma-separated):
```sql
ALTER DATABASE mydb SET hectic.inheritance_extra_excluded_schemas = 'legacy,etl';
```
### Apply via `postgres-init`
```sh
export PG_HECTIC_INHERITANCE=1
postgres-init
```
### Apply via `migrator` or any psql pipeline
```nix
# in your devshell
shellHook = ''
export HECTIC_INHERITANCE_SQL=${pkgs.hectic.hectic-inheritance}/share/hectic/hectic-inheritance.sql
'';
```
```sh
psql "$DB_URL" -v ON_ERROR_STOP=1 -f "$HECTIC_INHERITANCE_SQL"
```
The SQL is also exposed via `self.lib.hecticInheritance.sql` (string) and
`self.lib.hecticInheritance.path` (Nix path) for inline pipelines.
## Exit Codes ## Exit Codes
| Code | Meaning | | Code | Meaning |

View File

@@ -1,7 +1,14 @@
{ dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib }: { dash, hectic, postgresql_17, neovim, openssh, coreutils, gawk, lib, runCommand }:
let let
shell = "${dash}/bin/dash"; shell = "${dash}/bin/dash";
hecticInheritanceSqlPath = ./sql/hectic-inheritance.sql;
hecticInheritance = runCommand "hectic-inheritance" { } ''
mkdir -p "$out/share/hectic"
cp ${hecticInheritanceSqlPath} "$out/share/hectic/hectic-inheritance.sql"
'';
mkDatabase = mkDatabase =
{ postgresql ? postgresql_17 }: { postgresql ? postgresql_17 }:
hectic.writeShellApplication { hectic.writeShellApplication {
@@ -37,7 +44,11 @@ let
name = "postgres-init"; name = "postgres-init";
runtimeInputs = [ postgresql coreutils ]; runtimeInputs = [ postgresql coreutils ];
text = builtins.readFile ./postgres-init.sh; text = ''
HECTIC_INHERITANCE_SQL_DEFAULT="${hecticInheritance}/share/hectic/hectic-inheritance.sql"
export HECTIC_INHERITANCE_SQL_DEFAULT
${builtins.readFile ./postgres-init.sh}
'';
meta = { meta = {
description = "Initialize local PostgreSQL instance"; description = "Initialize local PostgreSQL instance";
@@ -62,7 +73,8 @@ let
}; };
in in
{ {
"db-tool" = lib.makeOverridable mkDatabase { }; "db-tool" = lib.makeOverridable mkDatabase { };
"postgres-init" = lib.makeOverridable mkPostgresInit { }; "postgres-init" = lib.makeOverridable mkPostgresInit { };
"postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { }; "postgres-cleanup" = lib.makeOverridable mkPostgresCleanup { };
"hectic-inheritance" = hecticInheritance;
} }

View File

@@ -48,6 +48,16 @@ postgres_init_main() {
fi fi
psql -h "$sockdir" -p "$PG_PORT" -d "$db" -v ON_ERROR_STOP=1 -c 'select 1;' || return 1 psql -h "$sockdir" -p "$PG_PORT" -d "$db" -v ON_ERROR_STOP=1 -c 'select 1;' || return 1
if [ "${PG_HECTIC_INHERITANCE:-0}" = "1" ]; then
sql_file="${HECTIC_INHERITANCE_SQL:-${HECTIC_INHERITANCE_SQL_DEFAULT:-}}"
if [ -z "$sql_file" ]; then
printf '%s\n' 'postgres-init: PG_HECTIC_INHERITANCE=1 but no SQL file resolved (set HECTIC_INHERITANCE_SQL)' >&2
return 3
fi
[ -r "$sql_file" ] || { printf '%s\n' "postgres-init: hectic-inheritance SQL not readable: $sql_file" >&2; return 1; }
psql -h "$sockdir" -p "$PG_PORT" -U "$user" -d "$db" -v ON_ERROR_STOP=1 -f "$sql_file" || return 1
fi
export POSTGRESQL_HOST="$sockdir" POSTGRESQL_PORT="$PG_PORT" POSTGRESQL_USER="$user" POSTGRESQL_DATABASE="$db" export POSTGRESQL_HOST="$sockdir" POSTGRESQL_PORT="$PG_PORT" POSTGRESQL_USER="$user" POSTGRESQL_DATABASE="$db"
_pg_url="postgresql://${POSTGRESQL_USER}@/${POSTGRESQL_DATABASE}?host=${POSTGRESQL_HOST}&port=${POSTGRESQL_PORT}" _pg_url="postgresql://${POSTGRESQL_USER}@/${POSTGRESQL_DATABASE}?host=${POSTGRESQL_HOST}&port=${POSTGRESQL_PORT}"
case $PG_URL_VAR in ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]* ) printf '%s\n' 'postgres-init: invalid PG_URL_VAR' >&2; return 1 ;; esac case $PG_URL_VAR in ''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_]* ) printf '%s\n' 'postgres-init: invalid PG_URL_VAR' >&2; return 1 ;; esac

View File

@@ -0,0 +1,155 @@
-- hectic.created_at / hectic.updated_at 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())
-- * function hectic.set_updated_at() -- BEFORE UPDATE row trigger function
-- * GUC hectic.inheritance_extra_excluded_schemas
-- (text, comma-separated list of schemas the enforcement trigger skips)
-- * 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.
--
-- Idempotent: safe to run on an already-bootstrapped database.
CREATE SCHEMA IF NOT EXISTS "hectic";
CREATE TABLE IF NOT EXISTS "hectic"."created_at" (
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS "hectic"."updated_at" (
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
DO $bootstrap$
BEGIN
PERFORM set_config('hectic.inheritance_extra_excluded_schemas',
current_setting('hectic.inheritance_extra_excluded_schemas', true),
false);
EXCEPTION WHEN undefined_object THEN
PERFORM set_config('hectic.inheritance_extra_excluded_schemas', '', false);
END
$bootstrap$;
CREATE OR REPLACE FUNCTION "hectic"."set_updated_at"() RETURNS trigger
LANGUAGE plpgsql AS $fn$
BEGIN
NEW."updated_at" := NOW();
RETURN NEW;
END
$fn$;
CREATE OR REPLACE FUNCTION "hectic"."_is_excluded_schema"(p_schema text) RETURNS boolean
LANGUAGE plpgsql STABLE AS $fn$
DECLARE
extra text;
s text;
BEGIN
IF p_schema = 'hectic'
OR p_schema = 'information_schema'
OR p_schema LIKE 'pg\_%' ESCAPE '\'
THEN
RETURN true;
END IF;
BEGIN
extra := current_setting('hectic.inheritance_extra_excluded_schemas', true);
EXCEPTION WHEN OTHERS THEN
extra := '';
END;
IF extra IS NULL OR extra = '' THEN
RETURN false;
END IF;
FOREACH s IN ARRAY string_to_array(extra, ',') LOOP
IF btrim(s) = p_schema THEN
RETURN true;
END IF;
END LOOP;
RETURN false;
END
$fn$;
CREATE OR REPLACE FUNCTION "hectic"."_table_inherits"(p_oid oid, p_parent regclass) RETURNS boolean
LANGUAGE sql STABLE AS $fn$
SELECT EXISTS (
SELECT 1 FROM pg_inherits
WHERE inhrelid = p_oid AND inhparent = p_parent
);
$fn$;
CREATE OR REPLACE FUNCTION "hectic"."enforce_created_at_inheritance"() RETURNS event_trigger
LANGUAGE plpgsql AS $fn$
DECLARE
obj record;
rel pg_class;
schema_name text;
parent_oid oid;
BEGIN
parent_oid := 'hectic.created_at'::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 "hectic"."_is_excluded_schema"(schema_name) THEN CONTINUE; END IF;
IF NOT "hectic"."_table_inherits"(rel.oid, parent_oid) THEN
RAISE EXCEPTION
'hectic: table %.% must INHERITS (hectic.created_at)',
quote_ident(schema_name), quote_ident(rel.relname)
USING HINT = 'add INHERITS ("hectic"."created_at") to the CREATE TABLE statement, '
|| 'or add the schema to hectic.inheritance_extra_excluded_schemas';
END IF;
END LOOP;
END
$fn$;
CREATE OR REPLACE FUNCTION "hectic"."attach_updated_at_trigger"() RETURNS event_trigger
LANGUAGE plpgsql AS $fn$
DECLARE
obj record;
rel pg_class;
schema_name text;
parent_oid oid;
trigger_name text;
has_trigger boolean;
BEGIN
parent_oid := 'hectic.updated_at'::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;
trigger_name := 'hectic_set_updated_at';
SELECT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = rel.oid AND tgname = trigger_name AND NOT tgisinternal
) INTO has_trigger;
IF has_trigger THEN CONTINUE; END IF;
EXECUTE format(
'CREATE TRIGGER %I BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION "hectic"."set_updated_at"()',
trigger_name, schema_name, rel.relname
);
END LOOP;
END
$fn$;
DROP EVENT TRIGGER IF EXISTS "hectic_enforce_created_at_inheritance";
CREATE EVENT TRIGGER "hectic_enforce_created_at_inheritance"
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE')
EXECUTE FUNCTION "hectic"."enforce_created_at_inheritance"();
DROP EVENT TRIGGER IF EXISTS "hectic_attach_updated_at_trigger";
CREATE EVENT TRIGGER "hectic_attach_updated_at_trigger"
ON ddl_command_end
WHEN TAG IN ('CREATE TABLE')
EXECUTE FUNCTION "hectic"."attach_updated_at_trigger"();

View File

@@ -145,6 +145,7 @@ in {
"db-tool" = dbToolPkgs."db-tool"; "db-tool" = dbToolPkgs."db-tool";
"postgres-init" = dbToolPkgs."postgres-init"; "postgres-init" = dbToolPkgs."postgres-init";
"postgres-cleanup" = dbToolPkgs."postgres-cleanup"; "postgres-cleanup" = dbToolPkgs."postgres-cleanup";
"hectic-inheritance" = dbToolPkgs."hectic-inheritance";
nbt2json = pkgs.callPackage ./nbt2json {}; nbt2json = pkgs.callPackage ./nbt2json {};
hemar-parser = pkgs.callPackage ./hemar/parser {}; hemar-parser = pkgs.callPackage ./hemar/parser {};
AstroTuxLauncher = pkgs.callPackage ./AstroTuxLauncher.nix {}; AstroTuxLauncher = pkgs.callPackage ./AstroTuxLauncher.nix {};

View File

@@ -0,0 +1,93 @@
# shellcheck shell=dash
HECTIC_NAMESPACE=test-db-tool-hectic-inheritance
PG_WORKING_DIR=$(mktemp -d)
export PG_WORKING_DIR PG_DATABASE=testdb PG_PORT=5432 PG_SHARED_PRELOAD_LIBRARIES=''
export PG_HECTIC_INHERITANCE=1
cleanup() {
postgres-cleanup >/dev/null 2>&1 || :
rm -rf "$PG_WORKING_DIR"
}
trap 'cleanup' EXIT INT TERM
if ! postgres-init; then
log error "postgres-init with PG_HECTIC_INHERITANCE=1 failed"
exit 1
fi
sockdir="$PG_WORKING_DIR/sock"
user=$(id -un)
pgurl="postgresql://${user}@/testdb?host=${sockdir}&port=5432"
run_sql() {
psql "$pgurl" -v ON_ERROR_STOP=1 -tAc "$1"
}
run_sql_expect_fail() {
if psql "$pgurl" -v ON_ERROR_STOP=1 -c "$1" >/dev/null 2>&1; then
return 1
fi
return 0
}
log notice "case 1: hectic schema and parent tables exist"
got=$(run_sql "SELECT count(*) FROM pg_namespace WHERE nspname='hectic';") || exit 1
[ "$got" = 1 ] || { log error "hectic schema missing"; exit 1; }
got=$(run_sql "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname IN ('created_at','updated_at');") || exit 1
[ "$got" = 2 ] || { log error "parent tables missing"; exit 1; }
log notice "case 2: CREATE TABLE without inheritance is rejected"
if ! run_sql_expect_fail 'CREATE TABLE public.bad_table (id int);'; then
log error "non-inheriting CREATE TABLE was accepted"
exit 1
fi
log notice "case 3: CREATE TABLE inheriting hectic.created_at is accepted"
run_sql 'CREATE TABLE public.good_table (id int) INHERITS ("hectic"."created_at");' || exit 1
log notice "case 4: tables inheriting hectic.updated_at get auto BEFORE UPDATE trigger"
run_sql 'CREATE TABLE public.with_updated (id int, val text) INHERITS ("hectic"."created_at", "hectic"."updated_at");' || exit 1
got=$(run_sql "SELECT count(*) FROM pg_trigger WHERE tgrelid='public.with_updated'::regclass AND tgname='hectic_set_updated_at' AND NOT tgisinternal;") || exit 1
[ "$got" = 1 ] || { log error "auto updated_at trigger missing"; exit 1; }
run_sql "INSERT INTO public.with_updated (id, val) VALUES (1, 'a');" || exit 1
sleep 1
run_sql "UPDATE public.with_updated SET val='b' WHERE id=1;" || exit 1
got=$(run_sql "SELECT (updated_at > created_at)::int FROM public.with_updated WHERE id=1;") || exit 1
[ "$got" = 1 ] || { log error "updated_at not bumped on UPDATE (got: $got)"; exit 1; }
log notice "case 5: GUC hectic.inheritance_extra_excluded_schemas exempts schemas"
run_sql 'CREATE SCHEMA legacy;' || exit 1
if ! run_sql_expect_fail 'CREATE TABLE legacy.t1 (id int);'; then
log error "legacy.t1 should be rejected before GUC set"
exit 1
fi
run_sql "ALTER DATABASE testdb SET hectic.inheritance_extra_excluded_schemas = 'legacy';" || exit 1
psql "$pgurl" -v ON_ERROR_STOP=1 -c 'CREATE TABLE legacy.t1 (id int);' || {
log error "legacy.t1 rejected even after GUC exclusion"
exit 1
}
log notice "case 6: declarative partitions are exempt"
run_sql 'CREATE TABLE public.parted (id int, region text) PARTITION BY LIST (region) INHERITS ("hectic"."created_at");' && {
log error "PARTITION BY combined with INHERITS unexpectedly succeeded"
exit 1
} || :
run_sql 'CREATE TABLE public.events (id int, region text, created_at timestamptz NOT NULL DEFAULT NOW()) PARTITION BY LIST (region);' && {
log error "partitioned parent without inheritance unexpectedly succeeded"
exit 1
} || :
run_sql "ALTER DATABASE testdb SET hectic.inheritance_extra_excluded_schemas = 'legacy,parts';" || exit 1
run_sql 'CREATE SCHEMA parts;' || exit 1
psql "$pgurl" -v ON_ERROR_STOP=1 -c 'CREATE TABLE parts.events (id int, region text) PARTITION BY LIST (region);' || {
log error "partitioned parent in excluded schema rejected"
exit 1
}
psql "$pgurl" -v ON_ERROR_STOP=1 -c "CREATE TABLE parts.events_us PARTITION OF parts.events FOR VALUES IN ('us');" || {
log error "declarative partition was rejected (should be exempt via relispartition)"
exit 1
}
log notice "test passed"