Skip to content

Commit 7f0d935

Browse files
committed
fix(folders): close the findings from auditing the whole release landing unit
Audited as the unit that actually ships — #6014 (pinning), #6025 (workflow folders onto the generic table), #6037 (the engine) and this branch together against `main`, rather than this branch against `staging`. Authorization - `PUT /api/folders/reorder` still operated on archived folders. `getFolderLockStatus` skips archived rows, so `assertFolderMutable` there was a guaranteed no-op — meaning a locked folder became freely reparentable the moment its parent was deleted. This branch closed exactly that hole on `PUT /api/folders/[id]` and left its sibling open, then widened reorder to three resource trees. Reordering an archived folder is also wrong on its own terms: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out of an archived subtree silently drops it from that folder's restore Data loss in the purge - `batchDeleteByWorkspaceAndTimestamp` forwarded its eligibility predicate only into the SELECT, never the DELETE. The `folder` target runs through it, so a restore committing between the two statements had its folder hard-deleted anyway — taking the placement of the children the restore had just brought back. The workflow purge re-checks for precisely this reason and the knowledge-base sweep gained the same guard earlier in this branch; `folder` had neither. The wrapper now re-asserts eligibility on the DELETE for every caller Wire boundary - `toPinnedItemApi` had no return type, so `pinned_item.resource_type` — plain `text` by design, against a closed contract enum — was never checked. Annotating it surfaced the real hole immediately: during a rolling deploy an older pod can read a pin a newer one wrote, and returning it would fail response validation and take the WHOLE list down rather than the one row. Unknown kinds are now narrowed out explicitly at the boundary instead of surviving by accident on a filter whose stated job is something else Interaction - The knowledge-base FOLDER move still compared against the snapshot taken when the menu opened. The resource move on that page and both Tables handlers were fixed earlier in this branch; this was the last one Operational - 0274's "re-run after drain" instruction needed a precondition. Cleanup hard-deletes from `folder` but never from `workspace_file_folders`, so a re-run after a purge would reinstate every purged file folder as a soft-deleted phantom whose files are already gone. Retention is far longer than any drain, so running it promptly is sufficient — but the instruction said nothing about ordering Accuracy - Four comments named symbols that do not exist or no longer apply: `useFolderCreateWithDedup` (never existed — it is `nextUntitledFolderName`), `collectActiveSubtreeIds`, `restoreFolderCascade` as the live restore path, and a claim that a server `createSearchParamsCache` reads the shared folder param. The shared param doc also now admits Files declares its own `?folderId=` rather than claiming to be the single declaration - `FOLDER_RESOURCES.file` is unreachable at runtime — `servedFolderResourceTypeSchema` does not serve `'file'` — and a reader would reasonably assume otherwise. Says so, and says what routing file folders through the generic engine would bypass - `pinnedItem.resourceType`'s inline comment was missing `'folder'` - `folders.test.ts` fixtures still carried `color` and `isExpanded`, both deliberately dropped from the generic table — pre-consolidation rows masquerading as folders - `getFolders` in the folder cache had one caller, in its own file
1 parent b1c2a33 commit 7f0d935

12 files changed

Lines changed: 105 additions & 26 deletions

File tree

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { folder as folderTable } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow'
55
import { getPostgresErrorCode } from '@sim/utils/errors'
6-
import { and, eq, inArray } from 'drizzle-orm'
6+
import { and, eq, inArray, isNull } from 'drizzle-orm'
77
import { type NextRequest, NextResponse } from 'next/server'
88
import { reorderFoldersContract } from '@/lib/api/contracts'
99
import { parseRequest } from '@/lib/api/server'
@@ -38,10 +38,24 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
3838
}
3939

