import { type ElectricColumnType, RUN_ELECTRIC_COLUMNS, serializeRunRow, } from "./electricStreamProtocol.server"; import { type RunHydrator, type RunListFilter, type RunListResolver } from "./runReader.server"; /** * Dual-run shadow-compare: the client is always served the Electric response while this re-derives what * the native backend would emit and diffs the two, to prove parity on real traffic before cutover. Checks * serialization (semantic per-column compare, gated on same updatedAt so a changed row is "skew", not a * divergence) and membership (emitted id-set, only on tag/batch initial snapshots). Pure but for the injected deps. */ export type ShadowFeed = "run" | "runs" | "batch"; type WireValue = Record; type ShapeMessage = { key?: string; value?: WireValue; headers: { operation?: string; control?: string }; }; const COLUMN_BY_NAME = new Map(RUN_ELECTRIC_COLUMNS.map((column) => [column.name, column])); export type ColumnDiff = { runId: string; column: string; electric: string | null; native: string | null; }; export type ShadowCompareOutcome = { feed: ShadowFeed; /** Runs whose every emitted column matched (same-version). */ serializationMatched: number; /** Runs with at least one semantic column divergence (same-version). */ serializationDiverged: number; /** Runs that changed between Electric's emit and our refetch (not a divergence). */ serializationSkew: number; /** Per-column divergences (capped) for logging. */ diffs: ColumnDiff[]; /** Set membership (tag/batch initial snapshot only). undefined when not checked. */ membershipMatch?: boolean; missingInNative?: string[]; extraInNative?: string[]; }; export type ShadowCompareInput = { feed: ShadowFeed; /** The served Electric response body (a JSON array of messages, or "" / "[]"). */ electricBody: string; environment: { id: string }; skipColumns: string[]; /** True when this was an initial snapshot request (offset=-1); enables membership compare. */ isInitialSnapshot: boolean; /** When set (tag/batch initial snapshot), compare the resolved id-set. */ membershipFilter?: RunListFilter; }; const MAX_DIFFS = 20; export class RealtimeShadowComparator { constructor( private readonly options: { runReader: RunHydrator; runListResolver: RunListResolver } ) {} async compare(input: ShadowCompareInput): Promise { const messages = parseBody(input.electricBody); const changes = messages.filter( (m): m is ShapeMessage & { value: WireValue } => typeof m.headers?.operation === "string" && !!m.value && m.headers.operation !== "delete" ); const outcome: ShadowCompareOutcome = { feed: input.feed, serializationMatched: 0, serializationDiverged: 0, serializationSkew: 0, diffs: [], }; // Bulk-hydrate every emitted run in one query rather than a per-message round // trip, so shadow mode doesn't inflate the very replica load it's measuring. const emittedIds = changes .map((m) => m.value.id) .filter((id): id is string => typeof id === "string"); const hydrated = await this.options.runReader.hydrateByIds(input.environment.id, emittedIds); const rowsById = new Map(hydrated.map((row) => [row.id, row])); for (const message of changes) { const runId = message.value.id ?? undefined; if (!runId) { continue; } const row = rowsById.get(runId); if (!row) { // Run no longer readable (deleted / replica miss). Not a serialization divergence. outcome.serializationSkew++; continue; } const nativeValue = serializeRunRow(row, input.skipColumns); // Only compare rows at the same version; otherwise the row advanced between // Electric's emit and our refetch (timing skew, not a divergence). if (!sameInstant(message.value.updatedAt, nativeValue.updatedAt)) { outcome.serializationSkew++; continue; } let rowDiverged = false; for (const [column, electricRaw] of Object.entries(message.value)) { const meta = COLUMN_BY_NAME.get(column); if (!meta) { continue; } const nativeRaw = nativeValue[column] ?? null; if (!valuesEqual(electricRaw, nativeRaw, meta.type, meta.dims, column)) { rowDiverged = true; if (outcome.diffs.length < MAX_DIFFS) { outcome.diffs.push({ runId, column, electric: electricRaw, native: nativeRaw }); } } } if (rowDiverged) { outcome.serializationDiverged++; } else { outcome.serializationMatched++; } } if (input.isInitialSnapshot && input.membershipFilter) { const electricIds = new Set( changes.map((m) => m.value.id).filter((id): id is string => typeof id === "string") ); const nativeIds = new Set( await this.options.runListResolver.resolveMatchingRunIds(input.membershipFilter) ); outcome.missingInNative = [...electricIds].filter((id) => !nativeIds.has(id)); outcome.extraInNative = [...nativeIds].filter((id) => !electricIds.has(id)); outcome.membershipMatch = outcome.missingInNative.length === 0 && outcome.extraInNative.length === 0; } return outcome; } } function parseBody(body: string): ShapeMessage[] { const text = body.trim(); if (!text) { return []; } try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? (parsed as ShapeMessage[]) : []; } catch { return []; } } /** Status carries a known legacy rewrite (DEQUEUED -> EXECUTING) applied equally to * both paths for non-current API versions; treat them as equivalent. */ function normalizeStatus(value: string): string { return value === "DEQUEUED" ? "EXECUTING" : value; } function sameInstant(a: string | null | undefined, b: string | null | undefined): boolean { if (a == null || b == null) { return a == null && b == null; } // Mirror the SDK's RawShapeDate (`new Date(val + "Z")`). return new Date(`${a}Z`).getTime() === new Date(`${b}Z`).getTime(); } function valuesEqual( electricRaw: string | null, nativeRaw: string | null, type: ElectricColumnType, dims: number | undefined, column: string ): boolean { if (electricRaw == null || nativeRaw == null) { return electricRaw == null && nativeRaw == null; } if (dims && dims > 0) { return arraysEqual(parsePgTextArray(electricRaw), parsePgTextArray(nativeRaw)); } switch (type) { case "timestamp": return new Date(`${electricRaw}Z`).getTime() === new Date(`${nativeRaw}Z`).getTime(); case "bool": return parseBool(electricRaw) === parseBool(nativeRaw); case "int4": case "int8": case "float8": return Number(electricRaw) === Number(nativeRaw); case "jsonb": return jsonEqual(electricRaw, nativeRaw); case "text": default: if (column === "status") { return normalizeStatus(electricRaw) === normalizeStatus(nativeRaw); } return electricRaw === nativeRaw; } } function parseBool(value: string): boolean { return value === "t" || value === "true"; } function jsonEqual(a: string, b: string): boolean { try { return deepEqual(JSON.parse(a), JSON.parse(b)); } catch { return a === b; } } function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b || a === null || b === null) return false; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((v, i) => deepEqual(v, b[i])); } if (typeof a === "object" && typeof b === "object") { const ak = Object.keys(a as object).sort(); const bk = Object.keys(b as object).sort(); return ( ak.length === bk.length && ak.every((k, i) => k === bk[i]) && ak.every((k) => deepEqual((a as any)[k], (b as any)[k])) ); } return false; } function arraysEqual(a: string[], b: string[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } /** Parse a Postgres text-array literal (`{"a","b"}` / `{}`). Mirrors the client's pgArrayParser. */ function parsePgTextArray(literal: string): string[] { if (literal === "{}" || literal === "") { return []; } const inner = literal.startsWith("{") && literal.endsWith("}") ? literal.slice(1, -1) : literal; const result: string[] = []; let i = 0; while (i < inner.length) { if (inner[i] === '"') { i++; let s = ""; while (i < inner.length && inner[i] !== '"') { if (inner[i] === "\\") { i++; } s += inner[i]; i++; } result.push(s); i++; if (inner[i] === ",") i++; } else { let s = ""; while (i < inner.length && inner[i] !== ",") { s += inner[i]; i++; } result.push(s); if (inner[i] === ",") i++; } } return result; }