Files
WeHub Mirror 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
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

2197 lines
77 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Row CRUD + query operations for the table service layer.
*
* Holds the row-write group (`insertRow`, `batchInsertRows`, `upsertRow`,
* `updateRow`, `deleteRow`, the bulk/filter variants, `replaceTableRows`) and the
* row-read group (`queryRows`, `getRowById`, `findRowMatches`). Mirrors the
* `@/lib/table` service conventions: plain exported async functions, drizzle
* inline, no repository pattern.
*
* Re-exported through the `@/lib/table` barrel.
*/
import { db } from '@sim/db'
import { tableJobs, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, count, eq, inArray, lte, notInArray, type SQL, sql } from 'drizzle-orm'
import {
assertRowCapacity,
getMaxRowsPerTable,
notifyTableRowUsage,
TableRowLimitError,
wouldExceedRowLimit,
} from '@/lib/table/billing'
import { getColumnId } from '@/lib/table/column-keys'
import { columnTypeOf } from '@/lib/table/column-types'
import { getMaxPageBytes, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants'
import { TableQueryValidationError } from '@/lib/table/errors'
import {
assertRowDelete,
assertRowInsert,
assertRowUpdate,
patchColumnIds,
} from '@/lib/table/mutation-locks'
import { nKeysBetween } from '@/lib/table/order-key'
import {
type DbExecutor,
type DbTransaction,
withReadGuards,
withSeqscanOff,
} from '@/lib/table/planner'
import { encodeCursor } from '@/lib/table/rows/cursor'
import {
applyExecutionsPatch,
deriveExecClearsForDataPatch,
loadExecutionsByRow,
loadExecutionsForRow,
writeExecutionsPatch,
} from '@/lib/table/rows/executions'
import {
acquireRowOrderLock,
deleteOrderedRow,
deleteOrderedRowsByIds,
insertOrderedRow,
nextRowPosition,
resolveBatchInsertOrderKeys,
resolveInsertOrderKey,
} from '@/lib/table/rows/ordering'
import { mutateTableRowsWithSecretProvenance } from '@/lib/table/rows/secret-provenance'
import {
buildFilterClause,
buildPredicateClause,
buildSortClause,
escapeLikePattern,
fieldPredicate,
} from '@/lib/table/sql'
import { fireTableTrigger } from '@/lib/table/trigger'
import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx'
import type {
BatchInsertData,
BatchUpdateByIdData,
BulkDeleteByIdsData,
BulkDeleteByIdsResult,
BulkDeleteData,
BulkOperationResult,
BulkUpdateData,
ColumnDefinition,
Filter,
InsertRowData,
QueryOptions,
QueryResult,
ReplaceRowsData,
ReplaceRowsResult,
RowData,
RowExecutionMetadata,
RowExecutions,
Sort,
TableDefinition,
TableDeleteJobPayload,
TableRow,
TableRowsCursor,
UpdateRowData,
UpsertResult,
UpsertRowData,
} from '@/lib/table/types'
import {
checkBatchUniqueConstraintsDb,
checkUniqueConstraintsDb,
coerceRowToSchema,
coerceRowValues,
getUniqueColumns,
validateRowSize,
} from '@/lib/table/validation'
import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns'
const logger = createLogger('TableRowsService')
/**
* Inserts a single row into a table.
*
* @param data - Row insertion data
* @param table - Table definition (to avoid re-fetching)
* @param requestId - Request ID for logging
* @returns Inserted row
* @throws Error if validation fails or capacity exceeded
*/
export async function insertRow(
data: InsertRowData,
table: TableDefinition,
requestId: string
): Promise<TableRow> {
const insertProof = assertRowInsert(table)
// Validate row size
const sizeValidation = validateRowSize(data.data)
if (!sizeValidation.valid) {
throw new Error(sizeValidation.errors.join(', '))
}
// Validate against schema
const schemaValidation = coerceRowToSchema(data.data, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`)
}
// Check unique constraints using optimized database query
const uniqueColumns = getUniqueColumns(table.schema)
if (uniqueColumns.length > 0) {
const uniqueValidation = await checkUniqueConstraintsDb(data.tableId, data.data, table.schema)
if (!uniqueValidation.valid) {
throw new Error(uniqueValidation.errors.join(', '))
}
}
// Best-effort capacity check against the workspace's current plan limit.
const rowLimit = await assertRowCapacity({
workspaceId: table.workspaceId,
currentRowCount: table.rowCount,
addedRows: 1,
})
const rowId = `row_${generateId().replace(/-/g, '')}`
const now = new Date()
const row = await insertOrderedRow({
tableId: data.tableId,
workspaceId: data.workspaceId,
data: data.data,
rowId,
position: data.position,
afterRowId: data.afterRowId,
beforeRowId: data.beforeRowId,
createdBy: data.userId,
now,
secretProvenance: data.secretProvenance,
proof: insertProof,
})
notifyTableRowUsage({
workspaceId: table.workspaceId,
currentRowCount: table.rowCount,
addedRows: 1,
limit: rowLimit,
})
logger.info(`[${requestId}] Inserted row ${rowId} into table ${data.tableId}`)
const insertedRow: TableRow = {
id: row.id,
data: row.data as RowData,
executions: {},
position: row.position,
orderKey: row.orderKey ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
void fireTableTrigger(
data.tableId,
table.name,
'insert',
[insertedRow],
null,
table.schema,
requestId
)
void runWorkflowColumn({
tableId: table.id,
workspaceId: table.workspaceId,
rowIds: [insertedRow.id],
mode: 'new',
isManualRun: false,
requestId,
triggeredByUserId: data.userId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (insertRow) failed:`, err))
return insertedRow
}
/**
* Inserts multiple rows into a table.
*
* @param data - Batch insertion data
* @param table - Table definition
* @param requestId - Request ID for logging
* @returns Array of inserted rows
* @throws Error if validation fails or capacity exceeded
*/
export async function batchInsertRows(
data: BatchInsertData,
table: TableDefinition,
requestId: string
): Promise<TableRow[]> {
// Best-effort capacity check against the workspace's current plan limit. Import
// paths call `batchInsertRowsWithTx` directly and gate capacity up front instead.
const rowLimit = await assertRowCapacity({
workspaceId: table.workspaceId,
currentRowCount: table.rowCount,
addedRows: data.rows.length,
})
const result = await db.transaction((trx) => batchInsertRowsWithTx(trx, data, table, requestId))
notifyTableRowUsage({
workspaceId: table.workspaceId,
currentRowCount: table.rowCount,
addedRows: result.length,
limit: rowLimit,
})
dispatchAfterBatchInsert(table, result, requestId, data.userId)
return result
}
/**
* Transaction-bound variant of `batchInsertRows`. Validates rows and unique
* constraints, then performs INSERTs inside the provided transaction. Caller
* is responsible for opening the transaction. Use when row inserts must be
* atomic with other writes (e.g., schema mutations) on the same tx.
*
* Capacity is NOT checked here (it would mean a billing-pool read inside the tx).
* Callers gate it before opening the tx — see `batchInsertRows` and the import paths.
*/
export async function batchInsertRowsWithTx(
trx: DbTransaction,
data: BatchInsertData,
table: TableDefinition,
requestId: string
): Promise<TableRow[]> {
assertRowInsert(table)
for (let i = 0; i < data.rows.length; i++) {
const row = data.rows[i]
const sizeValidation = validateRowSize(row)
if (!sizeValidation.valid) {
throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`)
}
const schemaValidation = coerceRowToSchema(row, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`)
}
}
const uniqueColumns = getUniqueColumns(table.schema)
if (uniqueColumns.length > 0) {
const uniqueResult = await checkBatchUniqueConstraintsDb(
data.tableId,
data.rows,
table.schema,
trx
)
if (!uniqueResult.valid) {
const errorMessages = uniqueResult.errors
.map((e) => `Row ${e.row + 1}: ${e.errors.join(', ')}`)
.join('; ')
throw new Error(errorMessages)
}
}
const now = new Date()
await setTableTxTimeouts(trx, { statementMs: 60_000 })
const buildRow = (rowData: RowData, position: number, orderKey: string) => ({
id: `row_${generateId().replace(/-/g, '')}`,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: rowData,
position,
orderKey,
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
})
await acquireRowOrderLock(trx, data.tableId)
// Undo restore passes exact saved keys; otherwise append after the current max.
const orderKeys =
data.orderKeys && data.orderKeys.length > 0
? data.orderKeys
: await resolveBatchInsertOrderKeys(trx, data.tableId, data.rows.length)
// order_key is authoritative — best-effort append positions, no shift.
const start = await nextRowPosition(trx, data.tableId)
const positions = Array.from({ length: data.rows.length }, (_, i) => start + i)
const rowsToInsert = data.rows.map((rowData, i) => buildRow(rowData, positions[i], orderKeys[i]))
const insertedRows = await mutateTableRowsWithSecretProvenance(trx, {
rows: rowsToInsert.map((row, index) => ({
rowId: row.id,
provenance: data.secretProvenance?.[index],
})),
rowState: 'new',
mode: 'replace',
mutate: async () => {
const rows = await trx.insert(userTableRows).values(rowsToInsert).returning()
return { value: rows, affectedRowIds: rows.map((row) => row.id) }
},
})
logger.info(`[${requestId}] Batch inserted ${data.rows.length} rows into table ${data.tableId}`)
const result: TableRow[] = insertedRows.map((r) => ({
id: r.id,
data: r.data as RowData,
executions: {},
position: r.position,
orderKey: r.orderKey ?? undefined,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}))
return result
}
/**
* Side-effect dispatch for an insert batch. Caller fires this AFTER the
* surrounding transaction commits — `fireTableTrigger` and `runWorkflowColumn`
* both read through the global db connection, so firing inside the tx can see
* no rows and no-op.
*/
export function dispatchAfterBatchInsert(
table: TableDefinition,
result: TableRow[],
requestId: string,
actorUserId?: string | null
): void {
void fireTableTrigger(table.id, table.name, 'insert', result, null, table.schema, requestId)
// Scope to the newly-inserted row ids so the dispatcher doesn't walk every
// row in the table. After the sidecar migration, all existing rows have
// zero entries → `mode:'new'`'s `NOT EXISTS` filter would otherwise include
// them, dispatching workflows on every row in a populated table.
void runWorkflowColumn({
tableId: table.id,
workspaceId: table.workspaceId,
rowIds: result.map((r) => r.id),
mode: 'new',
isManualRun: false,
requestId,
triggeredByUserId: actorUserId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchInsertRows) failed:`, err))
}
/**
* Replaces all rows in a table with a new set of rows. Deletes existing rows
* and inserts the provided rows inside a single transaction so the table is
* never observed in an empty intermediate state by other readers.
*
* Validates each row against the schema, enforces unique constraints within the
* new rows (existing rows are deleted, so DB-side checks are unnecessary), and
* enforces the workspace's current plan row limit before the replace executes.
*
* @param data - Replace data (rows to install)
* @param table - Table definition
* @param requestId - Request ID for logging
* @returns Count of rows deleted and inserted
* @throws Error if validation fails or capacity exceeded
*/
export async function replaceTableRows(
data: ReplaceRowsData,
table: TableDefinition,
requestId: string
): Promise<ReplaceRowsResult> {
// All existing rows are deleted, so the footprint is just the new set. Checked
// before the tx opens — never inside it (the plan lookup is a separate pool read).
const rowLimit = await assertRowCapacity({
workspaceId: table.workspaceId,
currentRowCount: 0,
addedRows: data.rows.length,
})
const result = await db.transaction((trx) => replaceTableRowsWithTx(trx, data, table, requestId))
notifyTableRowUsage({
workspaceId: table.workspaceId,
currentRowCount: 0,
addedRows: result.insertedCount,
limit: rowLimit,
})
return result
}
/**
* Transaction-bound variant of `replaceTableRows`. Caller opens the transaction.
* Use when the replace must be atomic with other writes (e.g., schema mutations).
*
* Capacity is NOT checked here (it would mean a billing-pool read inside the tx).
* Callers gate it before opening the tx — see `replaceTableRows` and `importReplaceRows`.
*/
export async function replaceTableRowsWithTx(
trx: DbTransaction,
data: ReplaceRowsData,
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}`)
}
if (data.workspaceId !== table.workspaceId) {
throw new Error(`Workspace ID mismatch: ${data.workspaceId} does not own table ${data.tableId}`)
}
for (let i = 0; i < data.rows.length; i++) {
const row = data.rows[i]
const sizeValidation = validateRowSize(row)
if (!sizeValidation.valid) {
throw new Error(`Row ${i + 1}: ${sizeValidation.errors.join(', ')}`)
}
const schemaValidation = coerceRowToSchema(row, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Row ${i + 1}: ${schemaValidation.errors.join(', ')}`)
}
}
const uniqueColumns = getUniqueColumns(table.schema)
if (uniqueColumns.length > 0 && data.rows.length > 0) {
const seen = new Map<string, Map<string, number>>()
for (const col of uniqueColumns) {
seen.set(getColumnId(col), new Map())
}
for (let i = 0; i < data.rows.length; i++) {
const row = data.rows[i]
for (const col of uniqueColumns) {
// Coerced rows are keyed by column id, not display name — reading
// `row[col.name]` silently misses renamed columns and lets dupes through.
const colId = getColumnId(col)
const value = row[colId]
if (value === null || value === undefined) continue
// Case-sensitive, consistent with the unique-constraint check leaf.
const normalized = typeof value === 'string' ? value : JSON.stringify(value)
const map = seen.get(colId)!
if (map.has(normalized)) {
throw new Error(
`Row ${i + 1}: Column "${col.name}" must be unique. Value "${String(value)}" duplicates row ${map.get(normalized)! + 1} in batch`
)
}
map.set(normalized, i)
}
}
}
const now = new Date()
const totalRowWork = Math.max(0, table.rowCount ?? 0) + data.rows.length
const statementMs = scaledStatementTimeoutMs(totalRowWork, {
baseMs: 120_000,
perRowMs: 3,
})
await setTableTxTimeouts(trx, { statementMs })
// Serialize concurrent replaces (and concurrent auto-position inserts) on the
// same table. Without this, two concurrent replaces each see their own MVCC
// snapshot for the DELETE; the second's DELETE would not observe rows the
// first inserted, so both transactions commit and the table ends up with
// the union of both row sets instead of only the last caller's rows.
await acquireRowOrderLock(trx, data.tableId)
const deletedRows = await trx
.delete(userTableRows)
.where(eq(userTableRows.tableId, data.tableId))
.returning({ id: userTableRows.id })
let insertedCount = 0
if (data.rows.length > 0) {
// All prior rows were just deleted — assign a fresh contiguous key run.
const orderKeys = nKeysBetween(null, null, data.rows.length)
const rowsToInsert = data.rows.map((rowData, i) => ({
id: `row_${generateId().replace(/-/g, '')}`,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: rowData,
position: i,
orderKey: orderKeys[i],
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
}))
insertedCount = await mutateTableRowsWithSecretProvenance(trx, {
rows: rowsToInsert.map((row, index) => ({
rowId: row.id,
provenance: data.secretProvenance?.[index],
})),
rowState: 'new',
mode: 'replace',
mutate: async () => {
const insertedRowIds: string[] = []
const batchSize = TABLE_LIMITS.MAX_BATCH_INSERT_SIZE
for (let i = 0; i < rowsToInsert.length; i += batchSize) {
const chunk = rowsToInsert.slice(i, i + batchSize)
const inserted = await trx.insert(userTableRows).values(chunk).returning({
id: userTableRows.id,
})
insertedRowIds.push(...inserted.map((row) => row.id))
}
return { value: insertedRowIds.length, affectedRowIds: insertedRowIds }
},
})
}
logger.info(
`[${requestId}] Replaced rows in table ${data.tableId}: deleted ${deletedRows.length}, inserted ${insertedCount}`
)
return { deletedCount: deletedRows.length, insertedCount }
}
/**
* Upserts a row: updates an existing row if a match is found on the conflict target
* column, otherwise inserts a new row.
*
* Uses a single unique column for matching (not OR across all unique columns) to avoid
* ambiguous matches when multiple unique columns exist. Capacity is checked best-effort
* against the current plan limit on the insert path. On the insert path we acquire the
* per-table advisory lock and re-check for an existing match before inserting, so a
* concurrent upsert racing on the same conflict target cannot produce a duplicate row.
*
* @param data - Upsert data including optional conflictTarget
* @param table - Table definition
* @param requestId - Request ID for logging
* @returns The upserted row and whether it was an insert or update
* @throws Error if no unique columns, ambiguous conflict target, or capacity exceeded
*/
export async function upsertRow(
data: UpsertRowData,
table: TableDefinition,
requestId: string
): Promise<UpsertResult> {
const schema = table.schema
const uniqueColumns = getUniqueColumns(schema)
if (uniqueColumns.length === 0) {
throw new Error(
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.'
)
}
// Determine the single conflict target column, resolving to its stable
// storage id (the row-data key). `conflictTarget` may arrive as an id
// (first-party) or a name (legacy/internal) — match either.
let targetColumnKey: string
if (data.conflictTarget) {
const col = uniqueColumns.find(
(c) => getColumnId(c) === data.conflictTarget || c.name === data.conflictTarget
)
if (!col) {
throw new Error(
`Column "${data.conflictTarget}" is not a unique column. Available unique columns: ${uniqueColumns.map((c) => c.name).join(', ')}`
)
}
targetColumnKey = getColumnId(col)
} else if (uniqueColumns.length === 1) {
targetColumnKey = getColumnId(uniqueColumns[0])
} else {
throw new Error(
`Table has multiple unique columns (${uniqueColumns.map((c) => c.name).join(', ')}). Specify a conflict column to indicate which one to match on.`
)
}
// Validate row data
const sizeValidation = validateRowSize(data.data)
if (!sizeValidation.valid) {
throw new Error(sizeValidation.errors.join(', '))
}
const schemaValidation = coerceRowToSchema(data.data, schema)
if (!schemaValidation.valid) {
throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`)
}
// Read the conflict-target value *after* coercion so `matchFilter` branches on
// the persisted type (e.g. a coerced `"123"` → `123` matches existing rows).
const targetValue = data.data[targetColumnKey]
if (targetValue === undefined || targetValue === null) {
// Surface the display name, not the internal id — v1 callers pass a name.
const targetColumnName =
uniqueColumns.find((c) => getColumnId(c) === targetColumnKey)?.name ?? targetColumnKey
throw new Error(`Upsert requires a value for the conflict target column "${targetColumnName}"`)
}
// Build the conflict probe through the SAME leaf as the unique-constraint check
// (`fieldPredicate` → case-sensitive JSONB containment). This is what makes
// "find the row to update" and "is this value unique" agree: a value differing
// only in case can no longer slip past the probe and then trip the guard.
// `eq` always yields a clause for a non-null value (guaranteed above).
const matchFilter = fieldPredicate(
USER_TABLE_ROWS_SQL_NAME,
targetColumnKey,
'eq',
targetValue,
table.schema.columns.find((c) => getColumnId(c) === targetColumnKey)
)
if (!matchFilter) {
throw new Error('Failed to build upsert conflict predicate')
}
// Resolve the plan limit BEFORE the tx (the lookup is a separate pool read; doing
// it inside the tx would hold a connection + the row-order lock during it). The
// insert branch enforces it; the update path doesn't add a row, so it's exempt.
const rowLimit = await getMaxRowsPerTable(table.workspaceId)
const result = await db.transaction(async (trx) => {
await setTableTxTimeouts(trx)
// The conflict lookups below match on `data->>key` — unestimatable, and an
// insert-path upsert (no existing match) can't exit early, so the planner
// would seq-scan the whole shared relation. See withSeqscanOff.
await trx.execute(sql`SET LOCAL enable_seqscan = off`)
// Find existing row by single conflict target column
const [existingRow] = await trx
.select()
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, data.tableId),
eq(userTableRows.workspaceId, data.workspaceId),
matchFilter
)
)
.limit(1)
// Check uniqueness on ALL unique columns (not just the conflict target)
const uniqueValidation = await checkUniqueConstraintsDb(
data.tableId,
data.data,
schema,
existingRow?.id, // exclude the matched row on updates
trx
)
if (!uniqueValidation.valid) {
throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`)
}
const now = new Date()
// Resolve which row (if any) we should update. If the initial SELECT missed,
// acquire the lock and re-check — a concurrent upsert may have inserted the
// matching row between our SELECT and the INSERT path; without the re-check
// both transactions would insert and bypass the app-level unique check.
let matchedRowId = existingRow?.id
let previousData = existingRow?.data as RowData | undefined
if (!matchedRowId) {
await acquireRowOrderLock(trx, data.tableId)
const [racedRow] = await trx
.select({ id: userTableRows.id, data: userTableRows.data })
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, data.tableId),
eq(userTableRows.workspaceId, data.workspaceId),
matchFilter
)
)
.limit(1)
if (racedRow) {
matchedRowId = racedRow.id
previousData = racedRow.data as RowData
}
}
if (matchedRowId) {
assertRowUpdate(table, patchColumnIds(data.data))
const updatedRow = await mutateTableRowsWithSecretProvenance(trx, {
rows: [{ rowId: matchedRowId, provenance: data.secretProvenance }],
rowState: 'existing',
mode: 'replace',
mutate: async () => {
const [row] = await trx
.update(userTableRows)
.set({ data: data.data, updatedAt: now })
.where(eq(userTableRows.id, matchedRowId))
.returning()
if (!row) return { value: undefined, affectedRowIds: [] }
return { value: row, affectedRowIds: [row.id] }
},
})
if (!updatedRow) throw new Error('Matched table row no longer exists')
const executions = await loadExecutionsForRow(trx, updatedRow.id)
return {
row: {
id: updatedRow.id,
data: updatedRow.data as RowData,
executions,
position: updatedRow.position,
orderKey: updatedRow.orderKey ?? undefined,
createdAt: updatedRow.createdAt,
updatedAt: updatedRow.updatedAt,
},
previousData,
operation: 'update' as const,
}
}
assertRowInsert(table)
if (wouldExceedRowLimit(rowLimit, table.rowCount, 1)) {
throw new TableRowLimitError(rowLimit)
}
const insertedRowId = `row_${generateId().replace(/-/g, '')}`
const insertedRow = await mutateTableRowsWithSecretProvenance(trx, {
rows: [{ rowId: insertedRowId, provenance: data.secretProvenance }],
rowState: 'new',
mode: 'replace',
mutate: async () => {
const [row] = await trx
.insert(userTableRows)
.values({
id: insertedRowId,
tableId: data.tableId,
workspaceId: data.workspaceId,
data: data.data,
position: await nextRowPosition(trx, data.tableId),
orderKey: await resolveInsertOrderKey(trx, data.tableId),
createdAt: now,
updatedAt: now,
...(data.userId ? { createdBy: data.userId } : {}),
})
.returning()
return { value: row, affectedRowIds: row ? [row.id] : [] }
},
})
if (!insertedRow) throw new Error('Failed to insert table row')
return {
row: {
id: insertedRow.id,
data: insertedRow.data as RowData,
executions: {},
position: insertedRow.position,
orderKey: insertedRow.orderKey ?? undefined,
createdAt: insertedRow.createdAt,
updatedAt: insertedRow.updatedAt,
},
operation: 'insert' as const,
}
})
logger.info(
`[${requestId}] Upserted (${result.operation}) row ${result.row.id} in table ${data.tableId}`
)
if (result.operation === 'insert') {
notifyTableRowUsage({
workspaceId: data.workspaceId,
currentRowCount: table.rowCount,
addedRows: 1,
limit: rowLimit,
})
void fireTableTrigger(
data.tableId,
table.name,
'insert',
[result.row],
null,
table.schema,
requestId
)
} else if (result.operation === 'update' && result.previousData) {
const oldRows = new Map([[result.row.id, result.previousData]])
void fireTableTrigger(
data.tableId,
table.name,
'update',
[result.row],
oldRows,
table.schema,
requestId
)
}
void runWorkflowColumn({
tableId: table.id,
workspaceId: table.workspaceId,
rowIds: [result.row.id],
mode: 'new',
isManualRun: false,
requestId,
triggeredByUserId: data.userId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (upsertRow) failed:`, err))
return result
}
/**
* Canonical ORDER BY for a table's rows, shared by `queryRows` (the paginated
* list) and `findRowMatches` so a match's ordinal lines up with its index in
* the list. Order: explicit data sort (if any) → fractional `order_key` → `id`.
* The `id` tiebreak is always appended so equal keys order deterministically —
* without it two separate query executions (a find vs a list page) could shuffle
* ties and misalign ordinals.
*/
function buildRowOrderBySql(
sort: Sort | undefined,
tableName: string,
columns: ColumnDefinition[]
): SQL {
const primary = `${tableName}.order_key`
const id = `${tableName}.id`
if (sort && Object.keys(sort).length > 0) {
const sortClause = buildSortClause(sort, tableName, columns)
if (sortClause) {
return sql.join([sortClause, sql.raw(primary), sql.raw(id)], sql.raw(', '))
}
}
return sql.raw(`${primary}, ${id}`)
}
/** One matching cell from {@link findRowMatches}. */
export interface FindRowMatch {
/** 0-based index of the row in the filtered+sorted view (aligns with the list query). */
ordinal: number
rowId: string
/** Stable column id of the matching cell (the JSONB storage key), not the display name. */
column: string
}
/** Max matching cells returned by {@link findRowMatches}; one extra is fetched to detect truncation. */
const FIND_MATCH_LIMIT = 1000
/**
* Builds a SQL text expression that resolves a scanned select cell (`kv.value`,
* keyed by `kv.key`) to its option **name(s)** — the label the user searches by,
* not the stored id. Single-select maps the id to its name; multiselect joins the
* array's option names. Non-select keys resolve to NULL. Returns `null` when the
* schema has no select columns (caller keeps the plain id match). Option ids/names
* are trusted schema data, escaped and embedded literally; the row alias is `o`.
*/
export function buildSelectFindNameExpr(columns: ColumnDefinition[]): string | null {
const selectColumns = columns.filter((c) => columnTypeOf(c).storesOpaqueIds)
if (selectColumns.length === 0) return null
const esc = (s: string) => s.replace(/'/g, "''")
const whens = selectColumns
.map((col) => {
const id = esc(getColumnId(col))
const caseWhens = (col.options ?? [])
.map((o) => `WHEN '${esc(o.id)}' THEN '${esc(o.name)}'`)
.join(' ')
const single = caseWhens ? `CASE kv.value ${caseWhens} ELSE kv.value END` : 'kv.value'
if (col.multiple) {
const elem = caseWhens ? `CASE e.v ${caseWhens} ELSE e.v END` : 'e.v'
// `jsonb_array_elements_text` throws "cannot extract elements from a
// scalar" on a JSON null — which a multiselect cell becomes when it is
// cleared, cut, or has its last option removed — so the array arm has to
// be gated on the cell actually being an array. Anything else falls back
// to the single mapping, which also keeps a scalar left over from a
// single→multi toggle searchable. Mirrors `buildSelectNameOrderExpr`.
// `ORDER BY e.ord` keeps the joined label in stored order, so Find matches
// the same text the grid renders, sorts by, and exports.
return `WHEN kv.key = '${id}' THEN CASE WHEN jsonb_typeof(o.data->'${id}') = 'array' THEN (SELECT string_agg(${elem}, ', ' ORDER BY e.ord) FROM jsonb_array_elements_text(o.data->'${id}') WITH ORDINALITY AS e(v, ord)) ELSE ${single} END`
}
return `WHEN kv.key = '${id}' THEN ${single}`
})
.join(' ')
return `CASE ${whens} ELSE NULL END`
}
/**
* Case-insensitive substring search across every cell of a table's rows. Each
* matching cell becomes a {@link FindRowMatch} carrying its row id, column, and
* 0-based ordinal in the filtered+sorted view (so the client can page up to and
* reveal it). `filter`/`sort` mirror the active list view via
* {@link buildRowOrderBySql}, keeping ordinals aligned.
*
* Cost: one pass over the table's rows — `ILIKE` over `jsonb_each_text` cannot
* use the JSONB GIN index, and the ordinal's `row_number()` needs every row
* counted regardless. The planner can't estimate the lateral ILIKE (jsonb is
* opaque to it), so left alone it seq-scans the entire shared relation and
* disk-sorts the window input (measured 75s on a 1M-row table in a 12M-row
* relation). `SET LOCAL` planner flags keep it tenant-bounded; on the default
* order they additionally force the streaming `(table_id, order_key, id)` index
* walk where `row_number()` needs no sort at all (measured 2s). A `pg_trgm` GIN
* index on a text projection is the future accelerator if needed.
*/
export async function findRowMatches(
table: TableDefinition,
options: { q: string; filter?: Filter; sort?: Sort },
requestId: string
): Promise<{ matches: FindRowMatch[]; truncated: boolean }> {
const tableName = USER_TABLE_ROWS_SQL_NAME
const columns = table.schema.columns
// Row data is keyed by stable column id, so scan/return JSONB keys as ids.
const columnIds = columns.map(getColumnId)
if (columnIds.length === 0) return { matches: [], truncated: false }
// Same visibility rule as queryRows: don't surface rows a running delete job will remove.
const deleteMask = await pendingDeleteMask(table)
const baseConditions = and(
eq(userTableRows.tableId, table.id),
eq(userTableRows.workspaceId, table.workspaceId),
deleteMask
)
let whereClause: SQL | undefined = baseConditions
if (options.filter && Object.keys(options.filter).length > 0) {
const filterClause = buildFilterClause(options.filter, tableName, columns)
if (filterClause) whereClause = and(baseConditions, filterClause)
}
const orderBySql = buildRowOrderBySql(options.sort, tableName, columns)
const pattern = `%${escapeLikePattern(options.q)}%`
// Select cells store option ids; also match the resolved option name so a search
// for the visible label finds the cell (the raw-id match below is kept too).
const selectNameExpr = buildSelectFindNameExpr(columns)
const nameMatchClause = selectNameExpr
? sql` OR (${sql.raw(selectNameExpr)}) ILIKE ${pattern}`
: sql``
const result = await db.transaction(async (trx) => {
// Planner flags, not correctness: `enable_* = off` only penalizes a plan shape, so a
// genuinely required sort still runs. Seqscan off keeps the scan inside the tenant's rows
// (the lateral ILIKE is unestimatable, so the planner otherwise walks the whole shared
// relation). On the default order, the remaining flags steer to the already-sorted
// `(table_id, order_key, id)` index walk so the window function streams without a 100MB+
// disk sort; a custom sort has no index to stream from, so those flags would only distort
// that plan.
await trx.execute(sql`SET LOCAL enable_seqscan = off`)
if (!options.sort) {
await trx.execute(sql`SET LOCAL enable_bitmapscan = off`)
await trx.execute(sql`SET LOCAL enable_sort = off`)
await trx.execute(sql`SET LOCAL max_parallel_workers_per_gather = 0`)
}
return trx.execute<{
ordinal: string | number
id: string
column_name: string
}>(sql`
WITH ordered AS (
SELECT id, data, row_number() OVER (ORDER BY ${orderBySql}) - 1 AS ordinal
FROM ${userTableRows}
WHERE ${whereClause}
)
SELECT o.ordinal, o.id, kv.key AS column_name
FROM ordered o
CROSS JOIN LATERAL jsonb_each_text(o.data) kv
WHERE (kv.value ILIKE ${pattern}${nameMatchClause})
AND ${inArray(sql`kv.key`, columnIds)}
ORDER BY o.ordinal
LIMIT ${FIND_MATCH_LIMIT + 1}
`)
})
const all = Array.from(result)
const truncated = all.length > FIND_MATCH_LIMIT
const sliced = truncated ? all.slice(0, FIND_MATCH_LIMIT) : all
const matches: FindRowMatch[] = sliced.map((r) => ({
ordinal: Number(r.ordinal),
rowId: r.id,
column: r.column_name,
}))
logger.info(
`[${requestId}] Find "${options.q}" in table ${table.id}: ${matches.length} match(es)${truncated ? ' (truncated)' : ''}`
)
return { matches, truncated }
}
/**
* Queries rows from a table with filtering, sorting, and pagination.
*
* Filter cost model: equality filters (`$eq`, `$in`) compile to JSONB
* containment (`@>`) and hit the GIN (jsonb_path_ops) index on
* `user_table_rows.data`. Range operators (`$gt`, `$gte`, `$lt`, `$lte`) and
* `$contains` compile to `data->>'field'` text extraction and bypass the GIN
* index — they fall back to a sequential scan of the rows for the table
* (bounded only by the btree on `table_id`). Prefer equality on hot paths; set
* `includeTotal: false` when the caller does not need the `COUNT(*)`.
*
* @param table - Table definition (provides id, workspaceId, and column schema for type-aware filter/sort casts)
* @param options - Query options (filter, sort, limit, offset)
* @param requestId - Request ID for logging
* @returns Query result with rows and pagination info
*/
/**
* Visibility mask for a running delete job: returns a clause keeping only rows the job will NOT
* delete, or `undefined` when no delete job is running. The job's persisted scope
* ({@link TableDeleteJobPayload}) defines the doomed set — `matches(filter) AND created_at <=
* cutoff AND id NOT IN excludeRowIds` — exactly what the worker's `selectRowIdPage` selects, so
* mid-job reads (refresh, other clients, exports) are consistent with the eventual result. The
* mask lifts automatically when the job leaves `running` (done, failed, or canceled).
*
* `(doomed) IS NOT TRUE` rather than `NOT (doomed)`: JSONB predicates evaluate to NULL on missing
* cells, and those rows are NOT selected for deletion (NULL ≠ TRUE) — they must stay visible.
*/
export async function pendingDeleteMask(table: TableDefinition): Promise<SQL | undefined> {
const [job] = await db
.select({ payload: tableJobs.payload })
.from(tableJobs)
.where(
and(
eq(tableJobs.tableId, table.id),
eq(tableJobs.status, 'running'),
eq(tableJobs.type, 'delete')
)
)
.limit(1)
if (!job?.payload) return undefined
const scope = job.payload as TableDeleteJobPayload
// A bounded delete (explicit limit) deletes only the first `maxRows` matches, so the filter-based
// mask — which hides every match — would over-hide the rows beyond the cap this job never touches.
// Leave those reads unmasked; the bounded delete is eventually consistent like a bounded update.
if (scope.maxRows !== undefined) return undefined
const doomedParts: SQL[] = []
if (scope.filter && Object.keys(scope.filter).length > 0) {
try {
const clause = buildFilterClause(scope.filter, USER_TABLE_ROWS_SQL_NAME, table.schema.columns)
if (clause) doomedParts.push(clause)
} catch (error) {
// Schema drifted mid-job (column renamed/deleted). Showing doomed rows briefly beats
// failing every read; the worker resolves the same way on its next page.
logger.warn(`Skipping delete-job mask for table ${table.id}: stale filter`, {
error: toError(error).message,
})
return undefined
}
}
if (scope.cutoff) doomedParts.push(lte(userTableRows.createdAt, new Date(scope.cutoff)))
if (scope.excludeRowIds && scope.excludeRowIds.length > 0) {
doomedParts.push(notInArray(userTableRows.id, scope.excludeRowIds))
}
if (doomedParts.length === 0) return undefined
return sql`(${and(...doomedParts)}) IS NOT TRUE`
}
/**
* `COUNT(*)` for a filtered view, kept inside the tenant's rows: measured
* 12.7s → 1.0s counting a rare ILIKE filter on a 1M-row table inside a 12M-row
* relation (see {@link withSeqscanOff} for why the planner gets this wrong).
*/
async function countRowsTenantBounded(whereClause: SQL | undefined): Promise<number> {
return withSeqscanOff(async (trx) => {
const [result] = await trx.select({ count: count() }).from(userTableRows).where(whereClause)
return Number(result.count)
})
}
export async function queryRows(
table: TableDefinition,
options: QueryOptions,
requestId: string
): Promise<QueryResult> {
const {
filter,
predicate,
sort,
// No default: an undefined limit returns every matching row, bounded only by
// the MAX_QUERY_RESULT_BYTES fail-fast guard below.
limit,
offset = 0,
after,
includeTotal = true,
withExecutions = true,
} = options
const tableName = USER_TABLE_ROWS_SQL_NAME
const columns = table.schema.columns
// Hide rows a running delete job is about to remove — both the page and the count below share
// this clause, so totals stay consistent with the visible rows.
const deleteMask = await pendingDeleteMask(table)
const baseConditions = and(
eq(userTableRows.tableId, table.id),
eq(userTableRows.workspaceId, table.workspaceId),
deleteMask
)
// v2 predicate takes precedence over the legacy `$`-filter; both compile to a
// WHERE through the same `fieldPredicate` leaf.
const userClause = predicate
? buildPredicateClause(predicate, tableName, columns)
: filter && Object.keys(filter).length > 0
? buildFilterClause(filter, tableName, columns)
: undefined
let whereClause = baseConditions
if (userClause) {
whereClause = and(baseConditions, userClause)
}
// Keyset seeks are only authoritative when the page order IS the
// `(order_key, id)` index order: no custom sort. Order keys are NOT guaranteed
// present — unkeyed rows sort last and the seek admits them explicitly.
const keysetValid = !sort
// Count and page drain are independent reads — run them concurrently so the
// `includeTotal` hot path doesn't pay two serial round-trips. Filtered counts
// go through the tenant-bounded variant (see countRowsTenantBounded); the
// unfiltered count already plans an index-only scan on the table_id prefix.
// The count uses the full-view WHERE (no cursor seek): totals cover the whole
// view, not the remaining pages.
const hasFilter = Boolean(userClause)
const countPromise = includeTotal
? hasFilter
? countRowsTenantBounded(whereClause)
: // Unfiltered count plans an index-only scan on the table_id prefix, but
// still runs under the read timeout so it can't pin a connection.
withReadGuards(async (trx) => {
const [r] = await trx
.select({ count: count() })
.from(userTableRows)
.where(whereClause ?? baseConditions)
return Number(r.count)
})
: null
// The inbound seek is honored whenever there's no custom sort; `keysetValid`
// only gates re-anchoring and plain-keyset cursor emission.
const drainPromise = fetchRowsBounded({
baseWhere: whereClause ?? baseConditions,
orderBy: buildRowOrderBySql(sort, tableName, columns),
sorted: Boolean(sort),
keysetValid,
seek: !sort ? after : undefined,
startOffset: offset,
limit,
budgetBytes: TABLE_LIMITS.MAX_QUERY_RESULT_BYTES,
pageCutBytes: getMaxPageBytes() ?? undefined,
})
const [fetched, totalCount] = await Promise.all([drainPromise, countPromise])
const rows = fetched.rows
const executionsByRow = withExecutions
? await loadExecutionsByRow(
db,
rows.map((r) => r.id)
)
: null
logger.info(
`[${requestId}] Queried ${rows.length} rows from table ${table.id} (total: ${totalCount}, bytes: ${fetched.bytes}, more: ${fetched.hasMore})`
)
const mappedRows = rows.map((r) => ({
id: r.id,
data: r.data as RowData,
executions: executionsByRow?.get(r.id) ?? {},
position: r.position,
orderKey: r.orderKey ?? undefined,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}))
// Opaque next-page cursor: non-null whenever more matching rows exist beyond
// this page — whether the page was cut by `limit` or by the byte budget. The
// drain loop proves `hasMore` with a fetched-but-unreturned witness row.
const lastRow = mappedRows[mappedRows.length - 1]
const nextCursor =
fetched.hasMore && lastRow
? encodeCursor({
lastRow,
keysetValid,
nextOffset: offset + mappedRows.length,
seekBase: fetched.anchor
? { anchor: fetched.anchor, offsetFromAnchor: fetched.anchorOffset }
: undefined,
sort,
})
: null
return {
rows: mappedRows,
rowCount: rows.length,
totalCount,
limit: limit ?? mappedRows.length,
offset,
nextCursor,
}
}
interface BoundedFetchParams {
/** Tenant + delete-mask + user filter — WITHOUT any seek predicate. */
baseWhere: SQL | undefined
orderBy: SQL
/** Custom sort present → per-batch withSeqscanOff (JSONB order is unestimatable). */
sorted: boolean
/** `(order_key, id)` order is authoritative → keyset re-anchoring + seeks. */
keysetValid: boolean
/** Inbound seek anchor (decoded keyset/compound cursor), if any. */
seek?: TableRowsCursor
/** Inbound offset — the whole-view offset, or the past-anchor offset of a compound cursor. */
startOffset: number
limit?: number
/** Drain ceiling: sizes batches, and the fail-fast bound for an unbounded query. */
budgetBytes: number
/**
* Opt-in byte cut for a **bounded** page (`TABLE_MAX_PAGE_BYTES`); `undefined`
* disables it, so a bounded page always returns its full `limit`.
*/
pageCutBytes?: number
}
interface BoundedFetchResult {
rows: Array<typeof userTableRows.$inferSelect>
bytes: number
/** Proven by a fetched-but-unreturned witness row — never inferred from page fullness. */
hasMore: boolean
/** Final keyset anchor, for compound-cursor emission when the last row is unkeyed. */
anchor?: TableRowsCursor
/** Rows consumed past `anchor` (0 when the anchor is the last returned row). */
anchorOffset: number
}
/** Belt-and-braces bound on drain iterations; unreachable in practice. */
const MAX_QUERY_BATCHES = 1000
/**
* Drains rows in adaptively-sized bounded batches until the caller's `limit`
* or the byte ceiling ends the page. Never issues an unbounded SELECT: the
* first batch is capped so its worst-case bytes stay within ~4× the budget at
* the max row size, and later batches are sized from the observed average.
*
* Byte ceiling: an **unbounded** query (no `limit`) always fails fast at
* `budgetBytes` — returning part of a result that promised everything would be
* silent truncation. A **bounded** page cuts short only when `pageCutBytes` is
* set (`TABLE_MAX_PAGE_BYTES`), because a short page is only safe for clients
* that terminate on `nextCursor === null` rather than on page fullness.
*
* Advance strategy: when `keysetValid`, the loop re-anchors on each consumed
* keyed row and seeks `(order_key, id) > (anchor)` — delete-tolerant and an
* index seek. Otherwise (custom sort, flag off) it advances by OFFSET from the
* inbound position; rows deleted mid-drain can skip/duplicate exactly as
* cross-request offset paging already does.
*
* Always returns at least one row when any match exists, even if that row
* alone exceeds the budget.
*/
async function fetchRowsBounded(params: BoundedFetchParams): Promise<BoundedFetchResult> {
const { baseWhere, orderBy, sorted, keysetValid, limit, budgetBytes, pageCutBytes } = params
const firstBatchCap = Math.max(1, Math.floor((4 * budgetBytes) / TABLE_LIMITS.MAX_ROW_SIZE_BYTES))
// The byte ceiling that ends the drain: an unbounded query fails fast at the
// budget; a bounded page cuts only when the operator opted in.
const cutBytes = limit === undefined ? budgetBytes : pageCutBytes
const rows: Array<typeof userTableRows.$inferSelect> = []
let bytes = 0
let maxRowBytes = 0
let hasMore = false
let anchor = params.seek
let anchorOffset = params.startOffset
let consumedSinceAnchor = 0
const nextBatchRows = (): number => {
if (rows.length === 0) return Math.min(limit ?? firstBatchCap, firstBatchCap)
const avg = Math.max(1, bytes / rows.length)
// Bytes we may still fetch this batch. When a cut is active it's the
// remainder of that cut; otherwise each batch gets a fresh budget's worth,
// so a large bounded page keeps draining in real steps instead of degrading
// to one row per query once cumulative bytes pass the budget.
const remaining = Math.max(1, cutBytes === undefined ? budgetBytes : cutBytes - bytes)
const byAverage = Math.ceil(remaining / avg) + 1
const varianceCap = Math.ceil((8 * remaining) / Math.max(maxRowBytes, 1))
return Math.max(1, Math.min(byAverage, TABLE_LIMITS.QUERY_BATCH_MAX_ROWS, varianceCap))
}
const runBatch = (batchSeek: TableRowsCursor | undefined, batchOffset: number, ask: number) => {
const buildQuery = (executor: DbExecutor) => {
// `order_key` is nullable (rows predating the backfill, and forked rows that
// inherit a NULL key). A bare row-constructor comparison evaluates to NULL for
// those rows, so they are dropped by WHERE — and because NULLs sort LAST under
// `ORDER BY order_key, id`, the entire unkeyed tail becomes unreachable and the
// drain terminates early reporting `hasMore: false`. Admitting NULLs keeps the
// seek set exactly "the tail after the anchor", which is also what the compound
// `{k,i,o}` cursor's `offsetFromAnchor` accounting assumes.
const seekWhere = batchSeek
? and(
baseWhere,
sql`(${userTableRows.orderKey} IS NULL OR (${userTableRows.orderKey}, ${userTableRows.id}) > (${batchSeek.orderKey}, ${batchSeek.id}))`
)
: baseWhere
const query = executor
.select()
.from(userTableRows)
.where(seekWhere)
.orderBy(orderBy)
.limit(ask)
return batchOffset > 0 ? query.offset(batchOffset) : query
}
// One tx per batch (SET LOCAL dies with it; holding a tx across JS
// accounting between batches would pin a pooled connection). Custom sorts
// order by `data->>'col'` — unestimatable — so they also penalize seq scans
// (9.7s→0.76s on a 1M-row table); default-order pages stream the index and
// just need the read timeout. Either way the batch runs under a statement
// timeout so a pathological filter can't scan unbounded.
return withReadGuards(async (trx) => buildQuery(trx), { seqscanOff: sorted })
}
for (let iteration = 0; iteration < MAX_QUERY_BATCHES; iteration++) {
const limitRemaining = limit === undefined ? Number.POSITIVE_INFINITY : limit - rows.length
const target = Math.min(nextBatchRows(), limitRemaining)
const ask = target + 1 // +1 = witness row proving more data exists past a cut
const batch = await runBatch(anchor, anchorOffset + consumedSinceAnchor, ask)
if (batch.length === 0) break
let cut = false
for (const row of batch) {
const rowBytes = Buffer.byteLength(JSON.stringify(row.data))
if (cutBytes !== undefined && rows.length > 0 && bytes + rowBytes > cutBytes) {
// Unbounded queries promise the ENTIRE result — a partial page would be
// silent truncation, so fail fast instead (the drain has only fetched
// ~budget bytes at this point, never the whole table).
if (limit === undefined) {
throw new TableQueryValidationError(
`Query result exceeds the ${Math.floor(cutBytes / (1024 * 1024))}MB limit. Add a filter or a limit to narrow the result.`,
'TABLE_QUERY_RESULT_TOO_LARGE'
)
}
// Bounded page, byte cut opted in: `row` is the witness. Requires a
// non-empty page so a single over-budget row is still returned alone.
hasMore = true
cut = true
break
}
// Limit cut: `row` is the +1 peek witness.
if (rows.length === limit) {
hasMore = true
cut = true
break
}
rows.push(row)
bytes += rowBytes
consumedSinceAnchor++
if (rowBytes > maxRowBytes) maxRowBytes = rowBytes
if (keysetValid && row.orderKey) {
anchor = { orderKey: row.orderKey, id: row.id }
anchorOffset = 0
consumedSinceAnchor = 0
}
}
if (cut) break
// Short batch = the source is exhausted; hasMore stays false.
if (batch.length < ask) break
}
return {
rows,
bytes,
hasMore,
anchor,
anchorOffset: anchorOffset + consumedSinceAnchor,
}
}
/**
* Gets a single row by ID.
*
* @param tableId - Table ID
* @param rowId - Row ID to fetch
* @param workspaceId - Workspace ID for access control
* @returns Row or null if not found
*/
export async function getRowById(
tableId: string,
rowId: string,
workspaceId: string
): Promise<TableRow | null> {
const results = await db
.select()
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
.limit(1)
if (results.length === 0) return null
const row = results[0]
const executions = await loadExecutionsForRow(db, row.id)
return {
id: row.id,
data: row.data as RowData,
executions,
position: row.position,
orderKey: row.orderKey ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
/**
* Fetches the `data` payloads for a set of rows by id, scoped to a table and
* workspace. Returns lightweight `{ id, data }` records (no executions) in the
* order the ids were requested, silently skipping ids that don't resolve. Used
* to materialize a `table_selection` chat context server-side so the agent gets
* fresh, authoritative cell values instead of trusting client-sent copies.
*/
export async function getRowsByIds(
tableId: string,
rowIds: string[],
workspaceId: string
): Promise<Array<{ id: string; data: RowData }>> {
const uniqueIds = Array.from(new Set(rowIds))
if (uniqueIds.length === 0) return []
const results = await db
.select({ id: userTableRows.id, data: userTableRows.data })
.from(userTableRows)
.where(
and(
inArray(userTableRows.id, uniqueIds),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, workspaceId)
)
)
const byId = new Map(results.map((r) => [r.id, r.data as RowData]))
return uniqueIds.filter((id) => byId.has(id)).map((id) => ({ id, data: byId.get(id) as RowData }))
}
/** Internal: thrown inside `db.transaction` to roll back when the executions
* guard rejects a write. The outer `.catch` translates it into a `null` return. */
class GuardRejected extends Error {
constructor() {
super('cell-write guard rejected')
}
}
/**
* Updates a single row.
*
* @param data - Update data
* @param table - Table definition
* @param requestId - Request ID for logging
* @param options - Internal persistence controls
* @returns Updated row
* @throws Error if row not found or validation fails
*/
export interface UpdateRowOptions {
/**
* 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
}
/**
* A row stores every cell in one jsonb `data` column, so a row update writes the changed cells as
* an in-DB JSONB merge (`data = data || {changed}::jsonb`) rather than replacing the whole object.
* Postgres evaluates the concat against the current committed row under its write lock, so
* concurrent edits to DIFFERENT cells of the same row both survive instead of the last writer
* clobbering the row from a stale read. Values come from the caller's already-coerced merged row;
* only the changed keys are sent, so an empty change set is a no-op on `data`. Whole-row
* replacement is `upsertRow`'s job, not this path (an update only ever adds/overwrites cells —
* it never deletes a key — so a full-object write would only differ by being racy).
*/
function jsonbMergePatch(changedColumnIds: string[], coercedRow: RowData): SQL {
const patch: RowData = {}
for (const columnId of changedColumnIds) patch[columnId] = coercedRow[columnId]
return sql`${userTableRows.data} || ${JSON.stringify(patch)}::jsonb`
}
export async function updateRow(
data: UpdateRowData,
table: TableDefinition,
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) {
throw new Error('Row not found')
}
// Merge partial update with existing row data so callers can pass only changed fields
const mergedData = {
...(existingRow.data as RowData),
...data.data,
}
// Auto-clear exec records for workflow output columns the user just wiped
// AND for downstream groups whose deps just changed. Surfaces the in-flight
// downstream groups so the caller can cancel + re-run them.
const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } =
deriveExecClearsForDataPatch(
data.data,
table.schema,
existingRow.executions,
data.executionsPatch,
mergedData
)
const mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch)
// Validate size
const sizeValidation = validateRowSize(mergedData)
if (!sizeValidation.valid) {
throw new Error(sizeValidation.errors.join(', '))
}
// Validate against schema
const schemaValidation = coerceRowToSchema(mergedData, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`)
}
// Check unique constraints using optimized database query
const uniqueColumns = getUniqueColumns(table.schema)
if (uniqueColumns.length > 0) {
const uniqueValidation = await checkUniqueConstraintsDb(
data.tableId,
mergedData,
table.schema,
data.rowId // Exclude current row
)
if (!uniqueValidation.valid) {
throw new Error(uniqueValidation.errors.join(', '))
}
}
const now = new Date()
const persistedData = jsonbMergePatch(Object.keys(data.data), mergedData)
// Cell-task partial writes pass `cancellationGuard` so the upsert into
// `tableRowExecutions` is a no-op when (a) a stop click already wrote
// `cancelled` for this run, or (b) a newer run has taken over the cell
// with a different executionId. Authoritative cancel writes from
// `cancelWorkflowGroupRuns` skip the guard entirely. Data + executions
// commit in one transaction so a partial write can't leave the sidecar
// and the row out of sync.
const guard = data.cancellationGuard
let persistedUpdatedAt: Date
try {
persistedUpdatedAt = await db.transaction(async (trx) => {
return await mutateTableRowsWithSecretProvenance(trx, {
rows: [{ rowId: data.rowId, provenance: data.secretProvenance }],
rowState: 'existing',
mode: 'merge',
mutate: async () => {
const updatedRows = await trx
.update(userTableRows)
.set({ data: persistedData, updatedAt: now })
.where(eq(userTableRows.id, data.rowId))
.returning({ id: userTableRows.id, updatedAt: userTableRows.updatedAt })
const [updatedRow] = updatedRows
if (!updatedRow) throw new Error('Table row no longer exists')
const result = await writeExecutionsPatch(
trx,
data.tableId,
data.rowId,
effectiveExecutionsPatch,
guard
)
if (result === 'guard-rejected') {
// Roll back the data update too — the worker isn't authoritative.
throw new GuardRejected()
}
return {
value: updatedRow.updatedAt,
affectedRowIds: [updatedRow.id],
}
},
})
})
} catch (err) {
if (err instanceof GuardRejected) return null
throw err
}
logger.info(`[${requestId}] Updated row ${data.rowId} in table ${data.tableId}`)
const updatedRow: TableRow = {
id: data.rowId,
data: mergedData,
executions: mergedExecutions,
position: existingRow.position,
createdAt: existingRow.createdAt,
updatedAt: persistedUpdatedAt,
}
const oldRows = new Map([[data.rowId, existingRow.data as RowData]])
void fireTableTrigger(
data.tableId,
table.name,
'update',
[updatedRow],
oldRows,
table.schema,
requestId
)
// Auto-fire only on user-facing data edits. Internal callers that mutate
// executions (cell-task partial/terminal writes, cancel writes) always pass
// `executionsPatch` — re-dispatching from those would recursively spawn new
// dispatches for every running/terminal write, flooding the dispatcher with
// redundant pre-stamps that strand `pending` cells.
const isInternalExecWrite = data.executionsPatch && Object.keys(data.executionsPatch).length > 0
if (isInternalExecWrite) {
return updatedRow
}
// Two passes:
// 1. Cancel in-flight downstream groups whose dep just changed, then
// manually re-run them — the cancel writes `cancelled` per cell and
// `mode: 'incomplete' + isManualRun: true` wipes those entries and
// re-enqueues.
// 2. `mode: 'new'` for groups that just had their exec entries cleared
// (own-output wipe OR terminal downstream dep-changed) — the
// dispatcher's `jsonb_exists_all` SQL filter lets the row through
// because at least one targeted group's exec is now missing.
if (inFlightDownstreamGroups.length > 0) {
void (async () => {
try {
await cancelWorkflowGroupRuns(data.tableId, data.rowId, {
groupIds: inFlightDownstreamGroups,
})
await runWorkflowColumn({
tableId: data.tableId,
workspaceId: data.workspaceId,
mode: 'incomplete',
isManualRun: true,
rowIds: [data.rowId],
groupIds: inFlightDownstreamGroups,
requestId,
triggeredByUserId: data.actorUserId,
})
} catch (err) {
logger.error(`[${requestId}] cancel+rerun for in-flight downstream groups failed:`, err)
}
})()
}
void runWorkflowColumn({
tableId: data.tableId,
workspaceId: data.workspaceId,
rowIds: [data.rowId],
mode: 'new',
isManualRun: false,
requestId,
triggeredByUserId: data.actorUserId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRow) failed:`, err))
return updatedRow
}
/**
* Deletes a single row (hard delete).
*
* @param tableId - Table ID
* @param rowId - Row ID to delete
* @param workspaceId - Workspace ID for access control
* @param requestId - Request ID for logging
* @throws Error if row not found
*/
export async function deleteRow(
table: TableDefinition,
rowId: string,
requestId: string
): Promise<void> {
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 ${table.id}`)
}
/**
* Updates multiple rows matching a filter.
*
* @param table - Table definition (provides column schema for type-aware filter casts)
* @param data - Bulk update data
* @param requestId - Request ID for logging
* @returns Bulk operation result
*/
export async function updateRowsByFilter(
table: TableDefinition,
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)
if (!filterClause) {
throw new Error('Filter is required for bulk update')
}
const baseConditions = and(
eq(userTableRows.tableId, table.id),
eq(userTableRows.workspaceId, table.workspaceId)
)
// A limit selects a SUBSET, so impose the default `(order_key, id)` order —
// without it Postgres returns planner-arbitrary rows and "update the first N"
// is nondeterministic. Sort is irrelevant (and skipped) when every match is updated.
// Tenant-bounded: the jsonb filter is unestimatable and otherwise sends the planner to a
// whole-shared-relation seq scan (14.4s measured on a 1M-row table).
const matchingRows = await withSeqscanOff(async (trx) => {
const base = trx
.select({ id: userTableRows.id, data: userTableRows.data })
.from(userTableRows)
.where(and(baseConditions, filterClause))
if (data.limit) {
return base
.orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns))
.limit(data.limit)
}
return base
})
if (matchingRows.length === 0) {
return { affectedCount: 0, affectedRowIds: [] }
}
// Coerce the patch itself in place — the write below persists `data.data`
// (as `patchJson`), so coercing only the per-row merged copies would be
// discarded. The merged validation in the loop still enforces required
// fields against the full row.
coerceRowValues(data.data, table.schema)
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...data.data }
const sizeValidation = validateRowSize(mergedData)
if (!sizeValidation.valid) {
throw new Error(`Row ${row.id}: ${sizeValidation.errors.join(', ')}`)
}
const schemaValidation = coerceRowToSchema(mergedData, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Row ${row.id}: ${schemaValidation.errors.join(', ')}`)
}
}
const uniqueColumns = getUniqueColumns(table.schema)
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in data.data)
if (uniqueColumnsInUpdate.length > 0) {
if (matchingRows.length > 1) {
throw new Error(
`Cannot set unique column values when updating multiple rows. ` +
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`
)
}
// Only one row — only the touched unique columns need re-checking.
const row = matchingRows[0]
const mergedData = { ...(row.data as RowData), ...data.data }
const uniqueValidation = await checkUniqueConstraintsDb(
table.id,
mergedData,
table.schema,
row.id
)
if (!uniqueValidation.valid) {
throw new Error(`Unique constraint violation: ${uniqueValidation.errors.join(', ')}`)
}
}
const now = new Date()
const ids = matchingRows.map((r) => r.id)
const patchJson = JSON.stringify(data.data)
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
await mutateTableRowsWithSecretProvenance(trx, {
rows: ids.map((rowId) => ({ rowId, provenance: data.secretProvenance })),
rowState: 'existing',
mode: 'merge',
mutate: async () => {
const affectedRowIds: string[] = []
for (let i = 0; i < ids.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batchIds = ids.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const updated = await trx
.update(userTableRows)
.set({
data: sql`${userTableRows.data} || ${patchJson}::jsonb`,
updatedAt: now,
})
.where(inArray(userTableRows.id, batchIds))
.returning({ id: userTableRows.id })
affectedRowIds.push(...updated.map((row) => row.id))
}
return { value: undefined, affectedRowIds }
},
})
})
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${table.id}`)
const oldRows = new Map(matchingRows.map((r) => [r.id, r.data as RowData]))
const updatedRows: TableRow[] = matchingRows.map((r) => ({
id: r.id,
data: { ...(r.data as RowData), ...data.data },
executions: {},
position: 0,
createdAt: now,
updatedAt: now,
}))
void fireTableTrigger(
table.id,
table.name,
'update',
updatedRows,
oldRows,
table.schema,
requestId
)
void runWorkflowColumn({
tableId: table.id,
workspaceId: table.workspaceId,
rowIds: updatedRows.map((r) => r.id),
mode: 'new',
isManualRun: false,
requestId,
triggeredByUserId: data.actorUserId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (updateRowsByFilter) failed:`, err))
return {
affectedCount: matchingRows.length,
affectedRowIds: ids,
}
}
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.
*/
export async function batchUpdateRows(
data: BatchUpdateByIdData,
table: TableDefinition,
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({
id: userTableRows.id,
data: userTableRows.data,
})
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, data.tableId),
eq(userTableRows.workspaceId, data.workspaceId),
inArray(userTableRows.id, rowIds)
)
)
const executionsByRow = await loadExecutionsByRow(
db,
existingRows.map((r) => r.id)
)
type ExistingRow = { data: RowData; executions: RowExecutions }
const existingMap = new Map<string, ExistingRow>(
existingRows.map((r) => [
r.id,
{ data: r.data as RowData, executions: executionsByRow.get(r.id) ?? {} },
])
)
const missing = rowIds.filter((id) => !existingMap.has(id))
if (missing.length > 0) {
throw new Error(`Rows not found: ${missing.join(', ')}`)
}
const mergedUpdates: Array<{
rowId: string
changedColumnIds: string[]
mergedData: RowData
mergedExecutions: RowExecutions
executionsPatch?: Record<string, RowExecutionMetadata | null>
inFlightDownstreamGroups: string[]
}> = []
for (const update of data.updates) {
const existing = existingMap.get(update.rowId)!
const merged = { ...existing.data, ...update.data }
// Auto-clear exec records for workflow output columns the user just
// wiped AND downstream dep-changed terminal groups — same rationale as
// `updateRow`. Per-row in-flight downstream groups are surfaced so we
// can run the cancel+rerun orchestration after the batch commits.
const { executionsPatch: effectiveExecutionsPatch, inFlightDownstreamGroups } =
deriveExecClearsForDataPatch(
update.data,
table.schema,
existing.executions,
update.executionsPatch,
merged
)
const mergedExecutions = applyExecutionsPatch(existing.executions, effectiveExecutionsPatch)
const sizeValidation = validateRowSize(merged)
if (!sizeValidation.valid) {
throw new Error(`Row ${update.rowId}: ${sizeValidation.errors.join(', ')}`)
}
const schemaValidation = coerceRowToSchema(merged, table.schema)
if (!schemaValidation.valid) {
throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`)
}
mergedUpdates.push({
rowId: update.rowId,
changedColumnIds: Object.keys(update.data),
mergedData: merged,
mergedExecutions,
executionsPatch: effectiveExecutionsPatch,
inFlightDownstreamGroups,
})
}
const uniqueColumns = getUniqueColumns(table.schema)
if (uniqueColumns.length > 0) {
for (const { rowId, mergedData } of mergedUpdates) {
const uniqueValidation = await checkUniqueConstraintsDb(
data.tableId,
mergedData,
table.schema,
rowId
)
if (!uniqueValidation.valid) {
throw new Error(`Row ${rowId}: ${uniqueValidation.errors.join(', ')}`)
}
}
}
const now = new Date()
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
await mutateTableRowsWithSecretProvenance(trx, {
rows: mergedUpdates.map((update) => ({
rowId: update.rowId,
provenance: data.secretProvenanceByRowId?.[update.rowId],
})),
rowState: 'existing',
mode: 'merge',
mutate: async () => {
const affectedRowIds: string[] = []
for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const dataPromises = batch.map(({ rowId, changedColumnIds, mergedData }) =>
trx
.update(userTableRows)
.set({ data: jsonbMergePatch(changedColumnIds, mergedData), updatedAt: now })
.where(eq(userTableRows.id, rowId))
.returning({ id: userTableRows.id })
)
const updatedRows = await Promise.all(dataPromises)
affectedRowIds.push(...updatedRows.flatMap((rows) => rows.map((row) => row.id)))
for (const { rowId, executionsPatch } of batch) {
await writeExecutionsPatch(trx, data.tableId, rowId, executionsPatch)
}
}
return { value: undefined, affectedRowIds }
},
})
})
logger.info(`[${requestId}] Batch updated ${mergedUpdates.length} rows in table ${data.tableId}`)
const oldRowsForTrigger = new Map(
data.updates.map((u) => [u.rowId, existingMap.get(u.rowId)!.data])
)
const updatedRowsForTrigger: TableRow[] = mergedUpdates.map(
({ rowId, mergedData, mergedExecutions }) => ({
id: rowId,
data: mergedData,
executions: mergedExecutions,
position: 0,
createdAt: now,
updatedAt: now,
})
)
void fireTableTrigger(
data.tableId,
table.name,
'update',
updatedRowsForTrigger,
oldRowsForTrigger,
table.schema,
requestId
)
// Per-row cancel+rerun for in-flight downstream groups whose deps just
// changed — same orchestration as single-row `updateRow`. Without this,
// batch updates would leave running workflows reading stale dep values.
// Each row needs its own cancel + manual-incomplete dispatch because
// `cancelWorkflowGroupRuns`'s `groupIds` filter is per-row.
const rowsWithInFlightDownstream = mergedUpdates.filter(
(u) => u.inFlightDownstreamGroups.length > 0
)
if (rowsWithInFlightDownstream.length > 0) {
void (async () => {
try {
for (const { rowId, inFlightDownstreamGroups } of rowsWithInFlightDownstream) {
await cancelWorkflowGroupRuns(data.tableId, rowId, {
groupIds: inFlightDownstreamGroups,
})
await runWorkflowColumn({
tableId: data.tableId,
workspaceId: data.workspaceId,
mode: 'incomplete',
isManualRun: true,
rowIds: [rowId],
groupIds: inFlightDownstreamGroups,
requestId,
triggeredByUserId: data.actorUserId,
})
}
} catch (err) {
logger.error(
`[${requestId}] cancel+rerun for in-flight downstream groups (batch) failed:`,
err
)
}
})()
}
void runWorkflowColumn({
tableId: table.id,
workspaceId: table.workspaceId,
rowIds: updatedRowsForTrigger.map((r) => r.id),
mode: 'new',
isManualRun: false,
requestId,
triggeredByUserId: data.actorUserId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (batchUpdateRows) failed:`, err))
return {
affectedCount: mergedUpdates.length,
affectedRowIds: mergedUpdates.map((u) => u.rowId),
}
}
/**
* Deletes multiple rows matching a filter.
*
* @param table - Table definition (provides column schema for type-aware filter casts)
* @param data - Bulk delete data
* @param requestId - Request ID for logging
* @returns Bulk operation result
*/
export async function deleteRowsByFilter(
table: TableDefinition,
data: BulkDeleteData,
requestId: string
): Promise<BulkOperationResult> {
const proof = assertRowDelete(table)
const tableName = USER_TABLE_ROWS_SQL_NAME
// Build filter clause
const filterClause = buildFilterClause(data.filter, tableName, table.schema.columns)
if (!filterClause) {
throw new Error('Filter is required for bulk delete')
}
// Find matching rows
const baseConditions = and(
eq(userTableRows.tableId, table.id),
eq(userTableRows.workspaceId, table.workspaceId)
)
// A limit deletes a SUBSET, so order deterministically by `(order_key, id)` —
// see updateRowsByFilter. Unbounded deletes affect every match, so order is moot.
// Tenant-bounded for the same reason as updateRowsByFilter — see withSeqscanOff.
const matchingRows = await withSeqscanOff(async (trx) => {
const base = trx
.select({ id: userTableRows.id, position: userTableRows.position })
.from(userTableRows)
.where(and(baseConditions, filterClause))
if (data.limit) {
return base
.orderBy(buildRowOrderBySql(undefined, tableName, table.schema.columns))
.limit(data.limit)
}
return base
})
if (matchingRows.length === 0) {
return { affectedCount: 0, affectedRowIds: [] }
}
const rowIds = matchingRows.map((r) => r.id)
await deleteOrderedRowsByIds({
tableId: table.id,
workspaceId: table.workspaceId,
rowIds,
proof,
})
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${table.id}`)
return {
affectedCount: matchingRows.length,
affectedRowIds: rowIds,
}
}
/**
* Deletes rows by their IDs.
*
* @param data - Row IDs and table context
* @param requestId - Request ID for logging
* @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)
const deletedIdSet = new Set(deletedIds)
const missingRowIds = uniqueRequestedRowIds.filter((id) => !deletedIdSet.has(id))
logger.info(`[${requestId}] Deleted ${deletedIds.length} rows by ID from table ${data.tableId}`)
return {
deletedCount: deletedIds.length,
deletedRowIds: deletedIds,
requestedCount: uniqueRequestedRowIds.length,
missingRowIds,
}
}