Skip to content

Commit b25c426

Browse files
feat(api): make v2 table PATCH state which operations landed on failure
Greptile held the PR at 4/5 on the residual non-atomicity and named two acceptable resolutions: make PATCH atomic, or have the contract adopt and expose partial-success explicitly. Atomicity would mean threading one transaction through renameTable, moveTableToFolder and updateTableLocks — three shared service functions with four non-test callers including the first-party route and two copilot tools — and deferring their per-operation audits to commit time. That is a refactor of shared write paths well outside this PR. So the contract states it instead. Every rejectable condition is already pre-validated, so a failure here is a genuine fault; when one follows a successful operation the error now carries `details.applied` listing what is live. Absent when nothing applied, so its presence always means "these changes took effect despite the error". Documented on the operation. `v2ErrorForOrchestration` gained the optional `details` this needs.
1 parent 83e04cb commit b25c426

5 files changed

Lines changed: 92 additions & 35 deletions

File tree

apps/docs/openapi-v2-tables.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@
373373
"patch": {
374374
"operationId": "updateTable",
375375
"summary": "Update Table",
376-
"description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.",
376+
"description": "Rename a table, move it between folders, and/or change its lock flags. Provide at least one of `name`, `folderId`, or `locks`. Each field is applied independently, so one request can rename and move at once, and the response reflects every applied change.\n\n`name` and `folderId` need workspace write. `locks` additionally needs workspace **admin** \u2014 a write-level caller gets 403. Clearing a lock always works; enabling one requires the table-locks feature to be on for the workspace, so an already-locked table can never be stranded.\n\n**Partial-success semantics.** The three operations commit independently, so this endpoint is not atomic. Everything that can be *rejected* \u2014 the lock feature gate, the admin check, folder existence \u2014 is validated before the first write, so a rejected request changes nothing. If a genuine fault (a lost race, the table archived mid-request, a database error) fails a later operation after an earlier one has committed, the response is an error whose `error.details.applied` lists the operations that are nevertheless live (`\"locks\"`, `\"name\"`, `\"folderId\"`). The field is absent when nothing was applied, so its presence always means \"these changes took effect despite the error\" \u2014 re-read the table to confirm before retrying.",
377377
"tags": ["Tables"],
378378
"x-codeSamples": [
379379
{

apps/sim/app/api/v2/lib/response.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,14 @@ const V2_CODE_BY_ORCHESTRATION_ERROR: Record<OrchestrationErrorCode, V2ErrorCode
175175
*/
176176
export function v2ErrorForOrchestration(
177177
code: OrchestrationErrorCode | undefined,
178-
message: string
178+
message: string,
179+
/** Structured context for the failure — e.g. which lock rejected a write. */
180+
details?: unknown
179181
): NextResponse {
180182
const v2Code = code ? V2_CODE_BY_ORCHESTRATION_ERROR[code] : 'INTERNAL_ERROR'
181-
return v2Error(v2Code, v2Code === 'INTERNAL_ERROR' ? 'Internal server error' : message)
183+
return v2Error(v2Code, v2Code === 'INTERNAL_ERROR' ? 'Internal server error' : message, {
184+
...(details !== undefined ? { details } : {}),
185+
})
182186
}
183187

184188
/**

apps/sim/app/api/v2/tables/[tableId]/route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,38 @@ describe('PATCH /api/v2/tables/[tableId]', () => {
282282
expect(mockPerformRenameTable).not.toHaveBeenCalled()
283283
})
284284

285+
it('reports which operations landed when a later one fails', async () => {
286+
// The three writes commit independently, so rather than pretending
287+
// atomicity the error states what is already live — a caller can reconcile
288+
// instead of re-reading and diffing.
289+
mockPerformRenameTable.mockResolvedValue({ success: true })
290+
mockPerformMoveTableToFolder.mockResolvedValue({
291+
success: false,
292+
errorCode: 'not_found',
293+
error: 'gone',
294+
})
295+
296+
const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' })
297+
298+
expect(res.status).toBe(404)
299+
expect((await res.json()).error.details).toEqual({ applied: ['name'] })
300+
})
301+
302+
it('omits the applied list when the very first operation fails', async () => {
303+
// `details.applied` present must always mean "these changes are live".
304+
mockPerformRenameTable.mockResolvedValue({
305+
success: false,
306+
errorCode: 'conflict',
307+
error: 'taken',
308+
})
309+
310+
const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' })
311+
312+
expect(res.status).toBe(409)
313+
expect((await res.json()).error.details).toBeUndefined()
314+
expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled()
315+
})
316+
285317
it('still signals collaborators when a later operation fails after an earlier one landed', async () => {
286318
// A mid-write fault can't be rolled back across three transactions, so the
287319
// clients must at least be told to refetch what did apply.

apps/sim/app/api/v2/tables/[tableId]/route.ts

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3-
import type { NextRequest, NextResponse } from 'next/server'
3+
import type { NextRequest } from 'next/server'
44
import {
55
v2DeleteTableContract,
66
v2GetTableContract,
@@ -32,6 +32,7 @@ import {
3232
v2ValidationError,
3333
v2WorkspaceAccessError,
3434
} from '@/app/api/v2/lib/response'
35+
import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils'
3536
import {
3637
toApiTable,
3738
v2TableAccessError,
@@ -171,11 +172,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
171172
}
172173

173174
// ── Apply ──
174-
// `applied` tracks whether anything reached the database, so a failure
175-
// partway through still signals open clients. Skipping the signal there
176-
// would leave every viewer rendering state that has already changed.
177-
let applied = false
178-
let failure: NextResponse | null = null
175+
// Every deterministic rejection is already behind us, so a failure here is
176+
// a genuine fault (lost race, archived mid-request, database error) rather
177+
// than a bad request. The three operations commit independently — a single
178+
// transaction would have to span three shared service functions that also
179+
// back the first-party route and two copilot tools, and would break their
180+
// per-operation audits — so instead of pretending atomicity the response
181+
// states exactly which operations landed. A caller that gets an error can
182+
// then reconcile rather than having to re-read and diff.
183+
const applied: ('locks' | 'name' | 'folderId')[] = []
184+
let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null
179185

180186
if (validated.locks !== undefined) {
181187
const outcome = await performUpdateTableLocks({
@@ -185,11 +191,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
185191
requestId,
186192
request,
187193
})
188-
if (outcome.success) {
189-
applied = true
190-
} else {
191-
failure = v2TableOrchestrationError(outcome, 'Failed to update table locks')
192-
}
194+
if (outcome.success) applied.push('locks')
195+
else failure = { outcome, fallback: 'Failed to update table locks' }
193196
}
194197

195198
if (!failure && validated.name !== undefined) {
@@ -200,11 +203,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
200203
requestId,
201204
request,
202205
})
203-
if (outcome.success) {
204-
applied = true
205-
} else {
206-
failure = v2TableOrchestrationError(outcome, 'Failed to rename table')
207-
}
206+
if (outcome.success) applied.push('name')
207+
else failure = { outcome, fallback: 'Failed to rename table' }
208208
}
209209

210210
if (!failure && validated.folderId !== undefined) {
@@ -216,20 +216,29 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
216216
request,
217217
})
218218
if (outcome.success) {
219-
applied = true
219+
applied.push('folderId')
220220
} else {
221221
// The move re-asserts workspace and active state, so a miss means the
222222
// table was archived between `checkAccess` and the write.
223-
failure = v2TableOrchestrationError(
224-
outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome,
225-
'Failed to move table'
226-
)
223+
failure = {
224+
outcome:
225+
outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome,
226+
fallback: 'Failed to move table',
227+
}
227228
}
228229
}
229230

230231
// Live-collab: tell open viewers the definition changed so they refetch.
231-
if (applied) signalTableSchemaChanged(tableId)
232-
if (failure) return failure
232+
if (applied.length > 0) signalTableSchemaChanged(tableId)
233+
if (failure) {
234+
return v2TableOrchestrationError(
235+
failure.outcome,
236+
failure.fallback,
237+
// Omitted when nothing landed, so `details.applied` present always
238+
// means "these changes are live despite the error".
239+
applied.length > 0 ? { applied } : undefined
240+
)
241+
}
233242

234243
// Re-read so the response reflects every applied change at once.
235244
const updated = await getTableById(tableId)

apps/sim/app/api/v2/tables/utils.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,13 @@ export function v2TableLockError(error: unknown): NextResponse | null {
191191
return null
192192
}
193193

194+
/** The failure half of any `lib/table/orchestration` result. */
195+
export interface OrchestrationOutcome {
196+
errorCode?: OrchestrationErrorCode
197+
error?: string
198+
lock?: TableLockKind
199+
}
200+
194201
/**
195202
* Renders a `lib/table/orchestration` failure in the v2 envelope, naming the
196203
* lock when one caused it.
@@ -203,17 +210,22 @@ export function v2TableLockError(error: unknown): NextResponse | null {
203210
* orchestration result goes through this instead.
204211
*/
205212
export function v2TableOrchestrationError(
206-
outcome: { errorCode?: OrchestrationErrorCode; error?: string; lock?: TableLockKind },
207-
fallback: string
213+
outcome: OrchestrationOutcome,
214+
fallback: string,
215+
/** Merged into `details` — e.g. which operations of a composite write landed. */
216+
extraDetails?: Record<string, unknown>
208217
): NextResponse {
209-
if (outcome.errorCode === 'locked') {
210-
return v2Error('LOCKED', outcome.error ?? fallback, {
211-
// Omitted rather than sent as null when the kind is unknown — a caller
212-
// branching on `details.lock` should see absence, not a phantom value.
213-
...(outcome.lock ? { details: { lock: outcome.lock } } : {}),
214-
})
218+
// `lock` is omitted rather than sent as null when the kind is unknown — a
219+
// caller branching on `details.lock` should see absence, not a phantom value.
220+
const details = {
221+
...(outcome.errorCode === 'locked' && outcome.lock ? { lock: outcome.lock } : {}),
222+
...extraDetails,
215223
}
216-
return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? fallback)
224+
return v2ErrorForOrchestration(
225+
outcome.errorCode,
226+
outcome.error ?? fallback,
227+
Object.keys(details).length > 0 ? details : undefined
228+
)
217229
}
218230

219231
/**

0 commit comments

Comments
 (0)