Skip to content

Commit cfbaee7

Browse files
fix(api): make v2 table PATCH all-or-nothing and name the lock in every 423
Greptile P1: PATCH applied locks, rename and move as three sequential transactions, so a folder rejected mid-request left the earlier writes persisted while the response reported failure — and the schema-changed signal was skipped, leaving open clients on stale state. Every rejectable condition now runs before the first write, and the signal fires whenever anything did land. Cursor: v2TableLockError dropped the lock kind, so async import, column run, enrichment and table mutations returned a bare LOCKED. A table has four independent locks, so the caller could not tell which to clear.
1 parent e1513f5 commit cfbaee7

4 files changed

Lines changed: 117 additions & 39 deletions

File tree

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

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ const {
1919
mockAssertRowInsert,
2020
mockAssertRowDelete,
2121
mockGateError,
22-
TableLockedError,
2322
} = vi.hoisted(() => ({
2423
mockCheckRateLimit: vi.fn(),
2524
mockResolveWorkspaceScope: vi.fn(),
@@ -30,7 +29,6 @@ const {
3029
mockAssertRowInsert: vi.fn(),
3130
mockAssertRowDelete: vi.fn(),
3231
mockGateError: vi.fn(),
33-
TableLockedError: class TableLockedError extends Error {},
3432
}))
3533

3634
vi.mock('@/app/api/v1/middleware', () => ({
@@ -43,25 +41,18 @@ vi.mock('@/app/api/table/utils', async (importOriginal) => ({
4341
checkAccess: mockCheckAccess,
4442
}))
4543

46-
vi.mock('@/app/api/v2/tables/utils', async (importOriginal) => ({
47-
...(await importOriginal<Record<string, unknown>>()),
48-
v2TableLockError: (error: unknown) =>
49-
error instanceof TableLockedError
50-
? new Response(JSON.stringify({ error: { code: 'LOCKED', message: error.message } }), {
51-
status: 423,
52-
})
53-
: null,
54-
}))
55-
5644
vi.mock('@/lib/table/jobs/service', () => ({
5745
markTableJobRunning: mockMarkTableJobRunning,
5846
releaseJobClaim: mockReleaseJobClaim,
5947
}))
60-
vi.mock('@/lib/table/mutation-locks', () => ({
48+
// Only the assert helpers are stubbed — `TableLockedError` stays real so the
49+
// route's `v2TableLockError` recognizes it by `instanceof` and reports the lock
50+
// kind, exactly as it would in production.
51+
vi.mock('@/lib/table/mutation-locks', async (importOriginal) => ({
52+
...(await importOriginal<Record<string, unknown>>()),
6153
assertRowInsert: mockAssertRowInsert,
6254
assertRowDelete: mockAssertRowDelete,
6355
assertSchemaMutable: vi.fn(),
64-
TableLockedError,
6556
}))
6657
vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() }))
6758
vi.mock('@/lib/core/utils/background', () => ({ runDetached: mockRunDetached }))
@@ -71,6 +62,7 @@ vi.mock('@/lib/users/queries', () => ({
7162
}))
7263
vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGateError }))
7364

65+
import { TableLockedError } from '@/lib/table/mutation-locks'
7466
import { POST } from '@/app/api/v2/tables/[tableId]/import-async/route'
7567

7668
const TABLE = { id: 'table-1', workspaceId: 'ws-1', schema: { columns: [] }, archivedAt: null }
@@ -134,15 +126,20 @@ describe('POST /api/v2/tables/[tableId]/import-async', () => {
134126
expect(mockMarkTableJobRunning).not.toHaveBeenCalled()
135127
})
136128

137-
it('asserts the insert lock BEFORE claiming the slot, so a locked table never holds it', async () => {
129+
it('asserts the insert lock BEFORE claiming the slot, and names the lock in the 423', async () => {
138130
mockAssertRowInsert.mockImplementation(() => {
139-
throw new TableLockedError('Inserts are locked for this table')
131+
throw new TableLockedError('insert')
140132
})
141133

142134
const res = await callPost(BODY)
143135

144136
expect(res.status).toBe(423)
145137
expect(mockMarkTableJobRunning).not.toHaveBeenCalled()
138+
// A table has four independent locks, so "LOCKED" alone doesn't tell the
139+
// caller which one to clear.
140+
const body = await res.json()
141+
expect(body.error.code).toBe('LOCKED')
142+
expect(body.error.details).toEqual({ lock: 'insert' })
146143
})
147144

148145
it('asserts the delete lock too when the mode replaces rows', async () => {

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const {
2222
mockFindActiveFolder,
2323
mockIsFeatureEnabled,
2424
mockGateError,
25+
mockSignalSchemaChanged,
2526
} = vi.hoisted(() => ({
2627
mockCheckRateLimit: vi.fn(),
2728
mockResolveWorkspaceScope: vi.fn(),
@@ -35,6 +36,7 @@ const {
3536
mockFindActiveFolder: vi.fn(),
3637
mockIsFeatureEnabled: vi.fn(),
3738
mockGateError: vi.fn(),
39+
mockSignalSchemaChanged: vi.fn(),
3840
}))
3941

4042
vi.mock('@sim/audit', () => ({
@@ -63,7 +65,9 @@ vi.mock('@/lib/table', () => ({
6365
buildIdByName: vi.fn(),
6466
}))
6567

66-
vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: vi.fn() }))
68+
vi.mock('@/lib/table/events', () => ({
69+
signalTableSchemaChanged: mockSignalSchemaChanged,
70+
}))
6771
vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder }))
6872
vi.mock('@/lib/core/config/feature-flags', () => ({ isFeatureEnabled: mockIsFeatureEnabled }))
6973
vi.mock('@/lib/workspaces/permissions/utils', () => ({
@@ -220,6 +224,55 @@ describe('PATCH /api/v2/tables/[tableId]', () => {
220224
expect(mockPerformMoveTableToFolder).not.toHaveBeenCalled()
221225
})
222226

227+
it('rejects a bad folder without applying the rename that came with it', async () => {
228+
// The three operations are separate transactions, so validation has to run
229+
// before the first write — otherwise a rejected PATCH still renames.
230+
mockFindActiveFolder.mockResolvedValue(null)
231+
232+
const res = await callPatch({
233+
workspaceId: 'ws-1',
234+
name: 'Renamed',
235+
folderId: 'folder-elsewhere',
236+
})
237+
238+
expect(res.status).toBe(404)
239+
expect(mockPerformRenameTable).not.toHaveBeenCalled()
240+
expect(mockPerformUpdateTableLocks).not.toHaveBeenCalled()
241+
expect(mockSignalSchemaChanged).not.toHaveBeenCalled()
242+
})
243+
244+
it('rejects a lock change from a non-admin without applying the rename beside it', async () => {
245+
mockCheckAccess.mockImplementation(async (_tableId, _userId, level) =>
246+
level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE }
247+
)
248+
249+
const res = await callPatch({
250+
workspaceId: 'ws-1',
251+
name: 'Renamed',
252+
locks: { deleteLocked: true },
253+
})
254+
255+
expect(res.status).toBe(403)
256+
expect(mockPerformRenameTable).not.toHaveBeenCalled()
257+
})
258+
259+
it('still signals collaborators when a later operation fails after an earlier one landed', async () => {
260+
// A mid-write fault can't be rolled back across three transactions, so the
261+
// clients must at least be told to refetch what did apply.
262+
mockPerformRenameTable.mockResolvedValue({ success: true })
263+
mockPerformMoveTableToFolder.mockResolvedValue({
264+
success: false,
265+
errorCode: 'not_found',
266+
error: 'gone',
267+
})
268+
269+
const res = await callPatch({ workspaceId: 'ws-1', name: 'Renamed', folderId: 'folder-1' })
270+
271+
expect(res.status).toBe(404)
272+
expect(mockPerformRenameTable).toHaveBeenCalled()
273+
expect(mockSignalSchemaChanged).toHaveBeenCalledWith('table-1')
274+
})
275+
223276
it('rejects a lock change from a write-level caller', async () => {
224277
mockCheckAccess.mockImplementation(async (_tableId, _userId, level) =>
225278
level === 'admin' ? { ok: false, status: 403 } : { ok: true, table: TABLE }

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

Lines changed: 43 additions & 21 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 } from 'next/server'
3+
import type { NextRequest, NextResponse } from 'next/server'
44
import {
55
v2DeleteTableContract,
66
v2GetTableContract,
@@ -127,6 +127,11 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
127127
return v2Error('NOT_FOUND', 'Table not found')
128128
}
129129

130+
// ── Validate every field BEFORE the first write ──
131+
// The three operations are separate transactions, so a rejection
132+
// discovered partway through would leave the earlier ones persisted while
133+
// the response reports failure. Everything a request can be rejected for
134+
// is therefore checked up front: a rejected PATCH changes nothing.
130135
if (validated.locks !== undefined) {
131136
// Only a lock transitioning off→on needs the feature; comparing against
132137
// the stored state is what lets a caller submitting the full flag set
@@ -151,65 +156,82 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl
151156
if (!adminResult.ok) {
152157
return v2Error('FORBIDDEN', 'Admin access required to change table locks')
153158
}
159+
}
160+
161+
if (validated.folderId != null) {
162+
// Scoped to `resourceType: 'table'` so a folder id from another resource's
163+
// tree can't file the table somewhere Tables never lists.
164+
if (!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))) {
165+
return v2Error('NOT_FOUND', 'Folder not found in this workspace')
166+
}
167+
}
168+
169+
// ── Apply ──
170+
// `applied` tracks whether anything reached the database, so a failure
171+
// partway through still signals open clients. Skipping the signal there
172+
// would leave every viewer rendering state that has already changed.
173+
let applied = false
174+
let failure: NextResponse | null = null
154175

176+
if (validated.locks !== undefined) {
155177
const outcome = await performUpdateTableLocks({
156178
tableId,
157179
partial: validated.locks,
158180
userId,
159181
requestId,
160182
request,
161183
})
162-
if (!outcome.success) {
163-
return v2ErrorForOrchestration(
184+
if (outcome.success) {
185+
applied = true
186+
} else {
187+
failure = v2ErrorForOrchestration(
164188
outcome.errorCode,
165189
outcome.error ?? 'Failed to update table locks'
166190
)
167191
}
168192
}
169193

170-
if (validated.name !== undefined) {
194+
if (!failure && validated.name !== undefined) {
171195
const outcome = await performRenameTable({
172196
table,
173197
newName: validated.name,
174198
userId,
175199
requestId,
176200
request,
177201
})
178-
if (!outcome.success) {
179-
return v2ErrorForOrchestration(outcome.errorCode, outcome.error ?? 'Failed to rename table')
202+
if (outcome.success) {
203+
applied = true
204+
} else {
205+
failure = v2ErrorForOrchestration(
206+
outcome.errorCode,
207+
outcome.error ?? 'Failed to rename table'
208+
)
180209
}
181210
}
182211

183-
if (validated.folderId !== undefined) {
184-
// Scoped to `resourceType: 'table'` so a folder id from another resource's
185-
// tree can't file the table somewhere Tables never lists.
186-
if (
187-
validated.folderId !== null &&
188-
!(await findActiveFolder(validated.folderId, table.workspaceId, 'table'))
189-
) {
190-
return v2Error('NOT_FOUND', 'Folder not found in this workspace')
191-
}
212+
if (!failure && validated.folderId !== undefined) {
192213
const outcome = await performMoveTableToFolder({
193214
table,
194215
folderId: validated.folderId,
195216
userId,
196217
requestId,
197218
request,
198219
})
199-
if (!outcome.success) {
200-
// The move re-asserts workspace and active state, so a miss means the
201-
// table was archived between `checkAccess` and the write.
202-
return v2ErrorForOrchestration(
220+
if (outcome.success) applied = true
221+
// The move re-asserts workspace and active state, so a miss means the
222+
// table was archived between `checkAccess` and the write.
223+
else
224+
failure = v2ErrorForOrchestration(
203225
outcome.errorCode,
204226
outcome.errorCode === 'not_found'
205227
? 'Table not found'
206228
: (outcome.error ?? 'Failed to move table')
207229
)
208-
}
209230
}
210231

211232
// Live-collab: tell open viewers the definition changed so they refetch.
212-
signalTableSchemaChanged(tableId)
233+
if (applied) signalTableSchemaChanged(tableId)
234+
if (failure) return failure
213235

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

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,15 @@ export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): Ne
166166
* Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope,
167167
* mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything
168168
* else so the caller falls through to its own classification.
169+
*
170+
* `details.lock` names the flag that rejected the write. A table carries four
171+
* independent locks, so "locked" on its own does not tell a caller which one to
172+
* clear — every 423 on the surface reports it.
169173
*/
170174
export function v2TableLockError(error: unknown): NextResponse | null {
171-
if (error instanceof TableLockedError) return v2Error('LOCKED', error.message)
175+
if (error instanceof TableLockedError) {
176+
return v2Error('LOCKED', error.message, { details: { lock: error.lock } })
177+
}
172178
return null
173179
}
174180

0 commit comments

Comments
 (0)