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:
17
db/reorg/01_roles.sql
Normal file
17
db/reorg/01_roles.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 01_roles.sql: cluster-level roles. Run as a superuser (postgres). Idempotent.
|
||||
-- In prod: needs `sudo docker exec -i <pg-container> psql -U postgres`; set passwords separately (\password), never in this file.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
CREATE ROLE personal_app LOGIN; -- app, ned-collector, house worker
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ddl') THEN
|
||||
CREATE ROLE personal_ddl NOLOGIN; -- owns schema lists; only reachable via SECURITY DEFINER fns
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
CREATE ROLE personal_ro LOGIN; -- read-only for audits/agents/pgAdmin
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER ROLE personal_ro SET default_transaction_read_only = on;
|
||||
ALTER ROLE personal_app SET search_path = '$user'; -- force schema-qualified names in app SQL
|
||||
ALTER ROLE personal_ro SET search_path = '$user';
|
||||
142
db/reorg/02_schemas_move.sql
Normal file
142
db/reorg/02_schemas_move.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- 02_schemas_move.sql: create schemas, snapshot counts, move + rename all 58 public tables.
|
||||
SET ROLE personal;
|
||||
|
||||
CREATE SCHEMA auth; CREATE SCHEMA money; CREATE SCHEMA house; CREATE SCHEMA ref;
|
||||
CREATE SCHEMA games; CREATE SCHEMA cs; CREATE SCHEMA animaldex; CREATE SCHEMA site;
|
||||
CREATE SCHEMA meta; CREATE SCHEMA hardware; CREATE SCHEMA energy; CREATE SCHEMA legacy;
|
||||
COMMENT ON SCHEMA legacy IS 'Pre-reorg list tables + reorg bookkeeping. Drop 30 days after cutover.';
|
||||
COMMENT ON SCHEMA ref IS 'Seeded third-party catalog data (Pokemon, CoD tracker definitions). Read-only in the GUI; seeders own it.';
|
||||
COMMENT ON SCHEMA games IS 'Stan''s own game progress.';
|
||||
|
||||
-- exact row counts of every public table before anything moves
|
||||
CREATE TABLE legacy.reorg_counts_before (table_name text PRIMARY KEY, n bigint NOT NULL);
|
||||
DO $$
|
||||
DECLARE t text; c bigint;
|
||||
BEGIN
|
||||
FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
|
||||
EXECUTE format('SELECT count(*) FROM public.%I', t) INTO c;
|
||||
INSERT INTO legacy.reorg_counts_before VALUES (t, c);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE legacy.reorg_table_map (
|
||||
old_name text PRIMARY KEY,
|
||||
old_prefix text NOT NULL, -- prefix used by its index/constraint/sequence names
|
||||
new_schema text NOT NULL,
|
||||
new_name text NOT NULL,
|
||||
UNIQUE (new_schema, new_name)
|
||||
);
|
||||
INSERT INTO legacy.reorg_table_map (old_name, old_prefix, new_schema, new_name) VALUES
|
||||
('user','user','auth','user'), ('session','session','auth','session'),
|
||||
('account','account','auth','account'), ('verification','verification','auth','verification'),
|
||||
('money_accounts','money_accounts','money','accounts'), ('money_transactions','money_transactions','money','transactions'),
|
||||
('money_recurring','money_recurring','money','recurring'), ('money_forecasts','money_forecasts','money','forecasts'),
|
||||
('money_insurances','money_insurances','money','insurances'), ('money_wishlist','money_wishlist','money','wishlist'),
|
||||
('money_projection_settings','money_projection_settings','money','projection_settings'),
|
||||
('money_utility_settings','money_utility_settings','money','utility_settings'),
|
||||
('money_utility_readings','money_utility_readings','money','utility_readings'),
|
||||
('house_listings','house_listings','house','listings'), ('house_price_history','house_price_history','house','price_history'),
|
||||
('house_searches','house_searches','house','searches'), ('house_worker_runs','house_worker_runs','house','worker_runs'),
|
||||
('house_alert_rules','house_alert_rules','house','alert_rules'),
|
||||
('pokemon','pokemon','ref','pokemon'), ('games','game','ref','pokemon_games'),
|
||||
('pokemon_game_entries','pokemon_game_entry','ref','pokemon_game_entries'),
|
||||
('pokemon_locations','pokemon_locations','ref','pokemon_locations'), ('pokemon_encounters','pokemon_encounters','ref','pokemon_encounters'),
|
||||
('pokemon_evolutions','pokemon_evolutions','ref','pokemon_evolutions'), ('pokemon_dex_numbers','pokemon_dex_numbers','ref','pokemon_dex_numbers'),
|
||||
('pokemon_moves','pokemon_moves','ref','pokemon_moves'), ('pokemon_learnsets','pokemon_learnsets','ref','pokemon_learnsets'),
|
||||
('pokemon_research','pokemon_research','ref','pokemon_research'), ('pokemon_game_research','pokemon_game_research','ref','pokemon_game_research'),
|
||||
('tracker_games','tracker_games','ref','tracker_games'), ('tracker_items','tracker_items','ref','tracker_items'),
|
||||
('tracker_challenges','tracker_challenges','ref','tracker_challenges'),
|
||||
('user_games','user_game','games','catch_em_all_games'), ('user_entries','user_entry','games','catch_em_all_entries'),
|
||||
('catch_em_all_settings','catch_em_all_settings','games','catch_em_all_settings'),
|
||||
('pokemon_journeys','pokemon_journeys','games','pokemon_journeys'), ('pokemon_seen','pokemon_seen','games','pokemon_seen'),
|
||||
('pokemon_catches','pokemon_catches','games','pokemon_catches'), ('pokemon_milestones','pokemon_milestones','games','pokemon_milestones'),
|
||||
('tracker_progress','tracker_progress','games','tracker_progress'), ('tracker_ranks','tracker_ranks','games','tracker_ranks'),
|
||||
('tracker_weapon_ranks','tracker_weapon_ranks','games','tracker_weapon_ranks'),
|
||||
('tracker_challenge_progress','tracker_challenge_progress','games','tracker_challenge_progress'),
|
||||
('osu_entries','osu_entries','games','osu_entries'),
|
||||
('cs_market_items','cs_market_items','cs','items'), ('cs_price_observations','cs_price_observations','cs','price_observations'),
|
||||
('cs_item_dimensions','cs_item_dimensions','cs','item_dimensions'), ('cs_price_streams','cs_price_streams','cs','price_streams'),
|
||||
('animals','animals','animaldex','animals'), ('sightings','sightings','animaldex','sightings'),
|
||||
('photos','photos','animaldex','sighting_photos'),
|
||||
('blog_articles','blog_articles','site','blog_articles'), ('tasks','tasks','site','tasks'),
|
||||
('topics','topics','site','topics'), ('tasks_to_topics','tasks_to_topics','site','task_topics'),
|
||||
('lists','lists','legacy','lists'), ('list_items','list_items','legacy','list_items'),
|
||||
('list_item_completions','list_item_completions','legacy','list_item_completions');
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(tablename, ', ') INTO missing FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename NOT IN (SELECT old_name FROM legacy.reorg_table_map);
|
||||
IF missing IS NOT NULL THEN RAISE EXCEPTION 'unmapped public tables: %', missing; END IF;
|
||||
END $$;
|
||||
|
||||
-- types + shared trigger function
|
||||
ALTER TYPE public.availability SET SCHEMA ref;
|
||||
ALTER TYPE public.blog_article_status SET SCHEMA site;
|
||||
ALTER FUNCTION public.site_touch_updated_at() SET SCHEMA meta;
|
||||
ALTER FUNCTION meta.site_touch_updated_at() RENAME TO touch_updated_at;
|
||||
|
||||
-- Duplicate indexes (hand SQL + Drizzle both created one). Keep Drizzle's `_index` / constraint-backed `_key`.
|
||||
DROP INDEX public.games_source_id_unique; -- dup of constraint games_source_id_key
|
||||
DROP INDEX public.pokemon_number_unique; -- dup of constraint pokemon_number_key
|
||||
DROP INDEX public.catch_em_all_settings_user_id_unique;-- dup of constraint ..._user_id_key
|
||||
DROP INDEX public.money_accounts_user_id_idx;
|
||||
DROP INDEX public.money_forecasts_user_date_idx;
|
||||
DROP INDEX public.money_insurances_user_id_idx;
|
||||
DROP INDEX public.money_recurring_user_id_idx;
|
||||
DROP INDEX public.money_transactions_account_id_idx;
|
||||
DROP INDEX public.money_transactions_user_date_idx;
|
||||
DROP INDEX public.money_wishlist_user_id_idx;
|
||||
DROP INDEX public.lists_slug_unique; -- dup of lists_slug_key (legacy table, tidy anyway)
|
||||
|
||||
-- Move + rename tables; rename their constraints, indexes and owned sequences to the new prefix.
|
||||
CREATE FUNCTION pg_temp.reprefix(name text, old_name text, old_prefix text, new_name text) RETURNS text
|
||||
LANGUAGE sql IMMUTABLE AS $$
|
||||
SELECT CASE
|
||||
WHEN name LIKE old_name || '\_%' THEN new_name || substr(name, length(old_name) + 1)
|
||||
WHEN name LIKE old_prefix || '\_%' THEN new_name || substr(name, length(old_prefix) + 1)
|
||||
ELSE NULL END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
m record; r record; tbl regclass; newn text;
|
||||
BEGIN
|
||||
FOR m IN SELECT * FROM legacy.reorg_table_map ORDER BY old_name LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA %I', m.old_name, m.new_schema);
|
||||
IF m.old_name <> m.new_name THEN
|
||||
EXECUTE format('ALTER TABLE %I.%I RENAME TO %I', m.new_schema, m.old_name, m.new_name);
|
||||
END IF;
|
||||
tbl := format('%I.%I', m.new_schema, m.new_name)::regclass;
|
||||
-- constraints (renaming a pk/unique constraint renames its index too)
|
||||
FOR r IN SELECT conname AS n FROM pg_constraint WHERE conrelid = tbl LOOP
|
||||
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||
EXECUTE format('ALTER TABLE %s RENAME CONSTRAINT %I TO %I', tbl, r.n, newn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
-- remaining plain indexes
|
||||
FOR r IN SELECT i.relname AS n FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid
|
||||
WHERE x.indrelid = tbl
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = x.indexrelid AND c.conrelid = tbl) LOOP
|
||||
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||
EXECUTE format('ALTER INDEX %I.%I RENAME TO %I', m.new_schema, r.n, newn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
-- owned sequences
|
||||
FOR r IN SELECT s.relname AS n FROM pg_class s JOIN pg_depend d ON d.objid = s.oid AND d.deptype IN ('a','i')
|
||||
WHERE s.relkind = 'S' AND d.refobjid = tbl LOOP
|
||||
newn := pg_temp.reprefix(r.n, m.old_name, m.old_prefix, m.new_name);
|
||||
IF newn IS NOT NULL AND newn <> r.n THEN
|
||||
EXECUTE format('ALTER SEQUENCE %I.%I RENAME TO %I', m.new_schema, r.n, newn);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN
|
||||
RAISE EXCEPTION 'public still has tables';
|
||||
END IF;
|
||||
END $$;
|
||||
RESET ROLE;
|
||||
32
db/reorg/03_fixes.sql
Normal file
32
db/reorg/03_fixes.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- 03_fixes.sql: missing PKs, type fixes, CHECKs, updated_at columns.
|
||||
SET ROLE personal;
|
||||
|
||||
-- missing primary keys (promote the existing unique indexes)
|
||||
ALTER TABLE cs.item_dimensions ADD CONSTRAINT item_dimensions_pkey PRIMARY KEY USING INDEX item_dimensions_unique_idx;
|
||||
ALTER TABLE cs.price_streams ADD CONSTRAINT price_streams_pkey PRIMARY KEY USING INDEX price_streams_unique_idx;
|
||||
ALTER TABLE house.price_history ADD CONSTRAINT price_history_pkey PRIMARY KEY (listing_id, observed_at);
|
||||
DROP INDEX house.price_history_listing_idx; -- now covered by the PK
|
||||
|
||||
-- include_recurring was an int used as a boolean (app code: charts.ts `!== 0`)
|
||||
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring DROP DEFAULT;
|
||||
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring TYPE boolean USING include_recurring <> 0;
|
||||
ALTER TABLE money.projection_settings ALTER COLUMN include_recurring SET DEFAULT true;
|
||||
|
||||
-- osu numbers stored as text (0 rows in prod)
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN pp TYPE numeric USING nullif(pp, '')::numeric;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN accuracy TYPE numeric USING nullif(replace(accuracy, '%', ''), '')::numeric;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN rate DROP DEFAULT;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN rate TYPE numeric(4,2) USING nullif(rate, '')::numeric;
|
||||
ALTER TABLE games.osu_entries ALTER COLUMN rate SET DEFAULT 1.00;
|
||||
|
||||
-- CHECKs matching the values the app already enforces (src/lib/server/money-tracker.ts, money page)
|
||||
ALTER TABLE money.recurring ADD CONSTRAINT recurring_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
|
||||
ALTER TABLE money.insurances ADD CONSTRAINT insurances_cadence_check CHECK (cadence IN ('weekly','monthly','yearly'));
|
||||
ALTER TABLE money.accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('spendable','investment'));
|
||||
ALTER TABLE money.utility_settings ADD CONSTRAINT utility_settings_kind_check CHECK (kind IN ('electricity','gas','water'));
|
||||
ALTER TABLE money.utility_readings ADD CONSTRAINT utility_readings_kind_check CHECK (kind IN ('electricity','gas','water'));
|
||||
ALTER TABLE money.projection_settings ADD CONSTRAINT projection_settings_contribution_mode_check CHECK (contribution_mode IN ('excess','fixed'));
|
||||
|
||||
-- user-editable tables without updated_at
|
||||
ALTER TABLE house.alert_rules ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||
RESET ROLE;
|
||||
224
db/reorg/04_meta.sql
Normal file
224
db/reorg/04_meta.sql
Normal 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
|
||||
176
db/reorg/05_lists.sql
Normal file
176
db/reorg/05_lists.sql
Normal file
@@ -0,0 +1,176 @@
|
||||
-- 05_lists.sql: the 22 legacy lists become real tables in schema `lists`.
|
||||
-- legacy.* stays untouched (rollback reference); typo fixes + dedupes are applied to a temp working copy.
|
||||
-- Runs as superuser (inserts into personal_ddl-owned tables); tables are created via meta.create_list.
|
||||
|
||||
CREATE TEMP TABLE src ON COMMIT DROP AS
|
||||
SELECT i.id, i.list_id, btrim(i.name) AS name, nullif(btrim(i.description), '') AS notes, i.picture_url,
|
||||
c.created_at AS done_at, i.created_at, i.updated_at
|
||||
FROM legacy.list_items i
|
||||
LEFT JOIN legacy.list_item_completions c ON c.item_id = i.id;
|
||||
|
||||
CREATE TEMP TABLE fix_log (item_id integer, list_id integer, action text, before text, after text) ON COMMIT DROP;
|
||||
|
||||
-- ---- dedupes: keep the lower id, carry over done_at / picture / notes, delete the other ----
|
||||
CREATE TEMP TABLE dedupe (keep_id integer, drop_id integer, new_name text) ON COMMIT DROP;
|
||||
INSERT INTO dedupe VALUES
|
||||
(165, 323, NULL), -- places: New York x2
|
||||
(168, 331, NULL), -- places: France x2
|
||||
(156, 271, 'Advanced Programming in the UNIX Environment'), -- books: "...UNIX® Environment" dup
|
||||
(139, 287, 'Building Knowledge Graphs: A Practitioner''s Guide'); -- books: short + full title
|
||||
UPDATE src k SET done_at = coalesce(k.done_at, d.done_at), picture_url = coalesce(k.picture_url, d.picture_url),
|
||||
notes = coalesce(k.notes, d.notes), name = coalesce(x.new_name, k.name)
|
||||
FROM dedupe x JOIN src d ON d.id = x.drop_id WHERE k.id = x.keep_id;
|
||||
INSERT INTO fix_log SELECT s.id, s.list_id, 'dedupe-drop', s.name, 'merged into ' || x.keep_id FROM src s JOIN dedupe x ON x.drop_id = s.id;
|
||||
DELETE FROM src WHERE id IN (SELECT drop_id FROM dedupe);
|
||||
|
||||
-- ---- typo / naming fixes ----
|
||||
CREATE TEMP TABLE renames (id integer PRIMARY KEY, new_name text NOT NULL) ON COMMIT DROP;
|
||||
INSERT INTO renames VALUES
|
||||
-- vinyls (kept as "Artist - Album" here; split below)
|
||||
(343, 'Pearl Jam - Ten'), (346, 'Amy Winehouse - Back to Black'),
|
||||
(27, 'Slipknot - Vol. 3: (The Subliminal Verses)'), (6, 'Them Crooked Vultures - Them Crooked Vultures'),
|
||||
(39, 'Various Artists - Katamari Damacy (OST)'),
|
||||
(40, 'Gojira - The Way of All Flesh'), (370, 'Gojira - The Link'),
|
||||
(80, 'Deftones - Private Music'), (308, 'Kendrick Lamar - DAMN.'),
|
||||
-- movies/shows
|
||||
(98, 'Kim''s Convenience'), (394, 'Azuki-chan'), (111, 'A Minecraft Movie'),
|
||||
-- books
|
||||
(150, 'Domain-Driven Design: Tackling Complexity in the Heart of Software'),
|
||||
(285, 'Molecular Biology of the Cell'), (288, 'Building Microservices'),
|
||||
(147, 'Scrum: The Art of Doing Twice the Work in Half the Time'),
|
||||
-- places
|
||||
(325, 'Curaçao'),
|
||||
-- cars
|
||||
(322, 'Mazda MX-5 Miata'),
|
||||
-- leetcode
|
||||
(217, 'Two Sum'),
|
||||
-- foods
|
||||
(221, 'Sotteok Sotteok'), (230, 'Bibim Naengmyeon'), (240, 'Doenjang Jjigae'), (241, 'Kimchi Jjigae'),
|
||||
-- papers
|
||||
(190, 'Order Matters: Sequence to Sequence for Sets'),
|
||||
(200, 'GPipe: Easy Scaling with Micro-Batch Pipeline Parallelism'),
|
||||
(203, 'Attention Is All You Need'),
|
||||
(201, 'Deep Speech 2: End-to-End Speech Recognition in English and Mandarin'),
|
||||
-- games
|
||||
(385, 'Chrono Trigger');
|
||||
DO $$ BEGIN
|
||||
IF (SELECT count(*) FROM renames r JOIN src s ON s.id = r.id) <> (SELECT count(*) FROM renames) THEN
|
||||
RAISE EXCEPTION 'rename list references missing item ids'; END IF;
|
||||
END $$;
|
||||
INSERT INTO fix_log SELECT s.id, s.list_id, 'rename', s.name, r.new_name FROM src s JOIN renames r ON r.id = s.id WHERE s.name <> r.new_name;
|
||||
UPDATE src s SET name = r.new_name FROM renames r WHERE r.id = s.id;
|
||||
|
||||
-- helper: copy standard columns for one list into lists.<t> and return the row count
|
||||
CREATE FUNCTION pg_temp.copy_std(p_list integer, p_table text, p_has_done boolean) RETURNS bigint LANGUAGE plpgsql AS $$
|
||||
DECLARE n bigint;
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO lists.%I (id, name, notes, picture_url, %s sort_order, created_at, updated_at)
|
||||
SELECT id, name, notes, picture_url, %s row_number() OVER (ORDER BY id), created_at, updated_at
|
||||
FROM src WHERE list_id = %s', p_table,
|
||||
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END,
|
||||
CASE WHEN p_has_done THEN 'done_at,' ELSE '' END, p_list);
|
||||
GET DIAGNOSTICS n = ROW_COUNT;
|
||||
EXECUTE format('SELECT setval(pg_get_serial_sequence(%L, ''id''), (SELECT max(id) FROM lists.%I))', 'lists.' || p_table, p_table);
|
||||
RETURN n;
|
||||
END $$;
|
||||
|
||||
CREATE TEMP TABLE list_map (list_id integer PRIMARY KEY, tbl text NOT NULL, has_done boolean NOT NULL, cols jsonb NOT NULL) ON COMMIT DROP;
|
||||
INSERT INTO list_map VALUES
|
||||
(1, 'vinyls', true, '[{"name":"artist","ui_type":"text"}]'),
|
||||
(2, 'watchlist', true, '[{"name":"kind","ui_type":"select","options":{"values":["movie","show","anime","documentary"]}}]'),
|
||||
(3, 'books', true, '[{"name":"author","ui_type":"text"}]'),
|
||||
(4, 'side_projects', false, '[{"name":"status","ui_type":"select","default":"idea","options":{"values":["idea","active","paused","done","dropped"]}}]'),
|
||||
(5, 'artists_to_see', true, '[]'),
|
||||
(6, 'osu_maps', true, '[{"name":"mods","ui_type":"text"},{"name":"goal","ui_type":"select","options":{"values":["pass","fc"]}}]'),
|
||||
(7, 'animals_to_spot', true, '[]'),
|
||||
(8, 'vehicles_to_spot', true, '[{"name":"kind","ui_type":"select","default":"car","options":{"values":["car","train","plane"]}}]'),
|
||||
(9, 'leetcode', true, '[{"name":"kind","ui_type":"select","default":"problem","options":{"values":["problem","topic"]}},{"name":"url","ui_type":"url"}]'),
|
||||
(10, 'restaurants', true, '[{"name":"city","ui_type":"text"}]'),
|
||||
(11, 'recipes', true, '[{"name":"cuisine","ui_type":"text"},{"name":"recipe_url","ui_type":"url"}]'),
|
||||
(12, 'places', true, '[{"name":"kind","ui_type":"select","options":{"values":["country","region","city","island"]}}]'),
|
||||
(13, 'papers', false, '[{"name":"authors","ui_type":"text"},{"name":"year","ui_type":"number"},{"name":"url","ui_type":"url"}]'),
|
||||
(14, 'piano_songs', true, '[{"name":"artist","ui_type":"text"},{"name":"reference_url","ui_type":"url"}]'),
|
||||
(15, 'games_to_complete', true, '[{"name":"platform","ui_type":"text"}]'),
|
||||
(22, 'things_to_learn', true, '[]'),
|
||||
(23, 'build_from_scratch', true, '[]'),
|
||||
(24, 'tools_to_try', true, '[{"name":"url","ui_type":"url"}]'),
|
||||
(25, 'setup_upgrades', true, '[]'),
|
||||
(26, 'bucket_list', true, '[]'),
|
||||
(27, 'docs_to_read', true, '[{"name":"rfc_number","ui_type":"number"},{"name":"topic","ui_type":"text"},{"name":"starter_pack","ui_type":"bool","default":"false"},{"name":"url","ui_type":"url"}]'),
|
||||
(28, 'banger_songs', false, '[{"name":"artist","ui_type":"text"},{"name":"kind","ui_type":"select","default":"song","options":{"values":["song","playlist"]}},{"name":"url","ui_type":"url"}]');
|
||||
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM legacy.lists l WHERE NOT EXISTS (SELECT 1 FROM list_map m WHERE m.list_id = l.id))
|
||||
OR EXISTS (SELECT 1 FROM list_map m JOIN legacy.lists l ON l.id = m.list_id WHERE l.is_progress <> m.has_done AND m.list_id NOT IN (4)) THEN
|
||||
RAISE EXCEPTION 'list_map does not cover legacy.lists / has_done mismatch';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- create tables (as personal_ddl via SECURITY DEFINER) and copy standard columns
|
||||
DO $$
|
||||
DECLARE m record; l record; n bigint;
|
||||
BEGIN
|
||||
FOR m IN SELECT * FROM list_map ORDER BY list_id LOOP
|
||||
SELECT * INTO l FROM legacy.lists WHERE id = m.list_id;
|
||||
PERFORM meta.create_list(l.slug, m.tbl, l.name, m.has_done, m.cols, l.picture_url, NULL, NULL, m.list_id);
|
||||
UPDATE meta.tables SET created_at = l.created_at WHERE slug = l.slug;
|
||||
n := pg_temp.copy_std(m.list_id, m.tbl, m.has_done);
|
||||
RAISE NOTICE 'lists.%: % rows', m.tbl, n;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- parsing must not bump updated_at: suppress user triggers for these UPDATEs (superuser only)
|
||||
SET LOCAL session_replication_role = replica;
|
||||
-- ---- per-list parsing into the extra columns (only where it parses cleanly) ----
|
||||
-- vinyls: "Artist - Album"
|
||||
UPDATE lists.vinyls SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
|
||||
WHERE strpos(name, ' - ') > 0;
|
||||
-- books: "Author - Title" only for the 3 personal-name ones (others contain ':' subtitles, never ' - ')
|
||||
UPDATE lists.books SET author = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3)
|
||||
WHERE id IN (28, 29, 306);
|
||||
-- side projects: everything starts as an idea
|
||||
UPDATE lists.side_projects SET status = 'idea';
|
||||
-- osu: trailing mods
|
||||
UPDATE lists.osu_maps SET name = 'Lonely Go', mods = 'HR' WHERE id = 310;
|
||||
UPDATE lists.osu_maps SET name = 'Imagination', mods = 'HR' WHERE id = 317;
|
||||
-- vehicles: all current entries are cars
|
||||
UPDATE lists.vehicles_to_spot SET kind = 'car';
|
||||
-- leetcode
|
||||
UPDATE lists.leetcode SET kind = CASE WHEN name IN ('Two Sum','3Sum') THEN 'problem' ELSE 'topic' END;
|
||||
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/two-sum/' WHERE name = 'Two Sum';
|
||||
UPDATE lists.leetcode SET url = 'https://leetcode.com/problems/3sum/' WHERE name = '3Sum';
|
||||
-- restaurants: "Name (City)"
|
||||
UPDATE lists.restaurants SET city = substring(name from '\(([^)]+)\)\s*$'), name = btrim(regexp_replace(name, '\s*\([^)]+\)\s*$', ''))
|
||||
WHERE name ~ '\([^)]+\)\s*$';
|
||||
-- recipes
|
||||
UPDATE lists.recipes SET cuisine = CASE WHEN name = 'Yogurt, honey & cruesli' THEN NULL ELSE 'korean' END;
|
||||
-- places
|
||||
UPDATE lists.places SET kind = CASE
|
||||
WHEN name IN ('London','Seattle','Miami','Dubai','New York') THEN 'city'
|
||||
WHEN name IN ('California','Texas') THEN 'region'
|
||||
WHEN name IN ('Curaçao','Aruba','Bonaire') THEN 'island'
|
||||
ELSE 'country' END;
|
||||
-- piano: "artist - title" + URL in notes
|
||||
UPDATE lists.piano_songs SET artist = 'ryo (supercell)', name = 'odds & ends', reference_url = notes, notes = NULL
|
||||
WHERE name = 'ryo/supercell - odds & ends';
|
||||
-- tools: notes that are a bare URL become url
|
||||
UPDATE lists.tools_to_try SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
|
||||
-- docs/RFCs: "RFC n – Title", notes "Topic · starter pack"
|
||||
UPDATE lists.docs_to_read SET
|
||||
rfc_number = substring(name from '^RFC (\d+)')::integer,
|
||||
topic = btrim(split_part(notes, '·', 1)),
|
||||
starter_pack = notes LIKE '%starter pack%',
|
||||
url = 'https://www.rfc-editor.org/rfc/rfc' || substring(name from '^RFC (\d+)'),
|
||||
notes = NULL
|
||||
WHERE name ~ '^RFC \d+';
|
||||
-- banger songs: "Artist - Title" + URL notes; the 2 YT mixes are playlists
|
||||
UPDATE lists.banger_songs SET url = notes, notes = NULL WHERE notes ~ '^https?://\S+$';
|
||||
UPDATE lists.banger_songs SET artist = split_part(name, ' - ', 1), name = substr(name, strpos(name, ' - ') + 3), kind = 'song'
|
||||
WHERE strpos(name, ' - ') > 0;
|
||||
UPDATE lists.banger_songs SET kind = 'playlist' WHERE artist IS NULL;
|
||||
|
||||
SET LOCAL session_replication_role = origin;
|
||||
|
||||
-- persist the fix log for review (legacy schema, dropped with it)
|
||||
CREATE TABLE legacy.reorg_list_fixes AS SELECT * FROM fix_log;
|
||||
ALTER TABLE legacy.reorg_list_fixes OWNER TO personal; -- so `personal` can pg_dump everything
|
||||
148
db/reorg/06_hardware_energy_cs.sql
Normal file
148
db/reorg/06_hardware_energy_cs.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- 06_hardware_energy_cs.sql: bring hardware + energy + CS prices over from dev.
|
||||
-- Expects schemas import_hardware / import_energy / import_cs, staged by run.sh from dumps/dev-import-staged.dump.
|
||||
SET ROLE personal;
|
||||
|
||||
-- ---------- energy (DDL identical to ned-collector/collect.py so its IF NOT EXISTS is a no-op) ----------
|
||||
CREATE TABLE energy.gas_storage_daily (
|
||||
gas_day date NOT NULL,
|
||||
site text NOT NULL,
|
||||
point_name text,
|
||||
volume_twh numeric(12,4),
|
||||
fill_pct numeric(7,3),
|
||||
source text NOT NULL DEFAULT 'ned-json',
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (gas_day, site)
|
||||
);
|
||||
CREATE TABLE energy.ned_utilization (
|
||||
valid_from timestamptz NOT NULL, valid_to timestamptz NOT NULL,
|
||||
point text NOT NULL, type text NOT NULL, activity text NOT NULL, classification text NOT NULL, granularity text NOT NULL,
|
||||
capacity_kw numeric, volume_kwh numeric, percentage numeric, emission_kg numeric, emission_factor numeric,
|
||||
source_file text,
|
||||
first_seen timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (valid_from, point, type, activity, classification, granularity)
|
||||
);
|
||||
CREATE INDEX ned_utilization_type_time ON energy.ned_utilization (type, granularity, valid_from DESC);
|
||||
CREATE TABLE energy.collector_runs (
|
||||
id bigserial PRIMARY KEY,
|
||||
started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
mode text NOT NULL, ok boolean, rows_upserted integer, errors text
|
||||
);
|
||||
CREATE VIEW energy.gas_storage_latest AS
|
||||
SELECT DISTINCT ON (site) site, gas_day, volume_twh, fill_pct, updated_at
|
||||
FROM energy.gas_storage_daily ORDER BY site, gas_day DESC;
|
||||
CREATE VIEW energy.electricity_hourly AS
|
||||
SELECT valid_from AT TIME ZONE 'Europe/Amsterdam' AS hour_local, type, classification,
|
||||
round(volume_kwh / 1000.0, 1) AS mwh, percentage, emission_factor AS kg_co2_per_kwh
|
||||
FROM energy.ned_utilization WHERE granularity = 'Hour';
|
||||
|
||||
INSERT INTO energy.gas_storage_daily SELECT * FROM import_energy.gas_storage_daily;
|
||||
INSERT INTO energy.ned_utilization SELECT * FROM import_energy.ned_utilization;
|
||||
INSERT INTO energy.collector_runs SELECT * FROM import_energy.collector_runs;
|
||||
SELECT setval('energy.collector_runs_id_seq', coalesce((SELECT max(id) FROM energy.collector_runs), 1));
|
||||
|
||||
-- ---------- hardware (prices -> integer cents) ----------
|
||||
CREATE TABLE hardware.devices (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL UNIQUE,
|
||||
kind text NOT NULL CHECK (kind IN ('desktop','laptop','phone','vm','monitor')),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE hardware.components (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
device_id bigint NOT NULL REFERENCES hardware.devices(id) ON DELETE CASCADE,
|
||||
category text NOT NULL CHECK (category IN ('case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','monitor','lighting','accessory')),
|
||||
brand text,
|
||||
model text NOT NULL,
|
||||
quantity integer NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
specs jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
unit_price_cents integer CHECK (unit_price_cents >= 0),
|
||||
total_price_cents integer CHECK (total_price_cents >= 0),
|
||||
shop text,
|
||||
order_number text,
|
||||
order_date date,
|
||||
warranty_until date,
|
||||
invoice_number text,
|
||||
status text NOT NULL DEFAULT 'in_use' CHECK (status IN ('in_use','retired','sold')),
|
||||
verified boolean NOT NULL DEFAULT true,
|
||||
notes text,
|
||||
source text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX components_category_idx ON hardware.components (category);
|
||||
CREATE INDEX components_device_idx ON hardware.components (device_id);
|
||||
CREATE UNIQUE INDEX components_dedupe_uq ON hardware.components (device_id, model, order_number) NULLS NOT DISTINCT;
|
||||
CREATE INDEX components_specs_gin ON hardware.components USING gin (specs);
|
||||
COMMENT ON SCHEMA hardware IS 'Stan''s hardware inventory: devices (PCs, laptops, phones, VMs, monitors) and their components/peripherals with purchase + warranty details.';
|
||||
COMMENT ON TABLE hardware.devices IS 'A physical or virtual machine Stan owns/uses (e.g. stan-pc). Components hang off a device.';
|
||||
COMMENT ON TABLE hardware.components IS 'One line item (part or peripheral) belonging to a device, with purchase, price and warranty info.';
|
||||
COMMENT ON COLUMN hardware.components.unit_price_cents IS 'Price per unit in EUR cents incl. VAT.';
|
||||
COMMENT ON COLUMN hardware.components.total_price_cents IS 'unit price * quantity in EUR cents incl. VAT (excl. shipping).';
|
||||
COMMENT ON COLUMN hardware.components.verified IS 'false = purchase is real but unconfirmed whether it is (still) part of this setup, or some details are guessed. See notes.';
|
||||
COMMENT ON COLUMN hardware.components.source IS 'Where the data came from: user_list, gmail, manual.';
|
||||
|
||||
INSERT INTO hardware.devices (id, name, kind, notes, created_at, updated_at) OVERRIDING SYSTEM VALUE
|
||||
SELECT id, name, kind, notes, created_at, created_at FROM import_hardware.devices;
|
||||
INSERT INTO hardware.components (id, device_id, category, brand, model, quantity, specs, unit_price_cents, total_price_cents,
|
||||
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at)
|
||||
OVERRIDING SYSTEM VALUE
|
||||
SELECT id, device_id, category, brand, model, quantity, specs, round(unit_price_eur * 100)::integer, round(total_price_eur * 100)::integer,
|
||||
shop, order_number, order_date, warranty_until, invoice_number, status, verified, notes, source, created_at, updated_at
|
||||
FROM import_hardware.components;
|
||||
SELECT setval(pg_get_serial_sequence('hardware.devices', 'id'), (SELECT max(id) FROM hardware.devices));
|
||||
SELECT setval(pg_get_serial_sequence('hardware.components', 'id'), (SELECT max(id) FROM hardware.components));
|
||||
DO $$ BEGIN
|
||||
IF (SELECT sum(total_price_cents) FROM hardware.components) <> (SELECT round(sum(total_price_eur) * 100) FROM import_hardware.components) THEN
|
||||
RAISE EXCEPTION 'hardware cents conversion mismatch'; END IF;
|
||||
END $$;
|
||||
|
||||
CREATE VIEW hardware.setup_overview AS
|
||||
SELECT d.name AS device, d.kind AS device_kind, c.category, c.brand, c.model, c.quantity,
|
||||
c.total_price_cents, round(c.total_price_cents / 100.0, 2) AS total_price_eur,
|
||||
c.shop, c.order_date, c.warranty_until, (c.warranty_until >= CURRENT_DATE) AS under_warranty,
|
||||
c.status, c.verified, c.specs, c.notes, c.order_number, c.invoice_number, c.source, c.id AS component_id
|
||||
FROM hardware.components c JOIN hardware.devices d ON d.id = c.device_id
|
||||
ORDER BY d.name, array_position(ARRAY['case','cpu','motherboard','ram','cpu_cooler','gpu','psu','storage','fan','lighting','monitor','accessory'], c.category),
|
||||
c.order_date, c.model;
|
||||
COMMENT ON VIEW hardware.setup_overview IS 'Flat view of every component per device, build order, live under_warranty flag. Filter status = ''in_use'' for the current setup.';
|
||||
|
||||
-- ---------- CS prices (prod tables are empty; copy dev) ----------
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM cs.items) OR EXISTS (SELECT 1 FROM cs.price_observations)
|
||||
OR EXISTS (SELECT 1 FROM cs.item_dimensions) OR EXISTS (SELECT 1 FROM cs.price_streams) THEN
|
||||
RAISE EXCEPTION 'cs.* not empty in target; refusing to merge dev data'; END IF;
|
||||
END $$;
|
||||
INSERT INTO cs.items (id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at)
|
||||
SELECT id, market_hash_name, name, icon_url, item_type, first_seen_at, last_seen_at FROM import_cs.cs_market_items;
|
||||
INSERT INTO cs.item_dimensions (item_id, key, value) SELECT item_id, key, value FROM import_cs.cs_item_dimensions;
|
||||
INSERT INTO cs.price_streams (source, currency, kind) SELECT source, currency, kind FROM import_cs.cs_price_streams;
|
||||
INSERT INTO cs.price_observations (id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at) OVERRIDING SYSTEM VALUE
|
||||
SELECT id, item_id, source, currency, observed_at, price_cents, volume, kind, imported_at FROM import_cs.cs_price_observations;
|
||||
SELECT setval(pg_get_serial_sequence('cs.items', 'id'), coalesce((SELECT max(id) FROM cs.items), 1));
|
||||
SELECT setval(pg_get_serial_sequence('cs.price_observations', 'id'), coalesce((SELECT max(id) FROM cs.price_observations), 1));
|
||||
|
||||
CREATE TABLE legacy.reorg_imports (dst text PRIMARY KEY, src text NOT NULL, n bigint NOT NULL);
|
||||
-- ---------- verify imports against the staged dev copies, then drop staging ----------
|
||||
RESET ROLE;
|
||||
DO $$
|
||||
DECLARE r record; a bigint; b bigint;
|
||||
BEGIN
|
||||
FOR r IN SELECT * FROM (VALUES
|
||||
('energy.gas_storage_daily','import_energy.gas_storage_daily'), ('energy.ned_utilization','import_energy.ned_utilization'),
|
||||
('energy.collector_runs','import_energy.collector_runs'), ('hardware.devices','import_hardware.devices'),
|
||||
('hardware.components','import_hardware.components'), ('cs.items','import_cs.cs_market_items'),
|
||||
('cs.item_dimensions','import_cs.cs_item_dimensions'), ('cs.price_streams','import_cs.cs_price_streams'),
|
||||
('cs.price_observations','import_cs.cs_price_observations')) v(dst, srct) LOOP
|
||||
EXECUTE 'SELECT count(*) FROM ' || r.dst INTO a;
|
||||
EXECUTE 'SELECT count(*) FROM ' || r.srct INTO b;
|
||||
IF a <> b THEN RAISE EXCEPTION '% has % rows, dev % has %', r.dst, a, r.srct, b; END IF;
|
||||
INSERT INTO legacy.reorg_imports VALUES (r.dst, r.srct, a);
|
||||
END LOOP;
|
||||
END $$;
|
||||
DROP SCHEMA import_energy CASCADE;
|
||||
DROP SCHEMA import_hardware CASCADE;
|
||||
DROP SCHEMA import_cs CASCADE;
|
||||
79
db/reorg/07_triggers_meta_grants.sql
Normal file
79
db/reorg/07_triggers_meta_grants.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- 07_triggers_meta_grants.sql: updated_at triggers everywhere, register core tables in meta, grants.
|
||||
SET ROLE personal;
|
||||
|
||||
-- 1) updated_at trigger on every table that has the column but no trigger (27 pre-existing + new ones)
|
||||
DO $$
|
||||
DECLARE r record; n integer := 0;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT n.nspname AS s, c.relname AS t
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
|
||||
WHERE c.relkind = 'r'
|
||||
AND n.nspname IN ('auth','money','house','ref','games','cs','animaldex','site','hardware','energy')
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND NOT tg.tgisinternal
|
||||
AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure)
|
||||
LOOP
|
||||
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', r.s, r.t);
|
||||
n := n + 1;
|
||||
END LOOP;
|
||||
RAISE NOTICE 'added updated_at triggers: %', n;
|
||||
END $$;
|
||||
-- the 16 old triggers were all named site_touch_updated_at; normalise the name
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN SELECT tg.tgname, tg.tgrelid::regclass AS rel FROM pg_trigger tg WHERE tg.tgname = 'site_touch_updated_at' LOOP
|
||||
EXECUTE format('ALTER TRIGGER site_touch_updated_at ON %s RENAME TO touch_updated_at', r.rel);
|
||||
END LOOP;
|
||||
END $$;
|
||||
RESET ROLE;
|
||||
|
||||
-- 2) register core + ref tables in meta (GUI visibility); auth + bookkeeping are not exposed
|
||||
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||
SELECT n.nspname, c.relname,
|
||||
replace(n.nspname || '-' || c.relname, '_', '-'),
|
||||
initcap(replace(c.relname, '_', ' ')),
|
||||
CASE WHEN n.nspname = 'ref' THEN 'ref' ELSE 'core' END,
|
||||
n.nspname <> 'ref', false, 1000
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind IN ('r','v')
|
||||
AND n.nspname IN ('money','house','ref','games','cs','animaldex','site','hardware','energy')
|
||||
ON CONFLICT DO NOTHING;
|
||||
UPDATE meta.tables SET editable = false WHERE kind = 'core' AND (schema_name, table_name) IN
|
||||
(SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'v');
|
||||
SELECT meta.sync();
|
||||
|
||||
-- 3) grants
|
||||
GRANT USAGE ON SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_app, personal_ro;
|
||||
-- app: full DML on its own data, read-only on ref
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA ref, meta TO personal_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy, lists TO personal_app;
|
||||
-- app may edit presentation fields of the registry directly
|
||||
GRANT UPDATE (slug, display_name, description, icon, picture_url, default_sort, sort_order) ON meta.tables TO personal_app; -- slug: URL only, table name stays
|
||||
GRANT UPDATE (label, ui_type, options, position, hidden, help_text) ON meta.columns TO personal_app;
|
||||
GRANT EXECUTE ON FUNCTION meta.create_list(text, text, text, boolean, jsonb, text, text, text, integer),
|
||||
meta.add_column(text, jsonb), meta.rename_column(text, text, text), meta.drop_column(text, text),
|
||||
meta.archive_list(text), meta.drop_list(text), meta.sync(integer) TO personal_app;
|
||||
-- ro: everything readable
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy, lists TO personal_ro;
|
||||
-- future objects
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, games, cs, animaldex, site, hardware, energy
|
||||
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA ref, meta GRANT SELECT ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA auth, money, house, ref, games, cs, animaldex, site, meta, hardware, energy
|
||||
GRANT SELECT ON TABLES TO personal_ro;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal_ro;
|
||||
-- migration owner can read the runtime lists, so deploy backups (pg_dump as `personal`) cover everything
|
||||
GRANT USAGE ON SCHEMA lists TO personal;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA lists TO personal;
|
||||
GRANT SELECT ON ALL SEQUENCES IN SCHEMA lists TO personal;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON TABLES TO personal;
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE personal_ddl IN SCHEMA lists GRANT SELECT ON SEQUENCES TO personal;
|
||||
-- nobody creates in public anymore
|
||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||
68
db/reorg/08_verify.sql
Normal file
68
db/reorg/08_verify.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- 08_verify.sql: hard assertions; any failure aborts the whole transaction.
|
||||
DO $$
|
||||
DECLARE r record; a bigint; bad text := '';
|
||||
BEGIN
|
||||
-- every moved table keeps its exact row count
|
||||
-- expected = prod count before + rows imported from dev (cs.* only; prod cs was empty)
|
||||
FOR r IN SELECT m.*, b.n + coalesce(i.n, 0) AS n FROM legacy.reorg_table_map m
|
||||
JOIN legacy.reorg_counts_before b ON b.table_name = m.old_name
|
||||
LEFT JOIN legacy.reorg_imports i ON i.dst = m.new_schema || '.' || m.new_name LOOP
|
||||
EXECUTE format('SELECT count(*) FROM %I.%I', r.new_schema, r.new_name) INTO a;
|
||||
IF a <> r.n THEN bad := bad || format('%s.%s %s<>%s; ', r.new_schema, r.new_name, a, r.n); END IF;
|
||||
END LOOP;
|
||||
IF bad <> '' THEN RAISE EXCEPTION 'row count mismatch: %', bad; END IF;
|
||||
IF (SELECT count(*) FROM legacy.reorg_counts_before) <> 58 THEN RAISE EXCEPTION 'expected 58 source tables'; END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'public') THEN RAISE EXCEPTION 'public not empty'; END IF;
|
||||
END $$;
|
||||
|
||||
-- lists: every legacy item is in exactly one lists.* table, except the 4 merged duplicates; done count preserved
|
||||
DO $$
|
||||
DECLARE t record; n bigint; total bigint := 0; done bigint := 0; d bigint;
|
||||
dropped bigint := (SELECT count(*) FROM legacy.reorg_list_fixes WHERE action = 'dedupe-drop');
|
||||
BEGIN
|
||||
FOR t IN SELECT * FROM meta.tables WHERE kind = 'list' LOOP
|
||||
EXECUTE format('SELECT count(*) FROM lists.%I', t.table_name) INTO n;
|
||||
total := total + n;
|
||||
IF t.has_done THEN EXECUTE format('SELECT count(*) FROM lists.%I WHERE done_at IS NOT NULL', t.table_name) INTO d; done := done + d; END IF;
|
||||
END LOOP;
|
||||
IF total + dropped <> (SELECT count(*) FROM legacy.list_items) THEN
|
||||
RAISE EXCEPTION 'lists total % + % dropped <> legacy %', total, dropped, (SELECT count(*) FROM legacy.list_items); END IF;
|
||||
-- expected done = legacy completions minus dedupe pairs where BOTH copies were done (they collapse to one)
|
||||
d := (SELECT count(*) FROM legacy.list_item_completions)
|
||||
- (SELECT count(*) FROM dedupe x
|
||||
WHERE EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.keep_id)
|
||||
AND EXISTS (SELECT 1 FROM legacy.list_item_completions c WHERE c.item_id = x.drop_id));
|
||||
IF done <> d THEN RAISE EXCEPTION 'done count % <> expected %', done, d; END IF;
|
||||
IF (SELECT count(*) FROM meta.tables WHERE kind = 'list') <> (SELECT count(*) FROM legacy.lists) THEN
|
||||
RAISE EXCEPTION 'list count mismatch'; END IF;
|
||||
RAISE NOTICE 'lists OK: % rows (+% merged dupes), % done', total, dropped, done;
|
||||
END $$;
|
||||
|
||||
-- every table with updated_at has the trigger
|
||||
DO $$
|
||||
DECLARE missing text;
|
||||
BEGIN
|
||||
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'updated_at' AND NOT a.attisdropped
|
||||
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_trigger tg WHERE tg.tgrelid = c.oid AND tg.tgfoid = 'meta.touch_updated_at()'::regprocedure);
|
||||
IF missing IS NOT NULL THEN RAISE EXCEPTION 'missing updated_at trigger: %', missing; END IF;
|
||||
-- every table has a PK
|
||||
SELECT string_agg(n.nspname || '.' || c.relname, ', ') INTO missing
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r' AND n.nspname NOT IN ('legacy','pg_catalog','information_schema') AND n.nspname !~ '^pg_'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p');
|
||||
IF missing IS NOT NULL THEN RAISE EXCEPTION 'tables without PK: %', missing; END IF;
|
||||
END $$;
|
||||
|
||||
-- list rows keep their original timestamps
|
||||
DO $$
|
||||
DECLARE t record; n bigint; bad bigint := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT table_name FROM meta.tables WHERE kind = 'list' LOOP
|
||||
EXECUTE format('SELECT count(*) FROM lists.%I x JOIN legacy.list_items i ON i.id = x.id WHERE x.updated_at <> i.updated_at OR x.created_at <> i.created_at', t.table_name) INTO n;
|
||||
bad := bad + n;
|
||||
END LOOP;
|
||||
IF bad > 0 THEN RAISE EXCEPTION '% list rows lost their original timestamps', bad; END IF;
|
||||
END $$;
|
||||
58
db/reorg/09_drizzle_names.sql
Normal file
58
db/reorg/09_drizzle_names.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- 09_drizzle_names.sql: rename constraints/indexes to the names Drizzle generates, so future
|
||||
-- drizzle-kit migrations (which address objects by name) match the database. Pure renames, no data change.
|
||||
-- Generated from a diff of the rehearsed DB against the Drizzle baseline (2026-10-23).
|
||||
SET ROLE personal;
|
||||
ALTER TABLE cs.item_dimensions RENAME CONSTRAINT item_dimensions_item_id_cs_market_items_id_fkey TO item_dimensions_item_id_items_id_fkey;
|
||||
ALTER TABLE cs.price_observations RENAME CONSTRAINT price_observations_item_id_cs_market_items_id_fkey TO price_observations_item_id_items_id_fkey;
|
||||
ALTER TABLE games.catch_em_all_entries RENAME CONSTRAINT catch_em_all_entries_pokemon_game_entry_id_pokemon_game_entry_i TO "catch_em_all_entries_HeABXvGNiB6D_fkey";
|
||||
ALTER TABLE games.catch_em_all_games RENAME CONSTRAINT catch_em_all_games_game_id_game_id_fkey TO catch_em_all_games_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.catch_em_all_settings RENAME CONSTRAINT catch_em_all_settings_user_id_fkey TO catch_em_all_settings_user_id_user_id_fkey;
|
||||
ALTER TABLE games.osu_entries RENAME CONSTRAINT osu_entries_user_id_fkey TO osu_entries_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_game_id_fkey TO pokemon_catches_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_pokemon_id_fkey TO pokemon_catches_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_fkey TO pokemon_catches_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_catches RENAME CONSTRAINT pokemon_catches_user_id_game_id_party_slot_key TO pokemon_catches_user_id_game_id_party_slot_unique;
|
||||
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_game_id_fkey TO pokemon_journeys_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_fkey TO pokemon_journeys_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_journeys RENAME CONSTRAINT pokemon_journeys_user_id_game_id_key TO pokemon_journeys_user_id_game_id_unique;
|
||||
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_game_id_fkey TO pokemon_milestones_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_fkey TO pokemon_milestones_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_milestones RENAME CONSTRAINT pokemon_milestones_user_id_game_id_key_key TO pokemon_milestones_user_id_game_id_key_unique;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_game_id_fkey TO pokemon_seen_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_pokemon_id_fkey TO pokemon_seen_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_fkey TO pokemon_seen_user_id_user_id_fkey;
|
||||
ALTER TABLE games.pokemon_seen RENAME CONSTRAINT pokemon_seen_user_id_game_id_pokemon_id_key TO pokemon_seen_user_id_game_id_pokemon_id_unique;
|
||||
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_challenge_id_fkey TO "tracker_challenge_progress_HtzyFtOyUAhk_fkey";
|
||||
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_challenge_id_key TO tracker_challenge_progress_user_id_challenge_id_unique;
|
||||
ALTER TABLE games.tracker_challenge_progress RENAME CONSTRAINT tracker_challenge_progress_user_id_fkey TO tracker_challenge_progress_user_id_user_id_fkey;
|
||||
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_game_id_fkey TO tracker_ranks_game_id_tracker_games_id_fkey;
|
||||
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_fkey TO tracker_ranks_user_id_user_id_fkey;
|
||||
ALTER TABLE games.tracker_ranks RENAME CONSTRAINT tracker_ranks_user_id_game_id_key TO tracker_ranks_user_id_game_id_unique;
|
||||
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_item_id_fkey TO tracker_weapon_ranks_item_id_tracker_items_id_fkey;
|
||||
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_fkey TO tracker_weapon_ranks_user_id_user_id_fkey;
|
||||
ALTER TABLE games.tracker_weapon_ranks RENAME CONSTRAINT tracker_weapon_ranks_user_id_item_id_key TO tracker_weapon_ranks_user_id_item_id_unique;
|
||||
ALTER TABLE house.price_history RENAME CONSTRAINT price_history_listing_id_fkey TO price_history_listing_id_listings_id_fkey;
|
||||
ALTER TABLE meta.columns RENAME CONSTRAINT columns_table_id_fkey TO columns_table_id_tables_id_fkey;
|
||||
ALTER TABLE money.forecasts RENAME CONSTRAINT forecasts_account_id_money_accounts_id_fkey TO forecasts_account_id_accounts_id_fkey;
|
||||
ALTER TABLE money.projection_settings RENAME CONSTRAINT "projection_settings_fqvzThdinM8E_fkey" TO projection_settings_investment_account_id_accounts_id_fkey;
|
||||
ALTER TABLE money.recurring RENAME CONSTRAINT recurring_account_id_money_accounts_id_fkey TO recurring_account_id_accounts_id_fkey;
|
||||
ALTER TABLE money.transactions RENAME CONSTRAINT transactions_account_id_money_accounts_id_fkey TO transactions_account_id_accounts_id_fkey;
|
||||
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_game_id_fkey TO pokemon_dex_numbers_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_fkey TO pokemon_dex_numbers_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE ref.pokemon_dex_numbers RENAME CONSTRAINT pokemon_dex_numbers_pokemon_id_game_id_dex_key TO pokemon_dex_numbers_pokemon_id_game_id_dex_unique;
|
||||
ALTER TABLE ref.pokemon_encounters RENAME CONSTRAINT pokemon_encounters_game_id_games_id_fkey TO pokemon_encounters_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_evolutions RENAME CONSTRAINT pokemon_evolutions_game_id_games_id_fkey TO pokemon_evolutions_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_game_entries RENAME CONSTRAINT pokemon_game_entries_game_id_game_id_fkey TO pokemon_game_entries_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_game_research RENAME CONSTRAINT pokemon_game_research_game_id_fkey TO pokemon_game_research_game_id_pokemon_games_id_fkey;
|
||||
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_fkey TO pokemon_learnsets_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE ref.pokemon_learnsets RENAME CONSTRAINT pokemon_learnsets_pokemon_id_version_group_key TO pokemon_learnsets_pokemon_id_version_group_unique;
|
||||
ALTER TABLE ref.pokemon_research RENAME CONSTRAINT pokemon_research_pokemon_id_fkey TO pokemon_research_pokemon_id_pokemon_id_fkey;
|
||||
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_fkey TO tracker_challenges_game_id_tracker_games_id_fkey;
|
||||
ALTER TABLE ref.tracker_challenges RENAME CONSTRAINT tracker_challenges_game_id_key_key TO tracker_challenges_game_id_key_unique;
|
||||
ALTER TABLE site.blog_articles RENAME CONSTRAINT blog_articles_slug_unique TO blog_articles_slug_key;
|
||||
ALTER INDEX games.osu_entries_owner_idx RENAME TO osu_entries_user_id_kind_index;
|
||||
ALTER INDEX games.pokemon_catches_owner_idx RENAME TO pokemon_catches_user_id_game_id_pokemon_id_index;
|
||||
ALTER INDEX games.pokemon_seen_user_idx RENAME TO pokemon_seen_user_id_index;
|
||||
ALTER INDEX money.utility_readings_user_kind_date_idx RENAME TO utility_readings_user_id_kind_date_index;
|
||||
ALTER INDEX ref.pokemon_dex_game_idx RENAME TO pokemon_dex_numbers_game_id_index;
|
||||
RESET ROLE;
|
||||
21
db/reorg/README.md
Normal file
21
db/reorg/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# One-time schema reorg (2026-10)
|
||||
|
||||
Hand-written, reviewed SQL that moved the old flat `public.*` tables into the
|
||||
domain schemas (`auth`, `money`, `house`, `ref`, `games`, `cs`, `animaldex`,
|
||||
`site`, `meta`, `lists`, `hardware`, `energy`, `legacy`), created the roles
|
||||
(`personal`, `personal_app`, `personal_ddl`, `personal_ro`), the `meta.*`
|
||||
registry/API and the 22 runtime lists. It is the state that the Drizzle baseline
|
||||
in `drizzle/` describes.
|
||||
|
||||
These files are kept for the record and for the cutover only. They were
|
||||
rehearsed on a copy of prod (PG17) and run in one transaction (`run.sh`,
|
||||
which refuses the real database names unless `ALLOW_REAL=yes`). After the
|
||||
cutover, record the baseline with `bun scripts/db-migrate.ts --init`; from then
|
||||
on every schema change is a normal Drizzle migration (`bun run db:generate`).
|
||||
|
||||
Passwords are never in these files; set them with `\password` in psql.
|
||||
|
||||
`09_drizzle_names.sql` runs just before `08_verify.sql` and only renames 48
|
||||
constraints and 5 indexes to Drizzle's generated names, so a catalog diff of the
|
||||
reorged DB against a DB built from the baseline is empty (columns, defaults,
|
||||
constraints, indexes, enums in the nine Drizzle schemas).
|
||||
24
db/reorg/run.sh
Executable file
24
db/reorg/run.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# run.sh <target-db> : stage the dev import, then apply 01..08 in ONE transaction as superuser.
|
||||
# Rehearsal: runs against the private PG17 cluster in ~/dev/setup/db/rehearsal (port 5441, unix socket only).
|
||||
# Prod: same files, but psql = `sudo docker exec -i <pg-container> psql -U postgres -d personal` (see PLAN.md §4c).
|
||||
set -euo pipefail
|
||||
DB=${1:?target db}
|
||||
case "$DB" in personal|personal_dev|personal_staging) [ "${ALLOW_REAL:-}" = yes ] || { echo "refusing $DB without ALLOW_REAL=yes"; exit 3; };; esac
|
||||
D=~/dev/setup/db
|
||||
export LD_LIBRARY_PATH=$D/tools/root/usr/lib/x86_64-linux-gnu
|
||||
BIN=$D/tools/root/usr/lib/postgresql/17/bin
|
||||
CONN=(-h "${PGSOCK:-$D/rehearsal/sock}" -p "${PGPORT:-5441}" -U postgres)
|
||||
STAGED=${STAGED:-$D/dumps/dev-import-staged.dump}
|
||||
|
||||
# 1) stage dev data as import_* schemas (owned by personal so 06 can read it)
|
||||
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 -qc "DROP SCHEMA IF EXISTS import_energy, import_hardware, import_cs CASCADE"
|
||||
$BIN/pg_restore "${CONN[@]}" -d "$DB" --no-owner --role=personal --single-transaction --exit-on-error "$STAGED"
|
||||
|
||||
# 2) the migration, all-or-nothing
|
||||
cd "$D/migration"
|
||||
$BIN/psql "${CONN[@]}" -d "$DB" -v ON_ERROR_STOP=1 --single-transaction \
|
||||
-c "SET lock_timeout = '5s'" -c "SET statement_timeout = '10min'" \
|
||||
-f 01_roles.sql -f 02_schemas_move.sql -f 03_fixes.sql -f 04_meta.sql -f 05_lists.sql \
|
||||
-f 06_hardware_energy_cs.sql -f 07_triggers_meta_grants.sql -f 09_drizzle_names.sql -f 08_verify.sql
|
||||
echo "migration committed on $DB"
|
||||
Reference in New Issue
Block a user