/** * Pure (no DB/Redis/env) Electric HTTP shape-stream wire serializer, byte-faithful to what the * deployed `@electric-sql/client` (1.0.14 + 0.4.0) and the SDK's `SubscribeRunRawShape` expect. * Each column value is wire-encoded as a string (or null) decoded via the `electric-schema` header; * `up-to-date` is the only control message that makes the client emit, and re-sending a full row is idempotent. */ export type ElectricColumnType = | "text" | "timestamp" | "int4" | "int8" | "float8" | "bool" | "jsonb"; type ElectricColumn = { name: string; type: ElectricColumnType; /** Array dimensionality. 1 => `type[]` (Postgres `{a,b}` literal). */ dims?: number; /** Array columns only: true when the column has no SQL default, so an empty value emits `null` (not `{}`). Prisma erases this distinction, so we re-derive it here. */ emptyArrayAsNull?: boolean; }; /** Columns the realtime run feed exposes; keep in sync with `DEFAULT_ELECTRIC_COLUMNS`. `type`/`dims` drive the schema header and value encoding. */ export const RUN_ELECTRIC_COLUMNS: ReadonlyArray = [ { name: "id", type: "text" }, { name: "taskIdentifier", type: "text" }, { name: "createdAt", type: "timestamp" }, { name: "updatedAt", type: "timestamp" }, { name: "startedAt", type: "timestamp" }, { name: "delayUntil", type: "timestamp" }, { name: "queuedAt", type: "timestamp" }, { name: "expiredAt", type: "timestamp" }, { name: "completedAt", type: "timestamp" }, { name: "friendlyId", type: "text" }, { name: "number", type: "int4" }, { name: "isTest", type: "bool" }, { name: "status", type: "text" }, { name: "usageDurationMs", type: "int4" }, { name: "costInCents", type: "float8" }, { name: "baseCostInCents", type: "float8" }, { name: "ttl", type: "text" }, { name: "payload", type: "text" }, { name: "payloadType", type: "text" }, { name: "metadata", type: "text" }, { name: "metadataType", type: "text" }, { name: "output", type: "text" }, { name: "outputType", type: "text" }, { name: "runTags", type: "text", dims: 1, emptyArrayAsNull: true }, { name: "error", type: "jsonb" }, { name: "realtimeStreams", type: "text", dims: 1 }, ]; /** Columns that can never be skipped via `skipColumns` (mirrors realtimeClient). */ export const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"]; /** A single run hydrated for the realtime feed; structurally compatible with the `RunHydrator` Prisma `TaskRun` projection. */ export type RealtimeRunRow = { id: string; taskIdentifier: string; createdAt: Date; updatedAt: Date; startedAt: Date | null; delayUntil: Date | null; queuedAt: Date | null; expiredAt: Date | null; completedAt: Date | null; friendlyId: string; number: number; isTest: boolean; status: string; usageDurationMs: number; costInCents: number; baseCostInCents: number; ttl: string | null; payload: string; payloadType: string; metadata: string | null; metadataType: string; output: string | null; outputType: string; runTags: string[]; error: unknown; realtimeStreams: string[]; }; type Operation = "insert" | "update" | "delete"; type ChangeMessage = { key: string; value: Record; headers: { operation: Operation }; }; type ControlMessage = { headers: { control: "up-to-date" | "must-refetch" }; }; type ShapeMessage = ChangeMessage | ControlMessage; const UP_TO_DATE: ControlMessage = { headers: { control: "up-to-date" } }; function effectiveSkipColumns(skipColumns: string[]): Set { return new Set(skipColumns.filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c))); } function quoteArrayElement(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } function pgArrayLiteral(values: unknown[]): string { if (values.length === 0) { return "{}"; } return `{${values.map((v) => quoteArrayElement(String(v))).join(",")}}`; } function serializeValue(value: unknown, column: ElectricColumn): string | null { if (value === null || value === undefined) { return null; } if (column.dims && column.dims > 0) { if (!Array.isArray(value)) { return null; } // A no-default array column stores NULL when empty, so Electric emits `null` // (not `{}`); match that here since Prisma handed us `[]` for the NULL value. if (value.length === 0 && column.emptyArrayAsNull) { return null; } return pgArrayLiteral(value); } switch (column.type) { case "bool": // Postgres text representation; the client's parseBool accepts "t"/"f". return value ? "t" : "f"; case "timestamp": // The SDK's RawShapeDate appends "Z" before parsing, so we emit the ISO // string WITHOUT the trailing "Z". return value instanceof Date ? value.toISOString().slice(0, -1) : String(value); case "jsonb": return JSON.stringify(value); case "int4": case "int8": case "float8": case "text": default: return String(value); } } /** The merge key the client uses to reassemble a row across insert/update cycles. */ export function runShapeKey(runId: string): string { return `"public"."TaskRun"/"${runId}"`; } /** Encode a single run row into the wire `value` object (column -> string|null). */ export function serializeRunRow( row: RealtimeRunRow, skipColumns: string[] = [] ): Record { const skip = effectiveSkipColumns(skipColumns); const value: Record = {}; for (const column of RUN_ELECTRIC_COLUMNS) { if (skip.has(column.name)) { continue; } value[column.name] = serializeValue((row as Record)[column.name], column); } return value; } /** The `electric-schema` response header value for the (optionally trimmed) column set. */ export function buildElectricSchemaHeader(skipColumns: string[] = []): string { const skip = effectiveSkipColumns(skipColumns); const schema: Record = {}; for (const column of RUN_ELECTRIC_COLUMNS) { if (skip.has(column.name)) { continue; } schema[column.name] = column.dims ? { type: column.type, dims: column.dims } : { type: column.type }; } return JSON.stringify(schema); } /** Initial snapshot body: an `insert` for the row (if present) then `up-to-date`; an absent row emits a bare `up-to-date` (empty shape). */ export function buildSnapshotBody(row: RealtimeRunRow | null, skipColumns: string[] = []): string { const messages: ShapeMessage[] = []; if (row) { messages.push({ key: runShapeKey(row.id), value: serializeRunRow(row, skipColumns), headers: { operation: "insert" }, }); } messages.push(UP_TO_DATE); return JSON.stringify(messages); } /** Live body when the row advanced: a full-row `update` followed by `up-to-date`. */ export function buildUpdateBody(row: RealtimeRunRow, skipColumns: string[] = []): string { const messages: ShapeMessage[] = [ { key: runShapeKey(row.id), value: serializeRunRow(row, skipColumns), headers: { operation: "update" }, }, UP_TO_DATE, ]; return JSON.stringify(messages); } /** Live body when nothing advanced: a bare `up-to-date` (no row emission). */ export function buildUpToDateBody(): string { return JSON.stringify([UP_TO_DATE]); } export type RowChange = { row: RealtimeRunRow; operation: "insert" | "update" }; /** Multi-row body for the tag-list feed: one change message per row then `up-to-date` (empty `changes` emits a bare `up-to-date`). */ export function buildRowsBody(changes: RowChange[], skipColumns: string[] = []): string { const messages: ShapeMessage[] = changes.map((change) => ({ key: runShapeKey(change.row.id), value: serializeRunRow(change.row, skipColumns), headers: { operation: change.operation }, })); messages.push(UP_TO_DATE); return JSON.stringify(messages); } /** A row change whose wire `value` was already serialized (once, shared across feeds by * the EnvChangeRouter); the per-feed `operation` is applied here. */ export type SerializedRowChange = { runId: string; value: Record; operation: "insert" | "update"; }; /** Like `buildRowsBody`, but from values serialized once per (runId, columnSet) upstream, * so a run matching many feeds is serialized once and reused across their bodies. */ export function buildRowsBodyFromSerialized(changes: SerializedRowChange[]): string { const messages: ShapeMessage[] = changes.map((change) => ({ key: runShapeKey(change.runId), value: change.value, headers: { operation: change.operation }, })); messages.push(UP_TO_DATE); return JSON.stringify(messages); } export const INITIAL_OFFSET = "-1"; /** Opaque `_` offset token (client `${number}_${number}` type); the first segment lets a live request detect whether the row advanced. */ export function encodeOffset(updatedAtMs: number, seq: number): string { return `${Math.trunc(updatedAtMs)}_${Math.trunc(seq)}`; } /** Extract the `updatedAt` epoch-ms a client last saw from its echoed offset. */ export function parseOffsetUpdatedAtMs(offset: string | null | undefined): number { if (!offset) { return 0; } const [first] = offset.split("_"); const value = Number(first); return Number.isFinite(value) && value > 0 ? value : 0; } /** Mirror of realtimeClient's DEQUEUED->EXECUTING rewrite for non-current API versions. */ export function rewriteBodyForLegacyApiVersion(body: string): string { return body.replace(/"status":"DEQUEUED"/g, '"status":"EXECUTING"'); }