|
| 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