chore(db): drop the legacy folder tables and adopt the deferred folder_id FKs (#6051)

* chore(db): drop the legacy folder tables and adopt the deferred folder_id FKs

Contract migration closing out the generic-folders cutover: drops workflow_folder
and workspace_file_folders, and adopts the two folder_id foreign keys 0272
deliberately deferred.

Runs a final INSERT-ONLY reconcile first so no stranded legacy folder is lost by
the drop. Insert-only because 0272 deliberately renamed 47 workflow folders to
dedupe them, and an upsert would revert all 47. In production this is a no-op
(verified 0 stranded); it exists for deployments that never ran the post-drain
reconcile as an operational step, self-hosted upgrades above all.

Hand-written rather than drizzle-generated: the generated form drops the tables
BEFORE adding the FKs with no step to re-root unresolvable folder_ids, so one
dangling id fails ADD CONSTRAINT after the legacy data is already gone; it also
validates the FK inline, holding ACCESS EXCLUSIVE across a full scan of the
1.7M-row workspace_files, and uses DROP TABLE CASCADE. The generated snapshot is
kept so drizzle-kit generate reports no schema changes.

Every re-root path dedupes, because all three destinations are partial unique
indexes keyed on a coalesced nullable column: folder, workflow, and
workspace_files. Parents must also be reachable — an active row re-roots off a
soft-deleted parent, while a soft-deleted row may keep one.

Also hardens the retention sweep, which the new ON DELETE SET NULL exposes: a
surviving active child re-rooted by the FK can collide at the workspace root,
and chunkedBatchDelete turns that 23505 into a permanent per-chunk stall. The
folder cleanup target now renames children first, covering workflows, files and
subfolders, re-asserts eligibility so a restore mid-batch is not stripped, and
treats the deduplicated name as a hint since both allocators can return a
colliding one.

* fix(db): stop the re-root dedupe renaming personal workflows

workflow.workspace_id is nullable, and NULL is treated as EQUAL by PARTITION BY
but UNKNOWN by the = in base_taken. So for personal workflows rn incremented
across the group while no collision was ever detected: the dedupe could only
fire spuriously, renaming a user-visible workflow that needed no rename, since
the unique index treats NULL workspace_id rows as distinct anyway. The file
block already carried the equivalent guard.

Also gives step 1's inserts ON CONFLICT (id) DO NOTHING. It restates the
stranded guard, closing the window between that read's snapshot and the index
check — an operational re-run of 0274, or a live pod, committing into folder
mid-statement would otherwise raise 23505, and migrate.ts retries only 55P03.
0272 and 0274 were already written this way; step 1 was the exception.

Corrects three comments that claimed more than the code delivers: the header's
'no legacy folder is lost' (an id already present under another resource_type
cannot be rescued), the reachability note (a cycle among stranded rows survives
a per-row parent check), and a note made stale by the ON CONFLICT above.
This commit is contained in:
Waleed
2026-07-29 12:25:39 -07:00
committed by GitHub
parent ee7c061681
commit 4793607ee9
10 changed files with 18973 additions and 143 deletions
@@ -0,0 +1,460 @@
-- Contract migration for the generic-folders cutover: adopt the deferred `folder_id` foreign
-- keys and drop the two legacy folder tables.
--
-- Ordering is deliberate and each step depends on the one before it:
-- 1. final insert-only reconcile, so no legacy folder is lost by the DROP — with one
-- inherent exception: a legacy id already present in `folder` under a DIFFERENT
-- resource_type cannot be inserted (the primary key is taken) and is dropped. That needs
-- an id collision across two tables whose ids were preserved from disjoint sources, so it
-- is not reachable in practice;
-- 2. re-root any `folder_id` that still does not resolve, so the FK can be validated;
-- 3. adopt the FKs the expand migration deliberately left off;
-- 4. drop the legacy tables.
--
-- Preconditions verified read-only against production before writing this file: 0 stranded
-- rows in either tree, 0 unresolvable `folder_id`s, 0 active rows filed under a soft-deleted
-- folder, and a FULL-ROW comparison (name, parent, deleted/archived state, workspace, user,
-- sort order, locked) clean on both trees. The only divergence is 47 workflow-folder names, all
-- matching `<old name> (N)` — 0272's deliberate dedup renames. That is precisely why step 1 is
-- INSERT-ONLY: an upsert would revert all 47.
--
-- In production steps 1 and 2 are therefore no-ops. They exist for deployments that never ran
-- the post-drain reconcile as an operational step — self-hosted upgrades above all, where a
-- rolling restart can strand a folder exactly the same way and no operator is watching for it.
-- This is the last moment the legacy rows exist, so it is the last chance to rescue them.
--
-- NAME DEDUPLICATION appears at four sites below and follows one pattern throughout, because
-- three separate partial unique indexes are in play and every one of them keys on a coalesced
-- nullable column, so re-rooting a row moves it into a namespace where its name may be taken:
-- * folder (workspace_id, resource_type, coalesce(parent_id,''), name) WHERE deleted_at IS NULL
-- * workflow (workspace_id, coalesce(folder_id,''), name) WHERE archived_at IS NULL
-- * workspace_files (workspace_id, coalesce(folder_id,''), original_name) WHERE deleted_at IS NULL AND context='workspace' AND workspace_id IS NOT NULL
-- The pattern: rank contenders within the batch (`rn`), ask whether an already-present row
-- holds the base name (`base_taken`), derive a `slot` into the free-suffix sequence, and probe
-- for the first free `" (N)"` — where "free" must consider BOTH the rows already in the table
-- AND the base names this batch is about to claim (`kept`). Probing only the table is the
-- subtle failure: a stranded row legitimately named `Docs (1)` is invisible to the probe run
-- for a stranded `Docs`, so both would be assigned `Docs (1)` and the statement aborts.
-- Suffixes start at (1). Inactive rows are never renamed — the indexes are partial.
-- Step 1 — final reconcile. Guarded on table existence so a replay after the DROP is a no-op
-- rather than an error, and written as DO blocks so each tree is a single atomic statement
-- (0272 ends with an embedded COMMIT, so this file is not guaranteed to run inside drizzle's
-- batch transaction).
DO $$
BEGIN
IF to_regclass('public.workflow_folder') IS NULL THEN
RETURN;
END IF;
INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
-- Keyed on `id` ALONE, matching the primary key it protects. Narrowing it by resource_type
-- would classify an id already present under a DIFFERENT type as stranded; the ON CONFLICT
-- below would then silently skip it rather than rescue it, so keeping the guard aligned with
-- the constraint is what makes the two agree.
WITH stranded AS (
SELECT l.id, l.name, l.user_id, l.workspace_id, l.parent_id, l.locked, l.sort_order,
l.created_at, l.updated_at, l.archived_at AS deleted_at
FROM "workflow_folder" l
WHERE NOT EXISTS (SELECT 1 FROM "folder" f WHERE f.id = l.id)
),
-- A parent is only usable if it will exist, shares this row's workspace, and leaves the row
-- REACHABLE. The workspace match is enforced by the `folder_parent_resource_type_match`
-- trigger and was never enforced by the legacy self-FK, so a cross-workspace parent is
-- representable in the source data. Reachability is the subtler half: filing an ACTIVE
-- folder under a soft-deleted parent hides it in Recently Deleted just as thoroughly as a
-- dangling parent would, so it re-roots too — matching `resolveRestoredFolderId`, which
-- re-roots a restored folder whose original parent is archived.
--
-- A soft-deleted row is exempt: it MAY keep a soft-deleted parent, because that is the
-- normal shape of an archived subtree and flattening it would destroy the hierarchy a
-- later restore rebuilds.
--
-- Anything else re-roots to the workspace root: losing one level of nesting beats losing the
-- folder and stranding every workflow inside it.
--
-- LIMITATION: this is a per-row check, so a CYCLE among stranded rows (a→b→a) survives it —
-- every row's parent exists, is same-workspace, and is active. Such rows land in `folder`
-- unreachable from the root. 0272's backfill has the identical hole, so this is not a
-- regression, and the client tolerates it (`getFolderPath` and `subtree.ts` both carry cycle
-- guards). Breaking cycles needs a recursive walk; it is deliberately not done here.
resolved AS (
SELECT s.*,
CASE
WHEN s.parent_id IS NULL THEN NULL
WHEN EXISTS (
SELECT 1 FROM "folder" f
WHERE f.id = s.parent_id AND f.resource_type = 'workflow' AND f.workspace_id = s.workspace_id
AND (s.deleted_at IS NOT NULL OR f.deleted_at IS NULL)
) THEN s.parent_id
WHEN EXISTS (
SELECT 1 FROM stranded s2
WHERE s2.id = s.parent_id AND s2.workspace_id = s.workspace_id
AND (s.deleted_at IS NOT NULL OR s2.deleted_at IS NULL)
) THEN s.parent_id
ELSE NULL
END AS resolved_parent
FROM stranded s
),
ranked AS (
SELECT r.*,
EXISTS (
SELECT 1 FROM "folder" a
WHERE a.workspace_id = r.workspace_id
AND a.resource_type = 'workflow'
AND coalesce(a.parent_id, '') = coalesce(r.resolved_parent, '')
AND a.name = r.name
AND a.deleted_at IS NULL
) AS base_taken,
row_number() OVER (
PARTITION BY r.workspace_id, coalesce(r.resolved_parent, ''), r.name
ORDER BY r.created_at, r.id
) AS rn
FROM resolved r
WHERE r.deleted_at IS NULL
),
slotted AS (
SELECT k.*, k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END) AS slot FROM ranked k
),
kept AS (
SELECT s.workspace_id, s.resolved_parent, s.name FROM slotted s WHERE s.slot < 0
),
named AS (
SELECT k.id,
CASE
WHEN k.slot < 0 THEN k.name
ELSE coalesce(
(
SELECT k.name || ' (' || candidate.n || ')'
FROM generate_series(1, 10000) AS candidate(n)
WHERE NOT EXISTS (
SELECT 1 FROM "folder" a
WHERE a.workspace_id = k.workspace_id
AND a.resource_type = 'workflow'
AND coalesce(a.parent_id, '') = coalesce(k.resolved_parent, '')
AND a.name = k.name || ' (' || candidate.n || ')'
AND a.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM kept kp
WHERE kp.workspace_id = k.workspace_id
AND coalesce(kp.resolved_parent, '') = coalesce(k.resolved_parent, '')
AND kp.name = k.name || ' (' || candidate.n || ')'
)
ORDER BY candidate.n
OFFSET k.slot
LIMIT 1
),
-- Suffix space exhausted. Fall back to the id, which is unique by
-- construction, so the reconcile still completes and the row is traceable.
-- Falling back to the base name would guarantee a collision and abort here
-- with an error naming the base name, hiding the real cause.
k.name || ' (' || k.id || ')'
)
END AS final_name
FROM slotted k
)
SELECT r.id, 'workflow', coalesce(n.final_name, r.name),
r.user_id, r.workspace_id, r.resolved_parent, r.locked, r.sort_order,
r.created_at, r.updated_at, r.deleted_at
FROM resolved r
LEFT JOIN named n ON n.id = r.id
-- Matches the `stranded` guard, which already means "no folder row with this id". Restating
-- it as ON CONFLICT closes the gap between that read's snapshot and the index check: an
-- operational re-run of 0274, or a live pod, committing into `folder` mid-statement would
-- otherwise raise 23505 — and migrate.ts retries only 55P03, so that hard-fails the deploy.
ON CONFLICT (id) DO NOTHING;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF to_regclass('public.workspace_file_folders') IS NULL THEN
RETURN;
END IF;
INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
WITH stranded AS (
SELECT l.id, l.name, l.user_id, l.workspace_id, l.parent_id, l.sort_order,
l.created_at, l.updated_at, l.deleted_at
FROM "workspace_file_folders" l
WHERE NOT EXISTS (SELECT 1 FROM "folder" f WHERE f.id = l.id)
),
-- Same reachability rule as the workflow tree above.
resolved AS (
SELECT s.*,
CASE
WHEN s.parent_id IS NULL THEN NULL
WHEN EXISTS (
SELECT 1 FROM "folder" f
WHERE f.id = s.parent_id AND f.resource_type = 'file' AND f.workspace_id = s.workspace_id
AND (s.deleted_at IS NOT NULL OR f.deleted_at IS NULL)
) THEN s.parent_id
WHEN EXISTS (
SELECT 1 FROM stranded s2
WHERE s2.id = s.parent_id AND s2.workspace_id = s.workspace_id
AND (s.deleted_at IS NOT NULL OR s2.deleted_at IS NULL)
) THEN s.parent_id
ELSE NULL
END AS resolved_parent
FROM stranded s
),
ranked AS (
SELECT r.*,
EXISTS (
SELECT 1 FROM "folder" a
WHERE a.workspace_id = r.workspace_id
AND a.resource_type = 'file'
AND coalesce(a.parent_id, '') = coalesce(r.resolved_parent, '')
AND a.name = r.name
AND a.deleted_at IS NULL
) AS base_taken,
row_number() OVER (
PARTITION BY r.workspace_id, coalesce(r.resolved_parent, ''), r.name
ORDER BY r.created_at, r.id
) AS rn
FROM resolved r
WHERE r.deleted_at IS NULL
),
slotted AS (
SELECT k.*, k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END) AS slot FROM ranked k
),
kept AS (
SELECT s.workspace_id, s.resolved_parent, s.name FROM slotted s WHERE s.slot < 0
),
named AS (
SELECT k.id,
CASE
WHEN k.slot < 0 THEN k.name
ELSE coalesce(
(
SELECT k.name || ' (' || candidate.n || ')'
FROM generate_series(1, 10000) AS candidate(n)
WHERE NOT EXISTS (
SELECT 1 FROM "folder" a
WHERE a.workspace_id = k.workspace_id
AND a.resource_type = 'file'
AND coalesce(a.parent_id, '') = coalesce(k.resolved_parent, '')
AND a.name = k.name || ' (' || candidate.n || ')'
AND a.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM kept kp
WHERE kp.workspace_id = k.workspace_id
AND coalesce(kp.resolved_parent, '') = coalesce(k.resolved_parent, '')
AND kp.name = k.name || ' (' || candidate.n || ')'
)
ORDER BY candidate.n
OFFSET k.slot
LIMIT 1
),
k.name || ' (' || k.id || ')'
)
END AS final_name
FROM slotted k
)
SELECT r.id, 'file', coalesce(n.final_name, r.name),
r.user_id, r.workspace_id, r.resolved_parent, false, r.sort_order,
r.created_at, r.updated_at, r.deleted_at
FROM resolved r
LEFT JOIN named n ON n.id = r.id
-- Same rationale as the workflow tree above.
ON CONFLICT (id) DO NOTHING;
END $$;
--> statement-breakpoint
-- Step 2 — a `folder_id` can only still dangle if its folder is absent from BOTH tables, which
-- step 1 cannot rescue. Re-root it so the resource stays reachable at the workspace root
-- instead of blocking validation, and rename on collision for the same reason step 1 does: the
-- root namespace is covered by a partial unique index, and two same-named rows re-rooted out of
-- two different vanished folders would abort the migration. A dangling row is already
-- unreachable in the UI (filed under a folder that does not exist), so surfacing it at the root
-- under a suffixed name strictly improves on leaving it invisible.
DO $$
BEGIN
-- `workspace_id IS NOT NULL` is load-bearing, not defensive. `workflow.workspace_id` is
-- nullable (personal workflows), and NULL is treated as EQUAL by `PARTITION BY` but as
-- UNKNOWN by the `=` in `base_taken`. Without this guard `rn` increments across every
-- personal workflow while no collision is ever detected, so the dedup can ONLY fire
-- spuriously — renaming a user-visible workflow that needed no rename, since the unique
-- index treats NULL `workspace_id` rows as distinct anyway. The file block below has always
-- carried the equivalent guard.
WITH dangling AS (
SELECT w.id, w.workspace_id, w.name,
(w.archived_at IS NULL AND w.workspace_id IS NOT NULL) AS is_active
FROM "workflow" w
WHERE w."folder_id" IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM "folder" f WHERE f.id = w."folder_id")
),
ranked AS (
SELECT d.*,
EXISTS (
SELECT 1 FROM "workflow" r
WHERE r.workspace_id = d.workspace_id
AND r."folder_id" IS NULL
AND r.archived_at IS NULL
AND r.name = d.name
) AS base_taken,
row_number() OVER (PARTITION BY d.workspace_id, d.name ORDER BY d.id) AS rn
FROM dangling d
WHERE d.is_active
),
slotted AS (
SELECT k.*, k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END) AS slot FROM ranked k
),
kept AS (
SELECT s.workspace_id, s.name FROM slotted s WHERE s.slot < 0
),
named AS (
SELECT k.id,
CASE
WHEN k.slot < 0 THEN k.name
ELSE coalesce(
(
SELECT k.name || ' (' || candidate.n || ')'
FROM generate_series(1, 10000) AS candidate(n)
WHERE NOT EXISTS (
SELECT 1 FROM "workflow" r
WHERE r.workspace_id = k.workspace_id
AND r."folder_id" IS NULL
AND r.archived_at IS NULL
AND r.name = k.name || ' (' || candidate.n || ')'
)
AND NOT EXISTS (
SELECT 1 FROM kept kp
WHERE kp.workspace_id = k.workspace_id
AND kp.name = k.name || ' (' || candidate.n || ')'
)
ORDER BY candidate.n
OFFSET k.slot
LIMIT 1
),
k.name || ' (' || k.id || ')'
)
END AS final_name
FROM slotted k
)
UPDATE "workflow" w
SET "folder_id" = NULL, "name" = coalesce(n.final_name, w.name)
FROM dangling d
LEFT JOIN named n ON n.id = d.id
WHERE w.id = d.id;
END $$;
--> statement-breakpoint
DO $$
BEGIN
WITH dangling AS (
SELECT wf.id, wf.workspace_id, wf.original_name,
(wf.deleted_at IS NULL AND wf.context = 'workspace' AND wf.workspace_id IS NOT NULL) AS is_active
FROM "workspace_files" wf
WHERE wf."folder_id" IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM "folder" f WHERE f.id = wf."folder_id")
),
ranked AS (
SELECT d.*,
EXISTS (
SELECT 1 FROM "workspace_files" r
WHERE r.workspace_id = d.workspace_id
AND r."folder_id" IS NULL
AND r.deleted_at IS NULL
AND r.context = 'workspace'
AND r.original_name = d.original_name
) AS base_taken,
row_number() OVER (PARTITION BY d.workspace_id, d.original_name ORDER BY d.id) AS rn
FROM dangling d
WHERE d.is_active
),
slotted AS (
SELECT k.*, k.rn - 1 - (CASE WHEN k.base_taken THEN 0 ELSE 1 END) AS slot FROM ranked k
),
kept AS (
SELECT s.workspace_id, s.original_name FROM slotted s WHERE s.slot < 0
),
named AS (
SELECT k.id,
CASE
WHEN k.slot < 0 THEN k.original_name
ELSE coalesce(
(
SELECT k.original_name || ' (' || candidate.n || ')'
FROM generate_series(1, 10000) AS candidate(n)
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_files" r
WHERE r.workspace_id = k.workspace_id
AND r."folder_id" IS NULL
AND r.deleted_at IS NULL
AND r.context = 'workspace'
AND r.original_name = k.original_name || ' (' || candidate.n || ')'
)
AND NOT EXISTS (
SELECT 1 FROM kept kp
WHERE kp.workspace_id = k.workspace_id
AND kp.original_name = k.original_name || ' (' || candidate.n || ')'
)
ORDER BY candidate.n
OFFSET k.slot
LIMIT 1
),
k.original_name || ' (' || k.id || ')'
)
END AS final_name
FROM slotted k
)
UPDATE "workspace_files" wf
SET "folder_id" = NULL, "original_name" = coalesce(n.final_name, wf.original_name)
FROM dangling d
LEFT JOIN named n ON n.id = d.id
WHERE wf.id = d.id;
END $$;
--> statement-breakpoint
-- Step 3 — adopt the FKs 0272 deliberately left off. Added NOT VALID so the ACCESS EXCLUSIVE
-- lock covers only the catalog write, not a full scan: `workspace_files` is ~1.7M rows / 1.8GB
-- and an immediately-validated FK would block every read and write on it for the whole scan.
-- NOT VALID still enforces the constraint on all new writes; VALIDATE below takes only SHARE
-- UPDATE EXCLUSIVE and so runs concurrently with normal traffic.
DO $$
BEGIN
ALTER TABLE "workflow"
ADD CONSTRAINT "workflow_folder_id_folder_id_fk"
FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE SET NULL NOT VALID;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
DO $$
BEGIN
ALTER TABLE "workspace_files"
ADD CONSTRAINT "workspace_files_folder_id_folder_id_fk"
FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE SET NULL NOT VALID;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
--> statement-breakpoint
-- The reconcile and the NOT VALID constraints must be durable before the scans below, which
-- deliberately run outside the surrounding transaction so they do not hold its locks.
COMMIT;
--> statement-breakpoint
ALTER TABLE "workflow" VALIDATE CONSTRAINT "workflow_folder_id_folder_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_files" VALIDATE CONSTRAINT "workspace_files_folder_id_folder_id_fk";
--> statement-breakpoint
-- Step 4 — drop the legacy tables. Named EXACTLY and never by pattern: `workflow_folder_sort_idx`
-- is an index on the LIVE `workflow` table, so anything globbing `workflow_folder*` would take
-- out a production index. Their own FKs and indexes go with them; nothing references either
-- table, so no CASCADE is needed and its absence is the safety check.
--
-- The cutover that stopped all reads and writes of these two tables shipped in EARLIER deploys
-- (#6037 / #6045), not in this PR. Production has since drained — last legacy write 06:27:21Z,
-- verified >10h earlier — and the full-row comparison described at the top of this file confirms
-- nothing is stranded. Step 1 rescues any straggler regardless.
-- migration-safe: reads/writes ceased in an earlier deploy; drained and full-row verified.
DROP TABLE IF EXISTS "workflow_folder";
--> statement-breakpoint
-- migration-safe: same cutover, same drain, same full-row verification as the drop above.
DROP TABLE IF EXISTS "workspace_file_folders";
File diff suppressed because it is too large Load Diff
@@ -1926,6 +1926,13 @@
"when": 1785344855092,
"tag": "0275_table_views",
"breakpoints": true
},
{
"idx": 276,
"version": "7",
"when": 1785352177983,
"tag": "0276_drop_legacy_folder_tables",
"breakpoints": true
}
]
}
+9 -117
View File
@@ -124,8 +124,8 @@ export const folderResourceTypeEnum = pgEnum('folder_resource_type', [
/**
* Generic folder hierarchy shared by workflows, files, knowledge bases, and tables.
* Supersedes the resource-specific `workflowFolder`/`workspaceFileFolder` tables (see
* the deprecation notes on those below).
* Supersedes the resource-specific `workflow_folder` and `workspace_file_folders` tables,
* dropped in migration 0276 once the cutover was verified against production.
*
* `resourceType` is a real `pgEnum` here — unlike `pinnedItem.resourceType` — because the
* set of folder-bearing resources is small and fixed. A folder may only parent a folder
@@ -138,7 +138,8 @@ export const folderResourceTypeEnum = pgEnum('folder_resource_type', [
* `false` and no lock cascade reads it for them. Dropping the column would regress
* shipped workflow-folder locking.
*
* `color` and `isExpanded` from `workflowFolder` are intentionally not carried over:
* `color` and `isExpanded` from the old `workflow_folder` table are intentionally not
* carried over:
* `color` has no UI consumer, and `isExpanded`'s real state lives client-side in the
* folders Zustand store and is never read back from the DB.
*/
@@ -176,9 +177,9 @@ export const folder = pgTable(
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
/**
* Mirrors `workspace_file_folders_workspace_parent_name_active_unique`, which file
* folders already enforce today. Workflow folders gain it here — the backfill
* deduplicates the existing violations.
* Carries over the active-unique key the old `workspace_file_folders` table enforced,
* and extends it to workflow folders, which never had one — 0272's backfill deduplicated
* the 47 pre-existing violations it surfaced.
*/
workspaceResourceParentNameActiveUnique: uniqueIndex(
'folder_workspace_resource_parent_name_active_unique'
@@ -224,43 +225,6 @@ export const pinnedItem = pgTable(
})
)
// DEPRECATED: superseded by the generic `folder` table (resourceType='workflow'). Kept
// (unread, unwritten) until the generic-folders cutover is verified in production; dropped
// in a follow-up contract migration.
export const workflowFolder = pgTable(
'workflow_folder',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
parentId: text('parent_id'), // Self-reference will be handled by foreign key constraint
color: text('color').default('#6B7280'),
isExpanded: boolean('is_expanded').notNull().default(true),
locked: boolean('locked').notNull().default(false),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
archivedAt: timestamp('archived_at'),
},
(table) => ({
userIdx: index('workflow_folder_user_idx').on(table.userId),
workspaceParentIdx: index('workflow_folder_workspace_parent_idx').on(
table.workspaceId,
table.parentId
),
parentSortIdx: index('workflow_folder_parent_sort_idx').on(table.parentId, table.sortOrder),
archivedAtIdx: index('workflow_folder_archived_at_idx').on(table.archivedAt),
workspaceArchivedAtPartialIdx: index('workflow_folder_workspace_archived_partial_idx')
.on(table.workspaceId, table.archivedAt)
.where(sql`${table.archivedAt} IS NOT NULL`),
})
)
export const workflow = pgTable(
'workflow',
{
@@ -269,15 +233,7 @@ export const workflow = pgTable(
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
/**
* contract-pending: re-add `.references(() => folder.id, { onDelete: 'set null' })`
* once the generic-folders expand migration is fully deployed and no old-code pod can
* still write a workflow_folder-only id here. The expand migration only DROPs the old
* (now-wrong) FK target; adding the new one in the same deploy would reject writes from
* still-running old app code. The invariant is enforced in the application layer
* meanwhile.
*/
folderId: text('folder_id'),
folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').notNull().default(0),
name: text('name').notNull(),
description: text('description'),
@@ -1902,62 +1858,6 @@ export const workspaceFile = pgTable(
})
)
/**
* DEPRECATED: superseded by the generic `folder` table (`resource_type = 'file'`).
*
* As of the file-folder cutover the application no longer reads or writes this table —
* every former query site now targets `folder` scoped to `resource_type = 'file'`. It is
* retained as the rollback copy for that deploy, NOT because it is already unused history:
* before the cutover it was the live table, and migration 0272's one-shot backfill did not
* cover folders created after it ran (migration 0274 catches those up).
*
* Do NOT drop it in a contract migration until BOTH hold: the cutover has been running in
* production long enough that a rollback is off the table, and a FULL-ROW comparison against
* `folder WHERE resource_type = 'file'` confirms nothing was stranded. A row COUNT is not
* sufficient — the pre-0274 divergence was in row contents (names, parents, `deleted_at`),
* which a count check passes straight through. Dropping this on the strength of "no code
* references it" alone destroys the only rollback path.
*/
export const workspaceFileFolder = pgTable(
'workspace_file_folders',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
parentId: text('parent_id').references((): AnyPgColumn => workspaceFileFolder.id, {
onDelete: 'set null',
}),
sortOrder: integer('sort_order').notNull().default(0),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceParentIdx: index('workspace_file_folders_workspace_parent_idx').on(
table.workspaceId,
table.parentId
),
parentSortIdx: index('workspace_file_folders_parent_sort_idx').on(
table.parentId,
table.sortOrder
),
deletedAtIdx: index('workspace_file_folders_deleted_at_idx').on(table.deletedAt),
workspaceDeletedAtPartialIdx: index('workspace_file_folders_workspace_deleted_partial_idx')
.on(table.workspaceId, table.deletedAt)
.where(sql`${table.deletedAt} IS NOT NULL`),
workspaceParentNameActiveUnique: uniqueIndex(
'workspace_file_folders_workspace_parent_name_active_unique'
)
.on(table.workspaceId, sql`coalesce(${table.parentId}, '')`, table.name)
.where(sql`${table.deletedAt} IS NULL`),
})
)
export const workspaceFiles = pgTable(
'workspace_files',
{
@@ -1967,15 +1867,7 @@ export const workspaceFiles = pgTable(
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
/**
* contract-pending: re-add `.references(() => folder.id, { onDelete: 'set null' })`
* once the generic-folders expand migration is fully deployed and no old-code pod can
* still write a workspace_file_folders-only id here. The expand migration only DROPs the old
* (now-wrong) FK target; adding the new one in the same deploy would reject writes from
* still-running old app code. The invariant is enforced in the application layer
* meanwhile.
*/
folderId: text('folder_id'),
folderId: text('folder_id').references(() => folder.id, { onDelete: 'set null' }),
context: text('context').notNull(), // 'workspace', 'mothership', 'copilot', 'chat', 'knowledge-base', 'profile-pictures', 'general', 'execution'
chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'cascade' }),
/**
-24
View File
@@ -89,19 +89,6 @@ export const schemaMock = {
resourceId: 'resourceId',
pinnedAt: 'pinnedAt',
},
workflowFolder: {
id: 'id',
name: 'name',
userId: 'userId',
workspaceId: 'workspaceId',
parentId: 'parentId',
color: 'color',
isExpanded: 'isExpanded',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
archivedAt: 'archivedAt',
},
workflow: {
id: 'id',
userId: 'userId',
@@ -557,17 +544,6 @@ export const schemaMock = {
completedAt: 'completedAt',
updatedAt: 'updatedAt',
},
workspaceFileFolder: {
id: 'id',
name: 'name',
userId: 'userId',
workspaceId: 'workspaceId',
parentId: 'parentId',
sortOrder: 'sortOrder',
deletedAt: 'deletedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
},
workspaceFile: {
id: 'id',
workspaceId: 'workspaceId',