Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c173eb9
feat(tables): per-table mutation locks (schema/insert/update/delete)
TheodoreSpeaks Jul 25, 2026
b348cd0
fix(tables): address review — CI snapshot, lock-aware jobs, UI gating
TheodoreSpeaks Jul 25, 2026
71b8746
fix(tables): close lock gaps in async import and undo/redo
TheodoreSpeaks Jul 25, 2026
9369f0a
fix(tables): stop locks over-blocking workflow runs, duplicate, and i…
TheodoreSpeaks Jul 25, 2026
bbebfa0
fix(tables): keep column menu usable and let locks always be cleared
TheodoreSpeaks Jul 25, 2026
1cfd3c8
fix(tables): scope the update-lock exemption and re-check locks per page
TheodoreSpeaks Jul 25, 2026
0f13ee2
fix(tables): revalidate locks in-transaction and unbreak backfill + p…
TheodoreSpeaks Jul 25, 2026
6f48eb6
fix(tables): guard import batches in-transaction and split paste by verb
TheodoreSpeaks Jul 25, 2026
7a6f376
fix(tables): guard the remaining import writes and explain blocked Sh…
TheodoreSpeaks Jul 25, 2026
62779c8
improvement(tables): surface blocked lock actions as a toast, not a m…
TheodoreSpeaks Jul 25, 2026
ed0d9f5
fix(tables): don't show the update-lock notice to users without write…
TheodoreSpeaks Jul 25, 2026
468097a
improvement(tables): trim the update-lock toast copy
TheodoreSpeaks Jul 25, 2026
1aa9424
fix(tables): map append-import locks to 423 and stop conflating locks…
TheodoreSpeaks Jul 25, 2026
fca76b0
fix(tables): revalidate sync imports under the advisory lock, explain…
TheodoreSpeaks Jul 25, 2026
43e497d
fix(tables): restore the workspace ownership check on copilot row del…
TheodoreSpeaks Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ import {
updateColumnType,
} from '@/lib/table'
import { columnMatchesRef } from '@/lib/table/column-keys'
import { accessError, checkAccess, normalizeColumn, rootErrorMessage } from '@/app/api/table/utils'
import {
accessError,
checkAccess,
normalizeColumn,
rootErrorMessage,
tableLockErrorResponse,
} from '@/app/api/table/utils'

const logger = createLogger('TableColumnsAPI')

Expand Down Expand Up @@ -61,6 +67,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
Expand Down Expand Up @@ -190,6 +198,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
Expand Down Expand Up @@ -255,6 +265,8 @@ export const DELETE = withRouteHandler(
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (isZodError(error)) {
return validationErrorResponse(error, 'Invalid request data')
}
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/app/api/table/[tableId]/delete-async/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner'
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
import { assertRowDelete } from '@/lib/table/mutation-locks'
import type { TableDeleteJobPayload } from '@/lib/table/types'
import { accessError, checkAccess, tableFilterError } from '@/app/api/table/utils'

Expand Down Expand Up @@ -55,6 +56,11 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
return NextResponse.json({ error: 'Cannot delete from an archived table' }, { status: 400 })
}

// Gate the delete lock at enqueue: an admitted job runs to completion, so the
// lock must be checked before the job is created (the worker is a trusted
// continuation and does not re-check).
assertRowDelete(table)
Comment thread
TheodoreSpeaks marked this conversation as resolved.

// Validate the filter up front so the caller gets immediate feedback (the worker reuses it).
const filterError = tableFilterError(filter, table.schema.columns)
if (filterError) return filterError
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/app/api/table/[tableId]/groups/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import {
deleteWorkflowGroup,
updateWorkflowGroup,
} from '@/lib/table/workflow-groups/service'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
accessError,
checkAccess,
normalizeColumn,
tableLockErrorResponse,
} from '@/app/api/table/utils'

const logger = createLogger('TableWorkflowGroupsAPI')

