Skip to content

Commit 8e355c3

Browse files
committed
fix(folders): fix migration-breaking prod data collision and KB unlock bug
- The backfill INSERT for workflow_folder assumed (workspace_id, parent_id, name) was unique, but that table has no such constraint -- verified against production via PlanetScale: 33 groups of genuine active duplicates exist today (up to 8x in one workspace), which would have aborted the entire migration with a unique-violation the moment it ran against prod data. Backfill now deduplicates active rows deterministically (ordered by created_at, then id) appending " (N)" per collision, matching the app's own "New folder (N)" dedup convention -- archived rows are exempt from the new constraint and are never renamed. Verified live: a reproduction of the exact prod collision shape backfills correctly, and the full 0000-0258 sequence still applies cleanly from scratch - Knowledge base unlock was completely broken: PUT /api/knowledge/[id] always builds a full literal update object with every field present (unset ones as `undefined`), so Object.keys(updates) always included name/description/etc regardless of what was actually sent, making the "is this a lock-only update" check permanently true -- an admin could never unlock a KB once locked (directly or via an ancestor folder), since the mutability check ran against the still-locked current row before the unlock could apply. Fixed to check `!== undefined` per field, matching the isLockOnlyUpdate pattern already used correctly by renameTable() and performRenameWorkspaceFile(). Added a regression test using the exact object shape the route actually constructs -- the existing test never caught this since it called the function with a clean `{ locked: false }` object that doesn't reproduce the route's actual literal-object shape - Trimmed 3 files' leftover WHAT-comments (pure restatement of the code below them) as part of a full comment-quality pass across the diff
1 parent f411f84 commit 8e355c3

6 files changed

Lines changed: 77 additions & 26 deletions

File tree

apps/sim/app/api/folders/[id]/route.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1515

1616
const logger = createLogger('FoldersIDAPI')
1717

