chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 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<ElectricColumn> = [
|
||||
{ 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<string, string | null>;
|
||||
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<string> {
|
||||
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<string, string | null> {
|
||||
const skip = effectiveSkipColumns(skipColumns);
|
||||
const value: Record<string, string | null> = {};
|
||||
|
||||
for (const column of RUN_ELECTRIC_COLUMNS) {
|
||||
if (skip.has(column.name)) {
|
||||
continue;
|
||||
}
|
||||
value[column.name] = serializeValue((row as Record<string, unknown>)[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<string, { type: string; dims?: number }> = {};
|
||||
|
||||
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<string, string | null>;
|
||||
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 `<updatedAtMs>_<seq>` 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"');
|
||||
}
|
||||
Reference in New Issue
Block a user