mirror of
https://github.com/simstudioai/sim.git
synced 2026-11-03 23:31:47 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+17
-4
@@ -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'
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { LockSettingsModal } from './lock-settings-modal'
|
||||
+122
@@ -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>
|
||||
)
|
||||
}
|
||||
+43
-18
@@ -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}>
|
||||
|
||||
+193
-20
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+24
-8
@@ -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,
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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()),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user