Files
simstudioai--sim/apps/sim/lib/table/service.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (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 / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

750 lines
25 KiB
TypeScript

/**
* Table service layer for internal programmatic access.
*
* Use this for: workflow executor, background jobs, testing business logic.
* Use API routes for: HTTP requests, frontend clients.
*
* Note: API routes have their own implementations for HTTP-specific concerns.
*/
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { tableJobs, userTableDefinitions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, count, eq, isNull, sql } from 'drizzle-orm'
import { generateRestoreName } from '@/lib/core/utils/restore-name'
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 { EMPTY_JOB_FIELDS, latestJobForTable, latestJobsForTables } from '@/lib/table/jobs/service'
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,
} from '@/lib/table/types'
import { validateTableName, validateTableSchema } from '@/lib/table/validation'
import { stripGroupDeps } from '@/lib/table/workflow-columns'
const logger = createLogger('TableService')
export class TableConflictError extends Error {
readonly code = 'TABLE_EXISTS' as const
constructor(name: string) {
super(`A table named "${name}" already exists in this workspace`)
}
}
export type TableScope = 'active' | 'archived' | 'all'
/**
* Serializes schema/metadata read-modify-writes for a single table so
* concurrent mutators can't clobber each other's `schema` JSONB
* (last-writer-wins). Takes a transaction-scoped advisory lock keyed on
* `tableId`, then re-reads the table INSIDE the lock and hands the fresh
* definition + transaction to `mutate`. Each serialized writer therefore
* validates and computes against the prior writer's committed columns.
*
* Uses an advisory lock (not `SELECT ... FOR UPDATE` on the definition row) so
* it adds no edges to the row-lock graph — the row-count trigger (migration
* 0198) locks the definition row from `insertRow`/`deleteRow`, and a FOR UPDATE
* here would invert that order. Mirrors `acquireTablePositionLock`. The lock and
* the read both release at COMMIT/ROLLBACK; the wait is bounded by the
* `statement_timeout` set in `setTableTxTimeouts`.
*/
export async function withLockedTable<T>(
tableId: string,
mutate: (table: TableDefinition, trx: DbTransaction) => Promise<T>,
opts?: { includeArchived?: boolean }
): Promise<T> {
return db.transaction(async (trx) => {
await setTableTxTimeouts(trx)
await trx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`user_table_schema:${tableId}`}, 0))`
)
const table = await getTableById(tableId, { tx: trx, includeArchived: opts?.includeArchived })
if (!table) {
throw new Error('Table not found')
}
return mutate(table, trx)
})
}
/**
* Returns `schema` with `columns` sorted by `metadata.columnOrder` (the user-
* editable visible order). Columns missing from `columnOrder` are appended at
* the end in their original (schema-creation) order — covers tables created
* before `columnOrder` existed and any drift from out-of-band column adds.
*
* This makes `schema.columns` the single source of truth for column order on
* the wire. The client doesn't have to join the two arrays itself — every
* consumer (grid, sidebar, copilot, mothership) gets the same ordered list.
*/
function applyColumnOrderToSchema(
schema: TableSchema,
metadata: TableMetadata | null
): TableSchema {
const order = metadata?.columnOrder
if (!order || order.length === 0) return schema
// `columnOrder` holds stable column ids (legacy entries equal the name == id).
const byId = new Map<string, TableSchema['columns'][number]>()
for (const c of schema.columns) byId.set(getColumnId(c), c)
const ordered: TableSchema['columns'] = []
for (const id of order) {
const c = byId.get(id)
if (c) {
ordered.push(c)
byId.delete(id)
}
}
for (const c of byId.values()) ordered.push(c)
return { ...schema, columns: ordered }
}
/**
* Gets a table by ID with full details.
*
* @param tableId - Table ID to fetch
* @returns Table definition or null if not found
*/
export async function getTableById(
tableId: string,
options?: { includeArchived?: boolean; tx?: DbOrTx }
): Promise<TableDefinition | null> {
const { includeArchived = false, tx } = options ?? {}
const executor = tx ?? db
const results = await executor
.select({
id: userTableDefinitions.id,
name: userTableDefinitions.name,
description: userTableDefinitions.description,
schema: userTableDefinitions.schema,
metadata: userTableDefinitions.metadata,
maxRows: userTableDefinitions.maxRows,
workspaceId: userTableDefinitions.workspaceId,
createdBy: userTableDefinitions.createdBy,
archivedAt: userTableDefinitions.archivedAt,
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
rowCount: userTableDefinitions.rowCount,
})
.from(userTableDefinitions)
.where(
includeArchived
? eq(userTableDefinitions.id, tableId)
: and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))
)
.limit(1)
if (results.length === 0) return null
const table = results[0]
const metadata = (table.metadata as TableMetadata) ?? null
const { pendingDeleteRemaining, ...jobFields } = await latestJobForTable(tableId, executor)
return {
id: table.id,
name: table.name,
description: table.description,
schema: applyColumnOrderToSchema(table.schema as TableSchema, metadata),
metadata,
rowCount: Math.max(0, table.rowCount - pendingDeleteRemaining),
maxRows: table.maxRows,
workspaceId: table.workspaceId,
createdBy: table.createdBy,
archivedAt: table.archivedAt,
createdAt: table.createdAt,
updatedAt: table.updatedAt,
...jobFields,
}
}
/**
* Lists all tables in a workspace.
*
* @param workspaceId - Workspace ID to list tables for
* @returns Array of table definitions
*/
export async function listTables(
workspaceId: string,
options?: { scope?: TableScope }
): Promise<TableDefinition[]> {
const { scope = 'active' } = options ?? {}
const tables = await db
.select({
id: userTableDefinitions.id,
name: userTableDefinitions.name,
description: userTableDefinitions.description,
schema: userTableDefinitions.schema,
metadata: userTableDefinitions.metadata,
maxRows: userTableDefinitions.maxRows,
workspaceId: userTableDefinitions.workspaceId,
createdBy: userTableDefinitions.createdBy,
archivedAt: userTableDefinitions.archivedAt,
createdAt: userTableDefinitions.createdAt,
updatedAt: userTableDefinitions.updatedAt,
rowCount: userTableDefinitions.rowCount,
})
.from(userTableDefinitions)
.where(
scope === 'all'
? eq(userTableDefinitions.workspaceId, workspaceId)
: scope === 'archived'
? and(
eq(userTableDefinitions.workspaceId, workspaceId),
sql`${userTableDefinitions.archivedAt} IS NOT NULL`
)
: and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
.orderBy(userTableDefinitions.createdAt)
const jobsByTable = await latestJobsForTables(tables.map((t) => t.id))
return tables.map((t) => {
const metadata = (t.metadata as TableMetadata) ?? null
const { pendingDeleteRemaining, ...jobFields } = jobsByTable.get(t.id) ?? EMPTY_JOB_FIELDS
return {
id: t.id,
name: t.name,
description: t.description,
schema: applyColumnOrderToSchema(t.schema as TableSchema, metadata),
metadata,
rowCount: Math.max(0, t.rowCount - pendingDeleteRemaining),
maxRows: t.maxRows,
workspaceId: t.workspaceId,
createdBy: t.createdBy,
archivedAt: t.archivedAt,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
...jobFields,
}
})
}
/**
* Creates a new table.
*
* @param data - Table creation data
* @param requestId - Request ID for logging
* @returns Created table definition
* @throws Error if validation fails or limits exceeded
*/
export async function createTable(
data: CreateTableData,
requestId: string
): Promise<TableDefinition> {
// Validate table name
const nameValidation = validateTableName(data.name)
if (!nameValidation.valid) {
throw new Error(`Invalid table name: ${nameValidation.errors.join(', ')}`)
}
// Validate schema
const schemaValidation = validateTableSchema(data.schema)
if (!schemaValidation.valid) {
throw new Error(`Invalid schema: ${schemaValidation.errors.join(', ')}`)
}
const tableId = `tbl_${generateId().replace(/-/g, '')}`
const now = new Date()
// Stamp stable ids so the table is id-keyed from its first row write.
const schema = withGeneratedColumnIds(data.schema)
// Row limits are enforced per-write against the current plan (see assertRowCapacity); the stored
// column is vestigial, so it just takes the caller's value (if any) or the default.
const maxRows = data.maxRows ?? TABLE_LIMITS.MAX_ROWS_PER_TABLE
const maxTables = data.maxTables ?? TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE
const newTable = {
id: tableId,
name: data.name,
description: data.description ?? null,
schema,
workspaceId: data.workspaceId,
createdBy: data.userId,
maxRows,
archivedAt: null,
createdAt: now,
updatedAt: now,
}
// Create-mode CSV import is born with a running job so its rows stay hidden until ready.
const initialJob =
data.jobStatus === 'running' && data.jobId
? { id: data.jobId, type: data.jobType ?? 'import', startedAt: now }
: null
// Starter rows count against the plan too. Checked before the tx (the lookup is a
// separate pool read) — a new table starts empty, so the footprint is just these.
const initialRowCount = data.initialRowCount ?? 0
let rowLimit: number | undefined
if (initialRowCount > 0) {
rowLimit = await assertRowCapacity({
workspaceId: data.workspaceId,
currentRowCount: 0,
addedRows: initialRowCount,
})
}
// Wrap count check, duplicate check, and insert in a transaction with FOR UPDATE
// to prevent TOCTOU race on the table count limit
try {
await db.transaction(async (trx) => {
await setTableTxTimeouts(trx)
await trx.execute(sql`SELECT 1 FROM workspace WHERE id = ${data.workspaceId} FOR UPDATE`)
const [{ count: existingCount }] = await trx
.select({ count: count() })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, data.workspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
if (Number(existingCount) >= maxTables) {
throw new Error(`Workspace has reached maximum table limit (${maxTables})`)
}
const duplicateName = await trx
.select({ id: userTableDefinitions.id })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, data.workspaceId),
eq(userTableDefinitions.name, data.name),
isNull(userTableDefinitions.archivedAt)
)
)
.limit(1)
if (duplicateName.length > 0) {
throw new TableConflictError(data.name)
}
await trx.insert(userTableDefinitions).values(newTable)
if (initialJob) {
await trx.insert(tableJobs).values({
id: initialJob.id,
tableId,
workspaceId: data.workspaceId,
type: initialJob.type,
status: 'running',
startedAt: initialJob.startedAt,
updatedAt: initialJob.startedAt,
})
}
if (initialRowCount > 0) {
const orderKeys = nKeysBetween(null, null, initialRowCount)
const rowsToInsert = Array.from({ length: initialRowCount }, (_, i) => ({
id: `row_${generateId().replace(/-/g, '')}`,
tableId,
data: {},
position: i,
orderKey: orderKeys[i],
workspaceId: data.workspaceId,
createdAt: now,
updatedAt: now,
}))
await trx.insert(userTableRows).values(rowsToInsert)
}
})
} catch (error: unknown) {
if (error instanceof TableConflictError) {
throw error
}
if (getPostgresErrorCode(error) === '23505') {
throw new TableConflictError(data.name)
}
throw error
}
if (initialRowCount > 0 && rowLimit !== undefined) {
notifyTableRowUsage({
workspaceId: data.workspaceId,
currentRowCount: 0,
addedRows: initialRowCount,
limit: rowLimit,
})
}
logger.info(`[${requestId}] Created table ${tableId} in workspace ${data.workspaceId}`)
return {
id: newTable.id,
name: newTable.name,
description: newTable.description,
schema: newTable.schema as TableSchema,
metadata: null,
rowCount: data.initialRowCount ?? 0,
maxRows: newTable.maxRows,
workspaceId: newTable.workspaceId,
createdBy: newTable.createdBy,
archivedAt: newTable.archivedAt,
createdAt: newTable.createdAt,
updatedAt: newTable.updatedAt,
jobStatus: initialJob ? 'running' : null,
jobId: initialJob?.id ?? null,
jobType: initialJob?.type ?? null,
jobError: null,
jobRowsProcessed: 0,
}
}
/**
* Adds multiple columns to an existing table inside a caller-provided
* transaction. This is atomic with respect to the surrounding `trx`: either
* all columns are added or none are. Validates each column the same way
* `addTableColumn` does and rejects if any name collides with an existing
* column or another entry in `columns`.
*
* Use this when composing a column addition with other writes (e.g., row
* inserts) that must succeed or roll back together.
*/
export async function addTableColumnsWithTx(
trx: DbTransaction,
table: TableDefinition,
columns: { id?: string; name: string; type: string; required?: boolean; unique?: boolean }[],
requestId: string
): Promise<TableDefinition> {
if (columns.length === 0) return table
const usedNames = new Set(table.schema.columns.map((c) => c.name.toLowerCase()))
const additions: TableSchema['columns'] = []
for (const column of columns) {
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.`
)
}
if (column.name.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
throw new Error(
`Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)`
)
}
if (!COLUMN_TYPES.includes(column.type as (typeof COLUMN_TYPES)[number])) {
throw new Error(
`Invalid column type "${column.type}". Must be one of: ${COLUMN_TYPES.join(', ')}`
)
}
const lower = column.name.toLowerCase()
if (usedNames.has(lower)) {
throw new Error(`Column "${column.name}" already exists`)
}
usedNames.add(lower)
// Honor a caller-assigned id (the CSV append path pre-assigns so coercion
// and persistence agree); otherwise mint one.
const id = column.id ?? generateColumnId()
additions.push({
id,
name: column.name,
type: column.type as TableSchema['columns'][number]['type'],
required: column.required ?? false,
unique: column.unique ?? false,
})
}
if (table.schema.columns.length + additions.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) {
throw new Error(
`Adding ${additions.length} column(s) would exceed maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})`
)
}
// Spread `table.schema` first so workflow groups (and any future top-level
// schema fields) survive a CSV import that only adds plain columns.
const updatedSchema: TableSchema = {
...table.schema,
columns: [...table.schema.columns, ...additions],
}
const now = new Date()
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, updatedAt: now })
.where(eq(userTableDefinitions.id, table.id))
logger.info(
`[${requestId}] Added ${additions.length} column(s) to table ${table.id}: ${additions.map((c) => c.name).join(', ')}`
)
return {
...table,
schema: updatedSchema,
updatedAt: now,
}
}
/**
* Records the "columns added" audit for a table. The column add shares a
* transaction with the import's row inserts, so the caller MUST emit this only
* AFTER that transaction commits — auditing inside the tx would log a false
* success if a later row batch rolls it back. Skipped when no actor resolves.
*/
export function auditTableColumnsAdded(
table: TableDefinition,
columnNames: string[],
actingUserId?: string
): void {
const actorId = actingUserId ?? table.createdBy
if (!actorId || columnNames.length === 0) return
recordAudit({
workspaceId: table.workspaceId ?? null,
actorId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: table.id,
resourceName: table.name,
description: `Added ${columnNames.length} column(s) to table "${table.name}"`,
metadata: { op: 'add_columns', columns: columnNames },
})
}
/**
* Renames a table.
*
* @param tableId - Table ID to rename
* @param newName - New table name
* @param requestId - Request ID for logging
* @returns Updated table definition
* @throws Error if name is invalid
*/
export async function renameTable(
tableId: string,
newName: string,
requestId: string,
actingUserId?: string
): Promise<{ id: string; name: string }> {
const nameValidation = validateTableName(newName)
if (!nameValidation.valid) {
throw new Error(nameValidation.errors.join(', '))
}
const now = new Date()
try {
const result = await db
.update(userTableDefinitions)
.set({ name: newName, updatedAt: now })
.where(eq(userTableDefinitions.id, tableId))
.returning({
id: userTableDefinitions.id,
createdBy: userTableDefinitions.createdBy,
workspaceId: userTableDefinitions.workspaceId,
})
if (result.length === 0) {
throw new Error(`Table ${tableId} not found`)
}
const { createdBy, workspaceId } = result[0]
const renameActorId = actingUserId ?? createdBy
if (renameActorId) {
recordAudit({
workspaceId: workspaceId ?? null,
actorId: renameActorId,
action: AuditAction.TABLE_UPDATED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: newName,
description: `Renamed table to "${newName}"`,
metadata: { op: 'rename' },
})
}
logger.info(`[${requestId}] Renamed table ${tableId} to "${newName}"`)
return { id: tableId, name: newName }
} catch (error: unknown) {
if (getPostgresErrorCode(error) === '23505') {
throw new TableConflictError(newName)
}
throw error
}
}
/**
* Updates a table's metadata (UI state like column widths/order, plus behavioral
* settings like `workflowColumnBatchSize`). Merges into the existing metadata blob.
*
* @param tableId - Table ID to update
* @param metadata - Partial metadata object (merged with existing)
* @param existingMetadata - Existing metadata from a prior fetch (avoids redundant DB read)
* @returns Updated metadata
*/
export async function updateTableMetadata(
tableId: string,
metadata: TableMetadata,
existingMetadata?: TableMetadata | null
): Promise<TableMetadata> {
const merged: TableMetadata = { ...(existingMetadata ?? {}), ...metadata }
// When `columnOrder` is in the patch, scrub any workflow-group dependency
// that now sits to the right of (or at the same index as) its group's
// leftmost column. Without this, reordering a column could leave a group
// depending on a column it can no longer reach in the dag — the group
// would never fire.
const newOrder = metadata.columnOrder
let nextSchema: TableSchema | null = null
if (Array.isArray(newOrder) && newOrder.length > 0) {
const [tableRow] = await db
.select({ schema: userTableDefinitions.schema })
.from(userTableDefinitions)
.where(eq(userTableDefinitions.id, tableId))
.limit(1)
if (tableRow) {
const schema = tableRow.schema as TableSchema
const groups = schema.workflowGroups ?? []
if (groups.length > 0) {
// `columnOrder` and group dep refs are both keyed by stable column id.
const positionOf = new Map<string, number>()
newOrder.forEach((id, i) => positionOf.set(id, i))
let mutated = false
const nextGroups = groups.map((group) => {
const ownCols = schema.columns.filter((c) => c.workflowGroupId === group.id)
let leftmost = Number.POSITIVE_INFINITY
for (const c of ownCols) {
const idx = positionOf.get(getColumnId(c)) ?? Number.POSITIVE_INFINITY
if (idx < leftmost) leftmost = idx
}
if (!Number.isFinite(leftmost)) return group
const deps = group.dependencies?.columns ?? []
const removed = new Set(deps.filter((dep) => (positionOf.get(dep) ?? -1) >= leftmost))
if (removed.size === 0) return group
const stripped = stripGroupDeps(group, removed)
if (stripped !== group) mutated = true
return stripped
})
if (mutated) nextSchema = { ...schema, workflowGroups: nextGroups }
}
}
}
await db
.update(userTableDefinitions)
.set(nextSchema ? { metadata: merged, schema: nextSchema } : { metadata: merged })
.where(eq(userTableDefinitions.id, tableId))
return merged
}
/**
* Archives a table.
*
* @param tableId - Table ID to delete
* @param requestId - Request ID for logging
*/
export async function deleteTable(
tableId: string,
requestId: string,
actingUserId?: string
): Promise<void> {
const now = new Date()
const result = await db
.update(userTableDefinitions)
.set({ archivedAt: now, updatedAt: now })
.where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt)))
.returning({
createdBy: userTableDefinitions.createdBy,
workspaceId: userTableDefinitions.workspaceId,
name: userTableDefinitions.name,
})
const deleted = result[0]
// 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) {
recordAudit({
workspaceId: deleted.workspaceId ?? null,
actorId: actingUserId,
action: AuditAction.TABLE_DELETED,
resourceType: AuditResourceType.TABLE,
resourceId: tableId,
resourceName: deleted.name,
description: `Archived table "${deleted.name}"`,
})
}
logger.info(`[${requestId}] Archived table ${tableId}`)
}
/**
* Restores an archived table.
*/
export async function restoreTable(tableId: string, requestId: string): Promise<void> {
const table = await getTableById(tableId, { includeArchived: true })
if (!table) {
throw new Error('Table not found')
}
if (!table.archivedAt) {
throw new Error('Table is not archived')
}
if (table.workspaceId) {
const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
const ws = await getWorkspaceWithOwner(table.workspaceId)
if (!ws || ws.archivedAt) {
throw new Error('Cannot restore table into an archived workspace')
}
}
/**
* A concurrent rename/create can claim the chosen name after `generateRestoreName`'s check (MVCC).
* Retries pick a new random suffix; 23505 maps to {@link TableConflictError} after exhaustion.
*/
const maxUniqueViolationRetries = 8
let attemptedRestoreName = ''
for (let attempt = 0; attempt < maxUniqueViolationRetries; attempt++) {
attemptedRestoreName = ''
try {
await db.transaction(async (tx) => {
await setTableTxTimeouts(tx)
await tx.execute(sql`SELECT 1 FROM user_table_definitions WHERE id = ${tableId} FOR UPDATE`)
attemptedRestoreName = await generateRestoreName(table.name, async (candidate) => {
const [match] = await tx
.select({ id: userTableDefinitions.id })
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, table.workspaceId),
eq(userTableDefinitions.name, candidate),
isNull(userTableDefinitions.archivedAt)
)
)
.limit(1)
return !!match
})
const now = new Date()
await tx
.update(userTableDefinitions)
.set({ archivedAt: null, updatedAt: now, name: attemptedRestoreName })
.where(eq(userTableDefinitions.id, tableId))
})
break
} catch (error: unknown) {
if (getPostgresErrorCode(error) !== '23505') {
throw error
}
if (attempt === maxUniqueViolationRetries - 1) {
throw new TableConflictError(attemptedRestoreName || table.name)
}
}
}
logger.info(`[${requestId}] Restored table ${tableId} as "${attemptedRestoreName}"`)
}