Skip to content

Commit 6ac4e3b

Browse files
authored
Merge pull request #745 from SprocketBot/codex/mledb-sprocket-migration
[codex] Add mledb to sprocket migration guardrails
2 parents f0abc12 + 1094d60 commit 6ac4e3b

9 files changed

Lines changed: 1260 additions & 0 deletions
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
\set ON_ERROR_STOP on
2+
3+
CREATE SCHEMA IF NOT EXISTS migration_guard;
4+
5+
CREATE TABLE IF NOT EXISTS migration_guard.runs (
6+
run_id uuid PRIMARY KEY,
7+
description text NOT NULL DEFAULT '',
8+
git_sha text,
9+
started_at timestamptz NOT NULL DEFAULT now(),
10+
completed_at timestamptz,
11+
status text NOT NULL DEFAULT 'baseline-captured',
12+
notes text
13+
);
14+
15+
CREATE TABLE IF NOT EXISTS migration_guard.sprocket_table_snapshots (
16+
run_id uuid NOT NULL REFERENCES migration_guard.runs(run_id),
17+
schema_name text NOT NULL,
18+
table_name text NOT NULL,
19+
row_count bigint NOT NULL,
20+
table_hash text NOT NULL,
21+
has_primary_key boolean NOT NULL,
22+
captured_at timestamptz NOT NULL DEFAULT now(),
23+
PRIMARY KEY (run_id, schema_name, table_name)
24+
);
25+
26+
CREATE TABLE IF NOT EXISTS migration_guard.sprocket_row_snapshots (
27+
run_id uuid NOT NULL REFERENCES migration_guard.runs(run_id),
28+
schema_name text NOT NULL,
29+
table_name text NOT NULL,
30+
primary_key jsonb NOT NULL,
31+
row_hash text NOT NULL,
32+
captured_at timestamptz NOT NULL DEFAULT now(),
33+
PRIMARY KEY (run_id, schema_name, table_name, primary_key)
34+
);
35+
36+
CREATE OR REPLACE FUNCTION migration_guard.capture_sprocket_baseline(
37+
p_run_id uuid,
38+
p_description text DEFAULT '',
39+
p_git_sha text DEFAULT NULL
40+
)
41+
RETURNS void
42+
LANGUAGE plpgsql
43+
AS $$
44+
DECLARE
45+
tbl record;
46+
pk_expr text;
47+
pk_cols int;
48+
table_hash text;
49+
row_count bigint;
50+
BEGIN
51+
INSERT INTO migration_guard.runs(run_id, description, git_sha)
52+
VALUES (p_run_id, p_description, p_git_sha)
53+
ON CONFLICT (run_id) DO UPDATE
54+
SET description = EXCLUDED.description,
55+
git_sha = EXCLUDED.git_sha,
56+
started_at = now(),
57+
completed_at = NULL,
58+
status = 'baseline-captured',
59+
notes = NULL;
60+
61+
DELETE FROM migration_guard.sprocket_row_snapshots WHERE run_id = p_run_id;
62+
DELETE FROM migration_guard.sprocket_table_snapshots WHERE run_id = p_run_id;
63+
64+
FOR tbl IN
65+
SELECT c.oid AS relation_oid, n.nspname AS schema_name, c.relname AS table_name
66+
FROM pg_class c
67+
JOIN pg_namespace n ON n.oid = c.relnamespace
68+
WHERE n.nspname = 'sprocket'
69+
AND c.relkind IN ('r', 'p')
70+
ORDER BY n.nspname, c.relname
71+
LOOP
72+
EXECUTE format(
73+
'SELECT count(*)::bigint, md5(coalesce(string_agg(to_jsonb(t)::text, E''\n'' ORDER BY to_jsonb(t)::text), '''')) FROM %I.%I t',
74+
tbl.schema_name,
75+
tbl.table_name
76+
)
77+
INTO row_count, table_hash;
78+
79+
SELECT count(*),
80+
string_agg(
81+
format('%L, t.%I', a.attname, a.attname),
82+
', ' ORDER BY k.ordinality
83+
)
84+
INTO pk_cols, pk_expr
85+
FROM pg_index i
86+
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON true
87+
JOIN pg_attribute a
88+
ON a.attrelid = i.indrelid
89+
AND a.attnum = k.attnum
90+
WHERE i.indrelid = tbl.relation_oid
91+
AND i.indisprimary;
92+
93+
INSERT INTO migration_guard.sprocket_table_snapshots(
94+
run_id,
95+
schema_name,
96+
table_name,
97+
row_count,
98+
table_hash,
99+
has_primary_key
100+
)
101+
VALUES (
102+
p_run_id,
103+
tbl.schema_name,
104+
tbl.table_name,
105+
row_count,
106+
table_hash,
107+
pk_cols > 0
108+
);
109+
110+
IF pk_cols > 0 THEN
111+
EXECUTE format(
112+
'INSERT INTO migration_guard.sprocket_row_snapshots(run_id, schema_name, table_name, primary_key, row_hash)
113+
SELECT $1, %L, %L, jsonb_build_object(%s), md5(to_jsonb(t)::text)
114+
FROM %I.%I t',
115+
tbl.schema_name,
116+
tbl.table_name,
117+
pk_expr,
118+
tbl.schema_name,
119+
tbl.table_name
120+
)
121+
USING p_run_id;
122+
END IF;
123+
END LOOP;
124+
END;
125+
$$;
126+
127+
CREATE OR REPLACE FUNCTION migration_guard.validate_sprocket_baseline(p_run_id uuid)
128+
RETURNS TABLE (
129+
issue_type text,
130+
schema_name text,
131+
table_name text,
132+
primary_key jsonb,
133+
before_hash text,
134+
after_hash text,
135+
detail text
136+
)
137+
LANGUAGE plpgsql
138+
AS $$
139+
DECLARE
140+
tbl record;
141+
pk_expr text;
142+
BEGIN
143+
FOR tbl IN
144+
SELECT s.schema_name,
145+
s.table_name,
146+
c.oid AS relation_oid,
147+
s.has_primary_key,
148+
s.row_count,
149+
s.table_hash
150+
FROM migration_guard.sprocket_table_snapshots s
151+
JOIN pg_namespace n ON n.nspname = s.schema_name
152+
JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = s.table_name
153+
WHERE s.run_id = p_run_id
154+
ORDER BY s.schema_name, s.table_name
155+
LOOP
156+
IF tbl.has_primary_key THEN
157+
SELECT string_agg(
158+
format('%L, t.%I', a.attname, a.attname),
159+
', ' ORDER BY k.ordinality
160+
)
161+
INTO pk_expr
162+
FROM pg_index i
163+
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON true
164+
JOIN pg_attribute a
165+
ON a.attrelid = i.indrelid
166+
AND a.attnum = k.attnum
167+
WHERE i.indrelid = tbl.relation_oid
168+
AND i.indisprimary;
169+
170+
RETURN QUERY EXECUTE format(
171+
'WITH current_rows AS (
172+
SELECT jsonb_build_object(%s) AS primary_key, md5(to_jsonb(t)::text) AS row_hash
173+
FROM %I.%I t
174+
)
175+
SELECT ''deleted_preexisting_sprocket_row''::text,
176+
s.schema_name,
177+
s.table_name,
178+
s.primary_key,
179+
s.row_hash,
180+
NULL::text,
181+
''row existed before migration and is now missing''::text
182+
FROM migration_guard.sprocket_row_snapshots s
183+
LEFT JOIN current_rows c ON c.primary_key = s.primary_key
184+
WHERE s.run_id = $1
185+
AND s.schema_name = %L
186+
AND s.table_name = %L
187+
AND c.primary_key IS NULL
188+
UNION ALL
189+
SELECT ''changed_preexisting_sprocket_row''::text,
190+
s.schema_name,
191+
s.table_name,
192+
s.primary_key,
193+
s.row_hash,
194+
c.row_hash,
195+
''row existed before migration and its JSON hash changed''::text
196+
FROM migration_guard.sprocket_row_snapshots s
197+
JOIN current_rows c ON c.primary_key = s.primary_key
198+
WHERE s.run_id = $1
199+
AND s.schema_name = %L
200+
AND s.table_name = %L
201+
AND c.row_hash <> s.row_hash',
202+
pk_expr,
203+
tbl.schema_name,
204+
tbl.table_name,
205+
tbl.schema_name,
206+
tbl.table_name,
207+
tbl.schema_name,
208+
tbl.table_name
209+
)
210+
USING p_run_id;
211+
ELSE
212+
RETURN QUERY EXECUTE format(
213+
'WITH current_table AS (
214+
SELECT count(*)::bigint AS row_count,
215+
md5(coalesce(string_agg(to_jsonb(t)::text, E''\n'' ORDER BY to_jsonb(t)::text), '''')) AS table_hash
216+
FROM %I.%I t
217+
)
218+
SELECT ''changed_sprocket_table_without_pk''::text,
219+
%L::text,
220+
%L::text,
221+
NULL::jsonb,
222+
$2::text,
223+
c.table_hash,
224+
format(''table has no primary key; baseline count/hash was %%s/%%s and current count/hash is %%s/%%s'', $1, $2, c.row_count, c.table_hash)::text
225+
FROM current_table c
226+
WHERE c.row_count <> $1 OR c.table_hash <> $2',
227+
tbl.schema_name,
228+
tbl.table_name,
229+
tbl.schema_name,
230+
tbl.table_name
231+
)
232+
USING tbl.row_count, tbl.table_hash;
233+
END IF;
234+
END LOOP;
235+
END;
236+
$$;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
\set ON_ERROR_STOP on
2+
3+
CREATE TEMP TABLE migration_preflight_issues (
4+
severity text NOT NULL,
5+
check_name text NOT NULL,
6+
detail text NOT NULL
7+
);
8+
9+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
10+
SELECT 'blocker', 'required_schema', format('missing schema %s', schema_name)
11+
FROM (VALUES ('mledb'), ('sprocket'), ('mledb_bridge')) AS required(schema_name)
12+
WHERE NOT EXISTS (
13+
SELECT 1
14+
FROM information_schema.schemata s
15+
WHERE s.schema_name = required.schema_name
16+
);
17+
18+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
19+
SELECT 'blocker', 'required_table', format('missing table %s.%s', schema_name, table_name)
20+
FROM (
21+
VALUES
22+
('mledb', 'division'),
23+
('mledb', 'team'),
24+
('mledb', 'team_branding'),
25+
('mledb', 'player'),
26+
('mledb', 'season'),
27+
('mledb', 'match'),
28+
('mledb', 'fixture'),
29+
('mledb', 'series'),
30+
('sprocket', 'organization'),
31+
('sprocket', 'organization_profile'),
32+
('sprocket', 'game'),
33+
('sprocket', 'game_mode'),
34+
('sprocket', 'franchise'),
35+
('sprocket', 'franchise_profile'),
36+
('sprocket', 'game_skill_group'),
37+
('sprocket', 'team'),
38+
('sprocket', 'user'),
39+
('sprocket', 'user_profile'),
40+
('sprocket', 'user_authentication_account'),
41+
('sprocket', 'member'),
42+
('sprocket', 'member_profile'),
43+
('sprocket', 'player'),
44+
('sprocket', 'schedule_group'),
45+
('sprocket', 'schedule_fixture'),
46+
('sprocket', 'match_parent'),
47+
('sprocket', 'match'),
48+
('mledb_bridge', 'division_to_franchise_group'),
49+
('mledb_bridge', 'team_to_franchise'),
50+
('mledb_bridge', 'league_to_skill_group'),
51+
('mledb_bridge', 'player_to_user'),
52+
('mledb_bridge', 'player_to_player'),
53+
('mledb_bridge', 'season_to_schedule_group'),
54+
('mledb_bridge', 'match_to_schedule_group'),
55+
('mledb_bridge', 'fixture_to_fixture'),
56+
('mledb_bridge', 'series_to_match_parent')
57+
) AS required(schema_name, table_name)
58+
WHERE NOT EXISTS (
59+
SELECT 1
60+
FROM information_schema.tables t
61+
WHERE t.table_schema = required.schema_name
62+
AND t.table_name = required.table_name
63+
);
64+
65+
TABLE migration_preflight_issues
66+
ORDER BY CASE severity WHEN 'blocker' THEN 0 ELSE 1 END, check_name, detail;
67+
68+
DO $$
69+
DECLARE
70+
structural_blocker_count int;
71+
BEGIN
72+
SELECT count(*) INTO structural_blocker_count
73+
FROM migration_preflight_issues
74+
WHERE severity = 'blocker'
75+
AND check_name IN ('required_schema', 'required_table');
76+
77+
IF structural_blocker_count > 0 THEN
78+
RAISE EXCEPTION 'mledb -> sprocket preflight found % structural blocker(s)', structural_blocker_count;
79+
END IF;
80+
END;
81+
$$;
82+
83+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
84+
SELECT 'blocker', 'duplicate_discord_id', format('discord_id %s appears %s times in mledb.player', discord_id, count(*))
85+
FROM mledb.player
86+
WHERE nullif(discord_id, '') IS NOT NULL
87+
GROUP BY discord_id
88+
HAVING count(*) > 1;
89+
90+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
91+
SELECT 'blocker', 'duplicate_player_name', format('player name %s appears %s times in mledb.player', name, count(*))
92+
FROM mledb.player
93+
GROUP BY name
94+
HAVING count(*) > 1;
95+
96+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
97+
SELECT 'blocker', 'fixture_missing_team_bridge_source', format('fixture %s references missing team home=%s away=%s', f.id, f.home_name, f.away_name)
98+
FROM mledb.fixture f
99+
LEFT JOIN mledb.team ht ON ht.name = f.home_name
100+
LEFT JOIN mledb.team at ON at.name = f.away_name
101+
WHERE ht.name IS NULL OR at.name IS NULL;
102+
103+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
104+
SELECT 'blocker', 'series_unknown_league', format('series %s has unsupported league %s', id, league)
105+
FROM mledb.series
106+
WHERE fixture_id IS NOT NULL
107+
AND league NOT IN ('FOUNDATION', 'ACADEMY', 'CHAMPION', 'MASTER', 'PREMIER');
108+
109+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
110+
SELECT 'blocker', 'series_unknown_mode', format('series %s has unsupported mode %s', id, mode)
111+
FROM mledb.series
112+
WHERE fixture_id IS NOT NULL
113+
AND mode NOT IN ('DOUBLES', 'STANDARD');
114+
115+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
116+
SELECT 'blocker', 'player_unknown_league', format('player %s (%s) has unsupported league %s', id, name, league)
117+
FROM mledb.player
118+
WHERE league NOT IN ('FOUNDATION', 'ACADEMY', 'CHAMPION', 'MASTER', 'PREMIER', 'UNKNOWN');
119+
120+
INSERT INTO migration_preflight_issues(severity, check_name, detail)
121+
SELECT 'warning', 'bridge_table_not_empty', format('%s.%s already contains %s rows', table_schema, table_name, row_count)
122+
FROM (
123+
SELECT 'mledb_bridge'::text AS table_schema, 'division_to_franchise_group'::text AS table_name, count(*)::bigint AS row_count FROM mledb_bridge.division_to_franchise_group
124+
UNION ALL SELECT 'mledb_bridge', 'team_to_franchise', count(*) FROM mledb_bridge.team_to_franchise
125+
UNION ALL SELECT 'mledb_bridge', 'league_to_skill_group', count(*) FROM mledb_bridge.league_to_skill_group
126+
UNION ALL SELECT 'mledb_bridge', 'player_to_user', count(*) FROM mledb_bridge.player_to_user
127+
UNION ALL SELECT 'mledb_bridge', 'player_to_player', count(*) FROM mledb_bridge.player_to_player
128+
UNION ALL SELECT 'mledb_bridge', 'season_to_schedule_group', count(*) FROM mledb_bridge.season_to_schedule_group
129+
UNION ALL SELECT 'mledb_bridge', 'match_to_schedule_group', count(*) FROM mledb_bridge.match_to_schedule_group
130+
UNION ALL SELECT 'mledb_bridge', 'fixture_to_fixture', count(*) FROM mledb_bridge.fixture_to_fixture
131+
UNION ALL SELECT 'mledb_bridge', 'series_to_match_parent', count(*) FROM mledb_bridge.series_to_match_parent
132+
) bridge_counts
133+
WHERE row_count > 0;
134+
135+
TABLE migration_preflight_issues
136+
ORDER BY CASE severity WHEN 'blocker' THEN 0 ELSE 1 END, check_name, detail;
137+
138+
DO $$
139+
DECLARE
140+
blocker_count int;
141+
BEGIN
142+
SELECT count(*) INTO blocker_count
143+
FROM migration_preflight_issues
144+
WHERE severity = 'blocker';
145+
146+
IF blocker_count > 0 THEN
147+
RAISE EXCEPTION 'mledb -> sprocket preflight found % blocker(s)', blocker_count;
148+
END IF;
149+
END;
150+
$$;

0 commit comments

Comments
 (0)