4040
const folderIds = updates.map((u) => u.id)
41+
/**
42+
* Archived folders are excluded here for the same reason `PUT /api/folders/[id]` excludes
43+
* them: `getFolderLockStatus` skips archived rows, so `assertFolderMutable` below is a
44+
* guaranteed no-op on one — meaning a locked folder becomes freely reparentable the moment
45+
* its parent is deleted. Reordering an archived folder is also a correctness problem in its
46+
* own right: `collectArchivedSubtreeIds` walks the cascade by parent, so moving a branch out
47+
* of an archived subtree silently drops it from that folder's restore.
48+
*/
4149
const existingFolders = await db
4250
.select({ id: folderTable.id, workspaceId: folderTable.workspaceId })
4351
.from(folderTable)
44-
.where(and(inArray(folderTable.id, folderIds), eq(folderTable.resourceType, resourceType)))
52+
.where(
53+
and(
54+
inArray(folderTable.id, folderIds),
55+
eq(folderTable.resourceType, resourceType),
56+
isNull(folderTable.deletedAt)
57+
)
58+
)
4559

4660
const validIds = new Set(
4761
existingFolders.filter((f) => f.workspaceId === workspaceId).map((f) => f.id)
@@ -138,7 +152,13 @@ export const PUT = withRouteHandler(async (req: NextRequest) => {
138152
await tx
139153
.update(folderTable)
140154
.set(updateData)
141-
.where(and(eq(folderTable.id, update.id), eq(folderTable.resourceType, resourceType)))
155+
.where(
156+
and(
157+
eq(folderTable.id, update.id),
158+
eq(folderTable.resourceType, resourceType),
159+
isNull(folderTable.deletedAt)
160+
)
161+
)
142162
}
143163
})
144164

apps/sim/app/api/pinned-items/route.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import { getPostgresErrorCode } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
55
import { and, eq } from 'drizzle-orm'
66
import { type NextRequest, NextResponse } from 'next/server'
7-
import { createPinnedItemContract, listPinnedItemsContract } from '@/lib/api/contracts'
7+
import {
8+
createPinnedItemContract,
9+
listPinnedItemsContract,
10+
type PinnedItemApi,
11+
pinnedResourceTypeSchema,
12+
} from '@/lib/api/contracts'
813
import { parseRequest } from '@/lib/api/server'
914
import { getSession } from '@/lib/auth'
1015
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -13,8 +18,22 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1318

1419
const logger = createLogger('PinnedItemsAPI')
1520

16-
function toPinnedItemApi(row: typeof pinnedItem.$inferSelect) {
17-
return { ...row, pinnedAt: row.pinnedAt.toISOString() }
21+
/**
22+
* Narrows a stored row to the wire shape, dropping any row whose `resourceType` this build does
23+
* not recognise.
24+
*
25+
* `pinned_item.resource_type` is plain `text` — deliberately, so the set of pinnable kinds can
26+
* grow — while the contract is a closed enum. During a rolling deploy an older pod can therefore
27+
* read a pin a newer one wrote. Returning it would fail response validation and take the WHOLE
28+
* list down rather than the single row, so the unknown kind is skipped instead.
29+
*
30+
* `filterToActiveResources` already drops these as a side effect of not having a table to look
31+
* them up in; this makes the guarantee explicit and compiler-checked at the wire boundary.
32+
*/
33+
function toPinnedItemApi(row: typeof pinnedItem.$inferSelect): PinnedItemApi | null {
34+
const resourceType = pinnedResourceTypeSchema.safeParse(row.resourceType)
35+
if (!resourceType.success) return null
36+
return { ...row, resourceType: resourceType.data, pinnedAt: row.pinnedAt.toISOString() }
1837
}
1938

2039
/** Lists the session user's pinned items in a workspace, optionally filtered to one `resourceType`. */
@@ -46,7 +65,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4665

4766
const activeRows = await filterToActiveResources(rows, workspaceId)
4867

49-
return NextResponse.json({ pinnedItems: activeRows.map(toPinnedItemApi) })
68+
const pinnedItems = activeRows
69+
.map(toPinnedItemApi)
70+
.filter((item): item is PinnedItemApi => item !== null)
71+
72+
return NextResponse.json({ pinnedItems })
5073
})
5174

