chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,964 @@
/**
* Workflow-group operations on user tables.
*
* Extracted from the table service: add/update/delete workflow groups and their
* output columns, plus stale-output pruning after a workflow deploy. These ops
* mutate `schema.workflowGroups` (and the bound output columns + row data) under
* the per-table advisory lock from `withLockedTable`.
*/
import { db } from '@sim/db'
import { userTableDefinitions } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import {
columnMatchesRef,
generateColumnId,
getColumnId,
remapGroupColumnRefs,
} from '@/lib/table/column-keys'
import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { stripGroupExecutions } from '@/lib/table/rows/executions'
import { getTableById, withLockedTable } from '@/lib/table/service'
import { setTableTxTimeouts } from '@/lib/table/tx'
import type {
AddWorkflowGroupData,
ColumnDefinition,
DeleteWorkflowGroupData,
TableDefinition,
TableMetadata,
TableSchema,
UpdateWorkflowGroupData,
WorkflowGroup,
WorkflowGroupOutput,
} from '@/lib/table/types'
import { assertValidSchema, runWorkflowColumn, stripGroupDeps } from '@/lib/table/workflow-columns'
const logger = createLogger('TableWorkflowGroupsService')
/**
* Drops references to deleted blocks from every workflow group on every table
* that targets the just-deployed workflow. Called from the workflow deploy
* orchestrator after the new deployment commits, so the table UI never holds
* stale `{blockId, path}` entries for blocks the user removed.
*
* - Filters `outputs[]` per group. If every output would be filtered out, the
* group is left untouched and a warning is logged — the user must
* reconfigure it manually.
* - Scoped to the workflow's workspace.
* - Idempotent: running twice with the same `validBlockIds` is a no-op on the
* second pass. Existing row data is left alone.
*/
export async function pruneStaleWorkflowGroupOutputs({
workflowId,
workspaceId,
validBlockIds,
requestId,
tx,
}: {
workflowId: string
workspaceId: string
validBlockIds: Set<string>
requestId: string
tx?: DbOrTx
}): Promise<void> {
const executor = tx ?? db
const tables = await executor
.select({
id: userTableDefinitions.id,
schema: userTableDefinitions.schema,
})
.from(userTableDefinitions)
.where(
and(
eq(userTableDefinitions.workspaceId, workspaceId),
isNull(userTableDefinitions.archivedAt)
)
)
for (const t of tables) {
const schema = t.schema as TableSchema
const groups = schema.workflowGroups ?? []
if (groups.length === 0) continue
let mutated = false
const nextGroups = groups.map((group) => {
if (group.workflowId !== workflowId) return group
const filtered = group.outputs.filter((o) => validBlockIds.has(o.blockId))
if (filtered.length === group.outputs.length) return group
if (filtered.length === 0) {
logger.warn(
`[${requestId}] All outputs for workflow group "${group.name ?? group.id}" in table ${t.id} reference deleted blocks; leaving group intact for user reconfiguration.`
)
return group
}
mutated = true
return { ...group, outputs: filtered }
})
if (!mutated) continue
await executor
.update(userTableDefinitions)
.set({
schema: { ...schema, workflowGroups: nextGroups },
updatedAt: new Date(),
})
.where(eq(userTableDefinitions.id, t.id))
logger.info(`[${requestId}] Pruned stale workflow=${workflowId} block refs from table ${t.id}`)
}
}
/**
* Atomically inserts a workflow group plus its output columns into a table's
* schema. Both arrays update in one DB write so the schema is never observed
* mid-mutation (e.g. columns referencing a group that doesn't yet exist).
*/
export async function addWorkflowGroup(
data: AddWorkflowGroupData,
requestId: string
): Promise<TableDefinition> {
const updatedTable = await withLockedTable(data.tableId, async (table, trx) => {
const schema = table.schema
const groups = schema.workflowGroups ?? []
if (groups.some((g) => g.id === data.group.id)) {
throw new Error(`Workflow group "${data.group.id}" already exists`)
}
const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase()))
for (const col of data.outputColumns) {
if (!NAME_PATTERN.test(col.name)) {
throw new Error(
`Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.`
)
}
if (existingNames.has(col.name.toLowerCase())) {
throw new Error(`Column "${col.name}" already exists`)
}
}
if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) {
throw new Error(
`Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).`
)
}
// Assign stable ids to the new output columns, then rewrite the group's
// column refs from name → id so outputs/deps/inputMappings key on ids —
// matching the row-data storage key and surviving future renames.
const outputColumns = data.outputColumns.map((col) =>
col.id ? col : { ...col, id: generateColumnId() }
)
const updatedColumns = [...schema.columns, ...outputColumns]
const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)]))
const group = remapGroupColumnRefs(data.group, idByName)
const updatedSchema: TableSchema = {
...schema,
columns: updatedColumns,
workflowGroups: [...groups, group],
}
// Keep `metadata.columnOrder` (column ids) in sync — see `addTableColumn`.
// New output columns get appended in the order the caller supplied.
const existingOrder = table.metadata?.columnOrder
let updatedMetadata = table.metadata
if (existingOrder && existingOrder.length > 0) {
const known = new Set(existingOrder)
const append = outputColumns.map(getColumnId).filter((id) => !known.has(id))
if (append.length > 0) {
updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] }
}
}
assertValidSchema(updatedSchema, updatedMetadata?.columnOrder)
const now = new Date()
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
logger.info(
`[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}`
)
return {
...table,
schema: updatedSchema,
metadata: updatedMetadata,
updatedAt: now,
}
})
// Auto-fire existing rows whose deps are already met for the new group.
// Fire-and-forget — the dispatcher bounds queue depth (window of 20) and
// walks the table in the background. HTTP returns instantly; cells fill
// in over the next minutes as the dispatcher walks. Mothership opts out
// by setting `autoRun: false`.
if (data.autoRun !== false) {
void runWorkflowColumn({
tableId: updatedTable.id,
workspaceId: updatedTable.workspaceId,
mode: 'new',
isManualRun: false,
groupIds: [data.group.id],
requestId,
triggeredByUserId: data.actorUserId,
}).catch((err) => logger.error(`[${requestId}] auto-dispatch (addWorkflowGroup) failed:`, err))
}
return updatedTable
}
/**
* Updates a workflow group: any combination of workflowId, name, dependencies,
* outputs[]. Computes added/removed outputs vs current state and inserts /
* removes columns transactionally. Removed outputs also clear their key from
* every row's `data`.
*/
export async function updateWorkflowGroup(
data: UpdateWorkflowGroupData,
requestId: string
): Promise<TableDefinition> {
const mappingUpdates = data.mappingUpdates ?? []
// Phase 1 (no lock): when there are mapping updates, load the workflow once to
// resolve each remap's new leaf type. Kept OFF the advisory-lock critical
// section so concurrent group edits on the same table don't time out waiting
// on this DB load. Best-effort — a resolution failure leaves column types
// unchanged (workflow deleted, block removed). The result is applied against
// the fresh schema under the lock in phase 2.
const remapLeafTypeByColumn = new Map<string, ColumnDefinition['type']>()
// The workflow id the leaf types above were resolved against. Phase 2 only
// applies the resolved types if the group still points at this workflow under
// the lock — a concurrent `workflowId` change would make them stale.
let resolvedForWorkflowId: string | undefined
if (mappingUpdates.length > 0) {
try {
const preTable = await getTableById(data.tableId)
const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId)
const targetWorkflowId = data.workflowId ?? preGroup?.workflowId
if (targetWorkflowId) {
resolvedForWorkflowId = targetWorkflowId
const [
{ loadWorkflowFromNormalizedTables },
{ flattenWorkflowOutputs },
{ columnTypeForLeaf },
] = await Promise.all([
import('@/lib/workflows/persistence/utils'),
import('@/lib/workflows/blocks/flatten-outputs'),
import('@/lib/table/column-naming'),
])
const normalized = await loadWorkflowFromNormalizedTables(targetWorkflowId)
if (normalized) {
const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({
id: b.id,
type: b.type,
name: b.name,
triggerMode: (b as { triggerMode?: boolean }).triggerMode,
subBlocks: b.subBlocks as Record<string, unknown> | undefined,
}))
const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? [])
const flatByKey = new Map(flattened.map((f) => [`${f.blockId}::${f.path}`, f]))
for (const u of mappingUpdates) {
const match = flatByKey.get(`${u.blockId}::${u.path}`)
if (!match) continue
const newType = columnTypeForLeaf(match.leafType)
if (newType) remapLeafTypeByColumn.set(u.columnName, newType)
}
}
}
} catch (err) {
logger.warn(
`[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`,
err
)
}
}
const { updatedTable, added, remappedColumnIds, newOutputs, previousAutoRun } =
await withLockedTable(data.tableId, async (table, trx) => {
await setTableTxTimeouts(trx, { statementMs: 60_000 })
const schema = table.schema
const groups = schema.workflowGroups ?? []
const groupIndex = groups.findIndex((g) => g.id === data.groupId)
if (groupIndex === -1) {
throw new Error(`Workflow group "${data.groupId}" not found`)
}
const group = groups[groupIndex]
// Normalize every caller-supplied column reference to its stable id, so
// the diff/splice/clear logic below operates uniformly in id-space (the
// row-data storage key). New output columns get ids first; then output
// `columnName`, deps, input mappings, and mapping-update targets are
// remapped name → id. Callers that already pass ids are unaffected.
const newColDefs = (data.newOutputColumns ?? []).map((col) =>
col.id ? col : { ...col, id: generateColumnId() }
)
const idByName = new Map(
[...schema.columns, ...newColDefs].map((c) => [c.name, getColumnId(c)])
)
const remapRef = (ref: string) => idByName.get(ref) ?? ref
const outputsInput = data.outputs?.map((o) => ({ ...o, columnName: remapRef(o.columnName) }))
const dependenciesInput = data.dependencies
? { columns: data.dependencies.columns?.map(remapRef) }
: undefined
const inputMappingsInput = data.inputMappings?.map((m) => ({
...m,
columnName: remapRef(m.columnName),
}))
const mappingUpdatesNorm = mappingUpdates.map((u) => ({
...u,
columnName: remapRef(u.columnName),
}))
// Re-key the out-of-lock leaf-type resolution to ids to match.
const remapLeafTypeById = new Map<string, ColumnDefinition['type']>()
for (const [name, type] of remapLeafTypeByColumn) remapLeafTypeById.set(remapRef(name), type)
// Apply `mappingUpdates` first: each entry repoints an existing output's
// `(blockId, path)` while preserving the column. We patch the **old** view
// of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the
// swap as a remove+add. The corresponding row data is cleared after the
// schema write so stale values from the old source don't linger.
const remappedColumnIds = new Set<string>()
// Per-column type override (keyed by id) resolved (out-of-lock) from the
// new mapping's leaf type. Only populated when a remap actually changes
// the column's type against the fresh schema.
const remappedColumnTypes = new Map<string, ColumnDefinition['type']>()
let oldOutputs = group.outputs
if (mappingUpdatesNorm.length > 0) {
const updateById = new Map(mappingUpdatesNorm.map((u) => [u.columnName, u]))
for (const u of mappingUpdatesNorm) {
const exists = oldOutputs.some((o) => o.columnName === u.columnName)
if (!exists) {
throw new Error(
`Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).`
)
}
}
oldOutputs = oldOutputs.map((o) => {
const u = updateById.get(o.columnName)
if (!u) return o
remappedColumnIds.add(o.columnName)
return { ...o, blockId: u.blockId, path: u.path }
})
// Only apply the out-of-lock leaf-type resolution if the group still
// points at the workflow we resolved against. If a concurrent writer
// changed `workflowId` between phase 1 and now, those types are stale —
// leave column types unchanged (best-effort, same as a resolution
// failure) rather than stamping types from the old workflow.
const finalWorkflowId = data.workflowId ?? group.workflowId
if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) {
logger.warn(
`[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.`
)
} else {
const colById = new Map(schema.columns.map((c) => [getColumnId(c), c]))
for (const u of mappingUpdatesNorm) {
const newType = remapLeafTypeById.get(u.columnName)
if (!newType) continue
const oldType = colById.get(u.columnName)?.type
if (newType !== oldType) {
remappedColumnTypes.set(u.columnName, newType)
}
}
}
}
// If the caller passed `outputs`, that's the new full set. If only
// `mappingUpdates` was sent, the new set is the remapped old set.
const newOutputs = outputsInput ?? oldOutputs
// Enrichment outputs all share empty `blockId`/`path`, so keying on those
// alone collapses every sibling to one entry (dropping columns on diff). Key
// on the registry `outputId` when present; fall back to `blockId::path` for
// workflow outputs.
const oldKey = (o: WorkflowGroupOutput) =>
o.outputId ? `out::${o.outputId}` : `${o.blockId}::${o.path}`
const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o]))
const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o]))
const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o)))
const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o)))
const newColById = new Map(newColDefs.map((c) => [getColumnId(c), c]))
for (const out of added) {
if (!newColById.has(out.columnName)) {
throw new Error(
`Missing column definition for new output "${out.columnName}" (group ${data.groupId}).`
)
}
}
const removedColumnIds = new Set(removed.map((o) => o.columnName))
let nextColumns = schema.columns
.filter((c) => !removedColumnIds.has(getColumnId(c)))
.map((c) => {
const newType = remappedColumnTypes.get(getColumnId(c))
return newType ? { ...c, type: newType } : c
})
if (newColDefs.length > 0) {
// Splice the new column defs into the group's contiguous run rather than
// appending at the end. The desired in-group order is `newOutputs` (the
// sidebar's BFS-of-the-workflow ordering); we walk it, anchor at the first
// surviving sibling's index in `nextColumns`, and emit each output's
// column def in turn.
const groupColIds = new Set(newOutputs.map((o) => o.columnName))
const firstGroupIdx = nextColumns.findIndex((c) => groupColIds.has(getColumnId(c)))
const anchorIdx = firstGroupIdx === -1 ? nextColumns.length : firstGroupIdx
const orderedGroupCols: ColumnDefinition[] = []
for (const out of newOutputs) {
const fresh = newColById.get(out.columnName)
if (fresh) {
orderedGroupCols.push(fresh)
} else {
const existing = nextColumns.find((c) => getColumnId(c) === out.columnName)
if (existing) orderedGroupCols.push(existing)
}
}
const remaining = nextColumns.filter((c) => !groupColIds.has(getColumnId(c)))
nextColumns = [
...remaining.slice(0, anchorIdx),
...orderedGroupCols,
...remaining.slice(anchorIdx),
]
}
const updatedGroup: WorkflowGroup = {
...group,
workflowId: data.workflowId ?? group.workflowId,
name: data.name ?? group.name,
dependencies: dependenciesInput ?? group.dependencies,
outputs: newOutputs,
...(inputMappingsInput !== undefined ? { inputMappings: inputMappingsInput } : {}),
...(data.deploymentMode !== undefined ? { deploymentMode: data.deploymentMode } : {}),
...(data.type !== undefined ? { type: data.type } : {}),
...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}),
}
// Removed outputs may be referenced as deps by sibling groups; strip those
// refs so we don't leave dangling-column deps that fail schema validation.
const nextGroups = groups
.map((g, i) => (i === groupIndex ? updatedGroup : g))
.map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnIds)))
const updatedSchema: TableSchema = {
...schema,
columns: nextColumns,
workflowGroups: nextGroups,
}
// `columnOrder` (column ids) mirrors the schema layout. Drop removed
// columns, then splice the new ones in at the same anchor as `nextColumns`
// so the table renders them inside the group's contiguous run.
let updatedColumnOrder = table.metadata?.columnOrder?.filter(
(id) => !removedColumnIds.has(id)
)
if (updatedColumnOrder && newColDefs.length > 0) {
const newColIds = new Set(newColDefs.map(getColumnId))
const orderWithoutNew = updatedColumnOrder.filter((id) => !newColIds.has(id))
const groupColIds = new Set(newOutputs.map((o) => o.columnName))
const orderedGroupIds = newOutputs.map((o) => o.columnName)
const firstGroupOrderIdx = orderWithoutNew.findIndex((id) => groupColIds.has(id))
const anchorOrderIdx =
firstGroupOrderIdx === -1 ? orderWithoutNew.length : firstGroupOrderIdx
const remainingOrder = orderWithoutNew.filter((id) => !groupColIds.has(id))
updatedColumnOrder = [
...remainingOrder.slice(0, anchorOrderIdx),
...orderedGroupIds,
...remainingOrder.slice(anchorOrderIdx),
]
}
assertValidSchema(updatedSchema, updatedColumnOrder)
const updatedMetadata: TableMetadata | null =
updatedColumnOrder && table.metadata
? { ...table.metadata, columnOrder: updatedColumnOrder }
: table.metadata
? { ...table.metadata }
: null
const now = new Date()
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
for (const id of removedColumnIds) {
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text`
)
}
// Remapped columns: clear stale values in-tx so rows the backfill can't
// repopulate (no log, no matching span output) end up empty rather than
// retaining the previous mapping's value. The backfill below then writes
// the new mapping's value into rows where it can find one.
for (const id of remappedColumnIds) {
if (removedColumnIds.has(id)) continue
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text`
)
}
logger.info(
`[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnIds.size})`
)
const updatedTable: TableDefinition = {
...table,
schema: updatedSchema,
metadata: updatedMetadata,
updatedAt: now,
}
return {
updatedTable,
added,
remappedColumnIds,
newOutputs,
previousAutoRun: group.autoRun,
}
})
// Backfill from saved execution logs so already-completed group runs surface
// the schema changes without re-running the workflow. Two passes:
// - added outputs (new columns): never overwrite hand-edited values.
// - remapped outputs (existing column re-pointed): overwrite, since the
// new mapping is the source of truth and the user expects the cell to
// refresh to the new output's value.
// Small tables backfill inline-awaited (response returns with consistent
// data); large ones run as a background job. A failed backfill is logged
// but doesn't fail the request — the schema change has already committed.
// Lazy import: backfill-runner closes a cycle back to this module.
const { maybeBackfillGroupOutputs } = await import('@/lib/table/backfill-runner')
if (added.length > 0) {
try {
await maybeBackfillGroupOutputs({
table: updatedTable,
groupId: data.groupId,
outputs: added,
overwrite: false,
requestId,
actorUserId: data.actorUserId,
})
} catch (err) {
logger.warn(
`[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`,
err
)
}
}
if (remappedColumnIds.size > 0) {
const remappedOutputs = newOutputs.filter((o) => remappedColumnIds.has(o.columnName))
try {
await maybeBackfillGroupOutputs({
table: updatedTable,
groupId: data.groupId,
outputs: remappedOutputs,
overwrite: true,
requestId,
actorUserId: data.actorUserId,
})
} catch (err) {
logger.warn(
`[${requestId}] Remap backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`,
err
)
}
}
// autoRun toggled false → true: fire deps-satisfied rows now via the
// dispatcher. Mirrors the post-add path so re-enabling auto-fire doesn't
// require manual run clicks for rows that are already eligible.
if (previousAutoRun === false && data.autoRun === true) {
void runWorkflowColumn({
tableId: updatedTable.id,
workspaceId: updatedTable.workspaceId,
mode: 'new',
isManualRun: false,
groupIds: [data.groupId],
requestId,
triggeredByUserId: data.actorUserId,
}).catch((err) =>
logger.error(`[${requestId}] auto-dispatch (updateWorkflowGroup autoRun=true) failed:`, err)
)
}
return updatedTable
}
/**
* Adds a single output to an existing workflow group. Mirrors `addTableColumn`
* for plain columns: one canonical op, one column created, type inferred from
* the workflow's flattened outputs (`leafType` for `(blockId, path)`). The
* column is spliced into the group's contiguous run so the table renders the
* new output next to its siblings.
*/
export async function addWorkflowGroupOutput(
data: {
tableId: string
groupId: string
blockId: string
path: string
/** Optional override; defaults to a slug derived from `path`. */
columnName?: string
/** The member adding the output — billed/gated for any backfill-triggered re-run. */
actorUserId?: string | null
},
requestId: string
): Promise<TableDefinition> {
// Phase 1 (no lock): load the workflow and resolve the pickable output plus
// its execution-order index. This depends only on the workflow graph (which
// is stable), so it runs OFF the advisory-lock critical section — holding the
// lock during this DB load would make concurrent adders on the same table
// time out waiting (the Mothership fan-out this fix targets). Phase 2
// re-validates that the group still maps to the same workflow under the lock.
const preTable = await getTableById(data.tableId)
if (!preTable) throw new Error('Table not found')
const preGroup = (preTable.schema.workflowGroups ?? []).find((g) => g.id === data.groupId)
if (!preGroup) {
throw new Error(`Workflow group "${data.groupId}" not found`)
}
const workflowId = preGroup.workflowId
const [
{ loadWorkflowFromNormalizedTables },
{ flattenWorkflowOutputs, getBlockExecutionOrder },
{ columnTypeForLeaf, deriveOutputColumnName },
] = await Promise.all([
import('@/lib/workflows/persistence/utils'),
import('@/lib/workflows/blocks/flatten-outputs'),
import('@/lib/table/column-naming'),
])
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) {
throw new Error(`Workflow ${workflowId} not found`)
}
const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({
id: b.id,
type: b.type,
name: b.name,
triggerMode: (b as { triggerMode?: boolean }).triggerMode,
subBlocks: b.subBlocks as Record<string, unknown> | undefined,
}))
const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? [])
const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path)
if (!match) {
throw new Error(
`Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}`
)
}
const newColumnType = columnTypeForLeaf(match.leafType)
const distances = getBlockExecutionOrder(blocks, normalized.edges ?? [])
const flatIndex = new Map(flattened.map((f, i) => [`${f.blockId}::${f.path}`, i]))
// Phase 2 (locked): re-read fresh, validate against the current schema, and
// write. The critical section holds no I/O — just the in-memory splice + the
// schema UPDATE — so concurrent adders queue behind it quickly.
const { updatedTable, newOutput } = await withLockedTable(data.tableId, async (table, trx) => {
const schema = table.schema
const groups = schema.workflowGroups ?? []
const groupIndex = groups.findIndex((g) => g.id === data.groupId)
if (groupIndex === -1) {
throw new Error(`Workflow group "${data.groupId}" not found`)
}
const group = groups[groupIndex]
if (group.workflowId !== workflowId) {
throw new Error(
`Workflow group "${data.groupId}" was remapped to a different workflow concurrently; retry the add.`
)
}
if (group.outputs.some((o) => o.blockId === data.blockId && o.path === data.path)) {
throw new Error(
`Workflow group "${data.groupId}" already has an output at ${data.blockId}::${data.path}`
)
}
const taken = new Set(schema.columns.map((c) => c.name))
const columnName = data.columnName ?? deriveOutputColumnName(data.path, taken)
if (!NAME_PATTERN.test(columnName)) {
throw new Error(`Invalid column name "${columnName}". Must satisfy ${NAME_PATTERN.source}.`)
}
if (taken.has(columnName)) {
throw new Error(`Column "${columnName}" already exists`)
}
if (schema.columns.length + 1 > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) {
throw new Error(
`Adding a column would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).`
)
}
const newColDef: ColumnDefinition = {
id: generateColumnId(),
name: columnName,
type: newColumnType,
required: false,
unique: false,
workflowGroupId: data.groupId,
}
const newColumnId = getColumnId(newColDef)
const newOutput: WorkflowGroupOutput = {
blockId: data.blockId,
path: data.path,
columnName: newColumnId,
}
// Sort all of the group's outputs (existing + new) in workflow execution
// order: BFS distance from the start block ASC, with discovery order as
// tiebreak. This matches what the column-sidebar does at create time, so
// columns from the same workflow always read in the order their blocks run
// — regardless of whether they were added at create time or one-by-one.
const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName))
const orderKey = (o: { blockId: string; path: string }) => {
const d = distances[o.blockId]
const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d
const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY
return [dist, idx] as const
}
const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => {
const [da, ia] = orderKey(a)
const [db, ib] = orderKey(b)
return da !== db ? da - db : ia - ib
})
const orderedGroupColIds = allGroupOutputs.map((o) => o.columnName)
const updatedGroup: WorkflowGroup = {
...group,
outputs: allGroupOutputs,
}
const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g))
// Splice the new column run into nextColumns: keep the columns outside the
// group where they were, replace the group's contiguous run with the
// BFS-ordered list. Anchor at the position of the first existing sibling
// (or append if the group was empty).
const colById = new Map(schema.columns.map((c) => [getColumnId(c), c]))
const orderedGroupCols: ColumnDefinition[] = orderedGroupColIds.map((id) => {
if (id === newColumnId) return newColDef
const existing = colById.get(id)
if (!existing) {
throw new Error(`Internal: column "${id}" missing while splicing group outputs`)
}
return existing
})
const remainingCols = schema.columns.filter((c) => !groupColIdsBefore.has(getColumnId(c)))
const firstGroupIdx = schema.columns.findIndex((c) => groupColIdsBefore.has(getColumnId(c)))
const colAnchor = firstGroupIdx === -1 ? remainingCols.length : firstGroupIdx
const nextColumns = [
...remainingCols.slice(0, colAnchor),
...orderedGroupCols,
...remainingCols.slice(colAnchor),
]
const updatedSchema: TableSchema = {
...schema,
columns: nextColumns,
workflowGroups: nextGroups,
}
const updatedColumnOrder = table.metadata?.columnOrder
? (() => {
const orderWithoutGroup = table.metadata!.columnOrder!.filter(
(id) => !groupColIdsBefore.has(id)
)
const firstGroupOrderIdx = table.metadata!.columnOrder!.findIndex((id) =>
groupColIdsBefore.has(id)
)
const orderAnchor =
firstGroupOrderIdx === -1 ? orderWithoutGroup.length : firstGroupOrderIdx
return [
...orderWithoutGroup.slice(0, orderAnchor),
...orderedGroupColIds,
...orderWithoutGroup.slice(orderAnchor),
]
})()
: undefined
assertValidSchema(updatedSchema, updatedColumnOrder)
const updatedMetadata: TableMetadata | null =
updatedColumnOrder && table.metadata
? { ...table.metadata, columnOrder: updatedColumnOrder }
: table.metadata
? { ...table.metadata }
: null
const now = new Date()
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
logger.info(
`[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}`
)
const updatedTable: TableDefinition = {
...table,
schema: updatedSchema,
metadata: updatedMetadata,
updatedAt: now,
}
return { updatedTable, newOutput }
})
// Backfill from saved execution logs — same flow `updateWorkflowGroup`
// uses for added outputs. Reads each row's saved trace spans for the
// group's executionId and writes the new output's value back. Existing
// rows that have hand-edited values are left alone (overwrite: false).
// Cheap compared to re-running the workflow on every row, which is what
// an earlier version of this code did — that mistakenly fanned out N
// workflow-group-cell jobs and burned compute the user didn't ask for.
// Small tables backfill inline; large ones run as a background job.
// Lazy import: backfill-runner closes a cycle back to this module.
try {
const { maybeBackfillGroupOutputs } = await import('@/lib/table/backfill-runner')
await maybeBackfillGroupOutputs({
table: updatedTable,
groupId: data.groupId,
outputs: [newOutput],
overwrite: false,
requestId,
actorUserId: data.actorUserId,
})
} catch (err) {
logger.warn(
`[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId} after adding output "${newOutput.columnName}":`,
err
)
}
return updatedTable
}
/**
* Removes a single output from a workflow group. Drops the bound column and
* strips the value from every row's `data` JSONB. If the output is the
* group's last, the empty group is left in place — drop it explicitly with
* `deleteWorkflowGroup` if needed.
*/
export async function deleteWorkflowGroupOutput(
data: { tableId: string; groupId: string; columnName: string },
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
const schema = table.schema
const groups = schema.workflowGroups ?? []
const groupIndex = groups.findIndex((g) => g.id === data.groupId)
if (groupIndex === -1) {
throw new Error(`Workflow group "${data.groupId}" not found`)
}
const group = groups[groupIndex]
// `data.columnName` may be a column id (first-party) or display name
// (mothership/legacy); resolve to the stable id used everywhere below.
const targetColumn = schema.columns.find((c) => columnMatchesRef(c, data.columnName))
const columnId = targetColumn ? getColumnId(targetColumn) : data.columnName
if (!group.outputs.some((o) => o.columnName === columnId)) {
throw new Error(
`Workflow group "${data.groupId}" has no output bound to column "${data.columnName}"`
)
}
const updatedGroup: WorkflowGroup = {
...group,
outputs: group.outputs.filter((o) => o.columnName !== columnId),
}
const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g))
const nextColumns = schema.columns.filter((c) => getColumnId(c) !== columnId)
const updatedSchema: TableSchema = {
...schema,
columns: nextColumns,
workflowGroups: nextGroups,
}
const updatedColumnOrder = table.metadata?.columnOrder?.filter((id) => id !== columnId)
assertValidSchema(updatedSchema, updatedColumnOrder)
const updatedMetadata: TableMetadata | null =
updatedColumnOrder && table.metadata
? { ...table.metadata, columnOrder: updatedColumnOrder }
: table.metadata
? { ...table.metadata }
: null
const now = new Date()
await setTableTxTimeouts(trx, { statementMs: 60_000 })
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${columnId}::text WHERE table_id = ${data.tableId} AND data ? ${columnId}::text`
)
logger.info(
`[${requestId}] Removed output "${data.columnName}" from workflow group "${data.groupId}" in table ${data.tableId}`
)
return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }
})
}
/**
* Removes a workflow group plus all its output columns. Also strips the
* group's `executions[groupId]` entry from every row.
*/
export async function deleteWorkflowGroup(
data: DeleteWorkflowGroupData,
requestId: string
): Promise<TableDefinition> {
return withLockedTable(data.tableId, async (table, trx) => {
const schema = table.schema
const groups = schema.workflowGroups ?? []
const group = groups.find((g) => g.id === data.groupId)
if (!group) {
throw new Error(`Workflow group "${data.groupId}" not found`)
}
const removedColumnIds = new Set(group.outputs.map((o) => o.columnName))
// Removed group's output columns may be referenced as deps by sibling groups.
// Strip those refs so we don't leave dangling-column deps behind.
const nextGroups = groups
.filter((g) => g.id !== data.groupId)
.map((g) => stripGroupDeps(g, removedColumnIds))
const updatedSchema: TableSchema = {
...schema,
columns: schema.columns.filter((c) => !removedColumnIds.has(getColumnId(c))),
workflowGroups: nextGroups,
}
const updatedColumnOrder = table.metadata?.columnOrder?.filter(
(id) => !removedColumnIds.has(id)
)
assertValidSchema(updatedSchema, updatedColumnOrder)
const updatedMetadata: TableMetadata | null =
updatedColumnOrder && table.metadata
? { ...table.metadata, columnOrder: updatedColumnOrder }
: table.metadata
? { ...table.metadata }
: null
const now = new Date()
await setTableTxTimeouts(trx, { statementMs: 60_000 })
await trx
.update(userTableDefinitions)
.set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now })
.where(eq(userTableDefinitions.id, data.tableId))
for (const id of removedColumnIds) {
await trx.execute(
sql`UPDATE user_table_rows SET data = data - ${id}::text WHERE table_id = ${data.tableId} AND data ? ${id}::text`
)
}
await stripGroupExecutions(trx, data.tableId, [data.groupId])
logger.info(
`[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}`
)
return {
...table,
schema: updatedSchema,
metadata: updatedMetadata,
updatedAt: now,
}
})
}