Skip to content

Commit 2366a4e

Browse files
fix(api): keep reporting applied operations when the PATCH re-read fails
The composite table PATCH promises that `error.details.applied` names the operations that are live despite an error, but `applied` was scoped inside the try. A rename or move that committed and was then followed by a throw in the final re-read — or a re-read finding the table archived — returned a bare 500/404 with no details, telling the caller nothing had landed. It would then retry into a duplicate-name conflict or repeat the move. `applied` is now function-scoped so every post-write exit carries it: the 404 on a missing re-read, a thrown lock error, a classified orchestration error, and the generic 500. `v2TableLockError` gains the same `extraDetails` parameter `v2TableOrchestrationError` already had.
1 parent 178a20d commit 2366a4e

3 files changed

Lines changed: 81 additions & 17 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,40 @@ describe('PATCH /api/v2/tables/[tableId]', () => {
341341
expect(mockPerformRenameTable).not.toHaveBeenCalled()
342342
})
343343

344+
/**
345+
* The re-read runs after the writes have committed, so a failure there must
346+
* still name what landed. Reporting a bare 500 tells the caller nothing took
347+
* effect and it retries into a duplicate-name conflict.
348+
*/
349+
it('reports the applied operations when the final re-read throws', async () => {
350+
mockPerformRenameTable.mockResolvedValue({ success: true })
351+
mockGetTableById.mockRejectedValue(new Error('connection reset'))
352+
353+
const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
354+
355+
expect(res.status).toBe(500)
356+
expect((await res.json()).error.details).toEqual({ applied: ['name'] })
357+
})
358+
359+
it('reports the applied operations when the re-read finds the table archived', async () => {
360+
mockPerformRenameTable.mockResolvedValue({ success: true })
361+
mockGetTableById.mockResolvedValue(null)
362+
363+
const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed' })
364+
365+
expect(res.status).toBe(404)
366+
expect((await res.json()).error.details).toEqual({ applied: ['name'] })
367+
})
368+
369+
it('omits applied details when the failure happened before any write', async () => {
370+
mockGetTableById.mockRejectedValue(new Error('connection reset'))
371+
372+
const res = await callPatch({ workspaceId: 'ws-1', folderId: 'nope' })
373+
374+
// Absence is meaningful: nothing is live, so a retry is safe.
375+
expect((await res.json()).error.details).toBeUndefined()
376+
})
377+
344378
it('still reports the stored lock flags on the table it returns', async () => {
345379
// The response is a re-read, so the locked state has to come from there.
346380
mockGetTableById.mockResolvedValue({

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

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
v2UpdateTableContract,
88
} from '@/lib/api/contracts/v2/tables'
99
import { parseRequest } from '@/lib/api/server'
10+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
1011
import { generateRequestId } from '@/lib/core/utils/request'
1112
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1213
import { findActiveFolder } from '@/lib/folders/queries'
@@ -21,7 +22,6 @@ import { checkAccess } from '@/app/api/table/utils'
2122
import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware'
2223
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
2324
import {
24-
v2CaughtOrchestrationError,
2525
v2Data,
2626
v2Error,
2727
v2RateLimitError,
@@ -38,6 +38,17 @@ import {
3838

3939
const logger = createLogger('V2TableDetailAPI')
4040

41+
/**
42+
* `details` payload naming the operations of a composite write that committed,
43+
* or `undefined` when none did — so `details.applied` being present always
44+
* means "these changes are live despite the error".
45+
*/
46+
function appliedDetails(
47+
applied: readonly ('name' | 'folderId')[]
48+
): { applied: readonly string[] } | undefined {
49+
return applied.length > 0 ? { applied } : undefined
50+
}
51+
4152
export const dynamic = 'force-dynamic'
4253
export const revalidate = 0
4354

@@ -101,6 +112,15 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
101112
export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => {
102113
const requestId = generateRequestId()
103114

115+
/**
116+
* Hoisted above the `try` so every exit path can report it. Once a write has
117+
* committed, the response must say so even when the failure came *after* the
118+
* writes — a throw in the final re-read, or the re-read finding the table
119+
* archived. Reporting a bare 500 there tells the caller nothing landed, and
120+
* it retries into a duplicate-name conflict or a repeated move.
121+
*/
122+
const applied: ('name' | 'folderId')[] = []
123+
104124
try {
105125
const rateLimit = await checkRateLimit(request, 'table-detail')
106126
if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
@@ -151,7 +171,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
151171
// per-operation audits — so instead of pretending atomicity the response
152172
// states exactly which operations landed. A caller that gets an error can
153173
// then reconcile rather than having to re-read and diff.
154-
const applied: ('name' | 'folderId')[] = []
155174
let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null
156175

157176
if (validated.name !== undefined) {
@@ -190,31 +209,38 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
190209
// Live-collab: tell open viewers the definition changed so they refetch.
191210
if (applied.length > 0) signalTableSchemaChanged(tableId)
192211
if (failure) {
193-
return v2TableOrchestrationError(
194-
failure.outcome,
195-
failure.fallback,
196-
// Omitted when nothing landed, so `details.applied` present always
197-
// means "these changes are live despite the error".
198-
applied.length > 0 ? { applied } : undefined
199-
)
212+
return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied))
200213
}
201214

202-
// Re-read so the response reflects every applied change at once.
215+
// Re-read so the response reflects every applied change at once. A miss
216+
// means the table was archived after the writes committed, so the caller
217+
// still has to be told what landed.
203218
const updated = await getTableById(tableId)
204-
if (!updated) return v2Error('NOT_FOUND', 'Table not found')
219+
if (!updated) {
220+
return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) })
221+
}
205222

206223
return v2Data({ table: toApiTable(updated) }, { rateLimit })
207224
} catch (error) {
208-
const lockError = v2TableLockError(error)
225+
const details = appliedDetails(applied)
226+
227+
const lockError = v2TableLockError(error, details)
209228
if (lockError) return lockError
210229

211-
const classified = v2CaughtOrchestrationError(error)
212-
if (classified) return classified
230+
const classified = asOrchestrationError(error)
231+
if (classified) {
232+
return v2TableOrchestrationError(
233+
{ errorCode: classified.code, error: classified.message },
234+
'Failed to update table',
235+
details
236+
)
237+
}
213238

214239
logger.error(`[${requestId}] Error updating table`, {
215240
error: getErrorMessage(error, 'Unknown error'),
241+
applied,
216242
})
217-
return v2Error('INTERNAL_ERROR', 'Internal server error')
243+
return v2Error('INTERNAL_ERROR', 'Internal server error', { details })
218244
}
219245
})
220246

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,13 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne
184184
* independent locks, so "locked" on its own does not tell a caller which one to
185185
* clear — every 423 on the surface reports it.
186186
*/
187-
export function v2TableLockError(error: unknown): NextResponse | null {
187+
export function v2TableLockError(
188+
error: unknown,
189+
/** Merged into `details` — e.g. which operations of a composite write landed. */
190+
extraDetails?: Record<string, unknown>
191+
): NextResponse | null {
188192
if (error instanceof TableLockedError) {
189-
return v2Error('LOCKED', error.message, { details: { lock: error.lock } })
193+
return v2Error('LOCKED', error.message, { details: { lock: error.lock, ...extraDetails } })
190194
}
191195
return null
192196
}

0 commit comments

Comments
 (0)