Skip to content

feat(folders): add the generic resourceType-driven folder engine - #6037

Merged
waleedlatif1 merged 5 commits into
stagingfrom
feat/generic-folder-engine
Jul 29, 2026
Merged

feat(folders): add the generic resourceType-driven folder engine#6037
waleedlatif1 merged 5 commits into
stagingfrom
feat/generic-folder-engine

Conversation

@waleedlatif1

@waleedlatif1 waleedlatif1 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Extracts the generic, resourceType-driven folder engine that knowledge bases and tables need to have folders. This is the shared layer only — no KB or Tables UI. Two follow-up PRs build on it in parallel.

Before this, folder create/update/delete/restore/reorder existed once in code, hardcoded to resourceType: 'workflow'. Rather than adding a switch with four backends, the per-type deltas are captured as data:

  • apps/sim/lib/folders/config.tsFOLDER_RESOURCES declares, per resource type: the child table, its id / folderId / workspace / soft-delete columns, the TS property key backing that soft-delete column (drizzle's .set() takes property names while .where() takes column objects, and they genuinely differ — archivedAt vs deletedAt), the membership scope, an audit label, and a cascade count key. Every .set() payload is built by a per-resource closure written with satisfies Partial<typeof table.$inferInsert>, so a dropped or renamed column is a compile error at the config rather than a silent no-op.
  • apps/sim/lib/folders/cascade.ts — archive/restore under one shared timestamp. Restore matches that timestamp exactly, which is what stops it from reviving resources that were deleted independently before or after. Statement count is fixed regardless of subtree depth: one subtree SELECT, one folder UPDATE, one child UPDATE, one per declared dependent table.
  • apps/sim/lib/folders/lifecycle.ts — the single createFolder / updateFolder / deleteFolder / restoreFolder implementation. lib/workflows/orchestration/folder-lifecycle.ts drops from 620 lines to thin workflow-bound wrappers, so every existing caller (folders API, copilot workflow tools, resource-restore orchestrator) is untouched.

Workflow-specific behavior that genuinely cannot be a row update — archiving through archiveWorkflowsByIdsInWorkspace so deployments deactivate, external webhooks tear down, the socket is notified and MCP tool lists republish; the last-workflow-in-workspace delete guard; restoring schedules / webhooks / chats / MCP tools — is declared as data (archiveChildren, guardDelete, restoreDependents) on the workflow entry. Knowledge bases and tables use the default path entirely.

The contract widens to workflow | knowledge_base | table, and resourceType is threaded through all four routes and the React Query hooks. Query keys now include it (folders → list → <resourceType> → <workspaceId> → <scope>), so the three folder trees stop sharing one cache entry.

Behavior change: folder restore no longer reactivates independently-archived dependents

This is the highest-risk item in the PR and it touches an already-shipped path. Read this one carefully.

Folder restore previously reactivated all schedules, webhooks, chats, and MCP tools belonging to a restored workflow, regardless of each row's own archivedAt. The generic cascade requires an exact timestamp match, consistent with how it treats folders and resources.

For the ordinary case nothing changes: archiveWorkflow stamps dependents with options.archivedAt, so everything archived by a folder delete shares the folder's timestamp and comes back together. The difference is a webhook (or schedule, or chat) a user disabled before the folder was deleted — the old code silently re-enabled it on restore; it now stays archived.

I believe the new behavior is correct: it preserves a deliberate user action the old code undid, and silently re-enabling a webhook someone turned off is the more dangerous of the two failure modes. But it is a semantic change nobody asked for, so it should be an explicit decision rather than something a reviewer discovers. If you'd rather not take it in this PR, the fallback is small and local: restore dependents unfiltered for the workflow config only, leaving kb/table on the timestamp-matched path.

assertFolderMutable is gated to workflow — deliberately

Folder locking is a workflow-only feature. folder.locked exists solely because workflow-folder locking shipped before the generic table, and the schema comment already states it is not extended to the other resource types. The routes now call assertFolderMutable only when resourceType === 'workflow'.

This is intent, not an oversight: kb and table folders have no lock semantics. Calling the lock check for them would be harmless today (their locked is always false) but would imply a feature that does not exist, and would quietly start enforcing if anything ever wrote the column. If kb/table folders should be lockable, that is a separate feature with its own UI.

Unique-index handling on every newly-reachable path

The folder table has a partial unique index on active (workspaceId, resourceType, coalesce(parentId,''), name). Every path this PR makes reachable for kb/table follows the established rule — 409 where the user picks the name, deduplicateFolderName where they cannot:

Path Handling
Create 409 conflict (user chose the name)
Rename (PUT) 409 conflict
Reparent (PUT) 409 conflict
Reparent via reorder 409 conflict — newly added; this path previously fell through to a 500
Restore deduplicateFolderName against the resolved parent (a folder whose parent is still archived is re-rooted first), with a 23505 → 409 fallback for the create-races-restore window

The reorder case is a real fix, not just plumbing: PUT /api/folders/reorder accepts parentId, so a drag that moves a folder into a sibling set that already has that name hit the constraint and returned 500 Failed to reorder folders. It now returns a 409 with an actionable message, for workflow folders too.

Latent bug fix: the folder cycle check could escape its own tree

checkForCircularReference in lib/workflows/utils.ts walked the parent chain with eq(folder.resourceType, 'workflow') hardcoded. Now that folder ids of other resource types exist, that walk could be handed a knowledge-base folder id, find no row, terminate early, and report "no cycle" — or with the filter removed, walk out of one resource's tree into another's. It moves to lib/folders/queries.ts as wouldCreateFolderCycle(folderId, parentId, resourceType), scoped to the caller's type. The old workflow-only copy is deleted, so there is no second implementation to drift.

Review round 1 — three divergences from the canonical delete paths, all fixed

All three findings said the same thing in different places: the generic row-update path was not a faithful stand-in for what each resource does when deleted on its own. Fixed in 62975bf4c by delegating to the canonical functions rather than replicating their writes.

1. Knowledge bases lost their dependent graph (Bugbot, High). deleteKnowledgeBase also archives the KB's document rows and sets its knowledge_connector rows to archivedAt + status: 'paused'. The bare knowledge_base row update left that graph live — connectors would have kept syncing into a KB the UI reported as deleted. Restore was worse: clearing the tombstone directly would trip kb_workspace_name_active_unique and abort the whole restore transaction on any name collision, where the canonical restore renames.

2. Folder delete bypassed table delete locks (Bugbot, Medium). deleteTable gates archiving on deleteLocked inline in its WHERE, because archiving destroys access to every row. Deleting the folder around a locked table was a way around that control. The table config now declares a guardDelete that refuses the whole folder up front — checked across the entire subtree, so the cascade never archives half the tables and then stops at a locked one — and surfaces 423, matching TableLockedError.statusCode rather than inventing a different status for the same condition.

3. Re-deleting an archived folder stranded its children permanently (Greptile, P1). A second DELETE on an already-archived folder stamped still-active children with a new timestamp while the folder row kept its original (the folder UPDATE is guarded by isNull(deletedAt)). Restore matches the folder's stamp, so those children could never come back. deleteFolder now reuses the folder's existing deletedAt, which also heals the stragglers into the original snapshot instead of orphaning them — and makes a repeated delete idempotent.

Supporting changes:

  • deleteKnowledgeBase and deleteTable gained an optional options.archivedAt, so a bulk caller can stamp one shared timestamp. This mirrors archiveWorkflow's existing option of the same name — the precedent the workflow cascade already depends on. Defaulted to now; every existing single-resource caller is unchanged.
  • Added the restoreChildren hook that pairs with archiveChildren. Their asymmetry was the underlying defect: an archive hook exists precisely because archiving touches more than the child row, so it requires a matching restore. cascade.test.ts now asserts that pairing across FOLDER_RESOURCES, so a future resource declaring one without the other fails the suite.
  • Hook-based restores run after the folder transaction commits. The canonical restores open their own transactions (with FOR UPDATE and collision-rename retries); nesting them would hold folder-row locks across a second connection. The default row-update path still runs inside the transaction.
  • One shared folderMutationStatus. The routes had three copies of the code→status mapper and the DELETE handler's was an inline ternary covering only two codes — locked would have returned 500 there even with the guard working. It lives in lib/folders/status.ts, deliberately outside lifecycle.ts so route tests that mock the lifecycle module don't stub out a pure mapping function.
  • resourceType is now a required argument on deduplicateFolderName and listFoldersForWorkspace. Both defaulted to 'workflow', which is exactly the silent-wrong-answer footgun that would bite the KB/Tables follow-ups: forget the argument and you dedup against the wrong tree, then fail on the unique index. Now a compile error.

Review round 2 — cascade ordering made partial failures permanent

Both round-2 findings were the same defect in opposite directions, and both were introduced by round 1: the hooks that walk resources one at a time through their canonical delete/restore can fail partway, and the cascade was ordered so that a mid-loop failure could never be retried into a correct state. Fixed in 7fe4e0373.

Delete stamped children before folders. A failure left some children soft-deleted at T1 with the folder still active, so the retry minted a fresh T2 and the T1 children fell outside every future restore. This is also why the round-1 P1 fix was incomplete on its own — reusing the folder's existing stamp only helps if the folder already has one. Folders are now stamped first, so the retry reuses T1 and sweeps the stragglers into the original snapshot.

Restore had the mirror image. Folders came back first, so a later child failure left the root's deletedAt cleared, and every retry short-circuited on the already-restored early return — reporting success with zero counts while the remaining resources stayed archived. Silent partial success is the worst possible shape for this. Hook-driven child restores now run before the folder transaction, so a failure leaves the folder archived and the whole restore retryable.

The tradeoff, stated plainly: both orderings now allow a transient window where a folder and its contents disagree — the inverse of what I originally optimized for ("a concurrent reader never sees an active resource inside a folder that has vanished"). That was the wrong thing to optimize for. The window is transient and self-healing on retry; the alternative silently and permanently strands data. The reasoning is written into the TSDoc on archiveFolderCascade and restoreFolder so it doesn't get "fixed" back.

Also in this round: the folder DELETE handler now rethrows HttpError instead of flattening it to a 500. deleteTable can still raise a 423 TableLockedError if a lock lands in the window between the subtree guard and the archive, and withRouteHandler already maps HttpError.statusCode correctly — the route's own catch was swallowing it.

Review round 3 — two follow-ons from the reordering

Nested stragglers were unreachable on retry (Greptile, P1). The delete subtree walk still collected active folders only. Because round 2 made the cascade stamp folders before children, a failure during the child pass leaves intermediate folders archived too — they then fail the IS NULL filter, drop out of the walk, and every resource beneath them becomes permanently unreachable. My round-2 reasoning ("children of the archived root are still found by parentId") only covered direct children of the root. The walk now admits folders that are active or already carry this cascade's own timestamp, which reaches the stragglers while still excluding folders archived independently under a different stamp. On a first delete no folder carries the new timestamp, so it is exactly equivalent to the old walk — the change only has effect on the retry path.

Locking was only half-confined to workflows (Bugbot, Medium). I gated assertFolderMutable but left the admin check and the locked write ungated — so an admin could persist locked: true on a kb/table folder (a column nothing reads for those types), and a non-admin update that merely mentioned locked was 403'd on a type where locking doesn't exist. This directly contradicted the invariant stated above, which is the giveaway that it belonged in the config rather than in scattered checks. Locking is now a declared capability (supportsLocking, set only on workflow), enforced at three levels: the routes reject the field outright with a 400, the admin gate and lock checks moved behind the capability, and the engine refuses to write it as a backstop for the copilot tools that bypass the route. This also removes the last three hardcoded resourceType === 'workflow' branches from the routes — that they survived round 1 is exactly how the gap got through.

Known follow-up (not fixed here, fails closed)

lib/resources/orchestration/restore-resource.ts dispatches its 'folder' arm to the workflow-bound performRestoreFolder, so it cannot restore an archived knowledge-base or table folder. It fails closed — a non-workflow folder id returns "Folder not found" rather than acting on the wrong tree — and the archived-resource listing that feeds it is itself resourceType-scoped, so no broken entry is surfaced today. POST /api/folders/[id]/restore already supports all three types. The KB/Tables follow-ups need to add arms to that union when they surface archived folders in their own recently-deleted lists.

Smaller decisions worth a line each

  • file stays in FOLDER_RESOURCES but is refused at the boundary. The config is the complete data set for all four types; servedFolderResourceTypeSchema narrows the API to workflow | knowledge_base | table. File folders still write the legacy workspace_file_folders table — that cutover is separate and still pending, and accepting file here would point the API at the wrong storage.
  • /api/folders/[id]/duplicate stays workflow-only. It duplicates the workflows contained in the folder, which has no kb/table analogue. Untouched, and it still filters resourceType = 'workflow'.
  • resourceType is required on the id-keyed routes (as a query param on PUT/DELETE /api/folders/[id]). Folder ids are UUIDs and cannot collide, but without the filter a caller holding a knowledge-base folder id could drive it through the workflow surface. Every folder query in the PR carries its resourceType filter, including id-keyed lookups.
  • One widened cleanup predicate, not one target per type. background/cleanup-soft-deletes.ts now hard-deletes folder rows for workflow | knowledge_base | table via a single inArray — same table, same soft-delete column, same workspace scoping, so splitting it would only multiply the batched scans. file stays excluded so the pass never touches rows it does not own.

Type of Change

  • New feature
  • Bug fix
  • Breaking change
  • Documentation
  • Other: internal refactor + one deliberate behavior change (see above)

Testing

New: apps/sim/lib/folders/cascade.test.ts (25 tests). The folder engine that preceded this one shipped with no cascade tests at all — that was the single biggest gap and it is closed here. Coverage:

  • Subtree resolution for both the active (delete) and archived (restore) directions, including that a parent cycle terminates the walk instead of recursing forever.
  • Timestamp isolation — every cascade statement is asserted to carry the folder's own soft-delete timestamp, so independently-deleted siblings are provably not swept in or revived.
  • archiveChildren delegation: the hook receives the resolved subtree and the child table is never written directly.
  • Dependent restore keyed by the restored child ids, and skipped entirely when nothing was restored.
  • An explicit "fixed statement count regardless of subtree depth" assertion over a six-folder subtree — this is the regression guard for the N+1 restore.
  • FOLDER_RESOURCES invariants: one entry per type, each keyed consistently with its own resourceType, distinct cascade count keys, and every buildSoftDeleteSet provably writing the property named by its own deletedKey.

Plus route tests for the reorder 409 and for a delete-locked resource surfacing as 423, unit tests on folderMutationStatus, a restoreFolderRows timestamp test, two FOLDER_RESOURCES invariants added in review round 1 (every archiveChildren has a restore counterpart; tables declare a delete guard), and two ordering tests added in round 2 — one asserting the folder UPDATE precedes the child UPDATE, and one asserting the folder stamp is already in place at the moment an archiveChildren hook is invoked, which is the property that actually makes the retry safe.

Gates: bunx tsc --noEmit clean in apps/sim and packages/testing; bun run lint, check:api-validation, check:migrations, check:react-query, check:client-boundary all pass. Vitest: 386 files / 4784 tests green across lib/folders, app/api/folders, lib/workflows, lib/knowledge, lib/table, hooks, app/workspace, background, lib/copilot, lib/resources, app/api/knowledge, app/api/table.

Reviewers should focus on: the restore behavior change above; the FOLDER_RESOURCES entries read as a schema diff (columns, deletedKey vs buildSoftDeleteSet, membership scope); and that no folder query lost its resourceType filter.

I also ran a separate sweep of every query against the folder table across the repo — routes, copilot/VFS, logs, forking, admin v1, background — checking each for its resourceType constraint, since kb/table rows becoming real turns a previously-harmless missing filter into a cross-type leak. All scoped; the only unconstrained write is a name update already pinned by primary key inside a scoped transaction.

No migration — #6014 already added the folder table, the resourceType enum, and the folder_id columns on knowledge_base and user_table_definitions. Those columns are all-NULL today; this PR is the first writer. KB and Tables folders are greenfield: no legacy table, no existing rows, nothing to backfill.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

No UI in this PR — it is the shared backend layer. The KB and Tables folder UIs land in the two follow-ups.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 29, 2026 2:06am

Request Review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@cursor

cursor Bot commented Jul 29, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Large refactor of folder delete/restore/cascade semantics across workflows, knowledge bases, and tables, including a deliberate change to workflow dependent restore behavior and new failure modes (423 locks, 409 conflicts).

Overview
Introduces a generic folder engine (lib/folders/config, cascade, lifecycle) and moves workflow-only orchestration (~620 lines) to thin wrappers that pass resourceType: 'workflow'. Folder APIs and React Query now take resourceType (workflow | knowledge_base | table; file excluded at the contract) on list/create/update/delete/restore/reorder, with per-type query cache keys so trees do not overwrite each other.

Workflow-only locking is gated via supportsLocking on the config (routes return 400 for locked on KB/table; assertFolderMutable only when locking applies). Delete/restore cascades use one shared timestamp, delegate KB/table/workflow child work to canonical services (not bare row updates), enforce delete-locked tables as 423, and fix retry ordering (folders stamped before children on delete; hook restores before folder rows on restore). Reorder reparent sibling name clashes map to 409 instead of 500; cycle checks are wouldCreateFolderCycle(..., resourceType).

Deliberate workflow restore change: dependents (schedules, webhooks, etc.) are restored only when their archivedAt matches the folder cascade timestamp—user-disabled dependents are no longer silently re-enabled. Soft-delete cleanup widens to workflow | knowledge_base | table folder rows.

Reviewed by Cursor Bugbot for commit bda9fc5. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/folders/config.ts
Comment thread apps/sim/lib/folders/config.ts
Comment thread apps/sim/lib/folders/cascade.ts Outdated
@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a shared resourceType-driven folder engine (config, cascade, lifecycle) so workflow, knowledge_base, and table folders share one create/update/delete/restore path, with thin workflow wrappers and resourceType-aware API/hooks.

  • Introduces FOLDER_RESOURCES config plus cascade/lifecycle modules for generic folder ops
  • Threads resourceType through folder API routes, contracts, React Query keys, and cleanup
  • Delegates workflow/KB/table child archive/restore to canonical services with shared timestamps
  • Fixes cascade ordering and subtree collection so partial delete/restore failures stay retryable

Confidence Score: 5/5

Safe to merge; prior cascade timestamp and nested-straggler issues are fixed and no remaining blocking failures were identified on this follow-up pass.

Prior cascade re-delete stamp desync and nested-straggler retry gaps are addressed in the current delete/collect path; no blocking failure remains for this PR.

Important Files Changed

Filename Overview
apps/sim/lib/folders/cascade.ts Shared cascade stamps folders before children, collects retry subtrees by active-or-same-timestamp, and restores only matching timestamps.
apps/sim/lib/folders/lifecycle.ts deleteFolder reuses existing deletedAt; restoreFolder runs hook children before clearing folder tombs so partial failures stay retryable.
apps/sim/lib/folders/config.ts Per-resource config wires workflow/KB/table archive and restore hooks, delete guards, and locking capability.
apps/sim/lib/folders/cascade.test.ts Covers subtree retry admission, stamp ordering, timestamp isolation, and FOLDER_RESOURCES pairing invariants.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  API["Folder API routes"] --> Life["lifecycle.ts"]
  Life --> Config["FOLDER_RESOURCES"]
  Life --> Cascade["cascade.ts"]
  Cascade --> Folders["folder rows + shared timestamp"]
  Cascade -->|default path| ChildUpdate["child / dependent UPDATEs"]
  Cascade -->|hooks| Canonical["archive/restore via KB/table/workflow services"]
Loading

Reviews (5): Last reviewed commit: "fix(folders): route the delete lock chec..." | Re-trigger Greptile

@waleedlatif1
waleedlatif1 force-pushed the feat/generic-folder-engine branch from 6e17144 to 62975bf Compare July 29, 2026 01:31
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/folders/lifecycle.ts Outdated
Comment thread apps/sim/lib/folders/lifecycle.ts Outdated
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/folders/lifecycle.ts Outdated
Comment thread apps/sim/app/api/folders/[id]/route.ts
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/api/folders/[id]/route.ts
Folder create/update/delete/restore/reorder existed once per resource
type in intent but only once in code, hardcoded to workflows. Extract a
config-driven engine so knowledge bases and tables get folders from the
same implementation instead of a fourth copy.

- lib/folders/config.ts declares FOLDER_RESOURCES: the child table, its
  id/folderId/workspace/soft-delete columns, the TS property key backing
  the soft-delete column, membership scope, audit label, and cascade
  count key. Workflow-only behavior (archiving through the workflow
  lifecycle, the last-workflow delete guard, restoring schedules and
  webhooks) is declared as data on the workflow entry, not branched on.
- lib/folders/cascade.ts performs archive/restore under one shared
  timestamp, in a fixed number of statements regardless of subtree depth.
- lib/folders/lifecycle.ts is the single create/update/delete/restore
  implementation; the workflow perform* functions become thin wrappers so
  existing callers are untouched.
- Widen the folder contract to workflow | knowledge_base | table and
  thread resourceType through all four routes and the React Query hooks,
  including the query keys so the three folder trees stop sharing a cache
  entry.
- Move the folder cycle check out of lib/workflows/utils.ts: it was
  hardcoded to resourceType 'workflow' and could walk out of one
  resource's tree into another's.
Review found three ways the generic cascade diverged from the behavior each
resource defines for itself.

- Deleting an already-archived folder stamped its still-active children with
  a fresh timestamp while the folder row kept the original. Restore matches
  the folder's stamp, so those children were stranded permanently. Delete now
  reuses the folder's existing deletedAt.
- A knowledge base is more than its own row: deleteKnowledgeBase also archives
  its documents and pauses its connectors. The generic row update left that
  graph live, so a connector kept syncing into a KB the UI showed as deleted.
  The kb config now delegates to the canonical delete and restore.
- deleteTable refuses to archive a delete-locked table, because archiving
  destroys access to every row. Deleting the folder around it bypassed that
  control entirely. Tables now declare a guardDelete that refuses the whole
  folder up front, so the cascade never archives half a subtree and then stops.

deleteKnowledgeBase and deleteTable gain an optional archivedAt so a bulk
caller can stamp one shared timestamp, matching archiveWorkflow's existing
option; single-resource callers are unchanged. Adds the restoreChildren hook
that pairs with archiveChildren, sequenced after the folder transaction commits
since the canonical restores open their own.

Also: make resourceType a required argument on deduplicateFolderName and
listFoldersForWorkspace so a future caller cannot silently operate on the
workflow tree, and give folder routes one shared status mapper rather than
three copies that already disagreed.
The hooks that walk resources one at a time through their canonical
delete/restore can fail partway. Both cascades were ordered so that a
mid-loop failure became permanent.

Delete stamped children before folders. A failure left some children
soft-deleted at T1 with the folder still active, so the retry minted a
fresh T2 and the T1 children could never be matched by any restore.
Folders are now stamped first, which makes the retry reuse that stamp and
sweep the stragglers into the original snapshot.

Restore had the mirror image: folders came back first, so a later child
failure left the root's deletedAt cleared and every retry short-circuited
on the already-restored early return, stranding whatever had not come
back. Hook-driven child restores now run before the folder transaction, so
a failure leaves the folder archived and the whole restore retryable.

Both orderings trade a transient window where the folder and its contents
disagree for a failure that heals on retry instead of silently losing data.

Also rethrow HttpError from the folder DELETE handler: deleteTable can
still raise a 423 TableLockedError if a lock lands between the subtree
guard and the archive, and the catch was flattening it to a 500.
… workflows

Two follow-ons from the cascade reordering.

The delete subtree walk still collected active folders only. Since the
cascade now stamps folders before children, a failure during the child pass
leaves intermediate folders archived, and an active-only walk drops them —
so resources still live underneath were never reached on the retry and
stayed outside every later timestamp-matched restore. The walk now admits
folders that are active OR already carry this cascade's own timestamp, which
reaches the stragglers while still excluding folders archived independently
under a different stamp.

Locking was only half-confined to workflows. `assertFolderMutable` was gated
but the admin check and the `locked` write were not, so an admin could
persist `locked` on a knowledge-base or table folder — a column nothing reads
for those types — and a non-admin update that merely mentioned `locked` was
rejected on a type where locking does not exist. Locking is now a declared
capability on the resource config; the routes reject the field outright for
types that lack it, and the engine refuses to write it as a backstop for the
copilot tools that call it directly.
The DELETE handler was the one branch still testing resourceType directly
after locking became a declared capability, so a future lockable resource
would silently skip the mutable check on delete.
@waleedlatif1
waleedlatif1 force-pushed the feat/generic-folder-engine branch from f07194a to bda9fc5 Compare July 29, 2026 02:06
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit bda9fc5. Configure here.

@waleedlatif1
waleedlatif1 merged commit 5b7cf99 into staging Jul 29, 2026
20 checks passed
@waleedlatif1
waleedlatif1 deleted the feat/generic-folder-engine branch July 29, 2026 02:30
waleedlatif1 added a commit that referenced this pull request Jul 29, 2026
…ing 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
waleedlatif1 added a commit that referenced this pull request Jul 29, 2026
…bles folders (#6045)

* feat(folders): cut file folders over, and give knowledge bases and tables folders

Builds on the generic resourceType-driven folder engine to finish the migration
and extend it to two more resource trees.

File-folder cutover
- Repoints all 30 `workspace_file_folders` query sites onto `folder` scoped to
  `resourceType = 'file'`, id-keyed lookups included — a caller can hand a
  workflow folder's id to a file-folder endpoint, and only the predicate stops it
- Adds the missing restore-conflict handling: a file folder whose name was taken
  while it was archived now deduplicates against the RESOLVED parent (restore
  re-roots when the original parent is still archived) instead of being
  permanently unrestorable
- The forking mapper's `resourceType` predicate lives in the `leftJoin`
  condition, not the WHERE, where it would silently make the outer join inner
- `servedFolderResourceTypeSchema` gains `file`; the soft-delete sweep enumerates
  all four types rather than dropping its predicate

Knowledge Base and Tables folders
- `knowledge_base.folder_id` and `user_table_definitions.folder_id` are now
  written and served; both create/move paths admit a folder only through
  `findActiveFolder`, scoped to workspace AND resourceType
- Restoring a knowledge base whose folder is still archived re-roots it rather
  than filing it somewhere the page never renders; a workspace move re-roots for
  the same reason
- Both pages get folder rows, breadcrumbs, inline rename on rows and on the
  breadcrumb, move-to submenus, pin/unpin, and drag-a-row-onto-a-folder

Shared folder UI
- One `components/folders/` module now backs Knowledge, Tables, and Files:
  `folderBreadcrumbItems` (data for `Resource.Header`, which owns the crumb
  chrome), `folderRow`, `folderRowId`/`parseFolderedRowId`, `buildMoveOptions` /
  `buildDescendantIndex` / `renderMoveOptions`, `nextUntitledFolderName`,
  `FolderContextMenu`, `useFolderNavigation`, `useFolderRowDragDrop`
- Deletes the per-page copies: `files/move-options.tsx` and the Tables-local
  folder context menu

Recently Deleted
- Restores folders of every type, not just workflow folders, driven by one
  declarative table rather than a branch per resource

Folder pins resolve against `folder` unscoped: file, knowledge-base, and table
folders are all pinnable and all live there, and a folder id addresses exactly
one row.

* fix(folders): close the folder-engine audit findings

Data loss
- Migration 0274 catches up file folders created AFTER 0272. That backfill is guarded
  by `WHERE NOT EXISTS (… resource_type = 'file')`, so it fires once; every file folder
  created between it and this cutover exists only in `workspace_file_folders`. Without
  the catch-up they vanish from Files on deploy and their contents become unreachable

Authorization
- Archived folders were mutable, which bypassed the folder lock: the folder routes and
  `updateFolder` filtered id + resourceType but never `deletedAt`, while
  `getFolderLockStatus` does — so an archived-but-locked folder reported unlocked. Lock a
  subfolder, delete its parent, and any write-level member could rename and reparent it.
  PUT and the engine now require an active row; DELETE deliberately still does not, because
  it reuses an archived folder's own `deletedAt` to make a partial cascade retryable
- `v1/admin/workflows/import` wrote `folderId` with no validation at all. Migration 0272
  dropped the FK, so it accepted a knowledge-base folder, another workspace's folder, or
  garbage — and the workflow then rendered under no sidebar node while still executing,
  billing, and escaping the folder delete cascade. It now runs the same
  `assertFolderInWorkspace` + `assertFolderMutable` pair as `v1/workflows/import`

Restore no longer loses state
- A folder restore ran a bare `archivedAt = null` over its workflows, while the delete had
  routed through `archiveWorkflow` — which also disables schedules, webhooks, chats, and MCP
  tools. Restoring a folder reported success while every schedule stayed disabled forever
  and every chat 404'd. The workflow tree now restores through `restoreWorkflow`, like the
  knowledge-base and table trees already did. Deployment state is still deliberately not
  revived, matching the single-workflow restore
- Those canonical restores re-root a resource whose folder is archived — correct on its own,
  catastrophic inside a cascade, which restores children BEFORE the folder rows and would
  therefore dump every child at the workspace root. `resolveRestoredFolderId` takes the
  subtree being restored and exempts it. `restoreTable` gained the re-root check it lacked

Correctness
- `getFolderPath` in `lib/folders/tree.ts` walked `parentId` with no `visited` set, so a
  cycle in the client cache — reachable through `useReorderFolders`' unchecked optimistic
  write — hung the tab. Its twin already had one. Dead `buildFolderMap` deleted
- The sidebar's "New folder" inside a folder hardcoded the name, so the second one 409'd,
  was swallowed with only a log line, and the user got no folder and no message. It now
  names through the existing `generateSubfolderName` and surfaces failures
- `chunkedBatchDelete` deleted by id with no re-check, so a resource restored between the
  SELECT and the DELETE was hard-deleted anyway. Callers now re-assert their soft-delete
  predicate on the DELETE
- Recently Deleted and the `restore_resource` tool cover every folder tree, not just
  workflow folders, so deleted knowledge-base and table folders are recoverable

Cleanup
- The folder duplicate route stops steering control flow through `throw new Error('literal')`
  matched by string, reuses the engine's `nextFolderSortOrder` instead of recomputing it, and
  names its workflow scope once
- `servedFolderResourceTypeSchema` documents that its default applies only to an omitted
  value; a present-but-unrecognized one is rejected, never coerced to `workflow`
- The `workspace_file_folders` comment described the table as unread and unwritten and
  invited a DROP. It was live until this cutover, and it is now the rollback copy — the
  comment says so, and names what must be true before anyone drops it
- Pinned rows carry a small non-interactive pin glyph again; they sort to the top and had
  nothing explaining why since the inline pin button was removed

Tests
- New `lib/folders/lifecycle.test.ts` covers create/update/delete/restore, the re-root, and
  the 23505 mappings; `cleanup-soft-deletes.test.ts` now pins the folder target's
  resourceType predicate, which is what keeps the sweep off a not-yet-cut-over type

* improvement(folders): show the pin glyph on Files rows and pin the deploy contract

Files rows were left without the pin indicator the other two lists gained, so a pinned
file or folder still sorted to the top of the Files page with nothing explaining why.

Adds `lib/api/contracts/folders.test.ts`, which pins the three cases that together ARE
the rolling-deploy contract: an omitted `resourceType` defaults to `workflow` (so a client
predating the field keeps working), a present-but-unrecognized one is rejected rather than
coerced, and every served tree is accepted.

* fix(folders): close the findings from the full-diff audit

Migration 0274 reconciles instead of catching up
- The deployed manager writes `workspace_file_folders` EXCLUSIVELY, so `folder`'s file rows
  are frozen at the 0272 snapshot: every rename, move, delete, and restore since then lives
  only in the legacy table. An insert-only catch-up left all of that diverged — renames
  reverted, deleted folders came back as active phantoms, and a stale-active row could hold
  the name a newer folder legitimately took
- That last case was silent: the bare `ON CONFLICT DO NOTHING` swallows a NAME violation as
  readily as an id one, so the newer folder was discarded and every file inside it stranded.
  The conflict target is now `(id)`, so a name collision — which would mean the source
  violated its own unique index — fails loudly instead
- Reconciles by parking mirrored names first, then upserting, so no transient state can
  collide with the partial unique index. Both statements are idempotent, and the header says
  what nothing else would: this has to be run again after the deploy drains, because old pods
  keep writing the legacy table until they are gone

`/api/folders` no longer serves file folders
- Adding `'file'` to `servedFolderResourceTypeSchema` opened a second writer over the same
  rows. It bypassed the `workspace_file_folders:${workspaceId}` advisory lock that makes the
  manager's check-then-write pairs atomic, and it accepted names containing `/`, `\`, `.` and
  `..`, which the file surface forbids because a folder name becomes a path segment — a
  folder named `a/b` is then unreachable by path forever, and breadcrumbs resolve it as two
  segments. The storage cutover never needed a second API, so there isn't one

Conflicts that returned 500
- `v1/admin/workspaces/[id]/import` created its folder with an unserialized SELECT-then-
  INSERT, so two imports sharing a folder path failed the whole import. The name is
  server-chosen, so it now adopts whichever folder won, like `ensureWorkspaceFileFolderPath`
- Folder duplicate never caught 23505. `newId` is client-supplied, so replaying a duplicate
  whose response was lost hit the primary key and returned 500 instead of a 409 the caller
  can act on

Performance
- `knowledgeKeys` moved to `hooks/queries/utils/knowledge-keys.ts`. Reading it from
  `hooks/queries/kb/knowledge` pulled a ~1000-line hook module — and the `@sim/emcn` barrel
  behind it — into `hooks/queries/folders`, which three server prefetches import. That is the
  same import edge the navigation-speed audit measured at 11.8MB per workspace route;
  `tableKeys` already lived in a keys-only module for exactly this reason

Merge drop
- Knowledge never got the chrome Tables did: its prefetch now fetches the folder tree
  alongside the bases, so the list does not paint ungrouped and a `?folderId=` deep link does
  not render an empty breadcrumb, and its loading fallback declares the "New folder" action so
  the header does not shift on hydration

Comments corrected to match reality
- The shared folder row and context menu claimed Files as a consumer; Files still builds its
  own row and routes folders through `FileRowContextMenu`, which the TSDoc now says
- `schema.ts` prescribed a row COUNT comparison before dropping the legacy table. The
  divergence 0274 repairs is in row CONTENTS, which a count check passes straight through

* fix(folders): address the review round and the full-PR audit

Reverts the workflow restore hook — it was the wrong fix
- Greptile flagged that restoring a folder leaves schedules disabled and webhooks/chats
  inactive. The hook added last round routed workflows through `restoreWorkflow` to address
  it, but `restoreDependents` ALREADY clears exactly those columns, in bulk, inside the
  restore transaction and matched on the archive timestamp. The hook bought nothing and cost
  a per-workflow read/transaction/read OUTSIDE that transaction — roughly 1600 round trips
  for a folder of 200 workflows — plus a window where the workflows were active and the
  folder was not, and it made `restoreDependents` dead code
- What no restore path can undo is the state archive OVERWRITES: `status: 'disabled'` with
  `nextRunAt` cleared, `isActive: false`. Archive does not record what those were, so
  restoring them to a constant would re-enable a schedule the user had disabled and re-run a
  completed one. Left explicit — redeploy re-activates a schedule — matching deployment state,
  which restore also deliberately leaves off. Documented on the config rather than guessed
- Kept the genuine bug the review surfaced underneath it: `restoreWorkflow` un-archived every
  dependent by `workflowId` alone, resurrecting a webhook or chat the user had archived days
  earlier. Now matched on the workflow's own `archivedAt`, which is the semantics this feature
  documents

Bugbot: orphaned knowledge bases vanished from every view
- Knowledge filtered on a raw `folderId === currentFolderId` and never re-rooted a base whose
  folder is missing or archived, so a base restored on its own — or left behind by a partial
  cascade — was invisible everywhere. Tables already fell those back to the root

A dead `?folderId=` is now healed instead of being a dead end
- A bookmark to a deleted folder rendered the root title over an empty grid, hid everything
  actually at the root, and — worse — left every create/upload action targeting the dead id,
  filing new resources somewhere nothing could reach. `useFolderNavigation` clears it once the
  folder list resolves, which fixes Files, Knowledge, and Tables at once

Parity gaps found by auditing against Files
- Tables sorted folders newest-first on a clean URL while Files and Knowledge sorted A→Z: its
  sort params are defaulted, so the raw column was never null. Reads `activeSort` now
- On Tables you could not delete the folder you were standing in — its own row is not in the
  list, and the crumb menu offered only Rename, which also made the step-out branch
  unreachable dead code
- A failed knowledge-base move was logged and never surfaced, so a rejected move — from the
  submenu or from a drag — looked like nothing happened
- A drag begun in another mount of the page could never be dropped: `onDragOver` bailed before
  `preventDefault`, so no drop event ever fired and the `dataTransfer` fallback in `onDrop`
  was unreachable. External/foreign drags are still ignored, so an OS file dropped on the list
  cannot navigate the tab away

Files: a folder cycle crashed the page
- The folder-size roll-up recursed with no `visited` guard, so a parent/child cycle in the
  cached tree — reachable through the optimistic folder-move write — recursed until the stack
  blew and the page went blank. Also indexes children once instead of re-scanning per node

Migration 0274 is now atomic
- 0272 ends with an embedded COMMIT for its CONCURRENTLY index builds, so this file is not
  guaranteed to run inside drizzle's batch transaction. Wrapped in a DO block so the parking
  pass cannot commit without the reconcile, which would leave every mirrored folder holding a
  placeholder name
- Validated with a read-only dry run: no active name duplicates, no orphaned or
  cross-workspace parents, and no id collisions with `workflow_folder`, so the insert cannot
  trip the unique index, the FKs, or the resource-type trigger

Smaller corrections
- Knowledge indexed its members for the owner comparator instead of two linear scans per
  comparison, and logs a load failure from an effect rather than the render body
- Files uses the shared root sentinel instead of a bare `'__root__'` in two places
- `restore_resource` documents that its two new folder types are unreachable until the enum in
  the copilot service's tool catalog — a different repository — widens
- Corrected a forking comment that still described file folders as a separate table

* docs(folders): record the 0272 + 0274 dry-run results on the migration

A read-only dry run materialises the complete post-0272 `folder` table from both source
tables and asserts every constraint it declares: no NULL names, no primary-key collisions
between the two source tables, no unique-index violations, no resource-type or workspace
trigger violations, and no parent/user/workspace FK violations. 0272's dedup renames only
workflow folders, never file folders — the latter is what lets pass 2 write raw names.

* fix(folders): close the findings from the line-by-line audit of the PR

Data integrity
- Workspace fork copied `folderId` verbatim onto the child's tables and knowledge bases. The
  column carries no workspace, so a forked row pointed at a folder owned by the SOURCE
  workspace — invisible in the fork, and mutated from under it when the source later deleted
  that folder (`ON DELETE SET NULL`). Latent until now because nothing wrote a non-null
  `folderId` for those two resources; this PR is what makes it reachable. Forked resources land
  at the root, like forked files already did

Interaction bugs
- Clearing a dead `?folderId=` wrote through the params' default `history: 'push'`, so Back
  returned to the dead URL, which healed and pushed again — the Back button was dead on that
  page. It replaces now: opening a folder is navigation, correcting a URL that never pointed
  anywhere is not
- That same heal keyed off `isLoading`, which is false for a DISABLED query, false for an
  ERRORED one, and false while `keepPreviousData` is showing the previous workspace's folders.
  In all three the list is empty or stale, so a transient fetch failure — or a workspace switch
  — threw away a perfectly good open folder. Gated on `isSuccess && !isPlaceholderData`
- "New folder" while a search was active created the folder but never showed it: the search
  filters folders too, so the new row did not match, the rename field never appeared, and the
  create read as a no-op. Both pages clear the search so the thing just created is on screen
- The drag ghost was removed only by `dragend`, which fires on the source row. The table is
  virtualized, so scrolling the source out of view mid-drag unmounted it, the event never
  arrived, and the ghost stayed on the page with every row stuck at drag opacity. Cleaned up on
  unmount as the backstop
- A drag begun in another mount highlighted every folder as a valid target — including the
  dragged folder itself — then silently did nothing on drop. It only highlights when the source
  is known and was actually checked

Correctness
- The folder-duplicate route caught 23505 across the WHOLE handler, so a unique violation
  raised while copying the workflows inside the folder was relabelled "a folder with this name
  already exists". Scoped to the folder INSERT, which is the only place the two conflicts the
  caller can act on (client-supplied `newId` replay, concurrent name take) actually arise
- The optimistic folder create derived its sort order from the workspace's WORKFLOWS regardless
  of resource type. Only the workflow tree interleaves folders and resources in one ordering
  space, so a knowledge-base or table folder was placed against an unrelated one
- A table archived between `checkAccess` and the move surfaced as a 500 with a "not found"
  body; it is a 404
- `FOLDER_RESOURCE_TYPE_BY_RESTORABLE` was a `Partial` record, so adding a folder tree without a
  mapping would compile and silently restore into the workflow tree. Total record now
- Recently Deleted paired query results to folder trees by ARRAY POSITION; reordering the const
  would have filed knowledge folders under Tables with no type error. Keyed by type
- The knowledge move's no-op guard compared against the snapshot taken when the menu opened
  rather than the live row

Performance — the module split now actually does what it claimed
- `knowledge-keys.ts` justified itself as keeping server prefetches off the ~1000-line
  `kb/knowledge` module, but `knowledge/prefetch.ts` still imported its stale-time constant from
  exactly that module, whose first line pulls the `@sim/emcn` barrel. Worse, this PR had ADDED
  the same class of edge to four prefetches via `FOLDER_LIST_STALE_TIME`/`mapFolder`
- `FOLDER_LIST_STALE_TIME`, `mapFolder`, and `KNOWLEDGE_BASE_LIST_STALE_TIME` now live beside
  their key factories, so all four server prefetches import only keys-and-constants modules.
  `mapFolder` keeps its explicit field list rather than a spread, so the cached shape cannot
  drift from a client fetch

Honesty in comments
- The KB retention `deleteFilter` narrows the restore race but does not close it — documents
  hard-deleted by `onBatch` do not come back, so a restore landing mid-batch returns an emptied
  base. Said so
- A failed folder restore leaves children active while their folders are still archived. They
  are reachable (both lists fall an unresolvable `folderId` back to the root) but they sit at
  the root until the restore is retried. Said so

Product policy made visible
- Restore deliberately does not re-enable schedules, webhooks, or chats, because archive
  overwrites their state without recording it. A deliberately-disabled schedule or webhook is a
  common state rather than an edge case, so reactivating to a constant would be wrong more often
  than right. Recently Deleted now says so at the moment of restore instead of leaving it to be
  discovered

Smaller
- Pin glyph: dropped the doubled gap, added `role='img'` so the state is announced
- "Move here" carries the folder glyph its sibling leaves do
- `useMoveTable` had stolen `useDeleteTable`'s doc block
- Dead `.returning` column and a `?? null` on a non-nullable param in `moveTableToFolder`
- Tables reads one sort source for both blocks, matching Knowledge
- New test pins that `updateFolder` refuses an archived folder — the guard that stops a delete
  from unlocking its locked subfolders

* fix(tables): re-read live placement before skipping a move as a no-op

`handleMoveTable` and `handleMoveFolder` decided "already there, nothing to do" from
`activeTable`/`activeFolder`, which are snapshots taken when the context menu opened. A refetch
or a concurrent move since then makes that comparison stale, so the write the user just asked
for is silently skipped and the row stays where it was. Both now compare against the live list,
matching the knowledge-base move.

* chore(folders): drop production figures from migration and code comments

This repository is public. The 0274 header and the Recently Deleted policy comment carried
concrete counts and ratios taken from the production database, which is operational detail that
does not belong in open source. Both now state the property that matters — what was asserted,
and why disabled-on-restore is the safe default — without the underlying numbers.

* 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

* fix(folders): gate the orphan fallback on a resolved folder index, not a loading flag

The URL heal was fixed to use `isSuccess && !isPlaceholderData`, but the list filters that
decide "this resource's folderId does not resolve, so show it at the root" were left on
`isLoading` — the same footgun, one layer down.

`isLoading` is false for a disabled query (no workspaceId), false for an errored one, and —
because `useFolders` sets `keepPreviousData` — false while the previous workspace's folders are
still on screen during a switch. In all three the index is empty or belongs to another
workspace, so every foldered knowledge base and table was treated as an orphan and dragged to
the root, or filtered out of a deep-linked folder entirely. A transient fetch failure was enough
to make a folder look empty.

`useFolderNavigation` now exposes `foldersResolved` instead of `isLoading`, so the heal and both
list filters share one signal and the footgun is no longer reachable from the hook's surface.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant