6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
/**
|
|
* 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>()
|
|
}
|