Files
simstudioai--sim/apps/sim/lib/api/contracts/tables.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1382 lines
47 KiB
TypeScript

import { isRecordLike } from '@sim/utils/object'
import { z } from 'zod'
import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import { ianaTimezoneSchema } from '@/lib/api/contracts/user'
import type {
CsvHeaderMapping,
EnrichmentRunDetail,
Filter,
RowData,
Sort,
TableDefinition,
TableMetadata,
TableRow,
TableRowsCursor,
} from '@/lib/table'
import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants'
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
export const domainObjectSchema = <T>() => z.custom<T>(isRecordLike)
/**
* Column types are a fixed enum derived from `COLUMN_TYPES` so callers cannot
* send arbitrary strings the server would reject downstream.
*/
export const columnTypeSchema = z.enum(COLUMN_TYPES)
/**
* Identifier for tables/columns: starts with letter or underscore, contains
* only alphanumerics + underscores, capped at `MAX_TABLE_NAME_LENGTH`.
*/
const tableNameSchema = z
.string()
.min(1, 'Name is required')
.max(
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
`Name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
)
.regex(
NAME_PATTERN,
'Name must start with a letter or underscore and contain only alphanumeric characters and underscores'
)
const columnNameSchema = z
.string()
.min(1, 'Column name is required')
.max(
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
)
.regex(
NAME_PATTERN,
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
)
const descriptionSchema = z
.string()
.max(
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
)
export const tableScopeSchema = z.enum(['active', 'archived', 'all'])
export const tableIdParamsSchema = z.object({
tableId: z.string().min(1),
})
export const tableRowParamsSchema = tableIdParamsSchema.extend({
rowId: z.string().min(1),
})
export const listTablesQuerySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
scope: tableScopeSchema.default('active'),
})
export const getTableQuerySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
export const tableColumnSchema = z.object({
/** Stable column id (server-assigned). Absent on legacy/ pre-backfill columns. */
id: z.string().optional(),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional().default(false),
unique: z.boolean().optional().default(false),
/** Set when the column is a workflow group's output. */
workflowGroupId: z.string().optional(),
})
export const createTableBodySchema = z.object({
name: tableNameSchema,
description: descriptionSchema.optional(),
schema: z.object({
columns: z
.array(tableColumnSchema)
.min(1, 'Table must have at least one column')
.max(
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
),
}),
workspaceId: z.string().min(1, 'Workspace ID is required'),
initialRowCount: z.number().int().min(0).max(100).optional(),
})
export const renameTableBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
name: tableNameSchema,
})
export const createTableColumnBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
column: z.object({
// Optional stable id — first-party undo of a delete re-creates the column
// with its original id so saved (id-keyed) cell data restores correctly.
id: z.string().optional(),
name: columnNameSchema,
type: columnTypeSchema,
required: z.boolean().optional(),
unique: z.boolean().optional(),
position: z.number().int().min(0).optional(),
}),
})
export const updateTableColumnBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
columnName: columnNameSchema,
updates: z.object({
name: columnNameSchema.optional(),
type: columnTypeSchema.optional(),
required: z.boolean().optional(),
unique: z.boolean().optional(),
}),
})
export const deleteTableColumnBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
columnName: columnNameSchema,
})
export const tableMetadataSchema = z.object({
columnWidths: z.record(z.string(), z.number().positive()).optional(),
columnOrder: z.array(z.string()).optional(),
pinnedColumns: z.array(z.string()).optional(),
}) satisfies z.ZodType<TableMetadata>
export const updateTableMetadataBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
metadata: tableMetadataSchema,
})
export const rowDataSchema = domainObjectSchema<RowData>()
export const tableDefinitionSchema = domainObjectSchema<TableDefinition>()
export const tableRowSchema = domainObjectSchema<TableRow>()
/**
* Plain-object base for the single-row insert body. Kept un-refined so callers
* (e.g. the v1 public contract) can `.omit()` fields before applying
* {@link rowAnchorMutexRefine} — Zod forbids `.omit()` on a refined schema.
*/
export const insertTableRowBodyBaseSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: rowDataSchema,
position: z.number().int().min(0).optional(),
/** Fractional ordering: insert directly after this row id. Takes precedence over `position`. */
afterRowId: z.string().min(1).optional(),
/** Fractional ordering: insert directly before this row id. Takes precedence over `position`. */
beforeRowId: z.string().min(1).optional(),
})
/** `afterRowId` and `beforeRowId` are mutually exclusive insert anchors. */
export const rowAnchorMutexRefine = [
(data: { afterRowId?: string; beforeRowId?: string }) => !data.afterRowId || !data.beforeRowId,
{ message: 'afterRowId and beforeRowId are mutually exclusive' },
] as const
export const insertTableRowBodySchema = insertTableRowBodyBaseSchema.refine(...rowAnchorMutexRefine)
/**
* POST `/api/table/[tableId]/rows/upsert` body — insert-or-update keyed by a
* unique column name. `conflictTarget` is optional (server picks a single
* unique column when omitted).
*/
export const upsertTableRowBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: rowDataSchema,
conflictTarget: z.string().min(1).optional(),
})
export const batchInsertTableRowsBodySchema = z
.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
rows: z
.array(rowDataSchema)
.min(1, 'At least one row is required')
.max(
TABLE_LIMITS.MAX_BATCH_INSERT_SIZE,
`Cannot insert more than ${TABLE_LIMITS.MAX_BATCH_INSERT_SIZE} rows per batch`
),
/** Fractional ordering: exact per-row order keys (undo restore). */
orderKeys: z.array(z.string().min(1)).max(TABLE_LIMITS.MAX_BATCH_INSERT_SIZE).optional(),
})
.refine((data) => !data.orderKeys || data.orderKeys.length === data.rows.length, {
message: 'orderKeys array length must match rows array length',
})
/**
* POST `/api/table/[tableId]/rows` body — accepts either a batch payload
* (`{ rows: [...] }`) or a single-row payload (`{ data: {...} }`). Branches
* narrow on `'rows' in body` since the discriminator is the shape, not a
* literal field. Order matters: the batch schema is checked first so payloads
* that include `rows` are routed to batch insert.
*/
export const insertTableRowsBodySchema = z.union([
batchInsertTableRowsBodySchema,
insertTableRowBodySchema,
])
export const updateTableRowBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: rowDataSchema,
})
export const batchUpdateTableRowsBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
updates: z
.array(
z.object({
rowId: z.string().min(1),
data: rowDataSchema,
})
)
.min(1, 'At least one update is required')
.max(
TABLE_LIMITS.MAX_BULK_OPERATION_SIZE,
`Cannot update more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per batch`
)
.refine((updates) => new Set(updates.map((update) => update.rowId)).size === updates.length, {
message: 'updates must not contain duplicate rowId values',
}),
})
/**
* Filter object that requires at least one key. Bulk delete/update operations
* cannot accept an empty filter — that would target every row in the table,
* which is rejected by `buildFilterClause` downstream.
*/
const nonEmptyFilterSchema = domainObjectSchema<Filter>().refine(
(value) => Object.keys(value).length > 0,
{ message: 'Filter must not be empty' }
)
const filterSchema = domainObjectSchema<Filter>()
const optionalPositiveLimit = (max: number, label: string) =>
z.preprocess(
(value) => (value === null || value === undefined || value === '' ? undefined : Number(value)),
z
.number()
.int(`${label} must be an integer`)
.min(1, `${label} must be at least 1`)
.max(max, `Cannot ${label.toLowerCase()} more than ${max} rows per operation`)
.optional()
)
export const deleteTableRowBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
export const deleteTableRowsBodySchema = z
.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: nonEmptyFilterSchema.optional(),
limit: optionalPositiveLimit(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit').optional(),
rowIds: z
.array(z.string().min(1))
.min(1, 'At least one row ID is required')
.max(
TABLE_LIMITS.MAX_BULK_OPERATION_SIZE,
`Cannot delete more than ${TABLE_LIMITS.MAX_BULK_OPERATION_SIZE} rows per operation`
)
.optional(),
})
.refine((data) => Boolean(data.filter) !== Boolean(data.rowIds), {
message: 'Provide either filter or rowIds, but not both',
})
/** Unrefined base so v1 contracts can `.extend()` — consumers use {@link tableRowsQuerySchema}. */
export const tableRowsQueryBaseSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: domainObjectSchema<Filter>().optional(),
sort: domainObjectSchema<Sort>().optional(),
/**
* Keyset cursor `(orderKey, id)` for the default row order — each page is an index seek
* instead of OFFSET's scan-and-discard. Mutually exclusive with `sort` (cursors only make
* sense on the default order); takes precedence over `offset`.
*/
after: domainObjectSchema<TableRowsCursor>().optional(),
limit: z
.preprocess(
(value) =>
value === null || value === undefined || value === '' ? undefined : Number(value),
z
.number({ error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
.optional()
)
.default(TABLE_LIMITS.DEFAULT_QUERY_LIMIT),
offset: z
.preprocess(
(value) =>
value === null || value === undefined || value === '' ? undefined : Number(value),
z
.number({ error: 'Offset must be a number' })
.int('Offset must be an integer')
.min(0, 'Offset must be 0 or greater')
.optional()
)
.default(0),
includeTotal: z
.preprocess(
(value) =>
value === null || value === undefined || value === '' ? undefined : value === 'true',
z.boolean().optional()
)
.default(true),
})
export const tableRowsQuerySchema = tableRowsQueryBaseSchema.refine(
(data) => !(data.after && data.sort),
{ message: 'after cursor cannot be combined with sort — cursors paginate the default order' }
)
export const updateRowsByFilterBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: nonEmptyFilterSchema,
data: rowDataSchema,
limit: optionalPositiveLimit(TABLE_LIMITS.MAX_BULK_OPERATION_SIZE, 'Limit').optional(),
})
const successResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.literal(true),
data: dataSchema,
})
const tableColumnsResponseDataSchema = z.object({
columns: z.array(tableColumnSchema),
})
export const listTablesContract = defineRouteContract({
method: 'GET',
path: '/api/table',
query: listTablesQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
tables: z.array(tableDefinitionSchema),
totalCount: z.number(),
})
),
},
})
export type ListTablesResponse = ContractJsonResponse<typeof listTablesContract>
export const createTableContract = defineRouteContract({
method: 'POST',
path: '/api/table',
body: createTableBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
table: tableDefinitionSchema,
message: z.string(),
})
),
},
})
/**
* Kickoff body for an asynchronous large-CSV import into a NEW table. The file is
* already uploaded to storage (the client sends its `fileKey`); the route creates an
* `importing` table and runs the load in the background.
*/
export const importTableAsyncBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
fileKey: z.string().min(1, 'fileKey is required'),
fileName: z.string().min(1, 'fileName is required'),
/**
* Whether the source object is deleted once the import is terminal. Defaults to true (the upload
* flow stores a single-use temp object); pass false when importing an existing workspace file
* (e.g. the file viewer's "Import as a table") that must survive the import.
*/
deleteSourceFile: z.boolean().optional(),
/**
* IANA zone used to interpret naive datetime strings in the file (Excel and
* Sheets exports carry no offset). Defaults to the importing user's saved
* timezone, else UTC.
*/
timezone: ianaTimezoneSchema.optional(),
})
export type ImportTableAsyncBody = z.input<typeof importTableAsyncBodySchema>
export const importTableAsyncContract = defineRouteContract({
method: 'POST',
path: '/api/table/import-async',
body: importTableAsyncBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
tableId: z.string(),
importId: z.string(),
})
),
},
})
export const getTableContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]',
params: tableIdParamsSchema,
query: getTableQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ table: tableDefinitionSchema })),
},
})
export type GetTableResponse = ContractJsonResponse<typeof getTableContract>
export const renameTableContract = defineRouteContract({
method: 'PATCH',
path: '/api/table/[tableId]',
params: tableIdParamsSchema,
body: renameTableBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ table: tableDefinitionSchema })),
},
})
export const deleteTableContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]',
params: tableIdParamsSchema,
query: getTableQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ message: z.string() })),
},
})
export const restoreTableContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/restore',
params: tableIdParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ table: tableDefinitionSchema })),
},
})
export const addTableColumnContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/columns',
params: tableIdParamsSchema,
body: createTableColumnBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(tableColumnsResponseDataSchema),
},
})
export const updateTableColumnContract = defineRouteContract({
method: 'PATCH',
path: '/api/table/[tableId]/columns',
params: tableIdParamsSchema,
body: updateTableColumnBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(tableColumnsResponseDataSchema),
},
})
export const deleteTableColumnContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]/columns',
params: tableIdParamsSchema,
body: deleteTableColumnBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(tableColumnsResponseDataSchema),
},
})
export const updateTableMetadataContract = defineRouteContract({
method: 'PUT',
path: '/api/table/[tableId]/metadata',
params: tableIdParamsSchema,
body: updateTableMetadataBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ metadata: tableMetadataSchema })),
},
})
export const listTableRowsContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/rows',
params: tableIdParamsSchema,
query: tableRowsQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
rows: z.array(tableRowSchema),
rowCount: z.number(),
totalCount: z.number().nullable(),
limit: z.number(),
offset: z.number(),
})
),
},
})
export const findTableRowsQuerySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
q: z.string().min(1, 'Search query is required'),
filter: domainObjectSchema<Filter>().optional(),
sort: domainObjectSchema<Sort>().optional(),
})
/** One matching cell: its 0-based ordinal in the filtered+sorted view, its row id, and the column name. */
export const tableFindMatchSchema = z.object({
ordinal: z.number().int(),
rowId: z.string(),
/** Stable column id of the matching cell (JSONB storage key), not the display name. */
column: z.string(),
})
export const findTableRowsContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/rows/find',
params: tableIdParamsSchema,
query: findTableRowsQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
matches: z.array(tableFindMatchSchema),
truncated: z.boolean(),
})
),
},
})
export type FindTableRowsQuery = z.input<typeof findTableRowsQuerySchema>
export type FindTableRowsResponse = ContractJsonResponse<typeof findTableRowsContract>
export type TableFindMatch = z.output<typeof tableFindMatchSchema>
export const createTableRowContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/rows',
params: tableIdParamsSchema,
body: insertTableRowBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
row: tableRowSchema,
message: z.string(),
})
),
},
})
export const batchCreateTableRowsContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/rows',
params: tableIdParamsSchema,
body: batchInsertTableRowsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
rows: z.array(tableRowSchema),
insertedCount: z.number(),
message: z.string(),
})
),
},
})
/**
* Server-side contract for `POST /api/table/[tableId]/rows`. Accepts the
* union body so the route can handle single and batch insert in one
* `parseRequest` call. Clients use the resource-specific contracts above
* (`createTableRowContract` / `batchCreateTableRowsContract`) to get a
* narrow response type.
*/
export const insertTableRowsContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/rows',
params: tableIdParamsSchema,
body: insertTableRowsBodySchema,
response: {
mode: 'json',
schema: z.union([
successResponseSchema(
z.object({
row: tableRowSchema,
message: z.string(),
})
),
successResponseSchema(
z.object({
rows: z.array(tableRowSchema),
insertedCount: z.number(),
message: z.string(),
})
),
]),
},
})
export type TableIdParamsInput = z.input<typeof tableIdParamsSchema>
export type TableRowParamsInput = z.input<typeof tableRowParamsSchema>
export type TableRowsQueryInput = z.input<typeof tableRowsQuerySchema>
export type CreateTableBodyInput = z.input<typeof createTableBodySchema>
export type CreateTableColumnBodyInput = z.input<typeof createTableColumnBodySchema>
export type UpdateTableColumnBodyInput = z.input<typeof updateTableColumnBodySchema>
export type InsertTableRowBodyInput = z.input<typeof insertTableRowBodySchema>
export type BatchInsertTableRowsBodyInput = z.input<typeof batchInsertTableRowsBodySchema>
export type BatchUpdateTableRowsBodyInput = z.input<typeof batchUpdateTableRowsBodySchema>
export type UpdateTableRowBodyInput = z.input<typeof updateTableRowBodySchema>
// ============================================================================
// CSV import form schemas
//
// Both `/api/table/import-csv` and `/api/table/[tableId]/import-csv` parse a
// `multipart/form-data` body, so these schemas are validated *form-field by
// form-field* in the routes (not as a single contract body).
// ============================================================================
export const csvFileSchema = z
.unknown()
.superRefine((value, ctx) => {
if (typeof File === 'undefined' || !(value instanceof File)) {
ctx.addIssue({ code: 'custom', message: 'CSV file is required' })
return
}
if (value.size > CSV_MAX_FILE_SIZE_BYTES) {
ctx.addIssue({
code: 'custom',
message: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`,
})
}
})
.transform((value) => value as File)
export const csvImportFormSchema = z.object({
file: csvFileSchema,
workspaceId: z.string({ error: 'Workspace ID is required' }).min(1, 'Workspace ID is required'),
})
export const csvImportModeSchema = z.enum(['append', 'replace'])
export const csvExtensionSchema = z.enum(['csv', 'tsv'], {
error: 'Only CSV and TSV files are supported',
})
/**
* Kickoff body for an asynchronous CSV import into an EXISTING table (append/replace).
* The file is already uploaded to storage; `mapping`/`createColumns` are the client's
* resolved column mapping (the dialog computes them from its preview).
*/
export const importIntoTableAsyncBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
fileKey: z.string().min(1, 'fileKey is required'),
fileName: z.string().min(1, 'fileName is required'),
mode: csvImportModeSchema,
mapping: z.record(z.string(), z.string().nullable()).optional(),
createColumns: z.array(z.string()).optional(),
/**
* IANA zone used to interpret naive datetime strings in the file (Excel and
* Sheets exports carry no offset). Defaults to the importing user's saved
* timezone, else UTC.
*/
timezone: ianaTimezoneSchema.optional(),
})
export type ImportIntoTableAsyncBody = z.input<typeof importIntoTableAsyncBodySchema>
export const importIntoTableAsyncContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/import-async',
params: tableIdParamsSchema,
body: importIntoTableAsyncBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
tableId: z.string(),
importId: z.string(),
})
),
},
})
/**
* `createColumns` form field — a JSON-encoded array of CSV header names that
* the import should auto-create as new columns on the target table.
*/
export const csvImportCreateColumnsSchema = z.unknown().transform((value, ctx): string[] => {
if (typeof value !== 'string') {
ctx.addIssue({ code: 'custom', message: 'createColumns must be valid JSON' })
return z.NEVER
}
try {
const parsed: unknown = JSON.parse(value)
if (!Array.isArray(parsed) || parsed.some((h) => typeof h !== 'string')) {
ctx.addIssue({
code: 'custom',
message: 'createColumns must be a JSON array of CSV header names',
})
return z.NEVER
}
return parsed as string[]
} catch {
ctx.addIssue({ code: 'custom', message: 'createColumns must be valid JSON' })
return z.NEVER
}
})
/**
* `format` query param for the table export route. Lower-cases the input
* before validating against the supported formats and defaults to `'csv'`.
*/
export const tableExportFormatSchema = z
.preprocess(
(value) => (typeof value === 'string' ? value.toLowerCase() : value),
z.enum(['csv', 'json'])
)
.default('csv')
export const exportTableAsyncBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
format: z.enum(['csv', 'json']).default('csv'),
})
export type ExportTableAsyncBody = z.input<typeof exportTableAsyncBodySchema>
/**
* Kickoff for a background export (large tables — small ones use the synchronous streaming
* `/export` route). The worker generates the file, uploads it to workspace storage, and the
* client fetches a presigned URL from the download contract once the job is `ready`.
*/
export const exportTableAsyncContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/export-async',
params: tableIdParamsSchema,
body: exportTableAsyncBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ tableId: z.string(), jobId: z.string() })),
},
})
export const tableJobSummarySchema = z.object({
jobId: z.string(),
tableId: z.string(),
tableName: z.string(),
status: z.enum(['running', 'ready', 'failed', 'canceled']),
rowsProcessed: z.number(),
format: z.enum(['csv', 'json']),
hasResult: z.boolean(),
error: z.string().nullable(),
})
export type TableJobSummary = z.output<typeof tableJobSummarySchema>
export const listTableJobsQuerySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
type: z.literal('export'),
})
/**
* Workspace-scoped job listing the header tray polls. Export-only today: exports are excluded
* from the table-level job derivation (they run concurrently with other jobs), so this is their
* dedicated read path — running jobs plus recently-finished ones for re-download.
*/
export const listTableJobsContract = defineRouteContract({
method: 'GET',
path: '/api/table/jobs',
query: listTableJobsQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ jobs: z.array(tableJobSummarySchema) })),
},
})
export const exportDownloadQuerySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
jobId: z.string().min(1, 'Job ID is required'),
})
/** Resolves a completed export job to a short-lived presigned download URL. */
export const exportDownloadContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/export/download',
params: tableIdParamsSchema,
query: exportDownloadQuerySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ url: z.string().min(1), fileName: z.string() })),
},
})
/**
* `mapping` form field — a JSON-encoded `CsvHeaderMapping` (CSV header →
* column name, or `null` to skip the header).
*/
export const csvImportMappingSchema = z.unknown().transform((value, ctx): CsvHeaderMapping => {
if (typeof value !== 'string') {
ctx.addIssue({ code: 'custom', message: 'mapping must be valid JSON' })
return z.NEVER
}
try {
const parsed: unknown = JSON.parse(value)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
ctx.addIssue({
code: 'custom',
message: 'mapping must be a JSON object mapping CSV headers to column names',
})
return z.NEVER
}
return parsed as CsvHeaderMapping
} catch {
ctx.addIssue({ code: 'custom', message: 'mapping must be valid JSON' })
return z.NEVER
}
})
export const upsertTableRowContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/rows/upsert',
params: tableIdParamsSchema,
body: upsertTableRowBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
row: tableRowSchema,
operation: z.enum(['insert', 'update']),
message: z.string(),
})
),
},
})
export const updateTableRowContract = defineRouteContract({
method: 'PATCH',
path: '/api/table/[tableId]/rows/[rowId]',
params: tableRowParamsSchema,
body: updateTableRowBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
row: tableRowSchema,
message: z.string(),
})
),
},
})
export const batchUpdateTableRowsContract = defineRouteContract({
method: 'PATCH',
path: '/api/table/[tableId]/rows',
params: tableIdParamsSchema,
body: batchUpdateTableRowsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
message: z.string(),
updatedCount: z.number(),
updatedRowIds: z.array(z.string()),
})
),
},
})
export const deleteTableRowContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]/rows/[rowId]',
params: tableRowParamsSchema,
body: deleteTableRowBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
message: z.string(),
deletedCount: z.number(),
})
),
},
})
export const enrichmentDetailParamsSchema = tableRowParamsSchema.extend({
groupId: z.string().min(1),
})
/**
* Per-(row, group) enrichment cascade breakdown. Modeled as a domain object so
* the `EnrichmentRunDetail` TS type stays the single source of truth (matching
* `tableRowSchema` / `tableDefinitionSchema`). `null` when the cell has no
* recorded run or the run predates this feature.
*/
export const getEnrichmentDetailContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/rows/[rowId]/enrichment/[groupId]',
params: enrichmentDetailParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({ detail: domainObjectSchema<EnrichmentRunDetail>().nullable() })
),
},
})
export type GetEnrichmentDetailResponse = ContractJsonResponse<typeof getEnrichmentDetailContract>
export const deleteTableRowsContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]/rows',
params: tableIdParamsSchema,
body: deleteTableRowsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
message: z.string(),
deletedCount: z.number(),
deletedRowIds: z.array(z.string()),
requestedCount: z.number().optional(),
missingRowIds: z.array(z.string()).optional(),
})
),
},
})
/**
* Kickoff body for an asynchronous "select all" delete. Sends the active filter (and an optional
* exclusion set for "select all then deselect a few") instead of every row id, so the background
* worker deletes in paginated batches. Omitting `filter` deletes the whole table (at the cutoff).
*/
export const deleteTableRowsAsyncBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: nonEmptyFilterSchema.optional(),
excludeRowIds: z
.array(z.string().min(1))
.max(
TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS,
`Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows`
)
.optional(),
/** Display-only doomed-row estimate (the filtered total minus deselections the client just
* showed). Persisted on the job so list/detail counts can subtract the not-yet-deleted
* remainder mid-job; clamped server-side, never used to scope the delete itself. */
estimatedCount: z.number().int().min(0).optional(),
})
export type DeleteTableRowsAsyncBody = z.input<typeof deleteTableRowsAsyncBodySchema>
export const deleteTableRowsAsyncContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/delete-async',
params: tableIdParamsSchema,
body: deleteTableRowsAsyncBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ tableId: z.string(), jobId: z.string() })),
},
})
// ============================================================================
// Workflow group contracts (`/api/table/[tableId]/groups`, `/cancel-runs`,
// `/columns/run`, `/rows/run`, `/rows/[rowId]/cells/[groupId]/run`)
// ============================================================================
const workflowGroupOutputSchema = z.object({
// Workflow outputs carry blockId/path; enrichment outputs carry outputId and
// leave these empty. `.default('')` keeps the parsed value a plain string.
blockId: z.string().default(''),
path: z.string().default(''),
outputId: z.string().optional(),
columnName: z.string().min(1),
})
const workflowGroupDependenciesSchema = z.object({
columns: z.array(z.string()).optional(),
})
const workflowGroupTypeSchema = z.enum(['manual', 'enrichment'])
/** Which workflow state a group's per-cell runs execute against: `'live'` (the
* editable draft) or `'deployed'` (the latest active deployment). Defaults to
* `'live'` when omitted. */
const workflowGroupDeploymentModeSchema = z.enum(['live', 'deployed'])
/** One workflow Start-block input field ← one table column. */
const workflowGroupInputMappingSchema = z.object({
inputName: z.string().min(1, 'inputName cannot be empty'),
columnName: z.string().min(1, 'columnName cannot be empty'),
})
const workflowGroupOutputColumnSchema = z.object({
name: z.string().min(1),
type: columnTypeSchema,
required: z.boolean().optional(),
unique: z.boolean().optional(),
workflowGroupId: z.string().min(1),
})
export const groupIdParamsSchema = tableIdParamsSchema.extend({
groupId: z.string().min(1),
})
export const addWorkflowGroupBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
group: z.object({
id: z.string().min(1),
/** Workflow id for manual groups; `''` (or omitted) for enrichment groups. */
workflowId: z.string().default(''),
/** Registry enrichment id for enrichment groups. */
enrichmentId: z.string().min(1).optional(),
name: z.string().optional(),
/** Provenance of the group; defaults to `'manual'` when omitted. */
type: workflowGroupTypeSchema.optional(),
dependencies: workflowGroupDependenciesSchema.optional(),
outputs: z.array(workflowGroupOutputSchema).min(1),
/** Maps the workflow's Start-block inputs to table columns. */
inputMappings: z.array(workflowGroupInputMappingSchema).optional(),
/** Which workflow state per-cell runs execute against. Defaults to `'live'`. */
deploymentMode: workflowGroupDeploymentModeSchema.optional(),
/** When `false`, the group never auto-fires from the scheduler — it can
* only be triggered manually. Defaults to `true`. Persisted on the
* group; distinct from the top-level `autoRun` below which is a
* one-shot "schedule existing rows on creation" flag. */
autoRun: z.boolean().optional(),
}),
outputColumns: z.array(workflowGroupOutputColumnSchema).min(1),
/** When false, skip auto-scheduling existing rows after the group is added.
* Defaults to true so UI adds populate cells immediately; the Mothership
* tool sends `false` so the AI can stage groups without firing runs. */
autoRun: z.boolean().optional(),
})
/**
* Re-points an existing column to a different workflow output. Use when the
* user changes which `(blockId, path)` flows into a column they already have,
* without restructuring the rest of the group's outputs. Distinct from the
* `outputs` add/remove diff: the column keeps its identity, type, deps, and
* row position; only its source mapping changes. Existing row values for the
* column are backfilled from saved execution logs at the new `(blockId, path)`
* — rows whose log has no value for the new mapping end up empty.
*/
const workflowGroupMappingUpdateSchema = z.object({
columnName: z.string().min(1),
blockId: z.string().min(1),
path: z.string().min(1),
})
export const updateWorkflowGroupBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
groupId: z.string().min(1),
workflowId: z.string().min(1).optional(),
name: z.string().optional(),
dependencies: workflowGroupDependenciesSchema.optional(),
outputs: z.array(workflowGroupOutputSchema).optional(),
newOutputColumns: z.array(workflowGroupOutputColumnSchema).optional(),
/**
* Per-column mapping swaps: keep the column, change the source `(blockId,
* path)`. Applied before the `outputs` add/remove diff. Each entry's
* `columnName` must already exist in the group's outputs.
*/
mappingUpdates: z.array(workflowGroupMappingUpdateSchema).optional(),
/** Replace the group's input mappings. Omit to leave unchanged. */
inputMappings: z.array(workflowGroupInputMappingSchema).optional(),
/** Change which workflow state the group runs against. Omit to leave unchanged. */
deploymentMode: workflowGroupDeploymentModeSchema.optional(),
/** Update the group's provenance. Omit to leave unchanged. */
type: workflowGroupTypeSchema.optional(),
/** Toggle the group's persisted auto-run flag. Omit to leave unchanged. */
autoRun: z.boolean().optional(),
})
export const deleteWorkflowGroupBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
groupId: z.string().min(1),
})
const workflowGroupColumnsResponseSchema = successResponseSchema(
z.object({
columns: z.array(z.unknown()),
workflowGroups: z.array(z.unknown()),
})
)
export const addWorkflowGroupContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/groups',
params: tableIdParamsSchema,
body: addWorkflowGroupBodySchema,
response: {
mode: 'json',
schema: workflowGroupColumnsResponseSchema,
},
})
export const updateWorkflowGroupContract = defineRouteContract({
method: 'PATCH',
path: '/api/table/[tableId]/groups',
params: tableIdParamsSchema,
body: updateWorkflowGroupBodySchema,
response: {
mode: 'json',
schema: workflowGroupColumnsResponseSchema,
},
})
export const deleteWorkflowGroupContract = defineRouteContract({
method: 'DELETE',
path: '/api/table/[tableId]/groups',
params: tableIdParamsSchema,
body: deleteWorkflowGroupBodySchema,
response: {
mode: 'json',
schema: workflowGroupColumnsResponseSchema,
},
})
/**
* Cancel scopes:
* - `all` — every running/pending cell in the table; with `filter`, only
* cells on rows matching it (filtered "select all" Stop)
* - `row` — every running/pending cell for a specific row (`rowId` required)
*/
export const cancelTableRunsBodySchema = z
.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
scope: z.enum(['all', 'row']),
rowId: z.string().min(1).optional(),
filter: domainObjectSchema<Filter>().optional(),
/** Scope-`all` only: rows deselected from the selection — their cells keep running. */
excludeRowIds: z
.array(z.string().min(1))
.max(
TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS,
`Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows`
)
.optional(),
})
.superRefine((value, ctx) => {
if (value.scope === 'row' && !value.rowId) {
ctx.addIssue({
code: 'custom',
path: ['rowId'],
message: 'rowId is required when scope is "row"',
})
}
if (value.scope === 'row' && value.filter) {
ctx.addIssue({
code: 'custom',
path: ['filter'],
message: 'filter only applies to scope "all"',
})
}
if (value.scope === 'row' && value.excludeRowIds) {
ctx.addIssue({
code: 'custom',
path: ['excludeRowIds'],
message: 'excludeRowIds only applies to scope "all"',
})
}
})
export const cancelTableRunsContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/cancel-runs',
params: tableIdParamsSchema,
body: cancelTableRunsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ cancelled: z.number() })),
},
})
export const cancelTableJobBodySchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
jobId: z.string().min(1, 'Job ID is required'),
})
/**
* Cancel an in-flight async table job (import or delete). The worker stops at its next ownership
* check; committed work (inserted/deleted rows) is left in place.
*/
export const cancelTableJobContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/job/cancel',
params: tableIdParamsSchema,
body: cancelTableJobBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.object({ canceled: z.boolean() })),
},
})
export type CancelTableJobBody = z.input<typeof cancelTableJobBodySchema>
/**
* Run modes for `POST /api/table/[tableId]/columns/run`:
* - `all` — every dep-satisfied row not already running/pending
* - `incomplete` — same, but additionally restricted to rows whose group has
* never run, or whose last run ended in `failed`/`aborted`
*
* Field is named `runMode` (not `mode`) to disambiguate from the table-import
* `mode` arg (`append` / `replace`) which lives on a different op.
*/
/**
* Run a set of workflow groups across the table or a row subset. The single
* canonical user-driven run op — every UI gesture (single cell, per-row Play,
* action-bar Play/Refresh, column-header menu) reduces to a `groupIds` +
* optional `rowIds` shape. AI uses the `run_column` tool op.
*/
/**
* Optional cap on how much work the dispatch does before completing. The
* discriminated `type` keeps it extensible — only `'rows'` exists today
* (`max` = number of eligible rows to run before stopping), but future kinds
* (`'cells'`, `'cost'`, …) can extend the union without reshaping the request.
*/
export const runLimitSchema = z.object({
type: z.literal('rows'),
max: z
.number()
.int('max must be a whole number')
.min(1, 'max must be at least 1')
.max(1_000_000, 'max cannot exceed 1,000,000'),
})
export const runColumnBodySchema = z
.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
groupIds: z.array(z.string().min(1)).min(1),
runMode: z.enum(['all', 'incomplete']).default('all'),
rowIds: z.array(z.string().min(1)).min(1).optional(),
/** "Select all under a filter" — run every row matching this filter instead of `rowIds`. The
* dispatcher walks only matching rows (paginated), so no id list is materialized. */
filter: nonEmptyFilterSchema.optional(),
/** Select-all scope only: rows deselected from the selection — the dispatcher skips them. */
excludeRowIds: z
.array(z.string().min(1))
.max(
TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS,
`Cannot exclude more than ${TABLE_LIMITS.MAX_EXCLUDE_ROW_IDS} rows`
)
.optional(),
/** Cap the run to the first `max` eligible rows. Omit for an unbounded run. */
limit: runLimitSchema.optional(),
})
.refine((data) => !(data.rowIds && data.filter), {
message: 'Provide either filter or rowIds, but not both',
})
.refine((data) => !(data.rowIds && data.excludeRowIds), {
message: 'excludeRowIds only applies to select-all scope (no rowIds)',
})
export const runColumnContract = defineRouteContract({
method: 'POST',
path: '/api/table/[tableId]/columns/run',
params: tableIdParamsSchema,
body: runColumnBodySchema,
response: {
mode: 'json',
/**
* `dispatchId` is the id of the `table_run_dispatches` row created for
* this run. The dispatcher task picks it up and crawls the table row by
* row; clients receive cell + dispatch events via SSE. Null when
* trigger.dev is disabled — in that mode cells run inline in-process and
* no dispatch row is created.
*/
schema: successResponseSchema(z.object({ dispatchId: z.string().min(1).nullable() })),
},
})
export type AddWorkflowGroupBodyInput = z.input<typeof addWorkflowGroupBodySchema>
export type UpdateWorkflowGroupBodyInput = z.input<typeof updateWorkflowGroupBodySchema>
export type DeleteWorkflowGroupBodyInput = z.input<typeof deleteWorkflowGroupBodySchema>
export type CancelTableRunsBodyInput = z.input<typeof cancelTableRunsBodySchema>
export type RunColumnBodyInput = z.input<typeof runColumnBodySchema>
/** Shared `runMode` union — used by every UI / hook / Mothership site that
* builds a run-column payload. Single source of truth for the literal pair. */
export type RunMode = NonNullable<RunColumnBodyInput['runMode']>
/** Run cap shape consumed by hooks/components building a capped run payload. */
export type RunLimit = z.input<typeof runLimitSchema>
/**
* Active dispatch overlay: rows in the scope ahead of `cursor` render as
* `pending` on refresh, so a long Run-all doesn't lose its queued indicators.
* Returned by `GET /api/table/[tableId]/dispatches`; mirrored client-side via
* `kind: 'dispatch'` SSE events.
*/
export const activeDispatchSchema = z.object({
id: z.string(),
status: z.enum(['pending', 'dispatching']),
mode: z.enum(['all', 'incomplete', 'new']),
isManualRun: z.boolean(),
cursor: z.number().int(),
scope: z.object({
groupIds: z.array(z.string()),
rowIds: z.array(z.string()).optional(),
}),
/** Present when the run is capped. The client's "about to run" overlay skips
* capped dispatches — it can't tell which rows ahead of the cursor fall
* within the budget, so it would over-render Queued; the dispatcher's real
* per-row pending stamps cover the actual rows instead. */
limit: runLimitSchema.optional(),
})
export const listActiveDispatchesContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/dispatches',
params: tableIdParamsSchema,
response: {
mode: 'json',
schema: successResponseSchema(
z.object({
dispatches: z.array(activeDispatchSchema),
/** Map rowId → number of in-flight (queued/running/pending) cells on
* that row. Sums to the "X running" badge and drives the per-row
* gutter Run/Stop button. Server-authoritative: refetched on a
* throttle as cell SSE events arrive, plus optimistic stamps on
* run-click. */
runningByRowId: z.record(z.string(), z.number().int().positive()),
/** Whether any in-flight cell is actually claimed by a worker
* (`status === 'running'`) — table-wide, unlike the client's
* loaded-rows view. Drives the header's "Queueing" vs "N running"
* label once the run's active window scrolls past the loaded rows. */
hasRunning: z.boolean(),
})
),
},
})
export type ActiveDispatch = z.output<typeof activeDispatchSchema>
export const tableEventStreamQuerySchema = z.object({
/** Replay cursor: events with `eventId > from` are replayed on connect.
* `0` replays the whole buffer (prune recovery). Absent → the server tails
* from the latest event id — a fresh mount has just fetched current state
* from the DB, so replaying history would only rewind it. */
from: z.preprocess((value) => {
if (typeof value !== 'string') return undefined
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
}, z.number().int().min(0).optional()),
})
export const tableEventStreamContract = defineRouteContract({
method: 'GET',
path: '/api/table/[tableId]/events/stream',
params: tableIdParamsSchema,
query: tableEventStreamQuerySchema,
response: {
mode: 'stream',
},
})