5275
/** Pins a resource for the session user. Idempotent from the client's perspective: re-pinning returns 409. */
@@ -81,7 +104,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
81104
})
82105
.returning()
83106

84-
return NextResponse.json({ pinnedItem: toPinnedItemApi(created) }, { status: 201 })
107+
/**
108+
* Built from the validated `resourceType` rather than round-tripping the raw row through
109+
* `toPinnedItemApi`: the value came from the contract enum, so narrowing here could never
110+
* fail, and threading a `| null` through a path that cannot produce one would only obscure
111+
* that.
112+
*/
113+
const pinned: PinnedItemApi = {
114+
...created,
115+
resourceType,
116+
pinnedAt: created.pinnedAt.toISOString(),
117+
}
118+
return NextResponse.json({ pinnedItem: pinned }, { status: 201 })
85119
} catch (error) {
86120
if (getPostgresErrorCode(error) === '23505') {
87121
return NextResponse.json({ error: 'This item is already pinned' }, { status: 409 })

apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { parseAsString } from 'nuqs/server'
22

33
/**
4-
* The open folder on any resource list built on the generic folder engine
5-
* (`workflow` / `file` / `knowledge_base` / `table`). Declared once here rather than per
6-
* feature so every foldered surface shares the same URL key and semantics, and so
7-
* `useFolderNavigation` and a server `createSearchParamsCache` can read the same parser.
4+
* The open folder on a resource list built on the generic folder engine. Declared here rather
5+
* than per feature so the surfaces that use the engine share one URL key and one set of
6+
* semantics, and so a server `createSearchParamsCache` could read the same parser.
7+
*
8+
* Files declares its own `?folderId=` in `files/search-params.ts` — same key, same meaning, but
9+
* it predates this module and has not been converged.
810
*
911
* Deliberately nullable (no `.withDefault`): a clean URL means the workspace root, which is
1012
* the only sane default and keeps a shared link short.

apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -830,7 +830,11 @@ export function Knowledge() {
830830
const folder = activeFolderRef.current
831831
if (!folder) return
832832
const parentId = parseMoveOptionValue(optionValue)
833-
if ((folder.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId)
833+
// Live placement, not the snapshot taken when the menu opened — a refetch or concurrent
834+
// move in between would otherwise skip the write the user just chose. Matches the
835+
// knowledge-base move below and both Tables handlers.
836+
const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder
837+
if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId)
834838
closeFolderContextMenu()
835839
},
836840
[moveFolderTo, closeFolderContextMenu]

apps/sim/hooks/queries/folders.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ describe('folder optimistic top insertion ordering', () => {
7171
userId: 'user-1',
7272
workspaceId: 'ws-1',
7373
parentId: 'parent-1',
74-
color: '#808080',
75-
isExpanded: false,
7674
sortOrder: 5,
7775
createdAt: new Date(),
7876
updatedAt: new Date(),
@@ -83,8 +81,6 @@ describe('folder optimistic top insertion ordering', () => {
8381
userId: 'user-1',
8482
workspaceId: 'ws-1',
8583
parentId: 'parent-2',
86-
color: '#808080',
87-
isExpanded: false,
8884
sortOrder: -100,
8985
createdAt: new Date(),
9086
updatedAt: new Date(),

apps/sim/hooks/queries/utils/folder-cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { WorkflowFolder } from '@/stores/folders/types'
55

66
const EMPTY_FOLDERS: WorkflowFolder[] = []
77

8-
export function getFolders(
8+
function getFolders(
99
workspaceId: string,
1010
resourceType: FolderResourceType = 'workflow'
1111
): WorkflowFolder[] {

apps/sim/lib/cleanup/batch-delete.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,19 +238,28 @@ export async function batchDeleteByWorkspaceAndTimestamp({
238238
dbClient = db,
239239
...rest
240240
}: BatchDeleteOptions): Promise<TableCleanupResult> {
241+
/**
242+
* Re-asserted on the DELETE, not just the SELECT. Every row here is soft-deleted and past
243+
* retention, so a restore committing between the two statements is exactly the case that must
244+
* not be hard-deleted — and for `folder` that would take the placement of the children the
245+
* restore had just brought back with it. Rebuilt rather than reused from `selectChunk` because
246+
* the id list already scopes the statement; only the eligibility half is re-checked.
247+
*/
248+
const eligibility = [lt(timestampCol, retentionDate)]
249+
if (requireTimestampNotNull) eligibility.push(isNotNull(timestampCol))
250+
if (additionalPredicate) eligibility.push(additionalPredicate)
251+
241252
return chunkedBatchDelete({
242253
tableDef,
243254
workspaceIds,
244255
tableName,
245256
dbClient,
257+
deleteFilter: and(...eligibility),
246258
selectChunk: (chunkIds, limit) => {
247-
const predicates = [inArray(workspaceIdCol, chunkIds), lt(timestampCol, retentionDate)]
248-
if (requireTimestampNotNull) predicates.push(isNotNull(timestampCol))
249-
if (additionalPredicate) predicates.push(additionalPredicate)
250259
return dbClient
251260
.select({ id: sql<string>`id` })
252261
.from(tableDef)
253-
.where(and(...predicates))
262+
.where(and(inArray(workspaceIdCol, chunkIds), ...eligibility))
254263
.limit(limit)
255264
},
256265
...rest,

apps/sim/lib/folders/cascade.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function collectCascadeSubtreeIds(
5353
*
5454
* Matching on the exact `timestamp` is what stops a restore from also reviving folders
5555
* that were archived independently before or after — and is why this cannot reuse
56-
* {@link collectActiveSubtreeIds}, which by definition cannot see an archived subtree.
56+
* {@link collectCascadeSubtreeIds}, which by definition cannot see an archived subtree.
5757
*/
5858
export async function collectArchivedSubtreeIds(
5959
tx: DbOrTx,

apps/sim/lib/folders/config.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ export const FOLDER_RESOURCES: Record<FolderResourceType, FolderResourceConfig>
385385
({ archivedAt: timestamp, updatedAt: now }) satisfies Partial<typeof workflow.$inferInsert>,
386386
sortOrderColumn: workflow.sortOrder,
387387
/**
388-
* Restored in bulk rather than through a `restoreChildren` hook. `restoreFolderCascade`
388+
* Restored in bulk rather than through a `restoreChildren` hook. `restoreFolderChildren`
389389
* already matches these on the archive timestamp, so a webhook or chat the user archived
390390
* independently stays archived — and it does so in a fixed number of statements inside the
391391
* restore transaction. Routing them through `restoreWorkflow` instead would add a
@@ -438,6 +438,14 @@ export const FOLDER_RESOURCES: Record<FolderResourceType, FolderResourceConfig>
438438
archiveChildren: archiveWorkflowChildren,
439439
guardDelete: guardLastWorkflows,
440440
},
441+
/**
442+
* Present to satisfy the total `Record<FolderResourceType, …>`, but UNREACHABLE at runtime:
443+
* `servedFolderResourceTypeSchema` does not serve `'file'`, and the Files surface goes through
444+
* `workspace-file-folder-manager.ts` instead. Do not treat this entry as a live path — routing
445+
* file folders through the generic engine would bypass the `workspace_file_folders:` advisory
446+
* lock that makes its check-then-write pairs atomic, and the name rules that keep a folder name
447+
* usable as a path segment.
448+
*/
441449
file: {
442450
resourceType: 'file',
443451
label: 'file',

apps/sim/lib/folders/naming.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type DbOrTx = Pick<typeof db, 'select'>
1616
* instead where they do (create, rename).
1717
*
1818
* The `" (N)"` shape deliberately matches both the client-side dedup in
19-
* `useFolderCreateWithDedup` and the backfill in migration 0272, so a deduped name reads the
19+
* `nextUntitledFolderName` and the backfill in migration 0272, so a deduped name reads the
2020
* same however it was produced.
2121
*/
2222
export async function deduplicateFolderName(

0 commit comments

Comments
 (0)