d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
669 lines
24 KiB
TypeScript
669 lines
24 KiB
TypeScript
/**
|
|
* Column and schema-management service for user tables.
|
|
*
|
|
* Standalone column-mutation operations (add, rename, delete, type change,
|
|
* constraint change) extracted from the table service. Each acquires the
|
|
* table's advisory lock via {@link withLockedTable} from `@/lib/table/service`.
|
|
*
|
|
* Use this for: workflow executor, background jobs, testing business logic.
|
|
* Use API routes for: HTTP requests, frontend clients.
|
|
*/
|
|
|
|
import { db } from '@sim/db'
|
|
import { userTableDefinitions, userTableRows } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { and, count, eq, sql } from 'drizzle-orm'
|
|
import { columnMatchesRef, generateColumnId, getColumnId } from '@/lib/table/column-keys'
|
|
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
|
|
import { stripGroupExecutions } from '@/lib/table/rows/executions'
|
|
import { withLockedTable } from '@/lib/table/service'
|
|
import { scaledStatementTimeoutMs, setTableTxTimeouts } from '@/lib/table/tx'
|
|
import type {
|
|
DeleteColumnData,
|
|
RenameColumnData,
|
|
RowData,
|
|
TableDefinition,
|
|
TableMetadata,
|
|
TableSchema,
|
|
UpdateColumnConstraintsData,
|
|
UpdateColumnTypeData,
|
|
} from '@/lib/table/types'
|
|
import { assertValidSchema, stripGroupDeps } from '@/lib/table/workflow-columns'
|
|
|
|
const logger = createLogger('TableColumnService')
|
|
|
|
/**
|
|
* Adds a column to an existing table's schema.
|
|
*
|
|
* @param tableId - Table ID to update
|
|
* @param column - Column definition to add
|
|
* @param requestId - Request ID for logging
|
|
* @returns Updated table definition
|
|
* @throws Error if table not found or column name already exists
|
|
*/
|
|
export async function addTableColumn(
|
|
tableId: string,
|
|
column: {
|
|
id?: string
|
|
name: string
|
|
type: string
|
|
required?: boolean
|
|
unique?: boolean
|
|
position?: number
|
|
},
|
|
requestId: string
|
|
): Promise<TableDefinition> {
|
|
return withLockedTable(tableId, async (table, trx) => {
|
|
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 schema = table.schema
|
|
if (schema.columns.some((c) => c.name.toLowerCase() === column.name.toLowerCase())) {
|
|
throw new Error(`Column "${column.name}" already exists`)
|
|
}
|
|
|
|
if (schema.columns.length >= TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) {
|
|
throw new Error(
|
|
`Table has reached maximum column limit (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE})`
|
|
)
|
|
}
|
|
|
|
const newColumn: TableSchema['columns'][number] = {
|
|
// Honor a caller-provided id (undo of a delete reuses the original id);
|
|
// otherwise mint a fresh one.
|
|
id: column.id ?? generateColumnId(),
|
|
name: column.name,
|
|
type: column.type as TableSchema['columns'][number]['type'],
|
|
required: column.required ?? false,
|
|
unique: column.unique ?? false,
|
|
}
|
|
const newColumnId = getColumnId(newColumn)
|
|
|
|
const columns = [...schema.columns]
|
|
if (column.position !== undefined && column.position >= 0 && column.position < columns.length) {
|
|
columns.splice(column.position, 0, newColumn)
|
|
} else {
|
|
columns.push(newColumn)
|
|
}
|
|
|
|
const updatedSchema: TableSchema = { ...schema, columns }
|
|
|
|
// Keep `metadata.columnOrder` (a list of column ids) in sync: splicing the
|
|
// new column's id at the same index we used in `columns` keeps display
|
|
// ordering aligned with the user's intent for `position`-based inserts.
|
|
const existingOrder = table.metadata?.columnOrder
|
|
let updatedMetadata = table.metadata
|
|
if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(newColumnId)) {
|
|
let insertIdx = existingOrder.length
|
|
if (column.position !== undefined && column.position >= 0) {
|
|
// Anchor on the column previously at `position` — that column shifted
|
|
// right by one in `columns`, so the new id slots in at its old spot.
|
|
const anchor = schema.columns[column.position]
|
|
if (anchor) {
|
|
const anchorIdx = existingOrder.indexOf(getColumnId(anchor))
|
|
if (anchorIdx !== -1) insertIdx = anchorIdx
|
|
}
|
|
}
|
|
const nextOrder = [...existingOrder]
|
|
nextOrder.splice(insertIdx, 0, newColumnId)
|
|
updatedMetadata = { ...table.metadata, columnOrder: nextOrder }
|
|
}
|
|
|
|
assertValidSchema(updatedSchema, updatedMetadata?.columnOrder)
|
|
|
|
const now = new Date()
|
|
|
|
await trx
|
|
.update(userTableDefinitions)
|
|
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
|
|
.where(eq(userTableDefinitions.id, tableId))
|
|
|
|
logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`)
|
|
|
|
return {
|
|
...table,
|
|
schema: updatedSchema,
|
|
metadata: updatedMetadata,
|
|
updatedAt: now,
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Renames a column in a table's schema and updates all row data keys.
|
|
*
|
|
* @param data - Rename column data
|
|
* @param requestId - Request ID for logging
|
|
* @returns Updated table definition
|
|
* @throws Error if table not found, column not found, or new name conflicts
|
|
*/
|
|
export async function renameColumn(
|
|
data: RenameColumnData,
|
|
requestId: string
|
|
): Promise<TableDefinition> {
|
|
return withLockedTable(data.tableId, async (table, trx) => {
|
|
if (!NAME_PATTERN.test(data.newName)) {
|
|
throw new Error(
|
|
`Invalid column name "${data.newName}". Column names must start with a letter or underscore, followed by alphanumeric characters or underscores.`
|
|
)
|
|
}
|
|
|
|
if (data.newName.length > TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH) {
|
|
throw new Error(
|
|
`Column name exceeds maximum length (${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters)`
|
|
)
|
|
}
|
|
|
|
const schema = table.schema
|
|
const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.oldName))
|
|
if (columnIndex === -1) {
|
|
throw new Error(`Column "${data.oldName}" not found`)
|
|
}
|
|
|
|
if (
|
|
schema.columns.some(
|
|
(c, i) => i !== columnIndex && c.name.toLowerCase() === data.newName.toLowerCase()
|
|
)
|
|
) {
|
|
throw new Error(`Column "${data.newName}" already exists`)
|
|
}
|
|
|
|
const targetColumn = schema.columns[columnIndex]
|
|
const actualOldName = targetColumn.name
|
|
|
|
// Rename is metadata-only: stored rows, metadata, and workflow-group refs all
|
|
// key on the column's stable id, which a rename never changes — so this is a
|
|
// pure schema write, no per-row JSONB rewrite or group/metadata cascade.
|
|
// Stamp the current storage key as the id (for any not-yet-backfilled column)
|
|
// so existing rows stay reachable as the display name changes.
|
|
const columnId = targetColumn.id ?? actualOldName
|
|
const updatedColumns = schema.columns.map((c, i) =>
|
|
i === columnIndex ? { ...c, id: columnId, name: data.newName } : c
|
|
)
|
|
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
|
|
assertValidSchema(updatedSchema, table.metadata?.columnOrder)
|
|
|
|
const now = new Date()
|
|
await trx
|
|
.update(userTableDefinitions)
|
|
.set({ schema: updatedSchema, updatedAt: now })
|
|
.where(eq(userTableDefinitions.id, data.tableId))
|
|
|
|
logger.info(
|
|
`[${requestId}] Renamed column "${actualOldName}" to "${data.newName}" in table ${data.tableId}`
|
|
)
|
|
return { ...table, schema: updatedSchema, updatedAt: now }
|
|
})
|
|
}
|
|
|
|
/** Removes the given column-id keys from a metadata blob (widths/order/pinned). */
|
|
function stripColumnIdsFromMetadata(
|
|
metadata: TableMetadata | null,
|
|
ids: ReadonlySet<string>
|
|
): TableMetadata | null {
|
|
if (!metadata) return metadata
|
|
let next = metadata
|
|
if (metadata.columnWidths) {
|
|
const widths = { ...metadata.columnWidths }
|
|
let changed = false
|
|
for (const id of ids)
|
|
if (id in widths) {
|
|
delete widths[id]
|
|
changed = true
|
|
}
|
|
if (changed) next = { ...next, columnWidths: widths }
|
|
}
|
|
if (metadata.columnOrder?.some((id) => ids.has(id))) {
|
|
next = { ...next, columnOrder: metadata.columnOrder.filter((id) => !ids.has(id)) }
|
|
}
|
|
if (metadata.pinnedColumns?.some((id) => ids.has(id))) {
|
|
next = { ...next, pinnedColumns: metadata.pinnedColumns.filter((id) => !ids.has(id)) }
|
|
}
|
|
return next
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget reclamation of a deleted column's row storage. The column is
|
|
* already gone from the schema, so reads never surface the orphaned id —
|
|
* dropping the JSONB key just frees space. Runs in its own transaction with a
|
|
* row-count-scaled timeout; failures are logged, not propagated.
|
|
*/
|
|
function stripColumnDataInBackground(
|
|
tableId: string,
|
|
columnIds: string[],
|
|
rowCount: number,
|
|
requestId: string
|
|
): void {
|
|
if (columnIds.length === 0) return
|
|
void (async () => {
|
|
try {
|
|
await db.transaction(async (trx) => {
|
|
const statementMs = scaledStatementTimeoutMs(rowCount, {
|
|
baseMs: 60_000,
|
|
perRowMs: 2 * columnIds.length,
|
|
})
|
|
await setTableTxTimeouts(trx, { statementMs })
|
|
for (const id of columnIds) {
|
|
await trx.execute(
|
|
sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${tableId} AND data ? ${id}::text`
|
|
)
|
|
}
|
|
})
|
|
logger.info(
|
|
`[${requestId}] Background-stripped deleted column data [${columnIds.join(', ')}] from table ${tableId}`
|
|
)
|
|
} catch (err) {
|
|
logger.error(
|
|
`[${requestId}] Background column-data strip failed for table ${tableId} [${columnIds.join(', ')}]:`,
|
|
err
|
|
)
|
|
}
|
|
})()
|
|
}
|
|
|
|
/**
|
|
* Deletes a column from a table's schema. When id-keyed, returns once the schema
|
|
* is updated and reclaims the column's row-data storage in the background
|
|
* (fire-and-forget); the legacy path strips the row key synchronously.
|
|
*
|
|
* @param data - Delete column data
|
|
* @param requestId - Request ID for logging
|
|
* @returns Updated table definition
|
|
* @throws Error if table not found, column not found, or it's the last column
|
|
*/
|
|
export async function deleteColumn(
|
|
data: DeleteColumnData,
|
|
requestId: string
|
|
): Promise<TableDefinition> {
|
|
const { def, stripKey } = await withLockedTable(data.tableId, async (table, trx) => {
|
|
const schema = table.schema
|
|
const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName))
|
|
if (columnIndex === -1) {
|
|
throw new Error(`Column "${data.columnName}" not found`)
|
|
}
|
|
|
|
if (schema.columns.length <= 1) {
|
|
throw new Error('Cannot delete the last column in a table')
|
|
}
|
|
|
|
const targetColumn = schema.columns[columnIndex]
|
|
const actualName = targetColumn.name
|
|
const columnId = getColumnId(targetColumn)
|
|
const ownerGroupId = targetColumn.workflowGroupId
|
|
|
|
// Drop this column's reference (by id) from every group's outputs and
|
|
// `columns` dependency. If the column is the last output of its parent
|
|
// group, the group itself is also removed (a group with zero outputs is
|
|
// invalid).
|
|
let groupRemovedId: string | null = null
|
|
const updatedGroups = (schema.workflowGroups ?? [])
|
|
.map((group) => {
|
|
let next = group
|
|
if (ownerGroupId && group.id === ownerGroupId) {
|
|
const remaining = group.outputs.filter((o) => o.columnName !== columnId)
|
|
if (remaining.length === 0) {
|
|
groupRemovedId = group.id
|
|
}
|
|
next = { ...next, outputs: remaining }
|
|
}
|
|
return stripGroupDeps(next, new Set([columnId]))
|
|
})
|
|
.filter((g) => g.id !== groupRemovedId)
|
|
|
|
const updatedSchema: TableSchema = {
|
|
...schema,
|
|
columns: schema.columns.filter((_, i) => i !== columnIndex),
|
|
...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}),
|
|
}
|
|
const updatedMetadata = stripColumnIdsFromMetadata(
|
|
table.metadata as TableMetadata | null,
|
|
new Set([columnId])
|
|
)
|
|
assertValidSchema(updatedSchema, updatedMetadata?.columnOrder)
|
|
|
|
const now = new Date()
|
|
|
|
// Schema/metadata update commits now; the column's row-data storage is
|
|
// reclaimed in the background (fire-and-forget) — reads never surface the
|
|
// orphaned id since the column is already gone from the schema.
|
|
await trx
|
|
.update(userTableDefinitions)
|
|
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
|
|
.where(eq(userTableDefinitions.id, data.tableId))
|
|
|
|
if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId])
|
|
|
|
logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`)
|
|
|
|
return {
|
|
def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now },
|
|
stripKey: columnId,
|
|
}
|
|
})
|
|
|
|
stripColumnDataInBackground(data.tableId, [stripKey], def.rowCount ?? 0, requestId)
|
|
return def
|
|
}
|
|
|
|
/**
|
|
* Deletes multiple columns from a table in a single transaction.
|
|
* Avoids the race condition of calling deleteColumn multiple times in parallel.
|
|
*/
|
|
export async function deleteColumns(
|
|
data: { tableId: string; columnNames: string[] },
|
|
requestId: string
|
|
): Promise<TableDefinition> {
|
|
const { def, stripKeys } = await withLockedTable(data.tableId, async (table, trx) => {
|
|
const schema = table.schema
|
|
const namesToDelete = new Set<string>()
|
|
const idsToDelete = new Set<string>()
|
|
const notFound: string[] = []
|
|
|
|
for (const name of data.columnNames) {
|
|
const col = schema.columns.find((c) => columnMatchesRef(c, name))
|
|
if (!col) {
|
|
notFound.push(name)
|
|
} else {
|
|
namesToDelete.add(col.name)
|
|
idsToDelete.add(getColumnId(col))
|
|
}
|
|
}
|
|
|
|
if (notFound.length > 0) {
|
|
throw new Error(`Columns not found: ${notFound.join(', ')}`)
|
|
}
|
|
|
|
const remaining = schema.columns.filter((c) => !namesToDelete.has(c.name))
|
|
if (remaining.length === 0) {
|
|
throw new Error('Cannot delete all columns from a table')
|
|
}
|
|
|
|
// For each group, drop outputs whose column (by id) is being deleted. Groups
|
|
// that end up with zero outputs are removed entirely (they'd be invalid).
|
|
// Then any remaining group's dependencies referencing a removed column are
|
|
// cleaned up.
|
|
const removedGroupIds = new Set<string>()
|
|
let updatedGroups = (schema.workflowGroups ?? []).map((group) => {
|
|
const remainingOutputs = group.outputs.filter((o) => !idsToDelete.has(o.columnName))
|
|
if (remainingOutputs.length === 0) {
|
|
removedGroupIds.add(group.id)
|
|
}
|
|
return remainingOutputs.length === group.outputs.length
|
|
? group
|
|
: { ...group, outputs: remainingOutputs }
|
|
})
|
|
updatedGroups = updatedGroups
|
|
.filter((g) => !removedGroupIds.has(g.id))
|
|
.map((group) => stripGroupDeps(group, idsToDelete))
|
|
const updatedSchema: TableSchema = {
|
|
...schema,
|
|
columns: remaining,
|
|
...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}),
|
|
}
|
|
const updatedMetadata = stripColumnIdsFromMetadata(
|
|
table.metadata as TableMetadata | null,
|
|
idsToDelete
|
|
)
|
|
assertValidSchema(updatedSchema, updatedMetadata?.columnOrder)
|
|
|
|
const now = new Date()
|
|
|
|
// Schema/metadata commit now; row storage for the deleted columns is
|
|
// reclaimed in the background (fire-and-forget).
|
|
await trx
|
|
.update(userTableDefinitions)
|
|
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
|
|
.where(eq(userTableDefinitions.id, data.tableId))
|
|
|
|
await stripGroupExecutions(trx, data.tableId, removedGroupIds)
|
|
|
|
logger.info(
|
|
`[${requestId}] Deleted columns [${[...namesToDelete].join(', ')}] from table ${data.tableId}`
|
|
)
|
|
|
|
return {
|
|
def: { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now },
|
|
stripKeys: Array.from(idsToDelete),
|
|
}
|
|
})
|
|
|
|
if (stripKeys.length > 0) {
|
|
stripColumnDataInBackground(data.tableId, stripKeys, def.rowCount ?? 0, requestId)
|
|
}
|
|
return def
|
|
}
|
|
|
|
/**
|
|
* Changes the type of a column. Validates that existing data is compatible.
|
|
*
|
|
* @param data - Update column type data
|
|
* @param requestId - Request ID for logging
|
|
* @returns Updated table definition
|
|
* @throws Error if table not found, column not found, or existing data is incompatible
|
|
*/
|
|
export async function updateColumnType(
|
|
data: UpdateColumnTypeData,
|
|
requestId: string
|
|
): Promise<TableDefinition> {
|
|
return withLockedTable(data.tableId, async (table, trx) => {
|
|
// Scale both statement and idle timeouts to row count: the compatibility
|
|
// check below iterates every row in Node between the row SELECT and the
|
|
// schema UPDATE, leaving the transaction idle for that gap. The default 5s
|
|
// `idle_in_transaction_session_timeout` would abort a valid type change on
|
|
// a large table.
|
|
const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
|
|
baseMs: 60_000,
|
|
perRowMs: 2,
|
|
})
|
|
await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs })
|
|
|
|
if (!(COLUMN_TYPES as readonly string[]).includes(data.newType)) {
|
|
throw new Error(
|
|
`Invalid column type "${data.newType}". Valid types: ${COLUMN_TYPES.join(', ')}`
|
|
)
|
|
}
|
|
|
|
const schema = table.schema
|
|
const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName))
|
|
if (columnIndex === -1) {
|
|
throw new Error(`Column "${data.columnName}" not found`)
|
|
}
|
|
|
|
const column = schema.columns[columnIndex]
|
|
if (column.type === data.newType) {
|
|
return table
|
|
}
|
|
const columnKey = getColumnId(column)
|
|
|
|
// Validate existing data is compatible with the new type
|
|
const rows = await trx
|
|
.select({ id: userTableRows.id, data: userTableRows.data })
|
|
.from(userTableRows)
|
|
.where(
|
|
and(
|
|
eq(userTableRows.tableId, data.tableId),
|
|
sql`${userTableRows.data} ? ${columnKey}`,
|
|
sql`${userTableRows.data}->>${columnKey}::text IS NOT NULL`
|
|
)
|
|
)
|
|
|
|
let incompatibleCount = 0
|
|
for (const row of rows) {
|
|
const rowData = row.data as RowData
|
|
const value = rowData[columnKey]
|
|
if (value === null || value === undefined) continue
|
|
|
|
if (!isValueCompatibleWithType(value, data.newType)) {
|
|
incompatibleCount++
|
|
}
|
|
}
|
|
|
|
if (incompatibleCount > 0) {
|
|
throw new Error(
|
|
`Cannot change column "${column.name}" to type "${data.newType}": ${incompatibleCount} row(s) have incompatible values. Fix or remove the incompatible values first.`
|
|
)
|
|
}
|
|
|
|
const updatedColumns = schema.columns.map((c, i) =>
|
|
i === columnIndex ? { ...c, type: data.newType } : c
|
|
)
|
|
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
|
|
const now = new Date()
|
|
|
|
await trx
|
|
.update(userTableDefinitions)
|
|
.set({ schema: updatedSchema, updatedAt: now })
|
|
.where(eq(userTableDefinitions.id, data.tableId))
|
|
|
|
logger.info(
|
|
`[${requestId}] Changed column "${column.name}" type from "${column.type}" to "${data.newType}" in table ${data.tableId}`
|
|
)
|
|
|
|
return { ...table, schema: updatedSchema, updatedAt: now }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Updates constraints (required, unique) on a column.
|
|
*
|
|
* @param data - Update column constraints data
|
|
* @param requestId - Request ID for logging
|
|
* @returns Updated table definition
|
|
* @throws Error if table not found, column not found, or existing data violates the constraint
|
|
*/
|
|
export async function updateColumnConstraints(
|
|
data: UpdateColumnConstraintsData,
|
|
requestId: string
|
|
): Promise<TableDefinition> {
|
|
return withLockedTable(data.tableId, async (table, trx) => {
|
|
// Scale both statement and idle timeouts to row count: the required/unique
|
|
// validation runs between separate queries inside this transaction, leaving
|
|
// it briefly idle. Match `updateColumnType` so the default 5s
|
|
// `idle_in_transaction_session_timeout` can't abort a valid change on a
|
|
// large table.
|
|
const timeoutMs = scaledStatementTimeoutMs(table.rowCount ?? 0, {
|
|
baseMs: 60_000,
|
|
perRowMs: 2,
|
|
})
|
|
await setTableTxTimeouts(trx, { statementMs: timeoutMs, idleMs: timeoutMs })
|
|
|
|
const schema = table.schema
|
|
const columnIndex = schema.columns.findIndex((c) => columnMatchesRef(c, data.columnName))
|
|
if (columnIndex === -1) {
|
|
throw new Error(`Column "${data.columnName}" not found`)
|
|
}
|
|
|
|
const column = schema.columns[columnIndex]
|
|
const columnKey = getColumnId(column)
|
|
if (column.workflowGroupId) {
|
|
throw new Error(
|
|
`Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.`
|
|
)
|
|
}
|
|
if (data.required === true && !column.required) {
|
|
const [result] = await trx
|
|
.select({ count: count() })
|
|
.from(userTableRows)
|
|
.where(
|
|
and(
|
|
eq(userTableRows.tableId, data.tableId),
|
|
sql`(NOT (${userTableRows.data} ? ${columnKey}) OR ${userTableRows.data}->>${columnKey}::text IS NULL)`
|
|
)
|
|
)
|
|
|
|
if (result.count > 0) {
|
|
throw new Error(
|
|
`Cannot set column "${column.name}" as required: ${result.count} row(s) have null or missing values`
|
|
)
|
|
}
|
|
}
|
|
|
|
if (data.unique === true && !column.unique) {
|
|
const duplicates = (await trx.execute(
|
|
sql`SELECT ${userTableRows.data}->>${columnKey}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${columnKey} AND ${userTableRows.data}->>${columnKey}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1`
|
|
)) as { val: string; cnt: number }[]
|
|
|
|
if (duplicates.length > 0) {
|
|
throw new Error(`Cannot set column "${column.name}" as unique: duplicate values exist`)
|
|
}
|
|
}
|
|
|
|
const updatedColumns = schema.columns.map((c, i) =>
|
|
i === columnIndex
|
|
? {
|
|
...c,
|
|
...(data.required !== undefined ? { required: data.required } : {}),
|
|
...(data.unique !== undefined ? { unique: data.unique } : {}),
|
|
}
|
|
: c
|
|
)
|
|
const updatedSchema: TableSchema = { ...schema, columns: updatedColumns }
|
|
const now = new Date()
|
|
|
|
await trx
|
|
.update(userTableDefinitions)
|
|
.set({ schema: updatedSchema, updatedAt: now })
|
|
.where(eq(userTableDefinitions.id, data.tableId))
|
|
|
|
logger.info(
|
|
`[${requestId}] Updated constraints for column "${column.name}" in table ${data.tableId}`
|
|
)
|
|
|
|
return { ...table, schema: updatedSchema, updatedAt: now }
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Checks if a value is compatible with a target column type.
|
|
*/
|
|
function isValueCompatibleWithType(
|
|
value: unknown,
|
|
targetType: (typeof COLUMN_TYPES)[number]
|
|
): boolean {
|
|
if (value === null || value === undefined) return true
|
|
|
|
switch (targetType) {
|
|
case 'string':
|
|
return true
|
|
case 'number': {
|
|
if (typeof value === 'number') return Number.isFinite(value)
|
|
if (typeof value === 'string') {
|
|
const num = Number(value)
|
|
return Number.isFinite(num) && value.trim() !== ''
|
|
}
|
|
return false
|
|
}
|
|
case 'boolean': {
|
|
if (typeof value === 'boolean') return true
|
|
if (typeof value === 'string')
|
|
return ['true', 'false', '1', '0'].includes(value.toLowerCase())
|
|
if (typeof value === 'number') return value === 0 || value === 1
|
|
return false
|
|
}
|
|
case 'date': {
|
|
if (value instanceof Date) return !Number.isNaN(value.getTime())
|
|
if (typeof value === 'string') return !Number.isNaN(Date.parse(value))
|
|
return false
|
|
}
|
|
case 'json':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|