import type { MapRowOutcome } from './durability-store'; import { stringifyPostgresJson } from './postgres-json'; export const RUNTIME_ROW_OUTPUT_NOT_JSON_SERIALIZABLE = 'RUNTIME_ROW_OUTPUT_NOT_JSON_SERIALIZABLE'; const RUNTIME_ROW_OUTPUT_NOT_JSON_SERIALIZABLE_MESSAGE = `${RUNTIME_ROW_OUTPUT_NOT_JSON_SERIALIZABLE}: Row output is not JSON-serializable. ` + 'Remove BigInt values, circular references, and throwing toJSON implementations.'; export type RuntimePreparedCompletedRow = { key: string; input_index: number | null; data_patch: Record; cell_meta_patch: Record; /** Serialized once at the row boundary so SQL batching cannot re-run toJSON. */ data_patch_json: string; /** Serialized once at the row boundary so SQL batching cannot re-run toJSON. */ cell_meta_patch_json: string; }; export type RuntimePreparedFailedRow = RuntimePreparedCompletedRow & { /** Row-level error persisted to `_error`; never empty. */ error: string; }; export function normalizeRuntimeMapInputIndex(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value)) { return null; } const normalized = Math.floor(value); return normalized >= 0 ? normalized : null; } export function completedRuntimeCellMetaPatch(input: { runId: string; outputFields: readonly string[]; rowPatch?: Record; nowMs?: number; }): Record { const patch: Record = {}; const completedAt = input.nowMs ?? Date.now(); for (const field of input.outputFields) { const existing = input.rowPatch?.[field] && typeof input.rowPatch[field] === 'object' && !Array.isArray(input.rowPatch[field]) ? (input.rowPatch[field] as Record) : {}; patch[field] = { status: 'completed', runId: input.runId, completedAt, ...existing, }; } for (const [field, meta] of Object.entries(input.rowPatch ?? {})) { if (!Object.hasOwn(patch, field)) { patch[field] = meta; } } return patch; } function serializeRuntimeRowPatch(value: Record): string { const serialized = stringifyPostgresJson(value); if (serialized === undefined) { throw new TypeError('JSON.stringify returned undefined.'); } return serialized; } function unserializableRuntimeRowFailure(input: { key: string; inputIndex: number | null; runId: string; outputFields: readonly string[]; }): RuntimePreparedFailedRow { let cellMetaPatch = Object.fromEntries( input.outputFields.map((field) => [ field, { status: 'failed', runId: input.runId, error: RUNTIME_ROW_OUTPUT_NOT_JSON_SERIALIZABLE_MESSAGE, }, ]), ); let cellMetaPatchJson: string; try { cellMetaPatchJson = serializeRuntimeRowPatch(cellMetaPatch); } catch { // Authored output field names can themselves collide after PostgreSQL JSON // normalization. Preserve the explicit row failure instead of allowing // diagnostic metadata to turn it back into a batch-fatal exception. cellMetaPatch = {}; cellMetaPatchJson = '{}'; } return { key: input.key, input_index: input.inputIndex, data_patch: {}, cell_meta_patch: cellMetaPatch, data_patch_json: '{}', cell_meta_patch_json: cellMetaPatchJson, error: RUNTIME_ROW_OUTPUT_NOT_JSON_SERIALIZABLE_MESSAGE, }; } export function prepareRuntimeSheetRowTransitions(input: { rows: Iterable; runId: string; outputFields: readonly string[]; }): { completedRows: RuntimePreparedCompletedRow[]; failedRows: RuntimePreparedFailedRow[]; } { const completedRows: RuntimePreparedCompletedRow[] = []; const failedRows: RuntimePreparedFailedRow[] = []; for (const row of input.rows) { if (!row.key) continue; const inputIndex = normalizeRuntimeMapInputIndex(row.inputIndex); let cellMetaPatch: Record; let dataPatchJson: string; let cellMetaPatchJson: string; try { cellMetaPatch = row.status === 'failed' ? (row.cellMetaPatch ?? {}) : completedRuntimeCellMetaPatch({ runId: input.runId, outputFields: input.outputFields, rowPatch: row.cellMetaPatch, }); dataPatchJson = serializeRuntimeRowPatch(row.data); cellMetaPatchJson = serializeRuntimeRowPatch(cellMetaPatch); } catch { failedRows.push( unserializableRuntimeRowFailure({ key: row.key, inputIndex, runId: input.runId, outputFields: input.outputFields, }), ); continue; } if (row.status === 'failed') { failedRows.push({ key: row.key, input_index: inputIndex, data_patch: row.data, cell_meta_patch: cellMetaPatch, data_patch_json: dataPatchJson, cell_meta_patch_json: cellMetaPatchJson, error: typeof row.error === 'string' && row.error.trim() ? row.error : 'Row execution failed.', }); continue; } completedRows.push({ key: row.key, input_index: inputIndex, data_patch: row.data, cell_meta_patch: cellMetaPatch, data_patch_json: dataPatchJson, cell_meta_patch_json: cellMetaPatchJson, }); } return { completedRows, failedRows }; } /** * Apply the row serialization boundary before a gateway-only runtime request. * This prevents JSON.stringify on the transport envelope from turning one * malformed row into a batch-fatal error before the gateway can persist the * healthy siblings. */ export function prepareRuntimeSheetRowsForJsonTransport(input: { rows: Iterable; runId: string; outputFields: readonly string[]; }): MapRowOutcome[] { const rows: MapRowOutcome[] = []; for (const row of input.rows) { const { completedRows, failedRows } = prepareRuntimeSheetRowTransitions({ rows: [row], runId: input.runId, outputFields: input.outputFields, }); const prepared = completedRows[0] ?? failedRows[0]; if (!prepared) continue; const failed = failedRows[0]; rows.push({ key: prepared.key, inputIndex: prepared.input_index, data: JSON.parse(prepared.data_patch_json) as Record, cellMetaPatch: JSON.parse(prepared.cell_meta_patch_json) as Record< string, unknown >, ...(failed ? { status: 'failed' as const, error: failed.error } : {}), }); } return rows; }