feat(tables): per-table mutation locks (schema/insert/update/delete) (#5960)

* feat(tables): per-table mutation locks (schema/insert/update/delete)

Adds four independent, admin-only locks to a table so a workspace can make it
append-only, read-only, or schema-frozen. Enforced at the lib/table service
layer rather than the routes, so the API, workflow blocks, and Mothership (which
calls the services directly) are all covered by one assert. Violations return
423; changing a lock requires workspace admin and returns 403.

* fix(tables): address review — CI snapshot, lock-aware jobs, UI gating

- Add the drizzle meta snapshot the hand-written migration was missing, which
  made CI regenerate the lock columns as a phantom 0271
- Stop a queued or retried delete/update job that starts after its lock is
  enabled; a run that has already committed pages still finishes, since pages
  are never rolled back and aborting would leave an uncancelled half-done state
- Map TableLockedError to 423 on the internal single-row DELETE (was a 500)
- Gate the lock settings UI behind NEXT_PUBLIC_TABLE_LOCKS so it can't open
  onto a Save that 403s against the server-side flag
- Respect the delete lock on column drops in the grid, matching
  assertColumnDestructive, instead of failing only on click

* fix(tables): close lock gaps in async import and undo/redo

- Assert insert (and delete for replace) at the start of the import worker.
  Replace deleted every row before the first insert assert, so an insert-locked
  table was wiped and then failed, leaving it empty.
- Run the delete/update worker start checks through assertRowDelete /
  assertRowUpdate instead of reading the lock flags directly, so the worker and
  its enqueue site apply identical rules — including the workflow-column
  exemption a bulk update was being cancelled despite.
- Make undo/redo verb mapping direction-aware for column ops: dropping or
  retyping a column needs the delete lock clear too, matching
  assertColumnDestructive, so those steps are skipped instead of stranded.

* fix(tables): stop locks over-blocking workflow runs, duplicate, and imports

- Run / Re-run / Stop no longer inherit the cell-edit gate: they write only
  workflow-output columns (which the update lock exempts) and Stop is a cancel
- Duplicate needs only the insert lock — it inserts a full copied row, so it
  stays available on an append-only table
- updateWorkflowGroup asserts the destructive rule only when a patch actually
  drops or remaps output columns; rename / autoRun / mapping edits need just
  the schema lock
- Gate the import-async enqueue on insert (and delete for replace) so a locked
  table 423s instead of claiming the write-job slot and failing in the worker
- Disable Import CSV under an insert lock, and wire the expanded cell editor
  and the previously-unused stable add-row handler to the lock-aware flags

* fix(tables): keep column menu usable and let locks always be cleared

- Pass the blocked-delete handler instead of undefined so ColumnOptionsMenu
  still mounts on a delete-locked table; withholding it hid the entire menu,
  including Insert column, which the delete lock must not affect
- Restore ColumnHeaderMenu readOnly to permission-only: it swaps the header for
  a static label, which was also disabling pin, column-select and open-config
- Assert the schema lock at the import-async enqueue when createColumns is set
- Allow a locks PATCH that only clears locks even when the feature flag is off,
  and keep the settings entry reachable on a locked table, so flipping the kill
  switch can't strand a table with locks nobody can remove

* fix(tables): scope the update-lock exemption and re-check locks per page

- Make the workflow-output carve-out opt-in via `computedWrite`, set only by
  the cell-write path. It was caller-agnostic, so an ordinary API caller could
  PATCH a workflow-output column on an update-locked table
- Re-read the lock before every page in the delete and update runners. The
  single worker-start check went stale immediately, so enabling a lock could
  not stop a job already deleting or overwriting rows. Committed pages still
  stay committed, exactly as with an explicit cancel. Import keeps its
  one-shot check: a half-imported table needs the deletes the lock now forbids
- Route Insert column to the locked-action modal under a schema lock instead
  of leaving it live (regular header) or hiding the whole menu (group header)

* fix(tables): revalidate locks in-transaction and unbreak backfill + partial unlock

- Re-assert the lock inside each page-write transaction, under the same
  advisory lock updateTableLocks takes, so check-then-write is atomic against
  a lock change. The per-page check alone still left the page-selection await
  between the assert and the write
- Pass computedWrite from the backfill runner: batchUpdateRows is the
  workflow-output backfill path, so scoping the exemption had made rebuilding
  outputs 423 on an update-locked table while live cell writes still worked
- Gate the feature flag on a lock actually going off->on rather than on an
  all-false payload. The settings UI always submits all four flags, so with
  the flag off an admin could not clear one lock while another stayed on
- Hoist the lock-kind -> flag map to lib/table/types as TABLE_LOCK_FLAGS

* fix(tables): guard import batches in-transaction and split paste by verb

- Re-assert the insert lock inside each import batch's insert transaction,
  under the same advisory lock updateTableLocks takes. Reversing the earlier
  "let the file finish" call: an admin can lift the lock to clean up a partial
  import, so honouring it beats letting the rest of the file land
- Gate paste per verb. Overwriting existing rows is an update and extending
  past the last row is a full-row insert, so a paste-append still works on an
  append-only table while an overwrite explains itself. Refuses the whole
  paste rather than applying half of it
- Space opens the same row editor as double-click, so it now follows the
  update lock instead of filling in a form that 423s on save

* fix(tables): guard the remaining import writes and explain blocked Shift+Enter

- Route the replace-mode wipe, the inferred-schema write and createColumns
  through the same guardBatch transaction as the batch inserts, so a delete or
  schema lock committed while the file is downloading or being sampled is seen
  instead of the job-start snapshot
- Shift+Enter takes the manual-add path, so it now opens the lock modal like
  the Add row button instead of returning silently

* improvement(tables): surface blocked lock actions as a toast, not a modal

- Replace TableLockedModal with a warning toast carrying a "Lock settings"
  action button for admins. Being told you can't edit shouldn't cost a dismiss
  click, and the button still routes admins straight to the panel
- Dedupe by id so repeated attempts on a locked cell replace one notice
  instead of stacking a column of them
- Move the copy into lock-copy.ts alongside the rest of the lock vocabulary
  and tighten it for toast length

* fix(tables): don't show the update-lock notice to users without write access

Double-click checked the update lock before any permission check, so a
read-only member on a locked table got "Editing rows is locked" — misleading
(the lock isn't why they can't edit) and it swallowed the expanded-cell
viewer, which is a legitimate read-only affordance.

* improvement(tables): trim the update-lock toast copy

* fix(tables): map append-import locks to 423 and stop conflating locks with permissions

- The sync append branch returns instead of rethrowing, so the outer catch's
  mapper never saw a TableLockedError and every lock violation became a 500.
  Map it in that catch, with a regression test (replace mode already rethrows)
- Stop mounting the workflow-group column menu for users without edit access:
  passing the blocked handlers unconditionally made it appear for read-only
  members and report a lock even on an unlocked table
- Enter/F2 now raises the same lock notice as double-click and Space instead
  of silently doing nothing
- guardBatch returns the freshly-read definition so addTableColumnsWithTx
  asserts live state; a schema lock cleared mid-import no longer fails the
  createColumns step. The snapshot pre-asserts now only run as the fallback
  for callers that pass no revalidator
- Append-only no longer labels a table whose schema is also locked, which
  claimed columns were mutable when they weren't
- Import dialog withholds Replace on a delete-locked table and create-column
  on a schema-locked one, instead of offering a configuration that only 423s

* fix(tables): revalidate sync imports under the advisory lock, explain every blocked key

- The sync append/replace paths asserted only the request-start snapshot, so a
  lock committed while the CSV was parsed still let the write through. Both now
  re-read under the schema advisory lock at the top of their own transaction,
  taken before acquireRowOrderLock so the order stays advisory -> rows_pos ->
  definitions
- Delete/Backspace, Cmd+D, typeahead and cut raised no notice on an
  update-locked table; they now explain the lock, and stay silent for users
  without write access
- The multipart import fetch threw a plain Error, dropping the 423 status, so
  the lock self-heal never ran and the stale detail cache survived. It throws
  ApiClientError now and both import-into-table hooks call
  handleTableLockRejection
- Force append at submit when the delete lock landed while the dialog was open
  with Replace already selected

* fix(tables): restore the workspace ownership check on copilot row deletes

Switching deleteRow/deleteRowsByIds to take a TableDefinition dropped the
workspaceId argument that previously scoped the query, and the two branches
I added loaded the table without the ownership comparison every other
operation in the tool performs — letting a caller delete rows from a table
in another workspace. Both now reject a foreign table as not found.
This commit is contained in:
Theodore Li
2026-07-25 14:21:05 -04:00
committed by GitHub
parent 8329dac4c5
commit 0dcbc56ef6
65 changed files with 19940 additions and 168 deletions
@@ -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')
@@ -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')
}
@@ -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')
}
@@ -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')
}
@@ -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'
@@ -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)
// 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
@@ -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')
@@ -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')) {
@@ -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'
@@ -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)
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 })
@@ -31,6 +31,7 @@ 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 }) => {
@@ -38,6 +39,10 @@ vi.mock('@/app/api/table/utils', async () => {
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 },
@@ -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 {
@@ -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(
@@ -44,6 +44,7 @@ import {
checkAccess,
csvProxyBodyCapResponse,
multipartErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'
const logger = createLogger('TableImportCSVExisting')
@@ -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,
@@ -408,6 +415,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
throw err
}
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (isMultipartError(error)) return multipartErrorResponse(error)
const message = toError(error).message
+62 -7
View File
@@ -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')
@@ -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()
@@ -104,7 +119,7 @@ export const PATCH = withRouteHandler(
}
const parsed = await parseRequest(
renameTableContract,
updateTableContract,
request,
{ params },
{
@@ -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)
@@ -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 })
}
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 }
)
}
@@ -188,6 +241,8 @@ export const DELETE = withRouteHandler(
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (isZodError(error)) {
return validationErrorResponse(error)
}
@@ -21,6 +21,7 @@ import {
checkAccess,
rootErrorMessage,
rowWriteErrorResponse,
tableLockErrorResponse,
} from '@/app/api/table/utils'
const logger = createLogger('TableRowAPI')
@@ -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)
return NextResponse.json({
success: true,
@@ -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') {
@@ -433,6 +433,7 @@ export const DELETE = withRouteHandler(
if (validated.rowIds) {
const result = await deleteRowsByIds(
table,
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
requestId
)
+2
View File
@@ -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()
@@ -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),
+24
View File
@@ -11,8 +11,27 @@ import type { MultipartError } from '@/lib/core/utils/multipart'
import type { ColumnDefinition, Filter, TableDefinition } from '@/lib/table'
import { buildFilterClause, getTableById, TableQueryValidationError } from '@/lib/table'
import { USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { TableLockedError } from '@/lib/table/mutation-locks'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
/**
* Maps a {@link TableLockedError} thrown by the service layer to a 423 response
* carrying `{ error, lock }`; returns `null` for any other error so the caller
* falls through to its existing handling. Call this as the FIRST statement of a
* table route's catch block — otherwise `rowWriteErrorResponse` (and the other
* substring funnels) turn the lock error into a generic 500.
*
* The body deliberately omits a `details` array: the client's `isValidationError`
* treats any `ApiClientError` with array-valued `details` as a field-validation
* error and swallows its toast, so a lock rejection must not carry one.
*/
export function tableLockErrorResponse(error: unknown): NextResponse | null {
if (error instanceof TableLockedError) {
return NextResponse.json({ error: error.message, lock: error.lock }, { status: 423 })
}
return null
}
/**
* Validates a `filter` against the table's column schema, returning a 400 response on a bad field
* (or `null` when the filter is valid or absent). Shared by the routes that accept a filter
@@ -78,6 +97,11 @@ const ROW_WRITE_ERROR_PATTERNS = [
* unrecognized and the caller should log it and return its generic 500.
*/
export function rowWriteErrorResponse(error: unknown): NextResponse | null {
// A lock violation is a 423, not a 400/500 — check before the pattern match,
// which would otherwise let it fall through to the caller's generic 500.
const lockResponse = tableLockErrorResponse(error)
if (lockResponse) return lockResponse
const message = rootErrorMessage(error)
if (ROW_WRITE_ERROR_PATTERNS.some((p) => message.includes(p)) || /^Row .+?:/.test(message)) {
@@ -18,7 +18,12 @@ import {
updateColumnType,
} from '@/lib/table'
import { columnMatchesRef } from '@/lib/table/column-keys'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
accessError,
checkAccess,
normalizeColumn,
tableLockErrorResponse,
} from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
@@ -84,6 +89,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
@@ -231,6 +238,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Colu
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
@@ -312,6 +321,8 @@ export const DELETE = withRouteHandler(
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
@@ -6,7 +6,12 @@ import { parseRequest } from '@/lib/api/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'
import {
accessError,
checkAccess,
normalizeColumn,
tableLockErrorResponse,
} from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
@@ -77,6 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
},
rowCount: table.rowCount,
maxRows: table.maxRows,
locks: table.locks,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
@@ -153,6 +159,8 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Tab
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
logger.error(`[${requestId}] Error deleting table:`, error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
@@ -13,10 +13,10 @@ import { parseRequest, validationErrorResponseFromError } from '@/lib/api/server
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { RowData, TableSchema } from '@/lib/table'
import { updateRow } from '@/lib/table'
import { deleteRow, updateRow } from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { accessError, checkAccess } from '@/app/api/table/utils'
import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
@@ -179,6 +179,8 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: RowR
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
@@ -232,20 +234,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [deletedRow] = await db
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.returning({ id: userTableRows.id })
if (!deletedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
// Route through the service (not a raw `db.delete`) so the delete lock is
// enforced — the raw path would return 200 on a locked table.
await deleteRow(result.table, rowId, requestId)
return NextResponse.json({
success: true,
@@ -255,6 +246,11 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Row
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
if (error instanceof Error && error.message === 'Row not found') {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
@@ -427,6 +427,7 @@ export const DELETE = withRouteHandler(
if (validated.rowIds) {
const result = await deleteRowsByIds(
table,
{ tableId, rowIds: validated.rowIds, workspaceId: validated.workspaceId },
requestId
)
@@ -9,7 +9,7 @@ import type { RowData, TableSchema } from '@/lib/table'
import { upsertRow } from '@/lib/table'
import { namedRowMapper } from '@/lib/table/cell-format'
import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys'
import { accessError, checkAccess } from '@/app/api/table/utils'
import { accessError, checkAccess, tableLockErrorResponse } from '@/app/api/table/utils'
import {
checkRateLimit,
checkWorkspaceScope,
@@ -92,6 +92,8 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Upser
},
})
} catch (error) {
const lockError = tableLockErrorResponse(error)
if (lockError) return lockError
const validationResponse = validationErrorResponseFromError(error)
if (validationResponse) return validationResponse
+2
View File
@@ -53,6 +53,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
},
rowCount: t.rowCount,
maxRows: t.maxRows,
locks: t.locks,
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
@@ -137,6 +138,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()
@@ -48,6 +48,12 @@ interface ContextMenuProps {
workflowCellScoped?: boolean
disableEdit?: boolean
disableInsert?: boolean
/**
* Duplicate is a one-shot insert carrying the copied row's data, so it needs
* only the insert lock — unlike the blank-row inserts above it, which also
* need the update lock to be fillable.
*/
disableDuplicate?: boolean
disableDelete?: boolean
}
@@ -71,6 +77,7 @@ export function ContextMenu({
workflowCellScoped = false,
disableEdit = false,
disableInsert = false,
disableDuplicate = false,
disableDelete = false,
}: ContextMenuProps) {
const count = selectedRowCount.toLocaleString()
@@ -132,20 +139,23 @@ export function ContextMenu({
View execution
</DropdownMenuItem>
)}
{/* Not gated on `disableEdit`: these write only workflow-output columns,
which the update lock exempts, and Stop is a cancel rather than a
write. Their handlers are already withheld without edit permission. */}
{hasWorkflowColumns && onRunWorkflows && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRunWorkflows}>
<DropdownMenuItem onSelect={onRunWorkflows}>
<PlayOutline />
{runLabel}
</DropdownMenuItem>
)}
{hasWorkflowColumns && onRefreshWorkflows && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRefreshWorkflows}>
<DropdownMenuItem onSelect={onRefreshWorkflows}>
<RefreshCw />
{refreshLabel}
</DropdownMenuItem>
)}
{hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && (
<DropdownMenuItem disabled={disableEdit} onSelect={onStopWorkflows}>
<DropdownMenuItem onSelect={onStopWorkflows}>
<Square className='size-[14px] text-[var(--text-icon)]' />
{stopLabel}
</DropdownMenuItem>
@@ -158,7 +168,10 @@ export function ContextMenu({
<ArrowDown />
Insert row below
</DropdownMenuItem>
<DropdownMenuItem disabled={disableInsert || selectedRowCount > 1} onSelect={onDuplicate}>
<DropdownMenuItem
disabled={disableDuplicate || selectedRowCount > 1}
onSelect={onDuplicate}
>
<Duplicate />
Duplicate row
</DropdownMenuItem>
@@ -2,6 +2,7 @@ export * from './column-config-sidebar'
export * from './context-menu'
export * from './enrichment-details'
export * from './enrichments-sidebar'
export * from './lock-settings-modal'
export * from './new-column-dropdown'
export * from './row-modal'
export * from './run-status-control'
@@ -0,0 +1 @@
export { LockSettingsModal } from './lock-settings-modal'
@@ -0,0 +1,122 @@
'use client'
import { useId, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalFooter,
ChipModalHeader,
Label,
Switch,
Tooltip,
} from '@sim/emcn'
import { CircleInfo, Lock } from '@sim/emcn/icons'
import type { TableLocks } from '@/lib/table'
import {
describeLocks,
LOCK_FIELDS,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
import { useUpdateTableLocks } from '@/hooks/queries/tables'
function locksEqual(a: TableLocks, b: TableLocks): boolean {
return (
a.schemaLocked === b.schemaLocked &&
a.insertLocked === b.insertLocked &&
a.updateLocked === b.updateLocked &&
a.deleteLocked === b.deleteLocked
)
}
interface LockSettingsModalProps {
isOpen: boolean
onClose: () => void
workspaceId: string
tableId: string
locks: TableLocks
}
/**
* Admin-only panel to toggle a table's four mutation locks. Changes are staged
* locally and applied on Save (one request); the server re-checks admin and
* rejects a `write`-only caller with a 403 surfaced as a toast. Gated at the
* call site on `canAdmin`.
*/
export function LockSettingsModal({
isOpen,
onClose,
workspaceId,
tableId,
locks,
}: LockSettingsModalProps) {
const idPrefix = useId()
const updateLocks = useUpdateTableLocks(workspaceId)
// Stage edits locally; reset to the server value each time the modal opens.
const [draft, setDraft] = useState<TableLocks>(locks)
const [prevOpen, setPrevOpen] = useState(isOpen)
if (prevOpen !== isOpen) {
setPrevOpen(isOpen)
if (isOpen) setDraft(locks)
}
const dirty = !locksEqual(draft, locks)
const summary = describeLocks(draft)
const handleSave = () => {
if (!dirty) {
onClose()
return
}
updateLocks.mutate({ tableId, locks: draft }, { onSuccess: () => onClose() })
}
return (
<ChipModal open={isOpen} onOpenChange={(open) => !open && onClose()} srTitle='Table locks'>
<ChipModalHeader icon={Lock} onClose={onClose}>
Table locks
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-muted)] text-caption'>
<span className='text-[var(--text-body)]'>{summary.name}</span> — {summary.detail}
</p>
{LOCK_FIELDS.map((field) => {
const fieldId = `${idPrefix}-${field.kind}`
return (
<div key={field.key} className='flex items-center justify-between px-2'>
<div className='flex items-center gap-1.5'>
<Label htmlFor={fieldId}>Lock {field.noun}</Label>
<Tooltip.Root>
{/* Not `asChild`: the hint is each lock's only explanation, so
the trigger must be a focusable button for keyboard users. */}
<Tooltip.Trigger type='button' className='inline-flex cursor-help'>
<CircleInfo className='size-[14px] text-[var(--text-icon)]' />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{field.hint}</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Switch
id={fieldId}
checked={draft[field.key]}
disabled={updateLocks.isPending}
onCheckedChange={(checked) =>
setDraft((prev) => ({ ...prev, [field.key]: checked }))
}
/>
</div>
)
})}
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
cancelDisabled={updateLocks.isPending}
primaryAction={{
label: updateLocks.isPending ? 'Saving...' : 'Save',
onClick: handleSave,
disabled: !dirty || updateLocks.isPending,
}}
/>
</ChipModal>
)
}
@@ -27,6 +27,14 @@ interface NewColumnDropdownProps {
onPickType: (type: ColumnDefinition['type']) => void
onPickWorkflow: () => void
onPickEnrichment: () => void
/**
* When true, the trigger stays visible and clickable but opens nothing — it
* calls {@link onBlocked} instead. Used when the table is schema-locked:
* hiding the control leaves the user guessing, so it stays and explains.
* Paired required so `blocked` can never be set without a handler.
*/
blocked: boolean
onBlocked: () => void
}
/**
@@ -40,27 +48,44 @@ export function NewColumnDropdown({
onPickType,
onPickWorkflow,
onPickEnrichment,
blocked,
onBlocked,
}: NewColumnDropdownProps) {
const triggerButton =
trigger === 'header' ? (
<button
type='button'
className={chipVariants()}
disabled={disabled}
onClick={blocked ? onBlocked : undefined}
>
<Plus className={chipContentIconClass} />
<span className={chipContentLabelClass}>New column</span>
<ChipChevronDown />
</button>
) : (
<button
type='button'
className='flex h-[20px] cursor-pointer items-center gap-2 outline-none'
disabled={disabled}
onClick={blocked ? onBlocked : undefined}
>
<Plus className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-small'>New column</span>
</button>
)
if (blocked) {
return trigger === 'inline-header' ? (
<th className={CELL_HEADER}>{triggerButton}</th>
) : (
triggerButton
)
}
const menu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{trigger === 'header' ? (
<button type='button' className={chipVariants()} disabled={disabled}>
<Plus className={chipContentIconClass} />
<span className={chipContentLabelClass}>New column</span>
<ChipChevronDown />
</button>
) : (
<button
type='button'
className='flex h-[20px] cursor-pointer items-center gap-2 outline-none'
disabled={disabled}
>
<Plus className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-small'>New column</span>
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
<>
<DropdownMenuItem onSelect={onPickEnrichment}>
@@ -11,10 +11,17 @@ import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables'
import { captureEvent } from '@/lib/posthog/client'
import type { ColumnDefinition, Filter, TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import type {
ColumnDefinition,
Filter,
TableLocks,
TableRow as TableRowType,
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
import { useTimezone } from '@/hooks/queries/general-settings'
import {
useAddTableColumn,
@@ -145,6 +152,13 @@ interface TableGridProps {
workspaceId?: string
tableId?: string
embedded?: boolean
/** The table's mutation locks; gates row/schema affordances alongside `canEdit`. */
locks?: TableLocks
/**
* A mutation the user attempted that a lock forbids. The wrapper explains it
* in a modal — locked controls stay visible and say why, rather than vanishing.
*/
onBlockedAction: (action: BlockedTableAction) => void
/**
* Pixel width to reserve on the right of the table's scroll content for the
* currently-open slideout panel (column config, workflow config, or log
@@ -327,6 +341,8 @@ export function TableGrid({
workspaceId: propWorkspaceId,
tableId: propTableId,
embedded,
locks,
onBlockedAction,
sidebarReservedPx,
onOpenColumnConfig,
onOpenWorkflowConfig,
@@ -534,6 +550,36 @@ export function TableGrid({
const userPermissions = useUserPermissionsContext()
const canEditRef = useRef(userPermissions.canEdit)
canEditRef.current = userPermissions.canEdit
const canEditCell = userPermissions.canEdit && !locks?.updateLocked
const canDeleteRow = userPermissions.canEdit && !locks?.deleteLocked
const canMutateSchema = userPermissions.canEdit && !locks?.schemaLocked
// Dropping or retyping a column rewrites every row's data, so `assertColumnDestructive`
// requires the delete lock clear too — mirror that here or the affordance
// stays live on an append-only table and only fails on click.
const canDestroyColumn = canMutateSchema && !locks?.deleteLocked
// Duplicate inserts a full copied row in one shot, so unlike the blank-row
// paths it needs the insert lock only — it is valid on an append-only table.
const canInsertFullRow = userPermissions.canEdit && !locks?.insertLocked
// Manual grid entry is "add an empty row, then type into its cells" — the
// typing is an update. So a *useful* manual add needs BOTH insert and update
// unlocked; on an append-only table (update locked) it would leave a blank
// row the user can't fill. The control stays visible and explains itself via
// `onBlockedAction`. Full-row inserts still flow through CSV import / API /
// blocks / Mothership, which the insert lock alone governs server-side.
const canManualAddRow = userPermissions.canEdit && !locks?.insertLocked && !locks?.updateLocked
const canEditCellRef = useRef(canEditCell)
canEditCellRef.current = canEditCell
const canManualAddRowRef = useRef(canManualAddRow)
canManualAddRowRef.current = canManualAddRow
const canInsertFullRowRef = useRef(canInsertFullRow)
canInsertFullRowRef.current = canInsertFullRow
// Read by the closure-free double-click handler to tell "locked" apart from
// "no write permission" — only the former gets the explanation modal.
const updateLockedRef = useRef(locks?.updateLocked)
updateLockedRef.current = locks?.updateLocked
const onBlockedActionRef = useRef(onBlockedAction)
onBlockedActionRef.current = onBlockedAction
const { dismiss: dismissToast } = useToast()
const dismissToastRef = useRef(dismissToast)
dismissToastRef.current = dismissToast
@@ -654,6 +700,7 @@ export function TableGrid({
const { pushUndo, undo, redo } = useTableUndo({
workspaceId,
tableId,
getLocks: () => locks,
onColumnOrderChange: handleColumnOrderChange,
onColumnRename: handleColumnRename,
onColumnWidthsChange: handleColumnWidthsChange,
@@ -1197,6 +1244,26 @@ export function TableGrid({
)
}, [])
// Stable identity so <AddRowButton>'s React.memo still bails out; lock state
// is read from refs instead of being closed over.
const handleAddRowClick = useCallback(() => {
if (!canManualAddRowRef.current) {
onBlockedActionRef.current('add-row')
return
}
void handleAppendRow()
}, [handleAppendRow])
/** Stable stand-in for `handleDeleteColumn` when a lock forbids the drop. */
const handleBlockedDeleteColumn = useCallback(() => {
onBlockedActionRef.current('delete-column')
}, [])
/** Stable stand-in for the insert-column handlers when the schema is locked. */
const handleBlockedAddColumn = useCallback(() => {
onBlockedActionRef.current('add-column')
}, [])
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, row: TableRowType) => {
setEditingCell(null)
@@ -2027,7 +2094,7 @@ export function TableGrid({
(rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => {
const column = columnsRef.current.find((c) => c.key === columnName)
if (column?.type === 'boolean') {
if (!options?.toggleBoolean || !canEditRef.current) return
if (!options?.toggleBoolean || !canEditCellRef.current) return
const row = rowsRef.current.find((r) => r.id === rowId)
if (row) {
toggleBooleanCell(rowId, columnName, row.data[columnName])
@@ -2048,11 +2115,21 @@ export function TableGrid({
const column = columnsRef.current.find((c) => c.key === columnKey)
if (column?.type === 'boolean') return
// Double-click means "edit this cell". On an update-locked table, say so
// rather than opening the expanded viewer — which looks like an editor
// that silently refuses to save. Only for users who could otherwise edit:
// without write access the lock isn't why they can't, and they still get
// the read-only expanded viewer below.
if (canEditRef.current && updateLockedRef.current) {
onBlockedActionRef.current('edit-cell')
return
}
setSelectionFocus(null)
setIsColumnSelection(false)
// Date/number: use inline editor (calendar picker / numeric input).
if ((column?.type === 'date' || column?.type === 'number') && canEditRef.current) {
if ((column?.type === 'date' || column?.type === 'number') && canEditCellRef.current) {
setEditingCell({ rowId, columnName })
setInitialCharacter(null)
return
@@ -2192,7 +2269,12 @@ export function TableGrid({
!rowSelectionIsEmpty(rowSelectionRef.current)
) {
if (editingCellRef.current) return
if (!canEditRef.current) return
if (!canEditCellRef.current) {
// Explain the lock rather than no-op, but only for users who
// could otherwise edit — for the rest it is a permission limit.
if (canEditRef.current) onBlockedActionRef.current('edit-cell')
return
}
e.preventDefault()
const rowSel = rowSelectionRef.current
void (async () => {
@@ -2231,6 +2313,13 @@ export function TableGrid({
if (e.shiftKey && e.key === 'Enter') {
if (!canEditRef.current) return
// Same manual-add path as the Add row button, so it owes the same
// explanation rather than silently doing nothing on a locked table.
if (!canManualAddRowRef.current) {
e.preventDefault()
onBlockedActionRef.current('add-row')
return
}
const row = currentRows[anchor.rowIndex]
if (!row) return
e.preventDefault()
@@ -2255,6 +2344,13 @@ export function TableGrid({
if (e.key === 'Enter' || e.key === 'F2') {
if (!canEditRef.current) return
e.preventDefault()
// The primary keyboard edit path — same lock notice as double-click and
// Space, rather than a keypress that silently does nothing.
if (updateLockedRef.current) {
onBlockedActionRef.current('edit-cell')
return
}
if (!canEditCellRef.current) return
const col = cols[anchor.colIndex]
if (!col) return
@@ -2273,6 +2369,12 @@ export function TableGrid({
if (e.key === ' ' && !e.shiftKey) {
if (!canEditRef.current) return
e.preventDefault()
// Space opens the same row editor as double-click, so it follows the
// update lock too — otherwise the form fills in and only 423s on save.
if (updateLockedRef.current) {
onBlockedActionRef.current('edit-cell')
return
}
const row = currentRows[anchor.rowIndex]
if (row) {
onOpenRowModalRef.current(row)
@@ -2382,7 +2484,12 @@ export function TableGrid({
if ((e.metaKey || e.ctrlKey) && e.key === 'd') {
e.preventDefault()
if (!canEditRef.current) return
if (!canEditCellRef.current) {
// Explain the lock rather than no-op, but only for users who
// could otherwise edit — for the rest it is a permission limit.
if (canEditRef.current) onBlockedActionRef.current('edit-cell')
return
}
const sel = computeNormalizedSelection(anchor, selectionFocusRef.current)
if (!sel || sel.startRow === sel.endRow) return
const sourceRow = currentRows[sel.startRow]
@@ -2416,7 +2523,12 @@ export function TableGrid({
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (!canEditRef.current) return
if (!canEditCellRef.current) {
// Explain the lock rather than no-op, but only for users who
// could otherwise edit — for the rest it is a permission limit.
if (canEditRef.current) onBlockedActionRef.current('edit-cell')
return
}
e.preventDefault()
const sel = computeNormalizedSelection(anchor, selectionFocusRef.current)
if (!sel) return
@@ -2477,7 +2589,12 @@ export function TableGrid({
}
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (!canEditRef.current) return
if (!canEditCellRef.current) {
// Explain the lock rather than no-op, but only for users who
// could otherwise edit — for the rest it is a permission limit.
if (canEditRef.current) onBlockedActionRef.current('edit-cell')
return
}
const col = cols[anchor.colIndex]
// Workflow-output cells are editable: the user can override the
// workflow's value if they want. Booleans toggle on space/click —
@@ -2680,7 +2797,12 @@ export function TableGrid({
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (editingCellRef.current) return
if (!canEditRef.current) return
if (!canEditCellRef.current) {
// Explain the lock rather than no-op, but only for users who
// could otherwise edit — for the rest it is a permission limit.
if (canEditRef.current) onBlockedActionRef.current('edit-cell')
return
}
const rowSel = rowSelectionRef.current
const cols = columnsRef.current
@@ -2823,6 +2945,20 @@ export function TableGrid({
}
}
// A paste can do two different things at once: overwrite existing rows
// (an update) and extend past the last row (a full-row insert). Gate each
// against its own lock, and refuse the whole paste rather than applying
// half of it — so a full-row paste-append still works on an append-only
// table, while an overwrite on that same table explains itself.
if (updateBatch.length > 0 && !canEditCellRef.current) {
onBlockedActionRef.current('edit-cell')
return
}
if (createBatchRows.length > 0 && !canInsertFullRowRef.current) {
onBlockedActionRef.current('add-row')
return
}
if (updateBatch.length > 0) {
batchUpdateRef.current({ updates: updateBatch })
pushUndoRef.current({
@@ -3740,17 +3876,38 @@ export function TableGrid({
onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined}
hasActiveFilter={Boolean(effectiveFilter)}
selectedRowIds={selectedRowIds}
// Every locked action passes its blocked handler rather
// than `undefined`: `ColumnOptionsMenu` only mounts when
// all three handlers exist, so withholding one would hide
// the whole menu — and each item should explain the lock
// rather than silently vanish or fail with a 423 toast.
onInsertLeft={
userPermissions.canEdit ? handleInsertColumnLeft : undefined
!userPermissions.canEdit
? undefined
: canMutateSchema
? handleInsertColumnLeft
: handleBlockedAddColumn
}
onInsertRight={
userPermissions.canEdit ? handleInsertColumnRight : undefined
!userPermissions.canEdit
? undefined
: canMutateSchema
? handleInsertColumnRight
: handleBlockedAddColumn
}
onDeleteColumn={
userPermissions.canEdit ? handleDeleteColumn : undefined
!userPermissions.canEdit
? undefined
: canDestroyColumn
? handleDeleteColumn
: handleBlockedDeleteColumn
}
onDeleteGroup={
userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined
!userPermissions.canEdit
? undefined
: canDestroyColumn
? handleDeleteWorkflowGroup
: handleBlockedDeleteColumn
}
onViewWorkflow={
workflowGroupById.get(g.groupId)?.type === 'enrichment'
@@ -3813,6 +3970,11 @@ export function TableGrid({
key={column.key}
column={column}
colIndex={idx}
// Permission-only: `readOnly` swaps the whole header for a
// static label, which would also kill pin, column-select and
// open-config — all metadata, not schema. The schema lock is
// enforced per-action instead (insert/delete below), and a
// rename attempt surfaces the server's 423 as a toast.
readOnly={!userPermissions.canEdit}
isRenaming={columnRename.editingId === column.key}
isColumnSelected={
@@ -3828,9 +3990,17 @@ export function TableGrid({
onRenameSubmit={columnRename.submitRename}
onRenameCancel={columnRename.cancelRename}
onColumnSelect={handleColumnSelect}
onInsertLeft={handleInsertColumnLeft}
onInsertRight={handleInsertColumnRight}
onDeleteColumn={handleDeleteColumn}
// Required props here, and the menu is already
// suppressed for non-editors by `readOnly`.
onInsertLeft={
canMutateSchema ? handleInsertColumnLeft : handleBlockedAddColumn
}
onInsertRight={
canMutateSchema ? handleInsertColumnRight : handleBlockedAddColumn
}
onDeleteColumn={
canDestroyColumn ? handleDeleteColumn : handleBlockedDeleteColumn
}
onResizeStart={handleColumnResizeStart}
onResize={handleColumnResize}
onResizeEnd={handleColumnResizeEnd}
@@ -3855,6 +4025,8 @@ export function TableGrid({
<NewColumnDropdown
trigger='inline-header'
disabled={addColumnMutation.isPending}
blocked={!canMutateSchema}
onBlocked={() => onBlockedAction('add-column')}
onPickType={handleAddColumnOfType}
onPickWorkflow={handleAddWorkflowColumn}
onPickEnrichment={onOpenEnrichments}
@@ -3985,7 +4157,7 @@ export function TableGrid({
)}
</div>
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && (
<AddRowButton onClick={handleAppendRow} />
<AddRowButton onClick={handleAddRowClick} />
)}
</div>
</div>
@@ -4021,9 +4193,10 @@ export function TableGrid({
runningInSelectionCount={runningInContextSelection}
hasWorkflowColumns={hasWorkflowColumns}
workflowCellScoped={Boolean(contextMenuGroupId)}
disableEdit={!userPermissions.canEdit}
disableInsert={!userPermissions.canEdit}
disableDelete={!userPermissions.canEdit}
disableEdit={!canEditCell}
disableInsert={!canManualAddRow}
disableDuplicate={!canInsertFullRow}
disableDelete={!canDeleteRow}
/>
<ExpandedCellPopover
@@ -4032,7 +4205,7 @@ export function TableGrid({
rows={rows}
columns={displayColumns}
onSave={handleInlineSave}
canEdit={userPermissions.canEdit}
canEdit={canEditCell}
scrollContainer={scrollRef.current}
/>
</div>
@@ -379,6 +379,15 @@ export function useTableEventStream({
else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event)
else if (entry.event?.kind === 'job') applyJob(entry.event)
else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event)
else if (entry.event?.kind === 'definition') {
// A lock/schema change on the definition — re-read it so every open
// viewer's gating updates. `exact` avoids refetching every rows page
// (rowsRoot nests under detail); no row data changed.
void queryClient.invalidateQueries({
queryKey: tableKeys.detail(tableId),
exact: true,
})
}
} catch (err) {
logger.warn('Failed to parse table event', { tableId, err })
}
@@ -0,0 +1,132 @@
/**
* Single source of truth for lock vocabulary shared by the lock settings modal,
* the blocked-action toast, and the table header chip. Kept out of
* `lib/table/mutation-locks.ts` — that module is server-tainted (importing it
* from a client component pulls `next/headers` into the browser bundle).
*/
import type { TableLockKind, TableLocks } from '@/lib/table/types'
export interface LockField {
/** The `TableLocks` flag this row toggles. */
key: keyof TableLocks
kind: TableLockKind
/** The action being locked, phrased to read after "Lock " and inside a list. */
noun: string
hint: string
}
export const LOCK_FIELDS: LockField[] = [
{
key: 'insertLocked',
kind: 'insert',
noun: 'adding rows',
hint: 'On: no new rows can be added — by anyone, including CSV import, the API, workflow blocks, and Sim.',
},
{
key: 'updateLocked',
kind: 'update',
noun: 'editing rows',
hint: 'On: existing cell values cannot be changed. Workflow and enrichment columns still populate.',
},
{
key: 'deleteLocked',
kind: 'delete',
noun: 'deleting rows',
hint: 'On: rows cannot be deleted, and the table cannot be archived.',
},
{
key: 'schemaLocked',
kind: 'schema',
noun: 'changing columns',
hint: 'On: columns cannot be added, renamed, retyped, or removed.',
},
]
/** The locked verbs' nouns, in display order. Empty when nothing is locked. */
export function lockedNouns(locks: TableLocks): string[] {
return LOCK_FIELDS.filter((f) => locks[f.key]).map((f) => f.noun)
}
/**
* Plain-language summary of a lock set — the named mode when the combination
* matches one, otherwise a list of what is locked.
*/
export function describeLocks(locks: TableLocks): { name: string; detail: string } {
const locked = lockedNouns(locks)
if (locked.length === 0) {
return { name: 'Unlocked', detail: 'anyone with edit access can change this table.' }
}
if (locked.length === LOCK_FIELDS.length) {
return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' }
}
// Only the exact three-lock shape is Append-only — with the schema locked too
// the chip would claim columns are mutable when they aren't.
if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked && !locks.schemaLocked) {
return { name: 'Append-only', detail: 'rows can be added, but not edited or deleted.' }
}
return { name: 'Locked', detail: `${locked.join(', ')} locked.` }
}
/**
* Why a locked-table notice was raised. `'status'` is the informational case
* (a non-admin clicking the header lock chip); the rest are actions the user
* just tried and couldn't do.
*/
export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status'
/**
* Copy for the action the user attempted. Explains what is blocked and — for
* the append-only manual-entry case — what to do instead, since that one is
* blocked by the *update* lock rather than the insert lock.
*/
export function describeBlockedAction(
action: BlockedTableAction,
locks: TableLocks
): { title: string; text: string } {
switch (action) {
case 'add-row':
if (locks.insertLocked) {
return {
title: 'Adding rows is locked',
text: 'No new rows can be added until an admin unlocks this table.',
}
}
return {
title: 'This table is append-only',
text: 'Rows can’t be edited once added, so typing one into the grid is unavailable. Import a CSV, or add rows from the API, a workflow, or Sim.',
}
case 'add-column':
return {
title: 'Changing columns is locked',
text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.',
}
case 'delete-column':
// Reachable with the schema lock off but the delete lock on — removing a
// column clears its value from every row, so it needs both.
return locks.schemaLocked
? {
title: 'Changing columns is locked',
text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.',
}
: {
title: 'Deleting columns is locked',
text: 'Removing a column deletes its value from every row, so it’s blocked while deleting is locked.',
}
case 'edit-cell':
return {
title: 'Editing rows is locked',
text: 'Existing cell values can’t be changed until an admin unlocks this table.',
}
case 'status': {
const nouns = lockedNouns(locks)
return {
title: 'Table locks',
text:
nouns.length > 0
? `An admin has locked ${nouns.join(', ')} on this table.`
: 'Nothing is locked on this table.',
}
}
}
}
@@ -2,12 +2,13 @@
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { captureEvent } from '@/lib/posthog/client'
import type {
ColumnDefinition,
@@ -47,6 +48,7 @@ import {
ColumnConfigSidebar,
EnrichmentDetails,
EnrichmentsSidebar,
LockSettingsModal,
NewColumnDropdown,
RowModal,
RunStatusControl,
@@ -60,6 +62,12 @@ import {
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
import { useTable, useTableEventStream } from './hooks'
import {
type BlockedTableAction,
describeBlockedAction,
describeLocks,
lockedNouns,
} from './lock-copy'
import {
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
tableDetailParsers,
@@ -70,6 +78,17 @@ import { generateColumnName } from './utils'
const logger = createLogger('Table')
/**
* Client mirror of the server's `table-locks` flag. Hides the settings entry
* point when locks can't be set, so the panel never opens onto a Save that
* 403s. An already-locked table still shows its state regardless, so the
* gating stays legible if the flag is turned off after locks were applied.
*/
const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS'))
/** Blocked-action toasts carry a button, so they linger past the 5s default. */
const BLOCKED_TOAST_MS = 8000
interface TableProps {
/** When set, the table renders without its page header / breadcrumbs / page-level
* options bar. Used by the mothership chat panel to embed a table inline. */
@@ -155,6 +174,10 @@ export function Table({
const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' })
const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false)
const [showLockSettings, setShowLockSettings] = useState(false)
// Id of the last blocked-action toast, so a user who keeps typing into a
// locked cell replaces one notice rather than stacking a column of them.
const blockedToastIdRef = useRef<string | null>(null)
const [isImportCsvOpen, setIsImportCsvOpen] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
const [deletingRows, setDeletingRows] = useState<DeletedRowSnapshot[]>([])
@@ -550,10 +573,23 @@ export function Table({
icon: Pencil,
onClick: handleStartTableRename,
},
// Reachable with the flag off when something is locked, so an
// admin can always clear locks (the route allows clearing).
...(userPermissions.canAdmin &&
(tableLocksEnabled || lockedNouns(tableData.locks).length > 0)
? [
{
label: 'Lock settings',
icon: Lock,
onClick: () => setShowLockSettings(true),
},
]
: []),
{
label: 'Delete',
icon: Trash,
onClick: onRequestDeleteTable,
disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked,
},
],
}
@@ -561,6 +597,8 @@ export function Table({
],
[
handleNavigateBack,
userPermissions.canAdmin,
userPermissions.canEdit,
tableData,
tableHeaderRename.editingId,
tableHeaderRename.editValue,
@@ -572,31 +610,89 @@ export function Table({
]
)
const headerActions = useMemo(
() =>
tableData
? [
{
label: 'Import CSV',
icon: Upload,
onClick: onRequestImportCsv,
disabled: userPermissions.canEdit !== true,
},
{
label: 'Export CSV',
icon: Download,
onClick: () => void handleExportCsv(),
disabled: tableData.rowCount === 0,
},
]
: undefined,
[tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv]
// An admin can always reach the settings on a locked table — clearing locks
// stays allowed with the flag off, so the kill switch can't strand one. With
// the flag off and nothing locked there is nothing to change, so the toast is
// a plain notice with no action.
const canOpenLockSettings =
userPermissions.canAdmin === true &&
(tableLocksEnabled || (tableData ? lockedNouns(tableData.locks).length > 0 : false))
/**
* Explains why a table mutation is unavailable. A toast rather than a modal:
* being told you can't edit shouldn't cost a dismiss click, and admins still
* get a direct route to the settings via the action button.
*/
const showBlockedToast = useCallback(
(action: BlockedTableAction) => {
if (!tableData) return
if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current)
const { title, text } = describeBlockedAction(action, tableData.locks)
blockedToastIdRef.current = toast.warning(title, {
description: text,
...(canOpenLockSettings
? {
action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) },
// An action would otherwise pin the toast open until dismissed.
duration: BLOCKED_TOAST_MS,
}
: {}),
})
},
[tableData, canOpenLockSettings]
)
const headerActions = useMemo(() => {
if (!tableData) return undefined
const anyLocked = lockedNouns(tableData.locks).length > 0
// Name the mode when locked so the state is legible on open; admins get the
// entry point on an unlocked table only where the feature is enabled —
// otherwise the panel opens and Save 403s against the server-side gate.
const lockLabel = anyLocked ? describeLocks(tableData.locks).name : 'Lock settings'
return [
...(anyLocked || (userPermissions.canAdmin && tableLocksEnabled)
? [
{
label: lockLabel,
icon: Lock,
onClick: () =>
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
},
]
: []),
{
label: 'Import CSV',
icon: Upload,
onClick: onRequestImportCsv,
// An import always inserts, so the insert lock disables it outright
// rather than letting the dialog run to a server-side 423.
disabled: userPermissions.canEdit !== true || tableData.locks.insertLocked,
},
{
label: 'Export CSV',
icon: Download,
onClick: () => void handleExportCsv(),
disabled: tableData.rowCount === 0,
},
]
}, [
tableData,
userPermissions.canEdit,
userPermissions.canAdmin,
handleExportCsv,
onRequestImportCsv,
showBlockedToast,
])
// Adding a column is a schema change. The trigger stays visible when the
// table is schema-locked and explains itself instead of disappearing.
const canMutateSchema = userPermissions.canEdit && !tableData?.locks.schemaLocked
const createTrigger = userPermissions.canEdit ? (
<NewColumnDropdown
trigger='header'
disabled={false}
blocked={!canMutateSchema}
onBlocked={() => showBlockedToast('add-column')}
onPickType={handleAddColumnOfType}
onPickWorkflow={handleAddWorkflowColumn}
onPickEnrichment={onOpenEnrichments}
@@ -719,6 +815,8 @@ export function Table({
workspaceId={workspaceId}
tableId={tableId}
embedded={embedded}
locks={tableData?.locks}
onBlockedAction={showBlockedToast}
sidebarReservedPx={sidebarReservedPx}
onOpenColumnConfig={onOpenColumnConfig}
onOpenWorkflowConfig={onOpenWorkflowConfig}
@@ -973,6 +1071,15 @@ export function Table({
}}
/>
)}
{tableData && userPermissions.canAdmin && (
<LockSettingsModal
isOpen={showLockSettings}
onClose={() => setShowLockSettings(false)}
workspaceId={workspaceId}
tableId={tableData.id}
locks={tableData.locks}
/>
)}
</Resource>
)
}
@@ -175,10 +175,16 @@ export function ImportCsvDialog({
resetState()
}
// Replace deletes every existing row and creating columns is a schema change,
// so each needs its own lock clear. Withholding them here keeps the dialog
// from offering a configuration the server can only answer with a 423.
const canReplace = !table.locks?.deleteLocked
const canCreateColumns = !table.locks?.schemaLocked
const columnOptions: ComboboxOption[] = useMemo(() => {
const options: ComboboxOption[] = [
{ label: 'Do not import', value: SKIP_VALUE },
{ label: '+ Create new column', value: CREATE_VALUE },
...(canCreateColumns ? [{ label: '+ Create new column', value: CREATE_VALUE }] : []),
]
for (const col of table.schema.columns) {
options.push({
@@ -187,7 +193,7 @@ export function ImportCsvDialog({
})
}
return options
}, [table.schema.columns])
}, [table.schema.columns, canCreateColumns])
async function handleFileSelected(file: File) {
const ext = file.name.split('.').pop()?.toLowerCase()
@@ -257,6 +263,7 @@ export function ImportCsvDialog({
function handleModeChange(value: string) {
setSubmitError(null)
if (value === 'replace' && !canReplace) return
setMode(value as CsvImportMode)
}
@@ -307,7 +314,11 @@ export function ImportCsvDialog({
async function handleSubmit() {
if (!parsed || !canSubmit) return
setSubmitError(null)
const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined
// Hiding the Replace control doesn't clear an already-selected `mode`, so a
// delete lock landing while the dialog is open would still submit replace.
const effectiveMode: CsvImportMode = canReplace ? mode : 'append'
const createColumns =
canCreateColumns && createHeaders.size > 0 ? [...createHeaders] : undefined
// Large files can't be POSTed through the server (request-body cap) — upload them
// straight to storage and import in the background instead. Seed the header tray and
@@ -326,7 +337,7 @@ export function ImportCsvDialog({
workspaceId,
tableId: table.id,
file: parsed.file,
mode,
mode: effectiveMode,
mapping,
createColumns,
onProgress: (percent) => {
@@ -357,12 +368,12 @@ export function ImportCsvDialog({
workspaceId,
tableId: table.id,
file: parsed.file,
mode,
mode: effectiveMode,
mapping,
createColumns,
})
const data = result.data
if (mode === 'append') {
if (effectiveMode === 'append') {
toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`)
} else {
toast.success(
@@ -426,14 +437,19 @@ export function ImportCsvDialog({
<ChipModalField type='custom' title='Mode'>
<ButtonGroup value={mode} onValueChange={handleModeChange}>
<ButtonGroupItem value='append'>Append</ButtonGroupItem>
<ButtonGroupItem value='replace'>Replace all rows</ButtonGroupItem>
{canReplace && <ButtonGroupItem value='replace'>Replace all rows</ButtonGroupItem>}
</ButtonGroup>
</ChipModalField>
<ChipModalField type='custom' title='Column mapping'>
{skipCount > 0 && (
<div className='flex justify-end'>
<Button variant='ghost' size='sm' onClick={handleCreateAllUnmapped}>
<Button
variant='ghost'
size='sm'
disabled={!canCreateColumns}
onClick={handleCreateAllUnmapped}
>
Create columns for {skipCount} unmapped
</Button>
</div>
@@ -523,6 +523,14 @@ export async function copyForkResourceContainers(
// Start at 0 - the post-commit content copy raises it to the rows actually
// copied, so a failed/partial copy never advertises the source's count.
rowCount: 0,
// Locks are workspace-local governance and never transit a fork edge —
// mirrors `copy-workflows.ts` writing `locked: false`. Inheriting them
// would hand the fork owner a frozen workspace they may lack admin to
// unlock. Reset explicitly since the spread above copies every column.
schemaLocked: false,
insertLocked: false,
updateLocked: false,
deleteLocked: false,
archivedAt: null,
createdAt: now,
updatedAt: now,
+2
View File
@@ -45,6 +45,8 @@ vi.mock('@/lib/api/client/request', () => ({
vi.mock('@/lib/api/client/errors', () => ({
isValidationError: vi.fn(() => false),
isApiClientError: vi.fn(() => false),
extractValidationIssues: vi.fn(() => []),
}))
vi.mock('@/lib/api/contracts/tables', () => ({
+113 -11
View File
@@ -16,7 +16,12 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { extractValidationIssues, isValidationError } from '@/lib/api/client/errors'
import {
ApiClientError,
extractValidationIssues,
isApiClientError,
isValidationError,
} from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import type { ContractJsonResponse } from '@/lib/api/contracts'
import {
@@ -61,12 +66,14 @@ import {
type TableFindMatch,
type TableIdParamsInput,
type TableJobSummary,
type TableLocksInput,
type TableRowParamsInput,
type TableRowsQueryInput,
type UpdateTableColumnBodyInput,
type UpdateTableRowBodyInput,
type UpdateWorkflowGroupBodyInput,
updateTableColumnContract,
updateTableContract,
updateTableMetadataContract,
updateTableRowContract,
updateWorkflowGroupContract,
@@ -546,6 +553,7 @@ export function useAddTableColumn({ workspaceId, tableId }: RowMutationContext)
})
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -580,6 +588,52 @@ export function useRenameTable(workspaceId: string) {
})
}
/**
* Toggle a table's mutation locks (admin-only; the server enforces the role).
* Optimistically patches the detail cache so the settings switches respond
* instantly, reconciling on settle. Uses `exact` on the detail invalidation so
* the rows pages (nested under detail) aren't needlessly refetched.
*/
export function useUpdateTableLocks(workspaceId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
tableId,
locks,
}: {
tableId: string
locks: Partial<TableLocksInput>
}) => {
return requestJson(updateTableContract, {
params: { tableId },
body: { workspaceId, locks },
})
},
onMutate: async ({ tableId, locks }) => {
await queryClient.cancelQueries({ queryKey: tableKeys.detail(tableId) })
const previousDetail = queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))
if (previousDetail) {
queryClient.setQueryData<TableDefinition>(tableKeys.detail(tableId), {
...previousDetail,
locks: { ...previousDetail.locks, ...locks },
})
}
return { previousDetail }
},
onError: (error, { tableId }, context) => {
if (context?.previousDetail) {
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
}
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, { tableId }) => {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true })
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
},
})
}
/**
* Delete a table from a workspace.
*/
@@ -593,7 +647,8 @@ export function useDeleteTable(workspaceId: string) {
query: { workspaceId },
})
},
onError: (error) => {
onError: (error, tableId) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -627,6 +682,26 @@ function notifyRowWriteError(error: Error, onUpgrade: () => void): void {
toast.error(error.message, { duration: 5000 })
}
/**
* Self-heals a 423 lock rejection: refreshes the (now-known-stale) table
* definition so the grid's gating catches up, refreshes the list, toasts the
* lock reason, and reports whether it handled the error. Call FIRST in a row
* mutation's `onError` — a lock set by another user (or Mothership) since this
* grid loaded is otherwise invisible until a manual refresh. `exact` avoids
* refetching every rows page (rowsRoot nests under detail).
*/
function handleTableLockRejection(
error: unknown,
queryClient: ReturnType<typeof useQueryClient>,
tableId: string
): boolean {
if (!isApiClientError(error) || error.status !== 423) return false
void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true })
void queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
toast.error(error.message, { duration: 5000 })
return true
}
export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
const router = useRouter()
@@ -674,8 +749,10 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext)
predicate: (query) => !isDefaultOrderRowsQuery(query.queryKey),
})
},
onError: (error) =>
notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))),
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables')))
},
onSettled: () => {
// `reconcileCreatedRow` (onSuccess) is the source of truth for the rows
// cache + its `totalCount`; only refresh the count surfaces here so a late
@@ -853,8 +930,10 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon
},
})
},
onError: (error) =>
notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))),
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables')))
},
onSettled: () => {
invalidateRowCount(queryClient, tableId)
},
@@ -938,6 +1017,7 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext)
if (context?.didBumpRunState) {
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1011,6 +1091,7 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon
if (context?.didBumpRunState) {
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1031,6 +1112,7 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext)
})
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1079,6 +1161,7 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext)
return { deletedRowIds }
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1174,6 +1257,7 @@ export function useDeleteTableRowsAsync({ workspaceId, tableId }: RowMutationCon
if (context?.previousDetail) {
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1242,6 +1326,7 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
if (context?.previousDetail) {
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1446,7 +1531,8 @@ export function useRestoreTable() {
params: { tableId },
})
},
onError: (error) => {
onError: (error, tableId) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1493,7 +1579,13 @@ export function useUploadCsvToTable() {
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'CSV import failed')
// Carry the status: a plain Error drops it, and the 423 self-heal below
// keys off `error.status`.
throw new ApiClientError({
status: response.status,
body: data,
message: data.error || 'CSV import failed',
})
}
return response.json()
@@ -1629,7 +1721,8 @@ export function useImportCsvIntoTableAsync() {
})
return response.data
},
onError: (error) => {
onError: (error, variables) => {
if (handleTableLockRejection(error, queryClient, variables.tableId)) return
logger.error('Failed to start async CSV import:', error)
toast.error(error.message, { duration: 5000 })
},
@@ -1708,7 +1801,8 @@ export function useImportCsvIntoTable() {
return response.json()
},
onError: (error) => {
onError: (error, variables) => {
if (handleTableLockRejection(error, queryClient, variables.tableId)) return
logger.error('Failed to import CSV into table:', error)
toast.error(error.message, { duration: 5000 })
},
@@ -1794,6 +1888,7 @@ export function useExportTableAsync({ workspaceId, tableId }: RowMutationContext
void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) })
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -1905,6 +2000,7 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
queryClient.setQueryData(key, data)
}
}
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -2136,12 +2232,15 @@ export function useRunColumn({ workspaceId, tableId }: RowMutationContext) {
const bumped = await bumpRunState(queryClient, tableId, stampedByRow)
return { snapshots, runStateSnapshot: bumped?.snapshot, didBumpRunState: bumped !== null }
},
onError: (_err, _variables, context) => {
onError: (error, _variables, context) => {
if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots)
// Roll back the optimistic counter bump (snapshot may be undefined).
if (context?.didBumpRunState) {
queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot)
}
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
onSuccess: (data, { groupIds, runMode = 'all', rowIds, limit }, context) => {
// Seed the dispatch into the overlay (drives resolveCellExec for
@@ -2202,6 +2301,7 @@ export function useAddWorkflowGroup({ workspaceId, tableId }: RowMutationContext
})
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -2235,6 +2335,7 @@ export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationCont
})
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
@@ -2259,6 +2360,7 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont
})
},
onError: (error) => {
if (handleTableLockRejection(error, queryClient, tableId)) return
if (isValidationError(error)) return
toast.error(error.message, { duration: 5000 })
},
+8 -1
View File
@@ -13,6 +13,10 @@ vi.mock('react', () => ({
const mockMutate = vi.fn()
const mockMutateAsync = vi.fn()
vi.mock('@sim/emcn', () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
}))
vi.mock('@/hooks/queries/tables', () => ({
useUpdateTableRow: vi.fn(() => ({ mutate: mockMutate })),
useCreateTableRow: vi.fn(() => ({ mutate: mockMutate })),
@@ -49,7 +53,10 @@ const storeState = {
}
vi.mock('@/stores/table/store', () => ({
useTableUndoStore: vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)),
useTableUndoStore: Object.assign(
vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)),
{ getState: () => storeState }
),
runWithoutRecording: (fn: () => unknown) => Promise.resolve(fn()),
}))
+86
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { TABLE_LOCK_FLAGS, type TableLockKind, type TableLocks } from '@/lib/table/types'
import {
useAddTableColumn,
useBatchCreateTableRows,
@@ -18,6 +20,67 @@ import type { TableUndoAction } from '@/stores/table/types'
const logger = createLogger('useTableUndo')
/**
* The mutation verb an undo/redo of `action` would perform in `direction`. Undo
* inverts row create/delete (undoing a create deletes; undoing a delete
* re-inserts), so the verb depends on direction. Column ops are always `schema`;
* rename/reorder touch no locked verb (`null`).
*/
function undoActionVerbs(action: TableUndoAction, direction: 'undo' | 'redo'): TableLockKind[] {
const undoing = direction === 'undo'
switch (action.type) {
case 'update-cell':
case 'update-cells':
case 'clear-cells':
return ['update']
case 'create-row':
case 'create-rows':
return undoing ? ['delete'] : ['insert']
case 'delete-rows':
return undoing ? ['insert'] : ['delete']
// Dropping or retyping a column clears its value from every row, so those
// directions need the delete lock clear too — mirroring
// `assertColumnDestructive`. Undoing a delete-column re-adds the column and
// writes the saved cells back, which is a row update.
case 'create-column':
return undoing ? ['schema', 'delete'] : ['schema']
case 'delete-column':
return undoing ? ['schema', 'update'] : ['schema', 'delete']
case 'update-column-type':
return ['schema', 'delete']
case 'rename-column':
case 'toggle-column-constraint':
return ['schema']
default:
return []
}
}
/** The first lock (if any) that would block replaying `action` in `direction`. */
function undoActionBlockedBy(
action: TableUndoAction,
direction: 'undo' | 'redo',
locks: TableLocks | undefined
): TableLockKind | null {
if (!locks) return null
return undoActionVerbs(action, direction).find((kind) => locks[TABLE_LOCK_FLAGS[kind]]) ?? null
}
const BLOCKED_UNDO_MESSAGE: Record<TableLockKind, string> = {
insert: 'This table is insert-locked — that step cannot be undone.',
update: 'This table is update-locked — that step cannot be undone.',
delete: 'This table is delete-locked — that step cannot be undone.',
schema: 'This table is schema-locked — that step cannot be undone.',
}
/**
* Top of a stack without popping, so a lock-blocked step can be left in place
* rather than popped-and-lost — see `undo`/`redo`.
*/
function peekAction(tableId: string, stack: 'undo' | 'redo'): TableUndoAction | undefined {
return useTableUndoStore.getState().stacks[tableId]?.[stack].at(-1)?.action
}
export function extractCreatedRowId(response: Record<string, unknown>): string | undefined {
const data = response?.data as Record<string, unknown> | undefined
const row = data?.row as Record<string, unknown> | undefined
@@ -27,6 +90,8 @@ export function extractCreatedRowId(response: Record<string, unknown>): string |
interface UseTableUndoProps {
workspaceId: string
tableId: string
/** Current table locks, so undo/redo skips a step whose verb is locked. */
getLocks?: () => TableLocks | undefined
onColumnOrderChange?: (order: string[]) => void
onColumnRename?: (oldName: string, newName: string) => void
onColumnWidthsChange?: (widths: Record<string, number>) => void
@@ -38,6 +103,7 @@ interface UseTableUndoProps {
export function useTableUndo({
workspaceId,
tableId,
getLocks,
onColumnOrderChange,
onColumnRename,
onColumnWidthsChange,
@@ -77,6 +143,8 @@ export function useTableUndo({
getPinnedColumnsRef.current = getPinnedColumns
const getColumnWidthsRef = useRef(getColumnWidths)
getColumnWidthsRef.current = getColumnWidths
const getLocksRef = useRef(getLocks)
getLocksRef.current = getLocks
useEffect(() => {
return () => clear(tableId)
@@ -435,12 +503,30 @@ export function useTableUndo({
)
const undo = useCallback(() => {
// Check the lock BEFORE popping — a blocked step must stay on the stack, not
// be silently discarded (the mutation would 423 and the entry would be lost).
const next = peekAction(tableId, 'undo')
if (next) {
const blocked = undoActionBlockedBy(next, 'undo', getLocksRef.current?.())
if (blocked) {
toast.error(BLOCKED_UNDO_MESSAGE[blocked], { duration: 5000 })
return
}
}
const entry = popUndo(tableId)
if (!entry) return
void runWithoutRecording(() => executeAction(entry.action, 'undo'))
}, [popUndo, tableId, executeAction])
const redo = useCallback(() => {
const next = peekAction(tableId, 'redo')
if (next) {
const blocked = undoActionBlockedBy(next, 'redo', getLocksRef.current?.())
if (blocked) {
toast.error(BLOCKED_UNDO_MESSAGE[blocked], { duration: 5000 })
return
}
}
const entry = popRedo(tableId)
if (!entry) return
void runWithoutRecording(() => executeAction(entry.action, 'redo'))
+50
View File
@@ -9,6 +9,7 @@ import type {
RowData,
Sort,
TableDefinition,
TableLocks,
TableMetadata,
TableRow,
TableRowsCursor,
@@ -171,6 +172,35 @@ export const renameTableBodySchema = z.object({
name: tableNameSchema,
})
/** Full per-table lock set (all four flags). */
export const tableLocksSchema = z.object({
schemaLocked: z.boolean(),
insertLocked: z.boolean(),
updateLocked: z.boolean(),
deleteLocked: z.boolean(),
}) satisfies z.ZodType<TableLocks>
/**
* PATCH /api/table/[tableId] body. Both fields are optional but at least one
* must be present. `name` is a `write`-level rename; `locks` is an
* admin-only governance change (a partial — only the toggled flags are sent).
*/
export const updateTableBodySchema = z
.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
name: tableNameSchema.optional(),
locks: tableLocksSchema.partial().optional(),
})
.superRefine((body, ctx) => {
if (body.name === undefined && body.locks === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide a new name or lock changes',
path: ['name'],
})
}
})
export const createTableColumnBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
column: z
@@ -515,6 +545,26 @@ export const renameTableContract = defineRouteContract({
},
})
/**
* Superset of {@link renameTableContract} on the same PATCH endpoint: accepts a
* `name` rename and/or a `locks` change. The route applies its own permission
* split — `name` needs workspace `write`, `locks` needs `admin`.
*/
export const updateTableContract = defineRouteContract({
method: 'PATCH',
path: '/api/table/[tableId]',
params: tableIdParamsSchema,
body: updateTableBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ table: tableDefinitionSchema })),
},
})
export type TableLocksInput = z.input<typeof tableLocksSchema>
export type UpdateTableBody = z.input<typeof updateTableBodySchema>
export type UpdateTableResponse = ContractJsonResponse<typeof updateTableContract>
export const deleteTableContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]',
@@ -49,6 +49,7 @@ function buildTable(overrides: Partial<TableDefinition> = {}): TableDefinition {
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
@@ -47,6 +47,7 @@ import {
import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service'
import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks'
import {
batchInsertRows,
batchUpdateRows,
@@ -773,7 +774,14 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
await deleteRow(args.tableId, args.rowId, workspaceId, requestId)
const deleteRowTable = await getTableById(args.tableId)
// The old signature passed `workspaceId` into `deleteRow`, which scoped
// the query; taking a TableDefinition instead means the ownership check
// has to happen here, as every other operation in this tool does.
if (!deleteRowTable || deleteRowTable.workspaceId !== workspaceId) {
return { success: false, message: `Table ${args.tableId} not found` }
}
await deleteRow(deleteRowTable, args.rowId, requestId)
return {
success: true,
@@ -841,6 +849,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
affectedCount: target,
maxRows: args.limit,
}
// Gate the update lock at enqueue — the background worker is a
// trusted continuation and does not re-check.
assertRowUpdate(table, patchColumnIds(idData))
assertNotAborted()
const claimed = await markTableJobRunning(table.id, jobId, 'update', payload)
if (!claimed) {
@@ -934,6 +945,8 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const payload: TableDeleteJobPayload = bounded
? { filter: idFilter, cutoff: cutoff.toISOString(), maxRows: args.limit }
: { filter: idFilter, cutoff: cutoff.toISOString(), doomedCount }
// Gate the delete lock at enqueue — the worker is a trusted continuation.
assertRowDelete(table)
assertNotAborted()
const claimed = await markTableJobRunning(table.id, jobId, 'delete', payload)
if (!claimed) {
@@ -1090,7 +1103,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
const requestId = generateId().slice(0, 8)
assertNotAborted()
const batchDeleteTable = await getTableById(args.tableId)
if (!batchDeleteTable || batchDeleteTable.workspaceId !== workspaceId) {
return { success: false, message: `Table ${args.tableId} not found` }
}
const result = await deleteRowsByIds(
batchDeleteTable,
{ tableId: args.tableId, rowIds, workspaceId },
requestId
)
+3
View File
@@ -473,6 +473,7 @@ export const env = createEnv({
DATA_DRAINS_ENABLED: z.boolean().optional(), // Enable data drains on self-hosted (bypasses hosted requirements)
FORKING_ENABLED: z.boolean().optional(), // Enable workspace forking on self-hosted (bypasses hosted requirements)
DEPLOY_AS_BLOCK: z.boolean().optional(), // Enable deploy-as-block (publish a workflow as a reusable org-wide custom block)
TABLE_LOCKS: z.boolean().optional(), // Enable per-table mutation locks (schema/insert/update/delete toggles)
// Organizations - for self-hosted deployments
ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
@@ -571,6 +572,7 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: z.boolean().optional(), // Enable SSO login UI components
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: z.boolean().optional(), // Enable custom blocks (deploy-as-block) settings on self-hosted
NEXT_PUBLIC_TABLE_LOCKS: z.boolean().optional(), // Surface the per-table mutation-lock settings UI (client mirror of the server-side TABLE_LOCKS gate)
NEXT_PUBLIC_WHITELABELING_ENABLED: z.boolean().optional(), // Enable whitelabeling on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: z.boolean().optional(), // Enable audit logs on self-hosted (bypasses hosted requirements)
NEXT_PUBLIC_DATA_RETENTION_ENABLED: z.boolean().optional(), // Enable data retention settings on self-hosted (bypasses hosted requirements)
@@ -612,6 +614,7 @@ export const env = createEnv({
NEXT_PUBLIC_SSO_ENABLED: process.env.NEXT_PUBLIC_SSO_ENABLED,
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED: process.env.NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED,
NEXT_PUBLIC_TABLE_LOCKS: process.env.NEXT_PUBLIC_TABLE_LOCKS,
NEXT_PUBLIC_WHITELABELING_ENABLED: process.env.NEXT_PUBLIC_WHITELABELING_ENABLED,
NEXT_PUBLIC_AUDIT_LOGS_ENABLED: process.env.NEXT_PUBLIC_AUDIT_LOGS_ENABLED,
NEXT_PUBLIC_DATA_RETENTION_ENABLED: process.env.NEXT_PUBLIC_DATA_RETENTION_ENABLED,
@@ -112,6 +112,15 @@ const FEATURE_FLAGS = {
'custom-block publish/list routes. Off-AppConfig falls back to DEPLOY_AS_BLOCK.',
fallback: 'DEPLOY_AS_BLOCK',
},
'table-locks': {
description:
'Per-table mutation locks (schema/insert/update/delete) an admin toggles to make a table ' +
'append-only or read-only. Gates the ability to SET locks (the settings UI + the PATCH ' +
'locks branch); enforcement of any lock already stored always runs. Since new tables are ' +
'created unlocked and forks reset locks, an off flag means no table can become locked, so ' +
'the woven-in asserts stay no-ops. Off-AppConfig falls back to TABLE_LOCKS.',
fallback: 'TABLE_LOCKS',
},
} satisfies Record<string, FeatureFlagDefinition>
/**
@@ -54,6 +54,7 @@ const TABLE: TableDefinition = {
maxRows: 1000,
workspaceId: 'ws-1',
createdBy: 'user-1',
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
@@ -39,6 +39,7 @@ const TABLE: TableDefinition = {
maxRows: 1000,
workspaceId: 'ws-1',
createdBy: 'user-1',
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
@@ -59,6 +59,7 @@ const TABLE: TableDefinition = {
maxRows: 1000,
workspaceId: 'ws-1',
createdBy: 'user-1',
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
@@ -89,6 +89,7 @@ const TABLE: TableDefinition = {
maxRows: 1000,
workspaceId: 'ws-1',
createdBy: 'user-1',
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
archivedAt: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
+4 -1
View File
@@ -176,7 +176,10 @@ async function processBackfillPage(opts: {
await batchUpdateRows(
{ tableId: table.id, updates, workspaceId: table.workspaceId, actorUserId },
table,
requestId
requestId,
// Every patched key is a workflow-group output column, so a backfill is a
// computed write and stays allowed on an update-locked table.
{ computedWrite: true }
)
return updates.length
}
+2 -1
View File
@@ -47,6 +47,7 @@ const TABLE: TableDefinition = {
maxRows: 100,
workspaceId: 'workspace-1',
createdBy: 'user-1',
locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false },
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
}
@@ -140,7 +141,7 @@ describe('writeWorkflowGroupState', () => {
},
TABLE,
CONTEXT.requestId,
{ dataWriteMode: 'patch' }
{ dataWriteMode: 'patch', computedWrite: true }
)
expect(mockAppendTableEvent).toHaveBeenCalledWith(
expect.objectContaining({
+3 -1
View File
@@ -84,7 +84,9 @@ export async function writeWorkflowGroupState(
},
table,
requestId,
{ dataWriteMode: 'patch' }
// `computedWrite` is what lets a workflow column keep populating on an
// update-locked table; the lock still covers user-authored columns.
{ dataWriteMode: 'patch', computedWrite: true }
)
} else {
result = await db.transaction((trx) =>
+8
View File
@@ -15,6 +15,7 @@ import { createLogger } from '@sim/logger'
import { and, count, eq, sql } from 'drizzle-orm'
import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks'
import type { DbTransaction } from '@/lib/table/planner'
import { stripGroupExecutions } from '@/lib/table/rows/executions'
import { selectValueToNames } from '@/lib/table/select-values'
@@ -67,6 +68,7 @@ export async function addTableColumn(
requestId: string
): Promise<TableDefinition> {
return withLockedTable(tableId, async (table, trx) => {
assertSchemaMutable(table)
if (!NAME_PATTERN.test(column.name)) {
throw new Error(
`Invalid column name "${column.name}". Must start with a letter or underscore and contain only alphanumeric characters and underscores.`
@@ -178,6 +180,7 @@ export async function renameColumn(
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
assertSchemaMutable(table)
if (!NAME_PATTERN.test(data.newName)) {
throw new Error(
`Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.`
@@ -312,6 +315,7 @@ export async function deleteColumn(
requestId: string
): Promise<TableDefinition> {
const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => {
assertColumnDestructive(table)
const schema = table.schema
const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName))
if (columnIndex === -1) {
@@ -390,6 +394,7 @@ export async function deleteColumns(
requestId: string
): Promise<TableDefinition> {
const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => {
assertColumnDestructive(table)
const schema = table.schema
const namesToDelete = new Set<string>()
const idsToDelete = new Set<string>()
@@ -482,6 +487,8 @@ export async function updateColumnType(
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
// Retype reinterprets every stored value under a new type — destructive.
assertColumnDestructive(table)
// Scale both statement and idle timeouts to row count: the compatibility
// check below iterates every row in Node between the row SELECT and the
// schema UPDATE, leaving the transaction idle for that gap. The default 5s
@@ -645,6 +652,7 @@ export async function updateColumnConstraints(
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
assertSchemaMutable(table)
// Scale both statement and idle timeouts to row count: the required/unique
// validation runs between separate queries inside this transaction, leaving
// it briefly idle. Match `updateColumnType` so the default 5s
+71 -4
View File
@@ -11,6 +11,7 @@ const {
mockUpdateJobProgress,
mockMarkJobReady,
mockMarkJobFailed,
mockMarkJobCanceled,
mockAppendTableEvent,
mockBuildFilterClause,
} = vi.hoisted(() => ({
@@ -21,6 +22,7 @@ const {
mockUpdateJobProgress: vi.fn(),
mockMarkJobReady: vi.fn(),
mockMarkJobFailed: vi.fn(),
mockMarkJobCanceled: vi.fn(),
mockAppendTableEvent: vi.fn(),
mockBuildFilterClause: vi.fn(),
}))
@@ -33,6 +35,7 @@ vi.mock('@/lib/table/jobs/service', () => ({
updateJobProgress: mockUpdateJobProgress,
markJobReady: mockMarkJobReady,
markJobFailed: mockMarkJobFailed,
markJobCanceled: mockMarkJobCanceled,
}))
vi.mock('@/lib/table/rows/ordering', () => ({
selectRowIdPage: mockSelectRowIdPage,
@@ -47,7 +50,13 @@ vi.mock('@/lib/table/constants', () => ({
import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner'
const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] } }
const UNLOCKED = {
schemaLocked: false,
insertLocked: false,
updateLocked: false,
deleteLocked: false,
}
const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] }, locks: UNLOCKED }
const cutoff = new Date('2026-06-05T00:00:00Z')
function basePayload(overrides = {}) {
@@ -66,6 +75,44 @@ describe('runTableDelete', () => {
mockBuildFilterClause.mockReturnValue({})
})
it('cancels without deleting when the table was delete-locked before the run started', async () => {
// The lock is asserted at enqueue, but a queued or retried job can start
// after an admin locks the table — nothing is written yet, so honor it.
mockGetTableById.mockResolvedValue({ ...table, locks: { ...UNLOCKED, deleteLocked: true } })
mockSelectRowIdPage.mockResolvedValue(['a', 'b'])
await expect(runTableDelete(basePayload())).resolves.toBeUndefined()
expect(mockDeletePageByIds).not.toHaveBeenCalled()
expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1')
expect(mockMarkJobReady).not.toHaveBeenCalled()
expect(mockAppendTableEvent).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled' })
)
})
it('stops mid-run when the delete lock is enabled between pages', async () => {
mockGetTableById
.mockResolvedValueOnce(table)
.mockResolvedValueOnce(table)
.mockResolvedValue({ ...table, locks: { ...UNLOCKED, deleteLocked: true } })
mockSelectRowIdPage.mockResolvedValueOnce(['a', 'b'])
await expect(runTableDelete(basePayload())).resolves.toBeUndefined()
// First page committed before the lock landed; the second never runs.
expect(mockDeletePageByIds).toHaveBeenCalledTimes(1)
expect(mockDeletePageByIds).toHaveBeenCalledWith(
'tbl_1',
'ws_1',
['a', 'b'],
expect.anything(),
expect.any(Function)
)
expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1')
expect(mockMarkJobReady).not.toHaveBeenCalled()
})
it('deletes every matching page then marks the job ready', async () => {
mockSelectRowIdPage
.mockResolvedValueOnce(['a', 'b'])
@@ -74,8 +121,22 @@ describe('runTableDelete', () => {
await runTableDelete(basePayload({ filter: { status: 'old' } }))
expect(mockDeletePageByIds).toHaveBeenNthCalledWith(1, 'tbl_1', 'ws_1', ['a', 'b'])
expect(mockDeletePageByIds).toHaveBeenNthCalledWith(2, 'tbl_1', 'ws_1', ['c'])
expect(mockDeletePageByIds).toHaveBeenNthCalledWith(
1,
'tbl_1',
'ws_1',
['a', 'b'],
expect.anything(),
expect.any(Function)
)
expect(mockDeletePageByIds).toHaveBeenNthCalledWith(
2,
'tbl_1',
'ws_1',
['c'],
expect.anything(),
expect.any(Function)
)
expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1')
expect(mockAppendTableEvent).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'job', type: 'delete', status: 'ready', progress: 3 })
@@ -104,7 +165,13 @@ describe('runTableDelete', () => {
await runTableDelete(basePayload({ excludeRowIds: ['keep'] }))
expect(mockDeletePageByIds).toHaveBeenCalledTimes(1)
expect(mockDeletePageByIds).toHaveBeenCalledWith('tbl_1', 'ws_1', ['x'])
expect(mockDeletePageByIds).toHaveBeenCalledWith(
'tbl_1',
'ws_1',
['x'],
expect.anything(),
expect.any(Function)
)
// Second page is queried after the last id of the first page (cursor advanced past 'keep').
expect(mockSelectRowIdPage).toHaveBeenNthCalledWith(
2,
+62 -2
View File
@@ -2,15 +2,18 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import type { Filter } from '@/lib/table'
import type { Filter, TableDefinition } from '@/lib/table'
import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { appendTableEvent } from '@/lib/table/events'
import {
getJobProgress,
markJobCanceled,
markJobFailed,
markJobReady,
updateJobProgress,
} from '@/lib/table/jobs/service'
import { assertRowDelete, type MutationProof, TableLockedError } from '@/lib/table/mutation-locks'
import type { DbTransaction } from '@/lib/table/planner'
import { deletePageByIds, selectRowIdPage } from '@/lib/table/rows/ordering'
import { getTableById } from '@/lib/table/service'
import { buildFilterClause } from '@/lib/table/sql'
@@ -66,6 +69,44 @@ export async function runTableDelete(payload: TableDeletePayload): Promise<void>
const table = await getTableById(tableId, { includeArchived: true })
if (!table) throw new Error(`Delete target table ${tableId} not found`)
// Gate the run on the delete lock, then re-gate it before every page (see
// the loop below) so enabling the lock mid-job stops the deletion rather
// than merely preventing the next one. Returning rather than throwing
// releases the job slot without burning this task's remaining
// `maxAttempts` on a deterministic failure.
const cancelForLock = async (processedSoFar: number): Promise<void> => {
logger.info(`[${requestId}] Delete job stopped — table is delete-locked`, {
tableId,
jobId,
processedSoFar,
})
await markJobCanceled(tableId, jobId)
void appendTableEvent({ kind: 'job', type: 'delete', tableId, jobId, status: 'canceled' })
}
const stopIfLocked = async (
fresh: TableDefinition,
processedSoFar: number
): Promise<MutationProof<'delete'> | null> => {
try {
return assertRowDelete(fresh)
} catch (err) {
if (!(err instanceof TableLockedError)) throw err
await cancelForLock(processedSoFar)
return null
}
}
if ((await stopIfLocked(table, 0)) === null) return
// Runs inside each batch's transaction, under the same advisory lock the
// lock toggle holds, so no page can be written after a lock commits.
const revalidate = async (trx: DbTransaction) => {
const fresh = await getTableById(tableId, { tx: trx, includeArchived: true })
if (fresh) assertRowDelete(fresh)
return fresh ?? undefined
}
const filterClause = filter
? buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns)
: undefined
@@ -87,6 +128,16 @@ export async function runTableDelete(payload: TableDeletePayload): Promise<void>
const owns = await updateJobProgress(tableId, processed, jobId)
if (!owns) throw new JobSupersededError()
// Cheap early-out before selecting a page we may not be allowed to
// write. The authoritative gate is `revalidate` below, which re-asserts
// inside each batch transaction; this read only avoids the wasted page
// fetch. Pages already committed stay committed — the same contract as
// an explicit cancel.
const current = await getTableById(tableId, { includeArchived: true })
if (!current) throw new JobSupersededError()
const pageProof = await stopIfLocked(current, processed)
if (pageProof === null) return
const page = await selectRowIdPage({
tableId,
workspaceId,
@@ -102,7 +153,16 @@ export async function runTableDelete(payload: TableDeletePayload): Promise<void>
const toDelete = excluded.size > 0 ? page.filter((id) => !excluded.has(id)) : page
if (toDelete.length > 0) {
processed += await deletePageByIds(tableId, workspaceId, toDelete)
try {
processed += await deletePageByIds(tableId, workspaceId, toDelete, pageProof, revalidate)
} catch (err) {
if (!(err instanceof TableLockedError)) throw err
// A lock landed between batches. Batches already committed stay
// deleted; `processed` undercounts them, which only affects the
// final progress number on an already-canceled job.
await cancelForLock(processed)
return
}
}
if (
+9
View File
@@ -141,6 +141,15 @@ export type TableEvent =
dispatchId?: string
message: string
}
| {
/** The table definition changed in a way that isn't row/cell data — a
* lock toggle or a schema mutation. Carries no payload; the client just
* invalidates the table-detail query so every open viewer re-reads the
* fresh locks/schema (otherwise an idle grid stays stale and writes 423). */
kind: 'definition'
tableId: string
reason: 'locks' | 'schema'
}
export interface TableEventEntry {
eventId: number
+83 -19
View File
@@ -11,10 +11,16 @@ import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing'
import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import'
import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks'
import { nKeysBetween } from '@/lib/table/order-key'
import { acquireRowOrderLock } from '@/lib/table/rows/ordering'
import type { DbTransaction } from '@/lib/table/planner'
import {
acquireRowOrderLock,
guardBatch,
type MutationRevalidator,
} from '@/lib/table/rows/ordering'
import { batchInsertRowsWithTx, replaceTableRowsWithTx } from '@/lib/table/rows/service'
import { addTableColumnsWithTx, auditTableColumnsAdded } from '@/lib/table/service'
import { addTableColumnsWithTx, auditTableColumnsAdded, getTableById } from '@/lib/table/service'
import type {
ReplaceRowsResult,
RowData,
@@ -60,8 +66,14 @@ export interface BulkImportBatch {
export async function bulkInsertImportBatch(
data: BulkImportBatch,
table: TableDefinition,
requestId: string
requestId: string,
/** Re-asserts the insert lock inside the write transaction. See {@link guardBatch}. */
revalidate?: MutationRevalidator
): Promise<{ inserted: number; lastOrderKey: string | null }> {
// Superseded by the in-tx revalidation when one is supplied; asserting
// the caller's snapshot too would reject a since-cleared lock.
if (!revalidate) assertRowInsert(table)
for (let i = 0; i < data.rows.length; i++) {
const sizeValidation = validateRowSize(data.rows[i])
if (!sizeValidation.valid) {
@@ -104,7 +116,10 @@ export async function bulkInsertImportBatch(
...(data.userId ? { createdBy: data.userId } : {}),
}))
await db.insert(userTableRows).values(rowsToInsert)
await db.transaction(async (trx) => {
await guardBatch(trx, data.tableId, revalidate)
await trx.insert(userTableRows).values(rowsToInsert)
})
logger.info(`[${requestId}] Bulk-imported ${rowsToInsert.length} rows into table ${data.tableId}`)
return {
inserted: rowsToInsert.length,
@@ -113,8 +128,18 @@ export async function bulkInsertImportBatch(
}
/** Deletes every row of a table (set-based; the statement-level trigger zeroes `row_count`). */
export async function deleteAllTableRows(tableId: string): Promise<void> {
await db.delete(userTableRows).where(eq(userTableRows.tableId, tableId))
export async function deleteAllTableRows(
table: TableDefinition,
/** Re-asserts the delete lock inside the write transaction. See {@link guardBatch}. */
revalidate?: MutationRevalidator
): Promise<void> {
// Superseded by the in-tx revalidation when one is supplied; asserting
// the caller's snapshot too would reject a since-cleared lock.
if (!revalidate) assertRowDelete(table)
await db.transaction(async (trx) => {
await guardBatch(trx, table.id, revalidate)
await trx.delete(userTableRows).where(eq(userTableRows.tableId, table.id))
})
}
/**
@@ -125,11 +150,17 @@ export async function addImportColumns(
table: TableDefinition,
additions: { name: string; type: string }[],
requestId: string,
actingUserId?: string
actingUserId?: string,
/** Re-asserts the schema lock inside the write transaction. See {@link guardBatch}. */
revalidate?: MutationRevalidator
): Promise<TableDefinition> {
const updated = await db.transaction((trx) =>
addTableColumnsWithTx(trx, table, additions, requestId)
)
const updated = await db.transaction(async (trx) => {
// `addTableColumnsWithTx` re-asserts the schema lock, so hand it the
// freshly-read definition — asserting the caller's snapshot would reject a
// lock that has since been cleared.
const fresh = await guardBatch(trx, table.id, revalidate)
return addTableColumnsWithTx(trx, fresh ?? table, additions, requestId)
})
auditTableColumnsAdded(
table,
additions.map((c) => c.name),
@@ -139,11 +170,44 @@ export async function addImportColumns(
}
/** Overwrites a table's schema during an import (used when inferring columns from the file). */
export async function setTableSchemaForImport(tableId: string, schema: TableSchema): Promise<void> {
await db
.update(userTableDefinitions)
.set({ schema, updatedAt: new Date() })
.where(eq(userTableDefinitions.id, tableId))
export async function setTableSchemaForImport(
table: TableDefinition,
schema: TableSchema,
/** Re-asserts the schema lock inside the write transaction. See {@link guardBatch}. */
revalidate?: MutationRevalidator
): Promise<void> {
// Superseded by the in-tx revalidation when one is supplied; asserting
// the caller's snapshot too would reject a since-cleared lock.
if (!revalidate) assertSchemaMutable(table)
await db.transaction(async (trx) => {
await guardBatch(trx, table.id, revalidate)
await trx
.update(userTableDefinitions)
.set({ schema, updatedAt: new Date() })
.where(eq(userTableDefinitions.id, table.id))
})
}
/**
* Re-reads the table under its schema advisory lock inside the caller's
* transaction. The sync import paths own their transaction rather than taking a
* revalidator, so they refresh here: a lock committed while the CSV was being
* parsed must be visible to the asserts in `addTableColumnsWithTx` /
* `batchInsertRowsWithTx` / `replaceTableRowsWithTx`, which all read the
* definition they are handed.
*
* Taken before `acquireRowOrderLock` so the order stays advisory → rows_pos →
* definitions, matching every other advisory-lock holder.
*/
async function refreshUnderLock(
trx: DbTransaction,
table: TableDefinition
): Promise<TableDefinition> {
const fresh = await guardBatch(trx, table.id, async (tx) => {
const latest = await getTableById(table.id, { tx, includeArchived: true })
return latest ?? undefined
})
return fresh ?? table
}
/**
@@ -165,7 +229,7 @@ export async function importAppendRows(
addedRows: rows.length,
})
const result = await db.transaction(async (trx) => {
let working = table
let working = await refreshUnderLock(trx, table)
if (additions.length > 0) {
// Take the row-order lock before creating columns so this path uses the
// same rows_pos → user_table_definitions order as plain inserts. Creating
@@ -173,7 +237,7 @@ export async function importAppendRows(
// the order and deadlocking concurrent inserts on this table. The lock is
// re-entrant, so the per-batch acquire below is a no-op.
await acquireRowOrderLock(trx, table.id)
working = await addTableColumnsWithTx(trx, table, additions, ctx.requestId)
working = await addTableColumnsWithTx(trx, working, additions, ctx.requestId)
}
const inserted: TableRow[] = []
for (let i = 0; i < rows.length; i += CSV_MAX_BATCH_SIZE) {
@@ -223,10 +287,10 @@ export async function importReplaceRows(
addedRows: data.rows.length,
})
const result = await db.transaction(async (trx) => {
let working = table
let working = await refreshUnderLock(trx, table)
if (additions.length > 0) {
await acquireRowOrderLock(trx, table.id)
working = await addTableColumnsWithTx(trx, table, additions, requestId)
working = await addTableColumnsWithTx(trx, working, additions, requestId)
}
return replaceTableRowsWithTx(
trx,
+22
View File
@@ -98,6 +98,28 @@ describe('runTableImport source-file cleanup', () => {
mockDeleteFile.mockResolvedValue(undefined)
})
it('fails an insert-locked replace before anything is deleted', async () => {
// `deleteAllTableRows` only asserts the delete lock, so without an up-front
// insert assert a replace on an insert-locked table would wipe every row
// and then fail on the first insert, leaving the table empty.
mockGetTableById.mockResolvedValue({
...table,
locks: { schemaLocked: false, insertLocked: true, updateLocked: false, deleteLocked: false },
})
mockMarkJobFailed.mockResolvedValue(undefined)
await runTableImport(buildPayload({ mode: 'replace' }))
expect(mockMarkJobFailed).toHaveBeenCalledWith(
'tbl_1',
'job_1',
expect.stringMatching(/insert-locked/i)
)
// Bailed before the file was even read, so the delete could never run.
expect(mockDownloadFileStream).not.toHaveBeenCalled()
})
it('deletes the single-use source object by default', async () => {
await runTableImport(buildPayload())
+43 -4
View File
@@ -28,6 +28,8 @@ import {
setTableSchemaForImport,
} from '@/lib/table/import-data'
import { markJobFailed, markJobReady, updateJobProgress } from '@/lib/table/jobs/service'
import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks'
import type { DbTransaction } from '@/lib/table/planner'
import { nextImportStartOrderKey, nextImportStartPosition } from '@/lib/table/rows/ordering'
import { getTableById } from '@/lib/table/service'
import { deleteFile, downloadFileStream, headObject } from '@/lib/uploads/core/storage-service'
@@ -97,6 +99,36 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
if (!loaded) throw new Error(`Import target table ${tableId} not found`)
const table = loaded
// Every mode ends in row inserts, and `replace` deletes first. Assert both
// verbs here — before the file is even read — so an insert-locked table
// fails up front instead of after `deleteAllTableRows` has already wiped it.
// (The sync replace path gets this for free from `replaceTableRowsWithTx`,
// which asserts both in one place; this path deletes and inserts separately.)
assertRowInsert(table)
if (mode === 'replace') assertRowDelete(table)
// Re-asserted inside every batch's insert transaction, under the same
// advisory lock `updateTableLocks` writes with, so enabling the insert lock
// mid-import stops it at the next batch instead of letting the rest of the
// file through. Rows already committed stay — as with an explicit cancel.
const revalidateInsert = async (trx: DbTransaction) => {
const fresh = await getTableById(tableId, { tx: trx, includeArchived: true })
if (fresh) assertRowInsert(fresh)
return fresh ?? undefined
}
/** Same guard for the replace-mode wipe, which lands before the first batch. */
const revalidateDelete = async (trx: DbTransaction) => {
const fresh = await getTableById(tableId, { tx: trx, includeArchived: true })
if (fresh) assertRowDelete(fresh)
return fresh ?? undefined
}
/** Same guard for the inferred-schema write and `createColumns`. */
const revalidateSchema = async (trx: DbTransaction) => {
const fresh = await getTableById(tableId, { tx: trx, includeArchived: true })
if (fresh) assertSchemaMutable(fresh)
return fresh ?? undefined
}
// Total byte size for the progress estimate — a cheap HEAD, no download. May be null on
// the local dev provider, in which case the bar stays indeterminate (rows still show).
const totalBytes = (await headObject(fileKey, 'workspace'))?.size ?? 0
@@ -159,7 +191,7 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
// the same ids).
schema = withGeneratedColumnIds({ columns: inferred.columns.map(normalizeColumn) })
headerToColumn = inferred.headerToColumn
await setTableSchemaForImport(tableId, schema)
await setTableSchemaForImport(table, schema, revalidateSchema)
return
}
@@ -188,7 +220,13 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
additions.push({ name: columnName, type: inferColumnType(sample.map((r) => r[header])) })
updatedMapping[header] = columnName
}
const updated = await addImportColumns(table, additions, requestId, userId)
const updated = await addImportColumns(
table,
additions,
requestId,
userId,
revalidateSchema
)
targetSchema = updated.schema
effectiveMapping = updatedMapping
}
@@ -204,7 +242,7 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
// Replace deletes existing rows only after schema/mapping validation passes, so an
// invalid or empty file fails the import with the old rows still intact (a mid-stream
// insert failure after this point leaves a partial replace — replace is destructive).
if (mode === 'replace') await deleteAllTableRows(tableId)
if (mode === 'replace') await deleteAllTableRows(table, revalidateDelete)
}
const flush = async (rows: Record<string, unknown>[]) => {
@@ -232,7 +270,8 @@ export async function runTableImport(payload: TableImportPayload): Promise<void>
afterOrderKey: lastOrderKey,
},
{ ...table, schema },
requestId
requestId,
revalidateInsert
)
notifyTableRowUsage({
workspaceId,
+120
View File
@@ -0,0 +1,120 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
assertColumnDestructive,
assertRowDelete,
assertRowInsert,
assertRowUpdate,
assertSchemaMutable,
TableLockedError,
} from '@/lib/table/mutation-locks'
import type { TableDefinition, TableLocks } from '@/lib/table/types'
const UNLOCKED: TableLocks = {
schemaLocked: false,
insertLocked: false,
updateLocked: false,
deleteLocked: false,
}
function makeTable(
locks: Partial<TableLocks> = {},
columns: TableDefinition['schema']['columns'] = []
): TableDefinition {
return {
id: 'tbl_1',
name: 't',
schema: { columns },
rowCount: 0,
maxRows: 100,
workspaceId: 'ws_1',
createdBy: 'user_1',
locks: { ...UNLOCKED, ...locks },
createdAt: new Date(),
updatedAt: new Date(),
}
}
describe('mutation-locks', () => {
it('passes every assert on an unlocked table', () => {
const table = makeTable()
expect(() => assertRowInsert(table)).not.toThrow()
expect(() => assertRowUpdate(table, ['col_a'])).not.toThrow()
expect(() => assertRowDelete(table)).not.toThrow()
expect(() => assertSchemaMutable(table)).not.toThrow()
expect(() => assertColumnDestructive(table)).not.toThrow()
})
it('blocks only its own verb with a 423 and the right lock name', () => {
expect(() => assertRowInsert(makeTable({ insertLocked: true }))).toThrowError(
expect.objectContaining({ statusCode: 423, lock: 'insert' })
)
expect(() => assertRowUpdate(makeTable({ updateLocked: true }), ['col_a'])).toThrowError(
expect.objectContaining({ statusCode: 423, lock: 'update' })
)
expect(() => assertRowDelete(makeTable({ deleteLocked: true }))).toThrowError(
expect.objectContaining({ statusCode: 423, lock: 'delete' })
)
expect(() => assertSchemaMutable(makeTable({ schemaLocked: true }))).toThrowError(
expect.objectContaining({ statusCode: 423, lock: 'schema' })
)
})
it('does not cross-block: an insert lock permits update/delete/schema', () => {
const table = makeTable({ insertLocked: true })
expect(() => assertRowUpdate(table, ['col_a'])).not.toThrow()
expect(() => assertRowDelete(table)).not.toThrow()
expect(() => assertSchemaMutable(table)).not.toThrow()
})
describe('update lock exemptions', () => {
it('permits an empty (executions-only) patch', () => {
expect(() => assertRowUpdate(makeTable({ updateLocked: true }))).not.toThrow()
expect(() => assertRowUpdate(makeTable({ updateLocked: true }), [])).not.toThrow()
})
it('permits a computed write touching only workflow-group output columns', () => {
const table = makeTable({ updateLocked: true }, [
{ id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' },
])
expect(() => assertRowUpdate(table, ['col_wf'], { computedWrite: true })).not.toThrow()
})
it('blocks an ordinary caller patching a workflow-group output column', () => {
const table = makeTable({ updateLocked: true }, [
{ id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' },
])
expect(() => assertRowUpdate(table, ['col_wf'])).toThrow(TableLockedError)
})
it('blocks a computed write that also touches a user-authored column', () => {
const table = makeTable({ updateLocked: true }, [
{ id: 'col_wf', name: 'Enriched', type: 'string', workflowGroupId: 'grp_1' },
{ id: 'col_user', name: 'Name', type: 'string' },
])
expect(() => assertRowUpdate(table, ['col_wf', 'col_user'], { computedWrite: true })).toThrow(
TableLockedError
)
})
})
describe('assertColumnDestructive', () => {
it('requires BOTH schema and delete clear', () => {
expect(() => assertColumnDestructive(makeTable({ schemaLocked: true }))).toThrowError(
expect.objectContaining({ lock: 'schema' })
)
// schema clear but delete locked → delete error (destroying row data)
expect(() => assertColumnDestructive(makeTable({ deleteLocked: true }))).toThrowError(
expect.objectContaining({ lock: 'delete' })
)
})
})
it('treats an absent locks field as fully unlocked (fail-open for mocks)', () => {
const table = { ...makeTable(), locks: undefined } as unknown as TableDefinition
expect(() => assertRowInsert(table)).not.toThrow()
expect(() => assertSchemaMutable(table)).not.toThrow()
})
})
+191
View File
@@ -0,0 +1,191 @@
/**
* Per-table mutation-lock enforcement — four independent locks, one per verb.
*
* Lives at the `lib/table` service layer, not the routes: Mothership calls these
* services directly, so route-level asserts would miss it entirely.
*
* Non-goals, each load-bearing:
* - Do NOT re-read the table inside a write tx to close the TOCTOU window. A
* `SELECT ... FOR UPDATE` on the definition row reintroduces the per-insert
* serialization hotspot migration 0198 removed; `getTableById` without `{ tx }`
* takes a second pool connection while holding the first (the billing
* pool-starvation shape). Locks gate *starting* a mutation, not a transactional
* fence — workspace `write` is still the security boundary.
* - Integrity, not confidentiality: reads/exports are never blocked, and forking
* (admin-gated, the same bar as unlocking) resets locks on the copy.
* - System writers outside these helpers (fork copy, workspace archive,
* soft-delete GC, order-key repair, seeds) must keep writing raw so they are
* never blocked.
*/
import { createLogger } from '@sim/logger'
import { HttpError } from '@/lib/core/utils/http-error'
import { getColumnId } from '@/lib/table/column-keys'
import type { RowData, TableDefinition, TableLockKind } from '@/lib/table/types'
const logger = createLogger('TableMutationLocks')
const LOCK_MESSAGES: Record<TableLockKind, string> = {
schema: 'This table is schema-locked: its columns cannot be changed.',
insert: 'This table is insert-locked: new rows cannot be added.',
update: 'This table is update-locked: existing rows cannot be edited.',
delete: 'This table is delete-locked: rows cannot be deleted.',
}
/**
* Thrown when a mutation violates a table lock. Extends {@link HttpError} with
* `statusCode = 423` so `withRouteHandler` forwards the message on routes that
* don't catch it, and route catch blocks map it via `tableLockErrorResponse`.
* The message names the lock and is retry-pointless — surface it verbatim.
*/
export class TableLockedError extends HttpError {
readonly statusCode = 423
readonly lock: TableLockKind
constructor(lock: TableLockKind, message?: string) {
super(message ?? LOCK_MESSAGES[lock])
this.name = 'TableLockedError'
this.lock = lock
}
}
/**
* An opaque, unforgeable proof that a lock assert ran for a verb. The brand
* symbol is module-private, so no other module can mint one by object literal
* or cast — the only source is the `assert*` functions here (and the explicit
* test/bypass escape hatch). The low-level write primitives in `rows/ordering.ts`
* require the matching proof, so a new row-write path cannot compile without
* first asserting.
*/
declare const proofBrand: unique symbol
export interface MutationProof<V extends TableLockKind = TableLockKind> {
readonly [proofBrand]: V
}
const PROOF = Object.freeze({}) as MutationProof<TableLockKind>
function proofFor<V extends TableLockKind>(): MutationProof<V> {
return PROOF as MutationProof<V>
}
function logBlocked(table: TableDefinition, lock: TableLockKind): void {
logger.warn('Table mutation blocked by lock', {
tableId: table.id,
workspaceId: table.workspaceId,
lock,
})
}
/** Asserts the table permits inserting rows. */
export function assertRowInsert(table: TableDefinition): MutationProof<'insert'> {
if (table.locks?.insertLocked) {
logBlocked(table, 'insert')
throw new TableLockedError('insert')
}
return proofFor<'insert'>()
}
/** Asserts the table permits deleting rows. */
export function assertRowDelete(table: TableDefinition): MutationProof<'delete'> {
if (table.locks?.deleteLocked) {
logBlocked(table, 'delete')
throw new TableLockedError('delete')
}
return proofFor<'delete'>()
}
/**
* Asserts the table permits updating existing rows.
*
* Two carve-outs keep the update lock meaning "user-authored data is immutable"
* without breaking the machinery that lives on the same write path:
*
* - An **empty data patch** (`columnIds` omitted or empty) is an
* executions-only write — cancellation, error/completed status stamps, and
* usage-limit pre-stamp clears. These are not row edits; blocking them would
* strand cells in `running` forever, so they always pass.
* - A **computed write** (`computedWrite: true`) that touches only workflow-group
* output columns is the workflow/enrichment engine filling its own cells, not
* a user edit, so it passes. The opt-in is what makes this safe: it is set
* only by `cell-write.ts`, so an ordinary API caller cannot get the exemption
* by aiming a PATCH at a workflow output column. A computed write that
* touches any user-authored column is still blocked.
*/
export function assertRowUpdate(
table: TableDefinition,
columnIds?: readonly string[],
options: UpdateAssertOptions = {}
): MutationProof<'update'> {
if (!table.locks?.updateLocked) return proofFor<'update'>()
if (!columnIds || columnIds.length === 0) return proofFor<'update'>()
if (options.computedWrite && patchTouchesOnlyWorkflowColumns(table, columnIds)) {
return proofFor<'update'>()
}
logBlocked(table, 'update')
throw new TableLockedError('update')
}
/** Asserts the table permits schema (column / workflow-group) changes. */
export function assertSchemaMutable(table: TableDefinition): MutationProof<'schema'> {
if (table.locks?.schemaLocked) {
logBlocked(table, 'schema')
throw new TableLockedError('schema')
}
return proofFor<'schema'>()
}
/**
* Asserts a **destructive** schema change (dropping or retyping a column, or
* deleting a workflow group/output). These rewrite `user_table_rows.data` for
* every row, so they are gated by BOTH the schema lock (structure) and the
* delete lock (data destruction) — a delete lock therefore genuinely means "no
* row data disappears by any route", including via column removal.
*/
export function assertColumnDestructive(table: TableDefinition): void {
assertSchemaMutable(table)
if (table.locks?.deleteLocked) {
logBlocked(table, 'delete')
throw new TableLockedError('delete')
}
}
export interface UpdateAssertOptions {
/**
* Set only by the workflow/enrichment cell-write path. Unlocks the
* workflow-output carve-out in {@link assertRowUpdate}; every other caller
* leaves it unset and is held to the full update lock.
*/
computedWrite?: boolean
}
/**
* True when every id in `columnIds` maps to a workflow-group output column in
* the table's schema. Used by {@link assertRowUpdate} to exempt computed-cell
* writes from the update lock. An unknown id (not in the schema) counts as
* user-authored, so it fails closed.
*/
function patchTouchesOnlyWorkflowColumns(
table: TableDefinition,
columnIds: readonly string[]
): boolean {
const workflowColumnIds = new Set<string>()
for (const col of table.schema.columns) {
if (col.workflowGroupId) workflowColumnIds.add(getColumnId(col))
}
return columnIds.every((id) => workflowColumnIds.has(id))
}
/** Column ids present in a row-data patch, for {@link assertRowUpdate}. */
export function patchColumnIds(data: RowData): string[] {
return Object.keys(data)
}
/**
* Escape hatch for tests and trusted system callers that legitimately invoke
* the low-level `rows/ordering.ts` primitives without a preceding assert
* (e.g. fixtures, or a call path already gated elsewhere). NOT for production
* mutation paths — those must assert so locks are enforced and violations logged.
*/
export function unsafeMutationProof<V extends TableLockKind = TableLockKind>(): MutationProof<V> {
return proofFor<V>()
}
+49 -3
View File
@@ -11,10 +11,11 @@ import { userTableRows } from '@sim/db/schema'
import { and, asc, desc, eq, gt, inArray, lt, lte, type SQL, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { TABLE_LIMITS } from '@/lib/table/constants'
import type { MutationProof } from '@/lib/table/mutation-locks'
import { keyBetween, nKeysBetween } from '@/lib/table/order-key'
import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner'
import { setTableTxTimeouts } from '@/lib/table/tx'
import type { RowData } from '@/lib/table/types'
import type { RowData, TableDefinition } from '@/lib/table/types'
/**
* Starting `position` for an append import — `max(position) + 1`, or 0 when empty. Read once,
@@ -194,6 +195,8 @@ export async function insertOrderedRow(params: {
beforeRowId?: string
createdBy?: string
now: Date
/** Proof the caller asserted the insert lock (see `mutation-locks.ts`). */
proof: MutationProof<'insert'>
}): Promise<{
id: string
data: RowData
@@ -252,6 +255,8 @@ export async function deleteOrderedRow(params: {
tableId: string
rowId: string
workspaceId: string
/** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */
proof: MutationProof<'delete'>
}): Promise<boolean> {
const { tableId, rowId, workspaceId } = params
return db.transaction(async (trx) => {
@@ -280,6 +285,8 @@ export async function deleteOrderedRowsByIds(params: {
tableId: string
workspaceId: string
rowIds: string[]
/** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */
proof: MutationProof<'delete'>
}): Promise<{ id: string }[]> {
const { tableId, workspaceId, rowIds } = params
if (rowIds.length === 0) return []
@@ -388,6 +395,35 @@ export async function selectRowDataPage(params: {
return rows.map((r) => ({ id: r.id, data: r.data as RowData }))
}
/**
* Re-verifies the table's locks from inside a write transaction. Supplied by
* the background runners; see {@link deletePageByIds}.
*/
export type MutationRevalidator = (trx: DbTransaction) => Promise<TableDefinition | undefined>
/**
* Takes the table's schema advisory lock and runs `revalidate` inside the
* caller's transaction. The lock toggle (`updateTableLocks`) writes under the
* same key, so check-then-write becomes atomic with respect to a lock change:
* a lock committed before this call is seen and throws; one committed after it
* waits for this batch to finish. Without it, the caller's proof would only
* describe the lock state at some earlier point in the run.
*
* Returns the freshly-read definition so tx-bound helpers can act on live state
* instead of the caller's snapshot.
*/
export async function guardBatch(
trx: DbTransaction,
tableId: string,
revalidate: MutationRevalidator | undefined
): Promise<TableDefinition | undefined> {
if (!revalidate) return undefined
await trx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))`
)
return revalidate(trx)
}
/**
* Deletes one page of rows for the async delete-job worker, committing each `DELETE_BATCH_SIZE`
* chunk in its own short transaction. One statement per transaction bounds how long the
@@ -401,13 +437,18 @@ export async function selectRowDataPage(params: {
export async function deletePageByIds(
tableId: string,
workspaceId: string,
rowIds: string[]
rowIds: string[],
/** Proof the caller asserted the delete lock (see `mutation-locks.ts`). */
_proof: MutationProof<'delete'>,
/** Re-asserts the lock inside each batch transaction. See {@link guardBatch}. */
revalidate?: MutationRevalidator
): Promise<number> {
let deleted = 0
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
const rows = await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
await guardBatch(trx, tableId, revalidate)
return trx
.delete(userTableRows)
.where(
@@ -433,7 +474,11 @@ export async function updatePageByIds(
tableId: string,
workspaceId: string,
rowIds: string[],
patchJson: string
patchJson: string,
/** Proof the caller asserted the update lock (see `mutation-locks.ts`). */
_proof: MutationProof<'update'>,
/** Re-asserts the lock inside each batch transaction. See {@link guardBatch}. */
revalidate?: MutationRevalidator
): Promise<number> {
const now = new Date()
let updated = 0
@@ -441,6 +486,7 @@ export async function updatePageByIds(
const batch = rowIds.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const rows = await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
await guardBatch(trx, tableId, revalidate)
return trx
.update(userTableRows)
.set({ data: sql`${userTableRows.data} || ${patchJson}::jsonb`, updatedAt: now })
+61 -5
View File
@@ -25,6 +25,12 @@ import {
} from '@/lib/table/billing'
import { getColumnId } from '@/lib/table/column-keys'
import { getMaxPageBytes, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import {
assertRowDelete,
assertRowInsert,
assertRowUpdate,
patchColumnIds,
} from '@/lib/table/mutation-locks'
import { nKeysBetween } from '@/lib/table/order-key'
import { type DbExecutor, type DbTransaction, withSeqscanOff } from '@/lib/table/planner'
import {
@@ -99,6 +105,8 @@ export async function insertRow(
table: TableDefinition,
requestId: string
): Promise<TableRow> {
const insertProof = assertRowInsert(table)
// Validate row size
const sizeValidation = validateRowSize(data.data)
if (!sizeValidation.valid) {
@@ -140,6 +148,7 @@ export async function insertRow(
beforeRowId: data.beforeRowId,
createdBy: data.userId,
now,
proof: insertProof,
})
notifyTableRowUsage({
@@ -231,6 +240,8 @@ export async function batchInsertRowsWithTx(
table: TableDefinition,
requestId: string
): Promise<TableRow[]> {
assertRowInsert(table)
for (let i = 0; i < data.rows.length; i++) {
const row = data.rows[i]
@@ -382,6 +393,9 @@ export async function replaceTableRowsWithTx(
table: TableDefinition,
requestId: string
): Promise<ReplaceRowsResult> {
assertRowDelete(table)
assertRowInsert(table)
if (data.tableId !== table.id) {
throw new Error(`Table ID mismatch: ${data.tableId} vs ${table.id}`)
}
@@ -626,6 +640,7 @@ export async function upsertRow(
}
if (matchedRowId) {
assertRowUpdate(table, patchColumnIds(data.data))
const [updatedRow] = await trx
.update(userTableRows)
.set({ data: data.data, updatedAt: now })
@@ -648,6 +663,8 @@ export async function upsertRow(
}
}
assertRowInsert(table)
if (wouldExceedRowLimit(rowLimit, table.rowCount, 1)) {
throw new TableRowLimitError(rowLimit)
}
@@ -1157,6 +1174,12 @@ export interface UpdateRowOptions {
* retaining the same merged-row validation and returned shape.
*/
dataWriteMode?: 'replace' | 'patch'
/**
* Marks the write as the workflow/enrichment engine filling its own output
* cells, which exempts it from the update lock. Set by `cell-write.ts` only —
* see {@link assertRowUpdate}.
*/
computedWrite?: boolean
}
export async function updateRow(
@@ -1165,6 +1188,8 @@ export async function updateRow(
requestId: string,
options: UpdateRowOptions = {}
): Promise<TableRow | null> {
assertRowUpdate(table, patchColumnIds(data.data), { computedWrite: options.computedWrite })
// Get existing row
const existingRow = await getRowById(data.tableId, data.rowId, data.workspaceId)
if (!existingRow) {
@@ -1347,15 +1372,20 @@ export async function updateRow(
* @throws Error if row not found
*/
export async function deleteRow(
tableId: string,
table: TableDefinition,
rowId: string,
workspaceId: string,
requestId: string
): Promise<void> {
const deleted = await deleteOrderedRow({ tableId, rowId, workspaceId })
const proof = assertRowDelete(table)
const deleted = await deleteOrderedRow({
tableId: table.id,
rowId,
workspaceId: table.workspaceId,
proof,
})
if (!deleted) throw new Error('Row not found')
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
logger.info(`[${requestId}] Deleted row ${rowId} from table ${table.id}`)
}
/**
@@ -1371,6 +1401,8 @@ export async function updateRowsByFilter(
data: BulkUpdateData,
requestId: string
): Promise<BulkOperationResult> {
assertRowUpdate(table, patchColumnIds(data.data))
const tableName = USER_TABLE_ROWS_SQL_NAME
const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns)
@@ -1500,6 +1532,14 @@ export async function updateRowsByFilter(
}
}
export interface BatchUpdateRowsOptions {
/**
* Marks the batch as workflow/enrichment output cells (the backfill runner),
* exempting it from the update lock. See {@link assertRowUpdate}.
*/
computedWrite?: boolean
}
/**
* Updates multiple rows with per-row data in a single transaction.
* Avoids the race condition of parallel update_row calls overwriting each other.
@@ -1507,12 +1547,21 @@ export async function updateRowsByFilter(
export async function batchUpdateRows(
data: BatchUpdateByIdData,
table: TableDefinition,
requestId: string
requestId: string,
options: BatchUpdateRowsOptions = {}
): Promise<BulkOperationResult> {
if (data.updates.length === 0) {
return { affectedCount: 0, affectedRowIds: [] }
}
// Doubles as the workflow-output backfill write path, which passes
// `computedWrite` so a rebuild keeps working on an update-locked table.
assertRowUpdate(
table,
data.updates.flatMap((u) => patchColumnIds(u.data)),
{ computedWrite: options.computedWrite }
)
const rowIds = data.updates.map((u) => u.rowId)
const existingRows = await db
.select({
@@ -1712,6 +1761,8 @@ export async function deleteRowsByFilter(
data: BulkDeleteData,
requestId: string
): Promise<BulkOperationResult> {
const proof = assertRowDelete(table)
const tableName = USER_TABLE_ROWS_SQL_NAME
// Build filter clause
@@ -1748,6 +1799,7 @@ export async function deleteRowsByFilter(
tableId: table.id,
workspaceId: table.workspaceId,
rowIds,
proof,
})
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`)
@@ -1766,15 +1818,19 @@ export async function deleteRowsByFilter(
* @returns Deletion result with deleted/missing row IDs
*/
export async function deleteRowsByIds(
table: TableDefinition,
data: BulkDeleteByIdsData,
requestId: string
): Promise<BulkDeleteByIdsResult> {
const proof = assertRowDelete(table)
const uniqueRequestedRowIds = Array.from(new Set(data.rowIds))
const deletedRows = await deleteOrderedRowsByIds({
tableId: data.tableId,
workspaceId: data.workspaceId,
rowIds: uniqueRequestedRowIds,
proof,
})
const deletedIds = deletedRows.map((r) => r.id)
+126 -6
View File
@@ -19,15 +19,19 @@ import type { DbOrTx } from '@/lib/db/types'
import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing'
import { generateColumnId, getColumnId, withGeneratedColumnIds } from '@/lib/table/column-keys'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { appendTableEvent } from '@/lib/table/events'
import { EMPTY_JOB_FIELDS, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service'
import { assertSchemaMutable, TableLockedError } from '@/lib/table/mutation-locks'
import { nKeysBetween } from '@/lib/table/order-key'
import type { DbTransaction } from '@/lib/table/planner'
import { setTableTxTimeouts } from '@/lib/table/tx'
import type {
CreateTableData,
TableDefinition,
TableMetadata,
TableSchema,
import {
type CreateTableData,
type TableDefinition,
type TableLocks,
type TableMetadata,
type TableSchema,
UNLOCKED_TABLE_LOCKS,
} from '@/lib/table/types'
import { validateTableName, validateTableSchema } from '@/lib/table/validation'
import { stripGroupDeps } from '@/lib/table/workflow-columns'
@@ -43,6 +47,32 @@ export class TableConflictError extends Error {
export type TableScope = 'active' | 'archived' | 'all'
/**
* Lock columns, selected together and read into a {@link TableLocks}. Kept
* beside `readLocks` so `getTableById`/`listTables` never drift — a missing
* column would surface `locks` as all-false and silently disable enforcement.
*/
const LOCK_SELECT = {
schemaLocked: userTableDefinitions.schemaLocked,
insertLocked: userTableDefinitions.insertLocked,
updateLocked: userTableDefinitions.updateLocked,
deleteLocked: userTableDefinitions.deleteLocked,
} as const
function readLocks(row: {
schemaLocked: boolean
insertLocked: boolean
updateLocked: boolean
deleteLocked: boolean
}): TableLocks {
return {
schemaLocked: row.schemaLocked,
insertLocked: row.insertLocked,
updateLocked: row.updateLocked,
deleteLocked: row.deleteLocked,
}
}
/**
* Serializes schema/metadata read-modify-writes for a single table so
* concurrent mutators can't clobber each other's `schema` JSONB
@@ -133,6 +163,7 @@ export async function getTableById(
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
rowCount: userTableDefinitions.rowCount,
...LOCK_SELECT,
})
.from(userTableDefinitions)
.where(
@@ -157,6 +188,7 @@ export async function getTableById(
maxRows: table.maxRows,
workspaceId: table.workspaceId,
createdBy: table.createdBy,
locks: readLocks(table),
archivedAt: table.archivedAt,
createdAt: table.createdAt,
updatedAt: table.updatedAt,
@@ -189,6 +221,7 @@ export async function listTables(
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
rowCount: userTableDefinitions.rowCount,
...LOCK_SELECT,
})
.from(userTableDefinitions)
.where(
@@ -221,6 +254,7 @@ export async function listTables(
maxRows: t.maxRows,
workspaceId: t.workspaceId,
createdBy: t.createdBy,
locks: readLocks(t),
archivedAt: t.archivedAt,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
@@ -392,6 +426,7 @@ export async function createTable(
maxRows: newTable.maxRows,
workspaceId: newTable.workspaceId,
createdBy: newTable.createdBy,
locks: UNLOCKED_TABLE_LOCKS,
archivedAt: newTable.archivedAt,
createdAt: newTable.createdAt,
updatedAt: newTable.updatedAt,
@@ -421,6 +456,10 @@ export async function addTableColumnsWithTx(
): Promise<TableDefinition> {
if (columns.length === 0) return table
// Runs outside `withLockedTable` (reachable from CSV import with new
// headers), so it must assert directly.
assertSchemaMutable(table)
const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase()))
const additions: TableSchema['columns'] = []
@@ -573,6 +612,52 @@ export async function renameTable(
}
}
/**
* Applies a partial lock change to a table. The caller authorizes (admin-only);
* this function does the write.
*
* Writes inside `withLockedTable` so the toggle is serialized against schema
* mutations on the same table — a lock can't be flipped in the middle of a
* concurrent column change, closing the check-then-act window on the mutators
* that assert under that same advisory lock. Deliberately asserts NO lock on
* itself, so a fully-locked table stays togglable (the reversibility carve-out).
* Emits a `definition` SSE event so other open viewers refresh their lock state.
*/
export async function updateTableLocks(
tableId: string,
partial: Partial<TableLocks>,
actingUserId: string,
requestId: string
): Promise<TableDefinition> {
const updated = await withLockedTable(tableId, async (table, trx) => {
const nextLocks: TableLocks = { ...table.locks, ...partial }
const now = new Date()
await trx
.update(userTableDefinitions)
.set({ ...nextLocks, updatedAt: now })
.where(eq(userTableDefinitions.id, tableId))
return { ...table, locks: nextLocks, updatedAt: now }
})
recordAudit({
workspaceId: updated.workspaceId,
actorId: actingUserId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: updated.name,
description: 'Updated table locks',
metadata: { op: 'update_locks', locks: updated.locks },
})
await appendTableEvent({ kind: 'definition', tableId, reason: 'locks' }).catch((error) => {
logger.warn(`[${requestId}] Failed to emit lock-change event for table ${tableId}`, { error })
})
logger.info(`[${requestId}] Updated locks for table ${tableId}`)
return updated
}
/**
* Updates a table's metadata (UI state like column widths/order, plus behavioral
* settings like `workflowColumnBatchSize`). Merges into the existing metadata blob.
@@ -650,10 +735,19 @@ export async function deleteTable(
actingUserId?: string
): Promise<void> {
const now = new Date()
// Archiving destroys access to every row, so it is gated on the delete lock.
// The guard is inline in the WHERE (atomic — no separate read, no TOCTOU);
// a zero-row result is then disambiguated below (locked vs already-archived).
const result = await db
.update(userTableDefinitions)
.set({ archivedAt: now, updatedAt: now })
.where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt)))
.where(
and(
eq(userTableDefinitions.id, tableId),
isNull(userTableDefinitions.archivedAt),
eq(userTableDefinitions.deleteLocked, false)
)
)
.returning({
createdBy: userTableDefinitions.createdBy,
workspaceId: userTableDefinitions.workspaceId,
@@ -661,6 +755,26 @@ export async function deleteTable(
})
const deleted = result[0]
if (!deleted) {
const [existing] = await db
.select({
archivedAt: userTableDefinitions.archivedAt,
deleteLocked: userTableDefinitions.deleteLocked,
workspaceId: userTableDefinitions.workspaceId,
})
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (existing && !existing.archivedAt && existing.deleteLocked) {
logger.warn('Table mutation blocked by lock', {
tableId,
workspaceId: existing.workspaceId,
lock: 'delete',
})
throw new TableLockedError('delete')
}
// Otherwise the table is missing or already archived — a silent no-op, as before.
}
// Audit only genuine user deletes — rollback callers omit `actingUserId`. The
// caller emits the `table_deleted` PostHog event, so it is not duplicated here.
if (deleted && actingUserId) {
@@ -680,6 +794,12 @@ export async function deleteTable(
/**
* Restores an archived table.
*
* Deliberately NOT gated by any lock. Restore is additive — it clears the
* archive tombstone and renames to dodge the unique index; it destroys nothing.
* Gating it on the delete lock would strand a delete-locked table that was
* archived by a bypass path (e.g. workspace archive, which has no un-archive),
* making the lock the thing that permanently loses the data it protects.
*/
export async function restoreTable(tableId: string, requestId: string): Promise<void> {
const table = await getTableById(tableId, { includeArchived: true })
+39
View File
@@ -348,6 +348,43 @@ export interface TableBackfillJobPayload {
overwrite: boolean
}
/**
* The four independent mutation verbs a table lock can guard. `schema` covers
* column/workflow-group structure; `insert`/`update`/`delete` cover row data.
* Shared by the DB columns, the wire contract, the {@link TableLockedError},
* and the settings UI so all four agree on one source of truth.
*/
export const TABLE_LOCK_KINDS = ['schema', 'insert', 'update', 'delete'] as const
export type TableLockKind = (typeof TABLE_LOCK_KINDS)[number]
/**
* Per-table mutation locks. Each flag independently forbids one verb. Enforced
* at the `lib/table` service layer (see `lib/table/mutation-locks.ts`).
* Append-only = `{ update: true, delete: true }`; read-only = all four true.
*/
export interface TableLocks {
schemaLocked: boolean
insertLocked: boolean
updateLocked: boolean
deleteLocked: boolean
}
/** Maps each verb to the {@link TableLocks} flag that guards it. */
export const TABLE_LOCK_FLAGS: Record<TableLockKind, keyof TableLocks> = {
schema: 'schemaLocked',
insert: 'insertLocked',
update: 'updateLocked',
delete: 'deleteLocked',
}
/** A fully-unlocked lock set — the state every new table is created in. */
export const UNLOCKED_TABLE_LOCKS: TableLocks = {
schemaLocked: false,
insertLocked: false,
updateLocked: false,
deleteLocked: false,
}
export interface TableDefinition {
id: string
name: string
@@ -358,6 +395,8 @@ export interface TableDefinition {
maxRows: number
workspaceId: string
createdBy: string
/** Per-table mutation locks; absent-as-all-false is normalized on read. */
locks: TableLocks
archivedAt?: Date | string | null
createdAt: Date | string
updatedAt: Date | string
+32 -3
View File
@@ -11,6 +11,7 @@ const {
mockUpdateJobProgress,
mockMarkJobReady,
mockMarkJobFailed,
mockMarkJobCanceled,
mockAppendTableEvent,
mockBuildFilterClause,
mockValidateRowSize,
@@ -24,6 +25,7 @@ const {
mockUpdateJobProgress: vi.fn(),
mockMarkJobReady: vi.fn(),
mockMarkJobFailed: vi.fn(),
mockMarkJobCanceled: vi.fn(),
mockAppendTableEvent: vi.fn(),
mockBuildFilterClause: vi.fn(),
mockValidateRowSize: vi.fn(),
@@ -37,6 +39,7 @@ vi.mock('@/lib/table/jobs/service', () => ({
updateJobProgress: mockUpdateJobProgress,
markJobReady: mockMarkJobReady,
markJobFailed: mockMarkJobFailed,
markJobCanceled: mockMarkJobCanceled,
}))
vi.mock('@/lib/table/rows/ordering', () => ({
selectRowDataPage: mockSelectRowDataPage,
@@ -56,7 +59,13 @@ vi.mock('@/lib/table/constants', () => ({
import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner'
const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] } }
const UNLOCKED = {
schemaLocked: false,
insertLocked: false,
updateLocked: false,
deleteLocked: false,
}
const table = { id: 'tbl_1', workspaceId: 'ws_1', schema: { columns: [] }, locks: UNLOCKED }
const cutoff = new Date('2026-06-05T00:00:00Z')
function basePayload(overrides = {}) {
@@ -86,6 +95,22 @@ describe('runTableUpdate', () => {
mockCoerceRowToSchema.mockReturnValue({ valid: true, errors: [] })
})
it('cancels without updating when the table was update-locked before the run started', async () => {
// The lock is asserted at enqueue, but a queued or retried job can start
// after an admin locks the table — nothing is written yet, so honor it.
mockGetTableById.mockResolvedValue({ ...table, locks: { ...UNLOCKED, updateLocked: true } })
mockSelectRowDataPage.mockResolvedValue([row('a')])
await expect(runTableUpdate(basePayload())).resolves.toBeUndefined()
expect(mockUpdatePageByIds).not.toHaveBeenCalled()
expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1')
expect(mockMarkJobReady).not.toHaveBeenCalled()
expect(mockAppendTableEvent).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'job', type: 'update', status: 'canceled' })
)
})
it('updates every matching page then marks the job ready', async () => {
mockSelectRowDataPage
.mockResolvedValueOnce([row('a'), row('b')])
@@ -99,14 +124,18 @@ describe('runTableUpdate', () => {
'tbl_1',
'ws_1',
['a', 'b'],
expect.any(String)
expect.any(String),
expect.anything(),
expect.any(Function)
)
expect(mockUpdatePageByIds).toHaveBeenNthCalledWith(
2,
'tbl_1',
'ws_1',
['c'],
expect.any(String)
expect.any(String),
expect.anything(),
expect.any(Function)
)
expect(mockMarkJobReady).toHaveBeenCalledWith('tbl_1', 'job_1')
expect(mockAppendTableEvent).toHaveBeenCalledWith(
+75 -7
View File
@@ -2,15 +2,23 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import type { Filter, RowData } from '@/lib/table'
import type { Filter, RowData, TableDefinition } from '@/lib/table'
import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { appendTableEvent } from '@/lib/table/events'
import {
getJobProgress,
markJobCanceled,
markJobFailed,
markJobReady,
updateJobProgress,
} from '@/lib/table/jobs/service'
import {
assertRowUpdate,
type MutationProof,
patchColumnIds,
TableLockedError,
} from '@/lib/table/mutation-locks'
import type { DbTransaction } from '@/lib/table/planner'
import { selectRowDataPage, updatePageByIds } from '@/lib/table/rows/ordering'
import { getTableById } from '@/lib/table/service'
import { buildFilterClause } from '@/lib/table/sql'
@@ -67,6 +75,46 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise<void>
const table = await getTableById(tableId, { includeArchived: true })
if (!table) throw new Error(`Update target table ${tableId} not found`)
// Gate the run on the update lock, then re-gate it before every page (see
// the loop below), so enabling the lock stops a job that is already
// running. Runs through `assertRowUpdate` rather than reading
// `updateLocked` directly so the enqueue site and the worker apply
// identical rules. This is a user-driven bulk patch, so it deliberately
// does not pass `computedWrite` — the workflow-output carve-out belongs to
// the cell-write path alone.
const cancelForLock = async (processedSoFar: number): Promise<void> => {
logger.info(`[${requestId}] Update job stopped — table is update-locked`, {
tableId,
jobId,
processedSoFar,
})
await markJobCanceled(tableId, jobId)
void appendTableEvent({ kind: 'job', type: 'update', tableId, jobId, status: 'canceled' })
}
const stopIfLocked = async (
fresh: TableDefinition,
processedSoFar: number
): Promise<MutationProof<'update'> | null> => {
try {
return assertRowUpdate(fresh, patchColumnIds(data))
} catch (err) {
if (!(err instanceof TableLockedError)) throw err
await cancelForLock(processedSoFar)
return null
}
}
if ((await stopIfLocked(table, 0)) === null) return
// Runs inside each batch's transaction, under the same advisory lock the
// lock toggle holds, so no page can be written after a lock commits.
const revalidate = async (trx: DbTransaction) => {
const fresh = await getTableById(tableId, { tx: trx, includeArchived: true })
if (fresh) assertRowUpdate(fresh, patchColumnIds(data))
return fresh ?? undefined
}
const filterClause = buildFilterClause(filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns)
if (!filterClause) throw new Error('Filter is required for bulk update')
@@ -88,6 +136,15 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise<void>
const owns = await updateJobProgress(tableId, processed, jobId)
if (!owns) throw new JobSupersededError()
// Cheap early-out before selecting a page we may not be allowed to
// write. The authoritative gate is `revalidate` below, which re-asserts
// inside each batch transaction. Pages already applied stay applied, as
// with an explicit cancel.
const current = await getTableById(tableId, { includeArchived: true })
if (!current) throw new JobSupersededError()
const pageProof = await stopIfLocked(current, processed)
if (pageProof === null) return
const page = await selectRowDataPage({
tableId,
workspaceId,
@@ -117,12 +174,23 @@ export async function runTableUpdate(payload: TableUpdatePayload): Promise<void>
}
}
processed += await updatePageByIds(
tableId,
workspaceId,
page.map((r) => r.id),
patchJson
)
try {
processed += await updatePageByIds(
tableId,
workspaceId,
page.map((r) => r.id),
patchJson,
pageProof,
revalidate
)
} catch (err) {
if (!(err instanceof TableLockedError)) throw err
// A lock landed between batches. Batches already committed stay
// applied; `processed` undercounts them, which only affects the final
// progress number on an already-canceled job.
await cancelForLock(processed)
return
}
if (
processed - lastReported >= PROGRESS_INTERVAL_ROWS ||
@@ -19,6 +19,7 @@ import {
remapGroupColumnRefs,
} from '@/lib/table/column-keys'
import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks'
import { stripGroupExecutions } from '@/lib/table/rows/executions'
import { getTableById, withLockedTable } from '@/lib/table/service'
import { setTableTxTimeouts } from '@/lib/table/tx'
@@ -48,6 +49,11 @@ const logger = createLogger('TableWorkflowGroupsService')
* - Scoped to the workflow's workspace.
* - Idempotent: running twice with the same `validBlockIds` is a no-op on the
* second pass. Existing row data is left alone.
*
* Deliberately does NOT assert the schema lock: this is system reconciliation
* triggered by a workflow deploy, not a user schema edit, and it only prunes
* references to blocks that no longer exist. Blocking it on a schema-locked
* table would leave the table pointing at dead blocks.
*/
export async function pruneStaleWorkflowGroupOutputs({
workflowId,
@@ -120,6 +126,7 @@ export async function addWorkflowGroup(
requestId: string
): Promise<TableDefinition> {
const updatedTable = await withLockedTable(data.tableId, async (table, trx) => {
assertSchemaMutable(table)
const schema = table.schema
const groups = schema.workflowGroups ?? []
if (groups.some((g) => g.id === data.group.id)) {
@@ -280,6 +287,11 @@ export async function updateWorkflowGroup(
const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } =
await withLockedTable(data.tableId, async (table, trx) => {
// Any group patch edits the schema; the stronger destructive assert is
// applied below, only once we know this patch actually drops or remaps
// output columns (a rename / autoRun / mapping-only edit must not need
// the delete lock clear).
assertSchemaMutable(table)
await setTableTxTimeouts(trx, { statementMs: 60_000 })
const schema = table.schema
@@ -394,6 +406,11 @@ export async function updateWorkflowGroup(
}
const removedColumnIds = new Set(removed.map((o) => o.columnName))
// Both paths strip values out of every row below, so they need the delete
// lock clear as well as the schema lock — same rule as a column drop.
if (removedColumnIds.size > 0 || remappedColumnIds.size > 0) {
assertColumnDestructive(table)
}
let nextColumns = schema.columns
.filter((c) => !removedColumnIds.has(getColumnId(c)))
.map((c) => {
@@ -655,6 +672,7 @@ export async function addWorkflowGroupOutput(
// write. The critical section holds no I/O — just the in-memory splice + the
// schema UPDATE — so concurrent adders queue behind it quickly.
const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => {
assertSchemaMutable(table)
const schema = table.schema
const groups = schema.workflowGroups ?? []
const groupIndex = groups.findIndex((g) => g.id === data.groupId)
@@ -841,6 +859,7 @@ export async function deleteWorkflowGroupOutput(
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
assertColumnDestructive(table)
const schema = table.schema
const groups = schema.workflowGroups ?? []
const groupIndex = groups.findIndex((g) => g.id === data.groupId)
@@ -907,6 +926,7 @@ export async function deleteWorkflowGroup(
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
assertColumnDestructive(table)
const schema = table.schema
const groups = schema.workflowGroups ?? []
const group = groups.find((g) => g.id === data.groupId)
@@ -0,0 +1,4 @@
ALTER TABLE "user_table_definitions" ADD COLUMN "schema_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user_table_definitions" ADD COLUMN "insert_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user_table_definitions" ADD COLUMN "update_locked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user_table_definitions" ADD COLUMN "delete_locked" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -1884,6 +1884,13 @@
"when": 1784911198876,
"tag": "0269_late_spencer_smythe",
"breakpoints": true
},
{
"idx": 270,
"version": "7",
"when": 1784960447583,
"tag": "0270_table_mutation_locks",
"breakpoints": true
}
]
}
+15
View File
@@ -3684,6 +3684,21 @@ export const userTableDefinitions = pgTable(
* from application code — the trigger is the only writer (bypass-proof).
*/
rowsVersion: bigint('rows_version', { mode: 'number' }).notNull().default(0),
/**
* @remarks
* Per-table mutation locks. Each guards one mutation verb; an admin toggles
* them independently. Enforced at the `lib/table` service layer (see
* `lib/table/mutation-locks.ts`), which covers every entry point — routes,
* workflow blocks, and Mothership — since all funnel through those helpers.
* A locked verb rejects with 423; toggling a lock requires workspace admin.
* Append-only = update + delete locked; read-only = all four locked. These
* are integrity controls (against accidental/agentic mutation), not
* confidentiality controls — reads and exports are never blocked.
*/
schemaLocked: boolean('schema_locked').notNull().default(false),
insertLocked: boolean('insert_locked').notNull().default(false),
updateLocked: boolean('update_locked').notNull().default(false),
deleteLocked: boolean('delete_locked').notNull().default(false),
archivedAt: timestamp('archived_at'),
createdBy: text('created_by')
.notNull()
@@ -1107,6 +1107,11 @@ export const schemaMock = {
metadata: 'metadata',
maxRows: 'maxRows',
rowCount: 'rowCount',
rowsVersion: 'rowsVersion',
schemaLocked: 'schemaLocked',
insertLocked: 'insertLocked',
updateLocked: 'updateLocked',
deleteLocked: 'deleteLocked',
archivedAt: 'archivedAt',
createdBy: 'createdBy',
createdAt: 'createdAt',