db: Drizzle schemas on domain Postgres schemas, baseline migration, reorg SQL

- pgSchema tables for auth, money, house, ref, games, cs, animaldex, site, meta
  (lists excluded via schemaFilter; runtime list tables are managed by meta.*)
- drizzle/ baseline matches the reorged DB exactly (catalog diff = 0 on rehearsal)
- db/reorg: one-time SQL that moves the flat public schema (rehearsed on a prod copy)
- scripts/db-migrate.ts: migrator with --init to record the baseline and a guard
  against running the baseline on a non-empty, unrecorded database
This commit is contained in:
2026-10-23 22:03:42 +00:00
parent f94e3dc8ac
commit 64fb8df262
29 changed files with 12507 additions and 189 deletions

224
db/reorg/04_meta.sql Normal file
View File

@@ -0,0 +1,224 @@
-- 04_meta.sql: GUI registry (meta.tables / meta.columns), runtime-DDL functions, schema `lists`.
SET ROLE personal;
CREATE TABLE meta.tables (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
schema_name text NOT NULL,
table_name text NOT NULL,
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
display_name text NOT NULL,
description text,
icon text,
picture_url text,
kind text NOT NULL CHECK (kind IN ('list','core','ref')),
editable boolean NOT NULL DEFAULT true,
has_done boolean NOT NULL DEFAULT false,
default_sort text,
sort_order integer NOT NULL DEFAULT 0,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (schema_name, table_name)
);
COMMENT ON TABLE meta.tables IS 'One row per GUI-visible table (runtime lists + core + ref). Structure truth stays in pg_catalog; this is presentation.';
CREATE TABLE meta.columns (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
table_id integer NOT NULL REFERENCES meta.tables(id) ON DELETE CASCADE,
column_name text NOT NULL,
label text,
ui_type text NOT NULL CHECK (ui_type IN ('text','longtext','url','image','number','cents','date','datetime','bool','select','multiselect','fk','json')),
options jsonb NOT NULL DEFAULT '{}'::jsonb,
position integer NOT NULL DEFAULT 0,
hidden boolean NOT NULL DEFAULT false,
help_text text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (table_id, column_name)
);
CREATE INDEX columns_table_id_index ON meta.columns (table_id);
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.tables FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
CREATE TRIGGER touch_updated_at BEFORE UPDATE ON meta.columns FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at();
RESET ROLE;
-- schema for runtime lists, owned by personal_ddl (not by the migration owner)
CREATE SCHEMA lists AUTHORIZATION personal_ddl;
COMMENT ON SCHEMA lists IS 'Runtime-created user lists. Never touched by Drizzle migrations (excluded via schemaFilter). Create/alter only via meta.* functions.';
GRANT USAGE, CREATE ON SCHEMA meta TO personal_ddl;
GRANT SELECT, INSERT, UPDATE, DELETE ON meta.tables, meta.columns TO personal_ddl;
SET ROLE personal_ddl;
-- guess a ui_type from a column's catalog type (format_type output) and name
CREATE FUNCTION meta.guess_ui_type(p_type text, p_col text, p_is_enum boolean DEFAULT false) RETURNS text
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
SELECT CASE
WHEN p_is_enum THEN 'select'
WHEN p_type LIKE '%[]' THEN 'multiselect'
WHEN p_col IN ('picture_url','image_url','icon_url','image') THEN 'image'
WHEN p_col LIKE '%\_url' OR p_col = 'url' THEN 'url'
WHEN p_col LIKE '%\_cents' THEN 'cents'
WHEN p_col IN ('notes','description','content','note') THEN 'longtext'
WHEN p_type IN ('smallint','integer','bigint','real','double precision') OR p_type LIKE 'numeric%' THEN 'number'
WHEN p_type = 'boolean' THEN 'bool'
WHEN p_type = 'date' THEN 'date'
WHEN p_type LIKE 'timestamp%' THEN 'datetime'
WHEN p_type IN ('json','jsonb') THEN 'json'
ELSE 'text' END
$$;
-- reconcile meta.columns with pg_catalog for one table (or all registered tables).
-- Uses pg_attribute (not information_schema, which hides tables the caller has no grants on).
CREATE FUNCTION meta.sync(p_table_id integer DEFAULT NULL) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE k integer;
BEGIN
IF to_regclass('pg_temp._meta_cols') IS NULL THEN CREATE TEMP TABLE _meta_cols ON COMMIT DROP AS SELECT 1::integer AS table_id, ''::text AS col, ''::text AS typ, false AS is_enum, 0::integer AS pos, NULL::jsonb AS vals LIMIT 0; END IF;
TRUNCATE _meta_cols;
INSERT INTO _meta_cols
SELECT t.id, a.attname, format_type(a.atttypid, a.atttypmod), ty.typtype = 'e', a.attnum,
CASE WHEN ty.typtype = 'e' THEN (SELECT jsonb_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = ty.oid) END
FROM meta.tables t
JOIN pg_class c ON c.relname = t.table_name
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.schema_name
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
JOIN pg_type ty ON ty.oid = a.atttypid
WHERE p_table_id IS NULL OR t.id = p_table_id;
DELETE FROM meta.columns mc
WHERE (p_table_id IS NULL OR mc.table_id = p_table_id)
AND NOT EXISTS (SELECT 1 FROM _meta_cols x WHERE x.table_id = mc.table_id AND x.col = mc.column_name);
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position, hidden)
SELECT x.table_id, x.col, initcap(replace(x.col, '_', ' ')), meta.guess_ui_type(x.typ, x.col, x.is_enum),
CASE WHEN x.vals IS NOT NULL THEN jsonb_build_object('values', x.vals) ELSE '{}'::jsonb END,
x.pos, x.col IN ('id','created_at','updated_at','user_id','sort_order')
FROM _meta_cols x
ON CONFLICT (table_id, column_name) DO NOTHING;
GET DIAGNOSTICS k = ROW_COUNT;
RETURN k;
END $$;
-- allowed column types for runtime lists -> SQL type
CREATE FUNCTION meta.sql_type(p_ui_type text) RETURNS text
LANGUAGE sql IMMUTABLE SET search_path = pg_catalog AS $$
SELECT CASE p_ui_type
WHEN 'text' THEN 'text' WHEN 'longtext' THEN 'text' WHEN 'url' THEN 'text' WHEN 'image' THEN 'text'
WHEN 'select' THEN 'text' WHEN 'number' THEN 'numeric' WHEN 'cents' THEN 'integer'
WHEN 'date' THEN 'date' WHEN 'datetime' THEN 'timestamptz' WHEN 'bool' THEN 'boolean'
WHEN 'multiselect' THEN 'text[]' WHEN 'json' THEN 'jsonb' END
$$;
CREATE FUNCTION meta.assert_ident(p text) RETURNS void
LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog AS $$
BEGIN
IF p IS NULL OR p !~ '^[a-z][a-z0-9_]{0,62}$' THEN
RAISE EXCEPTION 'invalid identifier: %', p USING ERRCODE = '22023';
END IF;
END $$;
-- add one column to a runtime list. p_col: {"name","ui_type","label"?,"options"?,"default"?}
CREATE FUNCTION meta.add_column(p_slug text, p_col jsonb) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE
t meta.tables; c text := p_col->>'name'; u text := coalesce(p_col->>'ui_type', 'text');
st text := meta.sql_type(u); vals jsonb := p_col->'options'->'values'; ddl text;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
PERFORM meta.assert_ident(c);
IF c IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
RAISE EXCEPTION 'column % is a standard column', c; END IF;
IF st IS NULL THEN RAISE EXCEPTION 'unsupported ui_type %', u; END IF;
ddl := format('ALTER TABLE lists.%I ADD COLUMN %I %s', t.table_name, c, st);
IF p_col ? 'default' THEN ddl := ddl || format(' DEFAULT %L', p_col->>'default'); END IF;
IF u = 'select' AND jsonb_typeof(vals) = 'array' THEN
ddl := ddl || format(' CONSTRAINT %I CHECK (%I IN (%s))', t.table_name || '_' || c || '_check', c,
(SELECT string_agg(quote_literal(v), ',') FROM jsonb_array_elements_text(vals) v));
END IF;
EXECUTE ddl;
INSERT INTO meta.columns (table_id, column_name, label, ui_type, options, position)
VALUES (t.id, c, coalesce(p_col->>'label', initcap(replace(c, '_', ' '))), u,
coalesce(p_col->'options', '{}'::jsonb),
(SELECT coalesce(max(position), 0) + 1 FROM meta.columns WHERE table_id = t.id));
END $$;
-- create a runtime list table with the standard columns + extras
CREATE FUNCTION meta.create_list(
p_slug text, p_table text, p_display_name text,
p_has_done boolean DEFAULT true, p_columns jsonb DEFAULT '[]'::jsonb,
p_picture_url text DEFAULT NULL, p_description text DEFAULT NULL, p_icon text DEFAULT NULL,
p_sort_order integer DEFAULT 0
) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE tid integer; col jsonb;
BEGIN
PERFORM meta.assert_ident(p_table);
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'lists' AND tablename = p_table) THEN
RAISE EXCEPTION 'lists.% already exists', p_table; END IF;
EXECUTE format($f$CREATE TABLE lists.%1$I (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL CHECK (btrim(name) <> ''),
notes text,
picture_url text,
%2$s
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now())$f$,
p_table, CASE WHEN p_has_done THEN 'done_at timestamptz,' ELSE '' END);
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON lists.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', p_table);
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, description, icon, picture_url, kind, editable, has_done, default_sort, sort_order)
VALUES ('lists', p_table, p_slug, p_display_name, p_description, p_icon, p_picture_url, 'list', true, p_has_done,
CASE WHEN p_has_done THEN 'done_at nulls first, sort_order, name' ELSE 'sort_order, name' END, p_sort_order)
RETURNING id INTO tid;
PERFORM meta.sync(tid);
UPDATE meta.columns SET label = 'Done', ui_type = 'datetime' WHERE table_id = tid AND column_name = 'done_at';
FOR col IN SELECT * FROM jsonb_array_elements(p_columns) LOOP
PERFORM meta.add_column(p_slug, col);
END LOOP;
RETURN tid;
END $$;
CREATE FUNCTION meta.rename_column(p_slug text, p_old text, p_new text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE t meta.tables;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
PERFORM meta.assert_ident(p_new);
IF p_old IN ('id','name','done_at','created_at','updated_at') THEN RAISE EXCEPTION 'standard column'; END IF;
EXECUTE format('ALTER TABLE lists.%I RENAME COLUMN %I TO %I', t.table_name, p_old, p_new);
UPDATE meta.columns SET column_name = p_new WHERE table_id = t.id AND column_name = p_old;
END $$;
CREATE FUNCTION meta.drop_column(p_slug text, p_col text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE t meta.tables;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
IF p_col IN ('id','name','notes','picture_url','done_at','sort_order','created_at','updated_at') THEN
RAISE EXCEPTION 'standard column'; END IF;
EXECUTE format('ALTER TABLE lists.%I DROP COLUMN %I', t.table_name, p_col);
DELETE FROM meta.columns WHERE table_id = t.id AND column_name = p_col;
END $$;
CREATE FUNCTION meta.archive_list(p_slug text) RETURNS void
LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
UPDATE meta.tables SET archived_at = now() WHERE slug = p_slug AND kind = 'list' AND archived_at IS NULL
$$;
-- real DROP only after 7 days in the archive (backups cover the rest)
CREATE FUNCTION meta.drop_list(p_slug text) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, meta AS $$
DECLARE t meta.tables;
BEGIN
SELECT * INTO t FROM meta.tables WHERE slug = p_slug AND kind = 'list';
IF NOT FOUND THEN RAISE EXCEPTION 'no runtime list %', p_slug; END IF;
IF t.archived_at IS NULL OR t.archived_at > now() - interval '7 days' THEN
RAISE EXCEPTION 'archive % first and wait 7 days', p_slug; END IF;
EXECUTE format('DROP TABLE lists.%I', t.table_name);
DELETE FROM meta.tables WHERE id = t.id;
END $$;
RESET ROLE;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA meta FROM PUBLIC;
GRANT EXECUTE ON FUNCTION meta.touch_updated_at() TO PUBLIC; -- trigger fn, must stay callable