Skip to content

Commit a9ad2ea

Browse files
committed
chore(db): drop the legacy folder tables and adopt the deferred folder_id FKs
Contract migration for the generic-folders cutover. Reconciles any stranded legacy folder one last time, NULLs folder_ids that resolve nowhere, adopts the two folder_id foreign keys 0272 deliberately deferred, then drops workflow_folder and workspace_file_folders.
1 parent 6ff6255 commit a9ad2ea

5 files changed

Lines changed: 18090 additions & 141 deletions

File tree

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
-- Contract migration for the generic-folders cutover: adopt the deferred `folder_id` foreign
2+
-- keys and drop the two legacy folder tables.
3+
--
4+
-- Ordering is deliberate and each step depends on the one before it:
5+
-- 1. final insert-only reconcile, so no legacy folder is lost by the DROP;
6+
-- 2. NULL any `folder_id` that still does not resolve, so the FK can be validated;
7+
-- 3. adopt the FKs the expand migration deliberately left off;
8+
-- 4. drop the legacy tables.
9+
--
10+
-- Preconditions verified read-only against production before writing this file: 0 stranded
11+
-- rows in either tree, 0 unresolvable `folder_id`s, and a FULL-ROW comparison (name, parent,
12+
-- deleted/archived state, workspace, user, sort order, locked) clean on both trees. The only
13+
-- divergence is 47 workflow-folder names, all matching `<old name> (N)` — 0272's deliberate
14+
-- dedup renames. That is precisely why step 1 is INSERT-ONLY: an upsert would revert all 47.
15+
--
16+
-- In production step 1 is therefore a no-op. It exists for deployments that never ran the
17+
-- post-drain reconcile as an operational step — self-hosted upgrades above all, where a
18+
-- rolling restart can strand a folder exactly the same way and no operator is watching for it.
19+
-- This is the last moment the legacy rows exist, so it is the last chance to rescue them.
20+
21+
-- Step 1 — final reconcile. Guarded on table existence so a replay after the DROP is a no-op
22+
-- rather than an error, and written as DO blocks so each tree is a single atomic statement
23+
-- (0272 ends with an embedded COMMIT, so this file is not guaranteed to run inside drizzle's
24+
-- batch transaction).
25+
DO $$
26+
BEGIN
27+
IF to_regclass('public.workflow_folder') IS NULL THEN
28+
RETURN;
29+
END IF;
30+
31+
INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
32+
WITH stranded AS (
33+
SELECT l.id, l.name, l.user_id, l.workspace_id, l.parent_id, l.locked, l.sort_order,
34+
l.created_at, l.updated_at, l.archived_at AS deleted_at
35+
FROM "workflow_folder" l
36+
WHERE NOT EXISTS (
37+
SELECT 1 FROM "folder" f WHERE f.id = l.id AND f.resource_type = 'workflow'
38+
)
39+
),
40+
-- A parent that survives in neither table cannot be referenced, so re-root to the
41+
-- workspace root rather than fail the FK. Losing one level of nesting beats losing the
42+
-- folder and stranding every workflow inside it.
43+
resolved AS (
44+
SELECT s.*,
45+
CASE
46+
WHEN s.parent_id IS NULL THEN NULL
47+
WHEN EXISTS (SELECT 1 FROM "folder" f WHERE f.id = s.parent_id AND f.resource_type = 'workflow') THEN s.parent_id
48+
WHEN EXISTS (SELECT 1 FROM stranded s2 WHERE s2.id = s.parent_id) THEN s.parent_id
49+
ELSE NULL
50+
END AS resolved_parent
51+
FROM stranded s
52+
),
53+
-- Only ACTIVE rows contend for the partial unique index, so soft-deleted rows keep their
54+
-- name verbatim. `base_taken` asks whether an already-present active sibling holds the
55+
-- name; `rn` orders contenders within this batch. Together they give a per-row offset into
56+
-- the free-suffix sequence that starts at (1) when the base name is taken and preserves the
57+
-- unsuffixed name for the first claimant when it is not.
58+
ranked AS (
59+
SELECT r.*,
60+
EXISTS (
61+
SELECT 1 FROM "folder" a
62+
WHERE a.workspace_id = r.workspace_id
63+
AND a.resource_type = 'workflow'
64+
AND coalesce(a.parent_id, '') = coalesce(r.resolved_parent, '')
65+
AND a.name = r.name
66+
AND a.deleted_at IS NULL
67+
) AS base_taken,
68+
row_number() OVER (
69+
PARTITION BY r.workspace_id, coalesce(r.resolved_parent, ''), r.name
70+
ORDER BY r.created_at, r.id
71+
) AS rn
72+
FROM resolved r
73+
WHERE r.deleted_at IS NULL
74+
),
75+
named AS (
76+
SELECT k.id,
77+
CASE
78+
WHEN k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END) < 0 THEN k.name
79+
ELSE (
80+
SELECT k.name || ' (' || candidate.n || ')'
81+
FROM generate_series(1, 10000) AS candidate(n)
82+
WHERE NOT EXISTS (
83+
SELECT 1 FROM "folder" a
84+
WHERE a.workspace_id = k.workspace_id
85+
AND a.resource_type = 'workflow'
86+
AND coalesce(a.parent_id, '') = coalesce(k.resolved_parent, '')
87+
AND a.name = k.name || ' (' || candidate.n || ')'
88+
AND a.deleted_at IS NULL
89+
)
90+
ORDER BY candidate.n
91+
OFFSET (k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END))
92+
LIMIT 1
93+
)
94+
END AS final_name
95+
FROM ranked k
96+
)
97+
SELECT r.id, 'workflow', coalesce(n.final_name, r.name),
98+
r.user_id, r.workspace_id, r.resolved_parent, r.locked, r.sort_order,
99+
r.created_at, r.updated_at, r.deleted_at
100+
FROM resolved r
101+
LEFT JOIN named n ON n.id = r.id;
102+
END $$;
103+
--> statement-breakpoint
104+
105+
DO $$
106+
BEGIN
107+
IF to_regclass('public.workspace_file_folders') IS NULL THEN
108+
RETURN;
109+
END IF;
110+
111+
INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
112+
WITH stranded AS (
113+
SELECT l.id, l.name, l.user_id, l.workspace_id, l.parent_id, l.sort_order,
114+
l.created_at, l.updated_at, l.deleted_at
115+
FROM "workspace_file_folders" l
116+
WHERE NOT EXISTS (
117+
SELECT 1 FROM "folder" f WHERE f.id = l.id AND f.resource_type = 'file'
118+
)
119+
),
120+
resolved AS (
121+
SELECT s.*,
122+
CASE
123+
WHEN s.parent_id IS NULL THEN NULL
124+
WHEN EXISTS (SELECT 1 FROM "folder" f WHERE f.id = s.parent_id AND f.resource_type = 'file') THEN s.parent_id
125+
WHEN EXISTS (SELECT 1 FROM stranded s2 WHERE s2.id = s.parent_id) THEN s.parent_id
126+
ELSE NULL
127+
END AS resolved_parent
128+
FROM stranded s
129+
),
130+
ranked AS (
131+
SELECT r.*,
132+
EXISTS (
133+
SELECT 1 FROM "folder" a
134+
WHERE a.workspace_id = r.workspace_id
135+
AND a.resource_type = 'file'
136+
AND coalesce(a.parent_id, '') = coalesce(r.resolved_parent, '')
137+
AND a.name = r.name
138+
AND a.deleted_at IS NULL
139+
) AS base_taken,
140+
row_number() OVER (
141+
PARTITION BY r.workspace_id, coalesce(r.resolved_parent, ''), r.name
142+
ORDER BY r.created_at, r.id
143+
) AS rn
144+
FROM resolved r
145+
WHERE r.deleted_at IS NULL
146+
),
147+
named AS (
148+
SELECT k.id,
149+
CASE
150+
WHEN k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END) < 0 THEN k.name
151+
ELSE (
152+
SELECT k.name || ' (' || candidate.n || ')'
153+
FROM generate_series(1, 10000) AS candidate(n)
154+
WHERE NOT EXISTS (
155+
SELECT 1 FROM "folder" a
156+
WHERE a.workspace_id = k.workspace_id
157+
AND a.resource_type = 'file'
158+
AND coalesce(a.parent_id, '') = coalesce(k.resolved_parent, '')
159+
AND a.name = k.name || ' (' || candidate.n || ')'
160+
AND a.deleted_at IS NULL
161+
)
162+
ORDER BY candidate.n
163+
OFFSET (k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END))
164+
LIMIT 1
165+
)
166+
END AS final_name
167+
FROM ranked k
168+
)
169+
SELECT r.id, 'file', coalesce(n.final_name, r.name),
170+
r.user_id, r.workspace_id, r.resolved_parent, false, r.sort_order,
171+
r.created_at, r.updated_at, r.deleted_at
172+
FROM resolved r
173+
LEFT JOIN named n ON n.id = r.id;
174+
END $$;
175+
--> statement-breakpoint
176+
177+
-- Step 2 — a `folder_id` can only still dangle if its folder is absent from BOTH tables, which
178+
-- step 1 cannot rescue. NULL it so the resource stays reachable at the workspace root instead
179+
-- of blocking validation. Verified 0 rows in production.
180+
UPDATE "workflow" w SET "folder_id" = NULL
181+
WHERE w."folder_id" IS NOT NULL
182+
AND NOT EXISTS (SELECT 1 FROM "folder" f WHERE f.id = w."folder_id");
183+
--> statement-breakpoint
184+
185+
UPDATE "workspace_files" wf SET "folder_id" = NULL
186+
WHERE wf."folder_id" IS NOT NULL
187+
AND NOT EXISTS (SELECT 1 FROM "folder" f WHERE f.id = wf."folder_id");
188+
--> statement-breakpoint
189+
190+
-- Step 3 — adopt the FKs 0272 deliberately left off. Added NOT VALID so the ACCESS EXCLUSIVE
191+
-- lock covers only the catalog write, not a full scan: `workspace_files` is ~1.7M rows / 1.8GB
192+
-- and an immediately-validated FK would block every read and write on it for the whole scan.
193+
-- NOT VALID still enforces the constraint on all new writes; VALIDATE below takes only SHARE
194+
-- UPDATE EXCLUSIVE and so runs concurrently with normal traffic.
195+
DO $$
196+
BEGIN
197+
ALTER TABLE "workflow"
198+
ADD CONSTRAINT "workflow_folder_id_folder_id_fk"
199+
FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE SET NULL NOT VALID;
200+
EXCEPTION
201+
WHEN duplicate_object THEN NULL;
202+
END $$;
203+
--> statement-breakpoint
204+
205+
DO $$
206+
BEGIN
207+
ALTER TABLE "workspace_files"
208+
ADD CONSTRAINT "workspace_files_folder_id_folder_id_fk"
209+
FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE SET NULL NOT VALID;
210+
EXCEPTION
211+
WHEN duplicate_object THEN NULL;
212+
END $$;
213+
--> statement-breakpoint
214+
215+
-- The reconcile and the NOT VALID constraints must be durable before the scans below, which
216+
-- deliberately run outside the surrounding transaction so they do not hold its locks.
217+
COMMIT;
218+
--> statement-breakpoint
219+
220+
ALTER TABLE "workflow" VALIDATE CONSTRAINT "workflow_folder_id_folder_id_fk";
221+
--> statement-breakpoint
222+
ALTER TABLE "workspace_files" VALIDATE CONSTRAINT "workspace_files_folder_id_folder_id_fk";
223+
--> statement-breakpoint
224+
225+
-- Step 4 — drop the legacy tables. Named EXACTLY and never by pattern: `workflow_folder_sort_idx`
226+
-- is an index on the LIVE `workflow` table, so anything globbing `workflow_folder*` would take
227+
-- out a production index. Their own FKs and indexes go with them; nothing references either
228+
-- table, so no CASCADE is needed and its absence is the safety check.
229+
DROP TABLE IF EXISTS "workflow_folder";
230+
--> statement-breakpoint
231+
DROP TABLE IF EXISTS "workspace_file_folders";

0 commit comments

Comments
 (0)