- 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
225 lines
12 KiB
PL/PgSQL
225 lines
12 KiB
PL/PgSQL
-- 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
|