refactor(tables): make lib/table/orchestration the single implementation - #6134
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cursor review |
PR SummaryMedium Risk Overview
Error handling introduces shared Reviewed by Cursor Bugbot for commit d449610. Bugbot is set up for automated code reviews on this repo. Configure here. |
Greptile SummaryThe PR consolidates table mutation behavior into shared orchestration functions.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the previously reported audit-provenance and partial-column-update paths are addressed at the current head.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/table/orchestration/columns.ts | Centralizes column-update guards, option normalization, transactional write selection, error classification, and auditing. |
| apps/sim/lib/table/columns/service.ts | Applies pending renames and requested constraints inside the same locked transaction as type, options, or currency updates. |
| apps/sim/lib/table/orchestration/tables.ts | Centralizes table and row deletion behavior while forwarding actor and request context to successful audits. |
| apps/sim/lib/table/select-options.ts | Normalizes name-only select options while preserving supplied or existing stable option IDs. |
| apps/sim/lib/core/orchestration/types.ts | Defines resource-neutral orchestration errors, status mapping, and optional HTTP request context. |
| apps/sim/app/api/v2/lib/response.ts | Adds consistent v2 response-envelope rendering for orchestration failure classes. |
| apps/sim/app/api/v2/tables/[tableId]/columns/route.ts | Reduces the v2 column endpoint to authorization, parsing, orchestration delegation, and response rendering. |
| apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts | Delegates single-row deletion to shared orchestration instead of bypassing row-lock and ordering services. |
Sequence Diagram
sequenceDiagram
participant Caller as UI / v1 / v2 / Copilot
participant Route as Route or Tool Handler
participant Orch as Table Orchestration
participant Service as Table Service
participant DB as Database
participant Audit as Audit Log
Caller->>Route: Authenticated table mutation
Route->>Orch: Parsed input + actor + optional request
Orch->>Orch: Normalize and preflight guards
Orch->>Service: Single coordinated mutation
Service->>DB: Locked transaction
DB-->>Service: Updated table
Service-->>Orch: Mutation result
Orch->>Audit: Record successful mutation
Orch-->>Route: Typed outcome
Route-->>Caller: Surface-specific response
Reviews (5): Last reviewed commit: "refactor(tables): classify failures by t..." | Re-trigger Greptile
…rkflows OrchestrationErrorCode and statusForOrchestrationError are the contract every lib/[resource]/orchestration module returns against, but they lived inside the workflows module, so resource-neutral code (lib/folders) already had to import from a workflow path. Moved to lib/core/orchestration/types. Adds a 'locked' class mapping to 423. Both tables and workflows have a lock that forbids a mutation, and each caller was translating that to a status itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Column update was implemented four times — the UI route, v1, v2, and the copilot table tool — each calling the same column services but owning its own guards, error mapping, and audit. The copies had drifted, and the drift was the bug: v2 was missing both guards, only the copilot copy minted stable option ids, and only v1/v2 audited. performUpdateTableColumn, performDeleteTable, and performDeleteTableRow now own that logic; all ten call sites reduce to auth, parse, call, render. The guards are asserted once in lib/table/orchestration rather than four times against four routes. Behavior this consolidates, previously true on only some paths: - The typeChanging guard. updateColumnType early-returns on an unchanged type and drops any options sent with it, so restating the current type alongside new options silently discarded them. v2 had no guard at all and, since its contract shares v1's body schema, accepted options and ignored them. - The select-unique guard. Each write is its own locked transaction, so a rename or type change paired with a constraint write that is going to fail commits first and then throws, half-applying the schema change. - Stable select-option ids. Cells reference the option id, so an edit that re-sends an option by name has to reuse it or every cell holding it is orphaned. Only the copilot path did this; normalizeSelectOptionsInput moves to lib/table/select-options and now covers every caller. It preserves a supplied id, so it is a no-op for the fully-formed options the HTTP contracts accept. - required forwarded into the type and options writes, so a conversion validates against the constraint the same request is setting. - An audit on every successful update. The UI route and the copilot tool emitted none. - Single-row delete through the row service. v2 did a raw db.delete, skipping assertRowDelete and deleteOrderedRow, so a delete-locked table returned 200 and the row-count bookkeeping never ran. - The delete actor handed to deleteTable, which audits only when a row was actually archived. v1 and v2 omitted it and audited themselves outside that check, emitting TABLE_DELETED for a no-op delete of an archived table. Failure classes come back as OrchestrationErrorCode; v2 renders them through a new v2ErrorForOrchestration, mirroring statusForOrchestrationError on the v1 and UI surfaces, so a given failure maps to the same status everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
569f89f to
9736288
Compare
|
@cursor review |
There was a problem hiding this comment.
✅ 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 9736288. Configure here.
The base rewrote column update while this branch was extracting it: writes now address the column by stable id, a rename and constraint change ride inside the typed write's transaction instead of following it, and `currencyCode` joins the update shape for the new `currency` column type. performUpdateTableColumn is rebuilt from that implementation rather than the one this branch originally extracted, so the extraction carries the base's logic forward instead of regressing it. All four callers delegate unchanged. lib/table/select-options.ts arrived independently on the base as a client-safe leaf module for option-id resolution; normalizeSelectOptionsInput joins it there rather than replacing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mprovement/orchestration-migration
The base's route tests assert which column service each payload reaches — the behavior that now lives in performUpdateTableColumn. They mocked the `@/lib/table` barrel; the orchestration module imports the service directly, so they mock that too and keep asserting the same thing through the extracted implementation. The orchestration tests move onto the base's semantics: writes address the stable column id, a rename rides inside the write it accompanies rather than running first, and the currency guards replace the non-select options guard the service now owns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lib/table/service.ts` wrote its own audit rows, so whether an operation was audited depended on which function a caller reached for rather than on a user having performed it. That is what let v1 and v2 audit a no-op delete, and what made `deleteTable`'s optional `actingUserId` double as an audit opt-out flag. Worse, most sites fell back to `actingUserId ?? createdBy`, so an unattributed call was logged against the table's *creator*. The copilot `mv` path passed no actor at all: renaming someone else's table recorded them as the renamer. Audit now lives in the orchestration functions — performDeleteTable, performRenameTable, performMoveTableToFolder, performUpdateTableLocks — and the services just write. Internal callers (folder cascade, import rollback) keep calling the service and are silent by construction rather than by remembering to omit an argument. Two services now return what the audit needs: `deleteTable` reports whether it actually archived a row, so a repeat delete logs nothing; `updateTableLocks` returns the before/after locks, since only the locked write can observe the transition its description names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation Moving the audits into the orchestration functions dropped three things the routes had been carrying, and added one the orchestration now owns twice. - The v1 and v2 column-update routes passed `request` to `recordAudit`, so their audit rows recorded the caller's IP and user-agent. The orchestration function had no way to receive it. Every table orchestration function now takes an optional `OrchestrationRequestContext` and every HTTP route forwards it; the copilot and VFS callers, which have no request, omit it. - `classifyTableMutation` matched `TableConflictError` on "already exists" appearing in the message and reported it as `validation`, turning the UI route's 409 on a duplicate table rename into a 400. It now matches the type, the way `performRestoreTable` already did. - `captureServerEvent` ran on every delete while the audit was gated on a row actually being archived, so a repeat delete of an archived table still reported `table_deleted`. Both now hang off the same evidence. - The copilot delete path kept its own `captureServerEvent` from when the service did not emit one, double-counting every copilot table delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a
|
@cursor review |
A copilot `update_column` payload whose only content was the column's current type used to return success with the live schema, while the v1, v2, and UI routes rejected the same payload with "No updates specified". Delegating to `performUpdateTableColumn` unified them onto the routes' rejection — correct, but the message tells the caller its request was empty when it named a type. The orchestration function now reports the same thing `updateColumnType` reports when it loses this race concurrently: the column is already that type, re-issue without the type change. An empty payload still reads "No updates specified". Drops the copilot's `outcome.table ?? tableForUpdate` fallback with it — the comment described the no-op that can no longer reach that line, and a success always carries a table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a
|
@cursor review |
There was a problem hiding this comment.
✅ 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 0ee4499. Configure here.
The table module decided HTTP statuses by searching error messages for phrases. `VALIDATION_MESSAGE_FRAGMENTS` and `ROW_WRITE_ERROR_PATTERNS` held 32 substrings between them, and fifteen more lists were inlined in routes — 83 matchers over 17 files, each its own copy of the guesswork and already drifted apart. It made message wording load-bearing: `TableRowLimitError`'s own doc comment noted that its text had to contain "row limit" for a route to answer 400, and adding "already exists" to a rename message silently demoted a 409 to a 400 (the bug fixed one commit ago, by adding another special case). Services now throw `OrchestrationError`, which carries the transport-neutral `OrchestrationErrorCode` the layers above already speak. Classification is one `instanceof` in `orchestrationErrorResponse` (UI + v1) and `v2CaughtOrchestrationError` (v2). Every pattern list is gone. Wording is free to change; an unclassified error still becomes a generic 500, which is what an unexpected fault should be. `asOrchestrationError` walks the `cause` chain rather than testing the caught value directly: drizzle wraps a throw raised inside a transaction callback in a `DrizzleQueryError` whose own message is the failed SQL, so a bare `instanceof` would drop every failure raised inside `withLockedTable`. That is the same reason `rootErrorMessage` had to dig for a root cause before. Three throws stay bare `Error` deliberately — `Table ID mismatch`, `Workspace ID mismatch`, and `Failed to build upsert conflict predicate` are internal invariants no consumer classified, and they keep falling through to a 500. `Insufficient capacity` was in the pattern list with no producer anywhere in the codebase. Status changes, all deliberate: - `'forbidden'` joins the code union so the table-row-limit ceiling keeps its 403; without it this refactor would have flattened it to 400. - import-async's table-limit rejection: 400 -> 403, matching the two other create routes it had drifted from. - Renaming a table to an invalid name: 500 -> 400. `validateTableName` messages don't contain "Invalid", so no matcher ever caught them. - Restoring a table that isn't archived, or into an archived workspace: 500 -> 400. - A duplicate *column* name stays `validation`/400 rather than becoming a 409 like a duplicate table name. Both v1 and the orchestration have always answered 400 for it; changing a published status is not this refactor's job. The twelve tests that changed were asserting the substring mechanism itself, constructing plain `Error`s with magic strings. They now assert the real contract, plus new cases pinning that identical wording carrying no classification stays internal and keeps its message off the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGzbVDZpe2dEALbu2BUU8a
|
@cursor review |
There was a problem hiding this comment.
✅ 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 d449610. Configure here.
#6150 branched before #6134, so skill-lifecycle.ts imports @/lib/workflows/orchestration/types — the module #6134 moved to @/lib/core/orchestration/types. Git merged a file deletion on one side with a new file referencing it on the other: no textual conflict, broken build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stacked on #5273 — base is
improvement/v2-endpoints, notstaging.The repo's convention is that business logic lives in
lib/[resource]/orchestrationso the Sim UI, the public API, and the copilot tools share one implementation.lib/table/orchestrationexported exactly one function (performRestoreTable); everything else was implemented per-caller. Column update existed four times — the UI route, v1, v2, and the copilot table tool — each calling the same column services but owning its own guards, error mapping, and audit.The copies had drifted, and the drift was the bug. This PR extracts the logic and deletes the copies; the fixes fall out of the extraction rather than being patched at each call site.
performUpdateTableColumn,performDeleteTable, andperformDeleteTableRownow own the logic, and all ten call sites reduce to auth → parse → call → render. Net ~570 lines deleted from routes.Behavior this consolidates
Each of these was previously true on only some paths:
typeChangingguard.updateColumnTypeearly-returns on an unchanged type and drops anyoptionssent with it, so restating the current type alongside new options silently discarded them. v2 had no guard at all — and since its contract shares v1's body schema, it acceptedoptionsand ignored them, returning 200.normalizeSelectOptionsInputmoves tolib/table/select-optionsand now covers every caller — it preserves a supplied id, so it is a no-op for the fully-formed options the HTTP contracts accept and a repair for name-only options.requiredforwarded into the type and options writes, so a conversion validates against the constraint the same request is setting rather than the column's current one.db.deleteonuser_table_rows, skippingassertRowDeleteanddeleteOrderedRow: a delete-locked table returned 200 and the row-count/order bookkeeping never ran. v1 carries a comment warning against exactly this.deleteTable, which audits only when a row was actually archived — omitting the actor is how rollback callers opt out. v1 and v2 omitted it and audited themselves outside that check, emittingTABLE_DELETEDfor a no-op delete of an already-archived table.Error contract
Failure classes come back as
OrchestrationErrorCode. The shared contract moves out oflib/workflows/orchestration/typesintolib/core/orchestration/types— resource-neutral code (lib/folders) already had to import it from a workflow path — and gains alockedclass mapping to 423, since both tables and workflows have a lock that forbids a mutation and every caller was translating that itself.v2 renders these through a new
v2ErrorForOrchestration, mirroringstatusForOrchestrationErroron the v1/UI surfaces, so a given failure maps to the same status on every surface.Review guide
The guard logic is in
lib/table/orchestration/columns.ts— read that once instead of four route diffs. The route diffs are mechanical deletions.Tests follow the same split:
lib/table/orchestration/columns.test.ts(12 cases) andtables.test.tscover the guards, classification, and audit; the route tests are now thin, asserting delegation and the failure-class → envelope mapping.Not in scope
performCreateTable(where the copilot path has no explicit permission check of its own and the UI has folder validation the others lack) and the knowledge resource (where the copilot path emits no audit for any KB mutation, defaultsminSize: 1against the API's100, and KB search has ~1200 duplicated lines) follow in the next PR on this branch.One user-visible bug found but left for the connector consolidation: the copilot's
delete_connectorreports "Associated documents have been removed", but reaches the route by internal HTTP self-call without thedeleteDocumentsparam, which defaults to false — the documents are kept.Verification
app/api/v2+app/api/v1/tables+app/api/table+lib/table+lib/copilot→ 1838 pass.bun run check:api-validation,bun run check:openapi(6 specs, 57 operations, 48 contracts),bun run type-check, and fullbun run lint:checkall clean.🤖 Generated with Claude Code