Expand Down Expand Up @@ -47,6 +52,8 @@ async function validateWorkflowInWorkspace(
* share this mapper instead of repeating the if-chain three times.
*/
function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (error instanceof Error) {
const msg = error.message
if (msg === 'Table not found' || msg.includes('not found')) {
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/app/api/table/[tableId]/import-async/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks'
import { getUserSettings } from '@/lib/users/queries'
import { accessError, checkAccess } from '@/app/api/table/utils'

Expand Down Expand Up @@ -53,6 +54,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 })
}

// Gate the locks before claiming the single write-job slot, so a locked table
// reports 423 here instead of holding the slot and failing inside the worker.
assertRowInsert(table)
if (mode === 'replace') assertRowDelete(table)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
if (createColumns && createColumns.length > 0) assertSchemaMutable(table)

const ext = fileName.split('.').pop()?.toLowerCase()
if (ext !== 'csv' && ext !== 'tsv') {
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/api/table/[tableId]/import/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,18 @@ vi.mock('@sim/utils/id', () => ({

vi.mock('@/app/api/table/utils', async () => {
const { NextResponse } = await import('next/server')
const { TableLockedError } = await import('@/lib/table/mutation-locks')
return {
checkAccess: mockCheckAccess,
accessError: (result: { status: number }) => {
const message = result.status === 404 ? 'Table not found' : 'Access denied'
return NextResponse.json({ error: message }, { status: result.status })
},
csvProxyBodyCapResponse: () => null,
tableLockErrorResponse: (error: unknown) =>
error instanceof TableLockedError
? NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 })
: null,
multipartErrorResponse: (error: { code: string; message: string }) =>
NextResponse.json(
{ error: error.message },
Expand Down Expand Up @@ -74,6 +79,7 @@ vi.mock('@/lib/table/billing', () => ({
limit >= 0 && current + added > limit,
}))

import { TableLockedError } from '@/lib/table/mutation-locks'
import { POST } from '@/app/api/table/[tableId]/import/route'

function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File {
Expand Down Expand Up @@ -377,6 +383,21 @@ describe('POST /api/table/[tableId]/import', () => {
expect(data.data?.insertedCount).toBe(0)
})

it('maps a lock violation from importAppendRows to 423, not 500', async () => {
// The append branch returns instead of rethrowing, so it must map the lock
// error itself — the outer catch's mapper never sees it.
mockImportAppendRows.mockRejectedValueOnce(new TableLockedError('insert'))
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })
)
expect(response.status).toBe(423)
const data = await response.json()
expect(data.lock).toBe('insert')
// A `details` array would make the client treat it as a validation error
// and swallow the toast.
expect(data.details).toBeUndefined()
})

it('accepts TSV files', async () => {
const response = await callPost(
createFormData(
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/app/api/table/[tableId]/import/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
checkAccess,
csvProxyBodyCapResponse,
multipartErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'

const logger = createLogger('TableImportCSVExisting')
Expand Down Expand Up @@ -336,6 +337,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
},
})
} catch (err) {
// This branch returns rather than rethrowing, so the outer catch's
// mapper is unreachable from here — map the lock error first or a 423
// degrades into a generic 500 (replace mode rethrows and maps fine).
const lockError = tableLockErrorResponse(err)
if (lockError) return lockError

const message = toError(err).message
logger.warn(`[${requestId}] Append failed for table ${tableId}`, {
total: coerced.length,
Expand Down Expand Up @@ -408,6 +415,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
throw err
}
} catch (error) {
const lockError = tableLockErrorResponse(error)
Comment thread
cursor[bot] marked this conversation as resolved.
if (lockError) return lockError
if (isMultipartError(error)) return multipartErrorResponse(error)

const message = toError(error).message
Expand Down
69 changes: 62 additions & 7 deletions apps/sim/app/api/table/[tableId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { getTableQuerySchema, renameTableContract } from '@/lib/api/contracts/tables'
import { getTableQuerySchema, updateTableContract } from '@/lib/api/contracts/tables'
import { isZodError, parseRequest, validationErrorResponse } from '@/lib/api/server/validation'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { deleteTable, renameTable, TableConflictError, type TableSchema } from '@/lib/table'
import {
deleteTable,
getTableById,
renameTable,
TableConflictError,
type TableSchema,
updateTableLocks,
} from '@/lib/table'
import { getWorkspaceTableLimits } from '@/lib/table/billing'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import { TABLE_LOCK_FLAGS, TABLE_LOCK_KINDS } from '@/lib/table/types'
import {
accessError,
checkAccess,
normalizeColumn,
tableLockErrorResponse,
} from '@/app/api/table/utils'

const logger = createLogger('TableDetailAPI')

Expand Down Expand Up @@ -65,6 +79,7 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
metadata: table.metadata ?? null,
rowCount: table.rowCount,
maxRows: maxRowsPerTable,
locks: table.locks,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
Expand Down Expand Up @@ -104,7 +119,7 @@ export const PATCH = withRouteHandler(
}

const parsed = await parseRequest(
renameTableContract,
updateTableContract,
request,
{ params },
{
Expand All @@ -116,6 +131,8 @@ export const PATCH = withRouteHandler(
const { tableId } = parsed.data.params
const validated = parsed.data.body

// `write` is the floor for either operation; a `locks` change additionally
// requires `admin` (checked below), matching the workflow-lock precedent.
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)

Expand All @@ -125,20 +142,56 @@ export const PATCH = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

const updated = await renameTable(tableId, validated.name, requestId, authResult.userId)
if (validated.locks !== undefined) {
// With the flag off you may still CLEAR locks — otherwise flipping the
// kill switch would strand an already-locked table with no way to
// unlock it, while enforcement of those stored locks keeps running.
// Only a lock actually transitioning off→on needs the feature enabled;
// comparing against the stored state (rather than "every value is
// false") is what lets the settings UI, which always submits the full
// four-flag draft, clear one lock while another stays on.
const enablesALock = TABLE_LOCK_KINDS.some((kind) => {
const flag = TABLE_LOCK_FLAGS[kind]
return validated.locks?.[flag] === true && !table.locks[flag]
})
if (enablesALock && !(await isFeatureEnabled('table-locks'))) {
return NextResponse.json({ error: 'Table locks are not enabled' }, { status: 403 })
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
const adminResult = await checkAccess(tableId, authResult.userId, 'admin')
if (!adminResult.ok) {
return NextResponse.json(
{ error: 'Admin access required to change table locks' },
{ status: 403 }
)
}
await updateTableLocks(tableId, validated.locks, authResult.userId, requestId)
}

if (validated.name !== undefined) {
await renameTable(tableId, validated.name, requestId, authResult.userId)
}

// Re-read so the response reflects both a rename and a lock change.
const updated = await getTableById(tableId)
if (!updated) {
return NextResponse.json({ error: 'Table not found' }, { status: 404 })
}

return NextResponse.json({
success: true,
data: { table: updated },
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError

if (error instanceof TableConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error renaming table:`, error)
logger.error(`[${requestId}] Error updating table:`, error)
return NextResponse.json(
{ error: getErrorMessage(error, 'Failed to rename table') },
{ error: getErrorMessage(error, 'Failed to update table') },
{ status: 500 }
)
}
Expand Down Expand Up @@ -188,6 +241,8 @@ export const DELETE = withRouteHandler(
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (isZodError(error)) {
return validationErrorResponse(error)
}
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
checkAccess,
rootErrorMessage,
rowWriteErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'

const logger = createLogger('TableRowAPI')
Expand Down Expand Up @@ -211,7 +212,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}

await deleteRow(tableId, rowId, validated.workspaceId, requestId)
await deleteRow(table, rowId, requestId)
Comment thread
TheodoreSpeaks marked this conversation as resolved.

return NextResponse.json({
success: true,
Expand All @@ -221,6 +222,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError

const errorMessage = toError(error).message

if (errorMessage === 'Row not found') {
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/table/[tableId]/rows/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ export const DELETE = withRouteHandler(

if (validated.rowIds) {
const result = await deleteRowsByIds(
table,
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
requestId
)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/table/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
rowCount: table.rowCount,
maxRows: table.maxRows,
locks: table.locks,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
Expand Down Expand Up @@ -206,6 +207,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
},
rowCount: t.rowCount,
maxRows: t.maxRows,
locks: t.locks,
workspaceId: t.workspaceId,
createdBy: t.createdBy,
createdAt: t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
Expand Down
Loading
Loading