18-
// PUT - Update a folder
1918
export const PUT = withRouteHandler(
2019
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
2120
try {
@@ -41,7 +40,6 @@ export const PUT = withRouteHandler(
4140
const { id } = parsed.data.params
4241
const { name, locked, parentId, sortOrder } = parsed.data.body
4342

44-
// Verify the folder exists
4543
const existingFolder = await db
4644
.select()
4745
.from(folder)
@@ -52,7 +50,6 @@ export const PUT = withRouteHandler(
5250
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
5351
}
5452

55-
// Check if user has write permissions for the workspace
5653
const workspacePermission = await getUserEntityPermissions(
5754
session.user.id,
5855
'workspace',
@@ -120,7 +117,6 @@ export const PUT = withRouteHandler(
120117
}
121118
)
122119

123-
// DELETE - Delete a folder and all its contents
124120
export const DELETE = withRouteHandler(
125121
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
126122
try {
@@ -131,7 +127,6 @@ export const DELETE = withRouteHandler(
131127

132128
const { id } = await params
133129

134-
// Verify the folder exists
135130
const existingFolder = await db
136131
.select()
137132
.from(folder)

apps/sim/app/api/folders/route.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ function folderMutationStatus(errorCode: string | undefined): number {
2020
return 500
2121
}
2222

23-
// GET - Fetch folders for a workspace
2423
export const GET = withRouteHandler(async (request: NextRequest) => {
2524
try {
2625
const session = await getSession()
@@ -32,7 +31,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
3231
if (!parsed.success) return parsed.response
3332
const { workspaceId, resourceType, scope } = parsed.data.query
3433

35-
// Check if user has workspace permissions
3634
const workspacePermission = await getUserEntityPermissions(
3735
session.user.id,
3836
'workspace',
@@ -52,7 +50,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5250
}
5351
})
5452

55-
// POST - Create a new folder
5653
export const POST = withRouteHandler(async (request: NextRequest) => {
5754
try {
5855
const session = await getSession()

apps/sim/lib/folders/orchestration.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -756,18 +756,11 @@ export async function performReorderFolders(
756756
const validUpdates = updates.filter((u) => validIds.has(u.id))
757757
if (validUpdates.length === 0) return { success: false, updated: 0 }
758758

759-
// A reparent (non-null parentId) must independently be validated against
760-
// `assertFolderParentValid` — the `validIds` check above only confirms `id`
761-
// itself belongs to this workspace/resourceType, not that the *new parent*
762-
// does. This is an all-or-nothing check: ANY invalid parentId fails the
763-
// whole batch before the transaction runs (rather than silently skipping
764-
// that one entry and reporting a partial `updated` count as success) —
765-
// matching this endpoint's pre-generalization behavior, where a bad
766-
// reparent surfaced as `Parent folder not found` up front.
767-
//
768-
// A subtree drag-drop can reparent many folders in one call, so the
769-
// distinct target parentIds are validated concurrently (one round-trip per
770-
// distinct parent, not one per update) rather than sequentially.
759+
// Reparents also need `assertFolderParentValid` on the new parent (the
760+
// `validIds` check above only validates `id`). Any invalid parentId fails
761+
// the whole batch up front rather than silently skipping that entry.
762+
// Distinct target parentIds are validated concurrently since a subtree
763+
// drag-drop can reparent many folders in one call.
771764
const targetParentIds = Array.from(
772765
new Set(validUpdates.map((u) => u.parentId).filter((id): id is string => Boolean(id)))
773766
)

apps/sim/lib/knowledge/service.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,30 @@ describe('updateKnowledgeBase — resource-lock enforcement', () => {
273273

274274
expect(mockAssertResourceMutable).not.toHaveBeenCalled()
275275
})
276+
277+
it('skips the lock check when unlocking via the route-shaped object (all keys present, unset ones undefined)', async () => {
278+
// Regression test: apps/sim/app/api/knowledge/[id]/route.ts always builds a full
279+
// literal object (`{ name: validatedData.name, ..., locked: validatedData.locked }`)
280+
// rather than spreading only the fields the client actually sent. `Object.keys()`
281+
// includes keys whose value is `undefined`, so a naive `Object.keys(updates).some(...)`
282+
// check always sees every field as "provided" and can never detect a lock-only
283+
// update — permanently blocking unlock, since the mutability check reads the
284+
// still-locked current row. This calls updateKnowledgeBase with that exact shape.
285+
dbChainMockFns.limit.mockResolvedValueOnce([{ workspaceId: 'ws-current', userId: 'u-1' }])
286+
287+
await updateKnowledgeBase(
288+
'kb-1',
289+
{
290+
name: undefined,
291+
description: undefined,
292+
workspaceId: undefined,
293+
folderId: undefined,
294+
chunkingConfig: undefined,
295+
locked: false,
296+
},
297+
'req-1'
298+
).catch(() => undefined)
299+
300+
expect(mockAssertResourceMutable).not.toHaveBeenCalled()
301+
})
276302
})

apps/sim/lib/knowledge/service.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,16 @@ export async function updateKnowledgeBase(
298298
updateData.chunkingConfig = updates.chunkingConfig
299299
}
300300

301-
const hasNonLockUpdate = Object.keys(updates).some((key) => key !== 'locked')
301+
// `Object.keys(updates)` can't distinguish "field genuinely provided" from "field
302+
// present as undefined" (the route always builds a full literal object) — reuse the
303+
// same `!== undefined` checks that already gate `updateData` above, matching the
304+
// isLockOnlyUpdate pattern used by renameTable()/performRenameWorkspaceFile().
305+
const hasNonLockUpdate =
306+
updates.name !== undefined ||
307+
updates.description !== undefined ||
308+
updates.workspaceId !== undefined ||
309+
updates.folderId !== undefined ||
310+
updates.chunkingConfig !== undefined
302311
if (hasNonLockUpdate) {
303312
await assertResourceMutable('knowledge_base', knowledgeBaseId)
304313
}

packages/db/migrations/0258_lyrical_flatman.sql

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,46 @@ FOR EACH ROW EXECUTE FUNCTION "folder_parent_resource_type_match"();
7676
-- Source table also has color/is_expanded columns, but the generic `folder` table dropped
7777
-- them (no UI consumer for color; is_expanded's real state lives client-side in the
7878
-- folders Zustand store, never read from the DB), so they are intentionally not carried over.
79+
--
80+
-- `workflow_folder` has no uniqueness constraint on (workspace_id, parent_id, name) today,
81+
-- but the new `folder` table does (folder_workspace_resource_parent_name_active_unique,
82+
-- scoped to active rows). Production has genuine active duplicates (verified against
83+
-- prod: 33 groups, mostly default "Folder 1"/"Folder 2" names) that would otherwise abort
84+
-- this INSERT with a unique-violation. Deduplicate active rows at backfill time by
85+
-- appending " (N)" per collision, ordered by created_at then id for determinism --
86+
-- matching the "New folder (N)" convention this app's own create-folder dedup already
87+
-- uses, so a renamed row reads as expected in the UI. Archived rows are exempt from the
88+
-- constraint (WHERE deleted_at IS NULL) and are partitioned separately so they're never
89+
-- renamed.
7990
INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
80-
SELECT id, 'workflow', name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, archived_at
81-
FROM "workflow_folder";
91+
SELECT
92+
id, 'workflow',
93+
CASE WHEN archived_at IS NULL AND rn > 1 THEN name || ' (' || rn || ')' ELSE name END,
94+
user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, archived_at
95+
FROM (
96+
SELECT *, ROW_NUMBER() OVER (
97+
PARTITION BY workspace_id, coalesce(parent_id, ''), name, (archived_at IS NULL)
98+
ORDER BY created_at, id
99+
) AS rn
100+
FROM "workflow_folder"
101+
) "ranked_workflow_folder";
82102
--> statement-breakpoint
83103
-- Backfill: copy existing file folders into the generic table, preserving id verbatim.
84-
-- Source table has no locked column, so use the same default as new rows.
104+
-- Source table has no locked column, so use the same default as new rows. No active
105+
-- duplicates exist in production today (verified), but the same defensive dedup as the
106+
-- workflow backfill above is applied in case one is created before this migration runs.
85107
INSERT INTO "folder" (id, resource_type, name, user_id, workspace_id, parent_id, locked, sort_order, created_at, updated_at, deleted_at)
86-
SELECT id, 'file', name, user_id, workspace_id, parent_id, false, sort_order, created_at, updated_at, deleted_at
87-
FROM "workspace_file_folders";
108+
SELECT
109+
id, 'file',
110+
CASE WHEN deleted_at IS NULL AND rn > 1 THEN name || ' (' || rn || ')' ELSE name END,
111+
user_id, workspace_id, parent_id, false, sort_order, created_at, updated_at, deleted_at
112+
FROM (
113+
SELECT *, ROW_NUMBER() OVER (
114+
PARTITION BY workspace_id, coalesce(parent_id, ''), name, (deleted_at IS NULL)
115+
ORDER BY created_at, id
116+
) AS rn
117+
FROM "workspace_file_folders"
118+
) "ranked_workspace_file_folders";
88119
--> statement-breakpoint
89120
ALTER TABLE "knowledge_base" ADD CONSTRAINT "knowledge_base_folder_id_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
90121
ALTER TABLE "user_table_definitions" ADD CONSTRAINT "user_table_definitions_folder_id_folder_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folder"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint

0 commit comments

Comments
 (0)