import type { ZodType } from "zod"; import { mapReverseRow } from "@jitsu/destination-functions/src/reverse-etl/mapping"; import type { BatchResult, FinishResult, JsonObject, PreparedBatch, ReverseEtlContext, ReverseEtlStream, ReverseEtlWriter, WriteBatch, } from "@jitsu/protocols/reverse-etl"; import { canonicalJson, contentHash, createBufferedSyncStore, } from "@jitsu/destination-functions/src/reverse-etl/identity"; import { ensureNoRejection, ReverseEtlRejectionError, validateBatchResult, validateFinishResult, validateStream, } from "@jitsu/destination-functions/src/reverse-etl/meta"; import { Database, openPersistence, type Effect, type Identity, type RunInput } from "./persistence"; import { effects } from "./persistence/effects"; import { ensure, PersistenceError } from "./persistence/types"; import { reverseEtlFailure } from "@jitsu/destination-functions/src/reverse-etl/failure"; import { rejectionDetailsMessage } from "@jitsu/destination-functions/src/reverse-etl/rejection-details"; /** Pure, deterministic normalization, invoked once per source row, never during delivery/recovery. */ export interface MirrorProjection { rowType: ZodType; /** [] deliberately excludes a valid source row; invalid input/projection must throw, never return []. */ project(row: Row): Identity[]; } export interface SnapshotMirrorAdapter { stream: ReverseEtlStream; projection: MirrorProjection; /** Async batches must resolve independently of finish(); finish-staged delivery is unsafe. */ batchDelivery: "accepted" | "asynchronous"; /** Refresh unchanged desired members through the normal journal after this age. */ refreshAfterMs?: number; } function projectedEnvelope(row: unknown): Effect { const value = row as Effect; const projected = effects([value])[0]; ensure(canonicalJson(projected) === canonicalJson(value), "Invalid persisted mirror effect"); return projected; } const mirrorSession = Symbol("mirror-persistence"); /** Bind the journal to already-normalized Effect envelopes, not an adapter that might hash twice. */ export async function openMirrorPersistence(db: Database, input: RunInput) { ensure(input.mode === "mirror" && input.extraction === "full", "Snapshot mirror requires full extraction"); const run = await openPersistence(db, input, (_, row) => { const { identity, upsert, remove } = projectedEnvelope(row); return [{ identity, upsert, remove }]; }); return { ...run, [mirrorSession]: true as const }; } type MirrorPersistence = Awaited>; type MirrorContext = Pick, "credentials" | "options" | "signal" | "log" | "fetch">; export interface MirrorOptions { persistence: MirrorPersistence; adapter: SnapshotMirrorAdapter; context: MirrorContext; /** Caller verifies an empty/tracked baseline, or exclusive native-replacement authority. */ targetBaseline: "new-empty" | "tracked" | "replace"; maxBatchBytes?: number; } export interface MirrorSourceRecord { key: string; row: Record; } export interface NewMirrorOptions extends MirrorOptions { mapping: Record; objectMappings?: Record>; /** A complete cursorless source. Opens only after Kubernetes admission and writer initialization. */ source(signal: AbortSignal): AsyncIterable; sourcePageSize?: number; /** Core-owned counts after staging a page or durably sealing the complete snapshot. */ onSnapshotProgress?( progress: | { sourceRows: number; sealed: false } | { sourceRows: number; sealed: true; projectedMembers: number; uniqueMembers: number; duplicatesCollapsed: number; } ): Promise; } const mirrorFailureHints = { initialization: "Check destination authorization. If the problem persists, contact support or your Jitsu administrator.", extraction: "Check warehouse connectivity and query timeouts, primary keys and temporary disk capacity (ClickHouse limit: 512 MiB per query). No audience changes were submitted.", validation: "Check identifier mappings, consent and primary keys. No audience changes were submitted.", snapshot: "Check object storage connectivity and permissions, duplicate keys, conflicting identities and temporary disk capacity. No audience changes were submitted.", delivery: "Check destination authorization. Some changes may have been submitted; contact support or your Jitsu administrator before retrying, and do not reset sync state.", }; /** Only stage names and core counters are exposed, never SDK errors or source values. */ export class MirrorRunError extends PersistenceError { constructor(stage: keyof typeof mirrorFailureHints, readRows: number, savedRows: number, error?: unknown) { // A destination's rejection code (already pattern-checked by the error) is shown, like in the run loop. const code = error instanceof ReverseEtlRejectionError && error.code ? ` Reason code: ${error.code}.` : ""; const details = error instanceof ReverseEtlRejectionError ? rejectionDetailsMessage(error.details) : ""; super( `Snapshot mirror stopped during ${stage} (read ${readRows} rows, saved ${savedRows}). ${ reverseEtlFailure(error)?.message ?? mirrorFailureHints[stage] }${code}${details}` ); } } export interface MirrorRecovery { /** Reattach the verified existing session using the recovered context/store; never blindly create a second session. */ attachWriter(context: ReverseEtlContext): Promise>; /** Verify outcomes or perform a provider-proven safe replay of this exact persisted request. */ reconcileBatch?( batch: WriteBatch, action: "upsert" | "remove", saved: BatchResult | undefined, context: ReverseEtlContext ): Promise; reconcileFinish?(saved: FinishResult | undefined, context: ReverseEtlContext): Promise; } /** Convert a journal manifest to the exact provider-ready request, without normalization. */ export function mirrorDeliveryBatch(batch: PreparedBatch): WriteBatch { return { batchId: batch.batchId, records: batch.records.map(record => { const effect = projectedEnvelope(record.row); return { ...record, row: batch.action === "upsert" ? effect.upsert : effect.remove }; }), }; } async function setup(input: MirrorOptions) { const { persistence: run, adapter: { stream }, context, } = input; ensure(run[mirrorSession], "Use mirror-bound persistence for snapshot delivery"); ensure(run.scope.mode === "mirror" && run.scope.extraction === "full", "Snapshot mirror requires full extraction"); const replacement = stream.capabilities.mirror === "native-replace"; ensure( replacement ? input.targetBaseline === "replace" : ["new-empty", "tracked"].includes(input.targetBaseline), "Verified target baseline is required" ); const snapshot = run.core.head.snapshot; if (snapshot) ensure((snapshot.strategy === "native-replace") === replacement, "Snapshot strategy changed"); validateStream(stream); ensure( ["accepted", "asynchronous"].includes(input.adapter.batchDelivery) && ["snapshot-diff", "native-replace"].includes(stream.capabilities.mirror) && stream.capabilities.supportsUpsert && stream.capabilities.supportsExplicitRemove && stream.removeRowType, "Snapshot diff requires per-batch acceptance and explicit removal" ); const options = stream.options.safeParse(context.options); ensure(options.success, "Invalid mirror options"); const maxBytes = input.maxBatchBytes ?? 1_000_000; ensure( Number.isSafeInteger(maxBytes) && maxBytes > 0 && maxBytes <= run.core.db.limits.batchBytes, "Invalid mirror batch byte limit" ); const store = createBufferedSyncStore((await run.core.state()).store); const ctx: ReverseEtlContext = { ...context, ...run.scope, mode: "mirror", fullRefresh: true, options: options.data, store, delivery: run.delivery, }; return { run, stream, ctx, maxBytes, asynchronous: input.adapter.batchDelivery === "asynchronous", replacement, batchSize: Math.min(stream.batchSize, run.core.db.limits.batchRecords), }; } export function validatePayloads(effect: Effect, stream: ReverseEtlStream) { for (const [schema, value] of [ [stream.rowType, effect.upsert], [stream.removeRowType!, effect.remove], ] as const) { const result = schema.safeParse(value); ensure(result.success && canonicalJson(result.data) === canonicalJson(value), "Invalid projected mirror payload"); } } function validateWriter(writer: ReverseEtlWriter) { ensure( typeof writer.init === "function" && typeof writer.upsert === "function" && typeof writer.remove === "function" && typeof writer.finish === "function" && typeof writer.abort === "function", "Incomplete snapshot mirror writer" ); } function hasPending(result: BatchResult, asynchronous: boolean) { ensureNoRejection(result.outcomes); const pending = result.outcomes.some(outcome => outcome.status === "staged"); ensure(!pending || asynchronous, "Staged mirror batches require reconciliation before planning"); ensure(!pending || result.remoteJobIds?.length, "Asynchronous batches require recoverable remote job IDs"); return pending; } function safeError(error?: unknown): PersistenceError | ReverseEtlRejectionError { // Already a fixed core-owned reason with a pattern-checked code: keep it, so the code reaches the user. if (error instanceof ReverseEtlRejectionError) return error; return new PersistenceError( reverseEtlFailure(error)?.reason ?? "Snapshot mirror stopped; inspect durable recovery state before retrying" ); } /** Core-owned full snapshot lifecycle for diff delivery or native replacement. */ export async function runSnapshotMirror(input: NewMirrorOptions) { const env = await setup(input); const { run, stream, ctx } = env; const pageSize = input.sourcePageSize ?? Math.min(1000, run.core.db.limits.batchRecords); ensure( Number.isSafeInteger(pageSize) && pageSize > 0 && pageSize <= run.core.db.limits.batchRecords, "Invalid snapshot source page size" ); ensure( Object.keys(input.mapping).length > 0 && Object.values(input.mapping).every(v => typeof v === "string" && v.length > 0), "Invalid mirror mapping" ); let writer: ReverseEtlWriter | undefined; let uncertain = false; let stage: keyof typeof mirrorFailureHints = "initialization"; let readRows = 0; let savedRows = 0; let projectedMembers = 0; let excludedRows = 0; try { ctx.signal.throwIfAborted(); await run.delivery.assertReady(); await run.snapshots.start(input.adapter.refreshAfterMs, env.replacement ? "native-replace" : "snapshot-diff"); await run.delivery.prepareInit(ctx.store.snapshot()); ctx.signal.throwIfAborted(); uncertain = true; writer = await stream.createWriter(ctx); validateWriter(writer); await run.core.recoveryStatus(); ctx.signal.throwIfAborted(); await writer.init(); await run.delivery.acknowledgeInit(ctx.store.snapshot()); uncertain = false; let page: { key: string; identities: Identity[] }[] = []; let pageBytes = 2; let sequence = 0; const flush = async () => { if (!page.length) return; stage = "snapshot"; ctx.signal.throwIfAborted(); await run.snapshots.append(page, ++sequence); savedRows += page.length; page = []; pageBytes = 2; await input.onSnapshotProgress?.({ sourceRows: savedRows, sealed: false }); }; ctx.signal.throwIfAborted(); stage = "extraction"; for await (const record of input.source(ctx.signal)) { stage = "validation"; readRows++; ctx.signal.throwIfAborted(); ensure(/^[a-f0-9]{64}$/.test(record.key), "Invalid mirror source key"); const mapped = mapReverseRow(record.row, input); const parsed = input.adapter.projection.rowType.safeParse(mapped); ensure(parsed.success, "Source row failed mirror validation"); const projected = effects(input.adapter.projection.project(parsed.data), { allowEmpty: true }); // A source row may produce zero or multiple members, so sourceRows minus // uniqueMembers is not a valid duplicate count. projectedMembers += projected.length; if (!projected.length) excludedRows++; for (const value of projected) validatePayloads(value, stream); const serialized = canonicalJson({ key: record.key, identities: projected.map(({ identity, upsert, remove }) => ({ identity, upsert, remove })), }); const row: { key: string; identities: Identity[] } = JSON.parse(serialized); const bytes = Buffer.byteLength(serialized); ensure(bytes + 2 <= run.core.db.limits.batchBytes, "Snapshot source row exceeds byte budget"); if (page.length && (page.length === pageSize || pageBytes + bytes + 1 > run.core.db.limits.batchBytes)) await flush(); pageBytes += bytes + (page.length ? 1 : 0); page.push(row); stage = "extraction"; } await flush(); stage = "snapshot"; ctx.signal.throwIfAborted(); const { uniqueMembers } = await run.snapshots.seal({ projectedMembers, excludedRows }); await input.onSnapshotProgress?.({ sourceRows: savedRows, sealed: true, projectedMembers, uniqueMembers, duplicatesCollapsed: projectedMembers - uniqueMembers, }); stage = "delivery"; return await deliver(env, writer, value => { uncertain = value; }); } catch (error) { if (writer && !uncertain) { try { await run.delivery.prepareAbort(); await writer.abort(ctx.signal.aborted ? "cancelled" : "error"); await run.delivery.acknowledgeAbort(); } catch { /* Preserve original failure and all unresolved evidence. */ } } throw new MirrorRunError(stage, readRows, savedRows, error); } } /** Resume only sealed input using its persisted payloads and an explicitly reattached writer. */ export async function resumeSnapshotMirror(input: MirrorOptions, recovery: MirrorRecovery) { const env = await setup(input); const { run, ctx } = env; try { ctx.signal.throwIfAborted(); ensure(run.recovery, "Mirror resume requires a recovery session"); const status = await run.core.recoveryStatus(); ensure((await run.snapshots.status())?.sealed, "Incomplete source requires reconciled abort and a new full run"); if ( ["finish_prepared", "finish_pending", "finish_resolving", "finish_accepted", "complete"].includes(status.phase) ) { if (["finish_prepared", "finish_pending"].includes(status.phase)) { ensure(recovery.reconcileFinish, "Finalization requires provider reconciliation"); const result = validateFinishResult(await recovery.reconcileFinish(status.finish, ctx)); await run.core.acknowledgeRecoveredFinish(result, ctx.store.snapshot()); if (result.delivery === "pending") return { delivery: "pending" as const, sourceSequence: status.nextSequence }; } else if (status.phase === "finish_resolving") { await run.core.acknowledgeRecoveredFinish({ delivery: "accepted" }, ctx.store.snapshot()); } if (status.phase !== "complete") await run.delivery.commitCheckpoint({ sourceSequence: status.nextSequence }, ctx.store.snapshot(), true); return { delivery: "accepted" as const, sourceSequence: status.nextSequence }; } ensure(status.phase === "running", "Recover provider initialization before mirror delivery"); let after = ""; let pending = false; for (;;) { const page = await run.core.recoveryPage(after); if (!page.length) break; for (const entry of page) { ctx.signal.throwIfAborted(); const saved = await run.core.recoveryBatch(entry.batchId); ensure(!saved.operations.some(op => op.status === "rejected"), "Rejected rows prohibit mirror continuation"); if (saved.operations.some(op => op.status !== "accepted")) { ensure(recovery.reconcileBatch, "Uncertain batch requires provider reconciliation"); const result = validateBatchResult( saved.batch, await recovery.reconcileBatch(mirrorDeliveryBatch(saved.batch), saved.batch.action, saved.result, ctx) ); await run.core.acknowledgeRecovered(entry.batchId, result, ctx.store.snapshot()); pending = hasPending(result, env.asynchronous) || pending; } after = entry.batchId; } } ctx.signal.throwIfAborted(); // Known async receipts need not block the rest of the upload. All uncertain // requests were reconciled above; the snapshot excludes prepared identities. // With no remaining additions, stay poll-only until receipts are accepted. if (pending && !(await run.snapshots.page("additions", "", 1)).length) return { delivery: "pending" as const, sourceSequence: status.nextSequence }; // Recheck lifecycle state after callbacks and before authorizing provider-session attachment. await run.core.recoveryStatus(); const writer = await recovery.attachWriter(ctx); validateWriter(writer); return await deliver(env, writer, () => {}, true, pending); } catch (error) { throw safeError(error); } } async function deliver( env: Awaited>>, writer: ReverseEtlWriter, uncertain: (value: boolean) => void, recovered = false, pendingAdditions = false ) { const { run, stream, ctx, maxBytes, batchSize, asynchronous } = env; let sequence = (await run.core.recoveryStatus()).nextSequence; for (const kind of env.replacement ? (["additions"] as const) : (["additions", "removals"] as const)) { const action = kind === "additions" ? "upsert" : "remove"; let after = ""; // Earlier uploads must still block removals/native cleanup, even if every // newly submitted batch is accepted synchronously in this attempt. let pending = kind === "additions" && pendingAdditions; for (;;) { ctx.signal.throwIfAborted(); const page = await run.snapshots.page(kind, after, Math.min(batchSize, 1000)); if (!page.length) break; let records: PreparedBatch["records"] = []; const prepared = (): PreparedBatch => ({ batchId: contentHash([run.scope.logicalRunId, action, records.map(row => row.operationId)]), action, records, payloadHash: contentHash(records.map(row => row.row)), }); const flush = async () => { if (!records.length) return; ctx.signal.throwIfAborted(); const batch = prepared(); await run.delivery.prepare(batch, ctx.store.snapshot()); // From prepare onward even a lost DB response / cancelled request belongs to recovery. uncertain(true); ctx.signal.throwIfAborted(); let result: BatchResult; try { const wire = mirrorDeliveryBatch(batch); result = validateBatchResult( batch, action === "upsert" ? await writer.upsert(wire) : await writer.remove!(wire) ); } catch { await run.delivery.markUnknown(batch.batchId); throw safeError(); } if (recovered) await run.core.acknowledgeRecovered(batch.batchId, result, ctx.store.snapshot()); else await run.delivery.acknowledge(batch.batchId, result, ctx.store.snapshot()); // Independently processing jobs cannot be cancelled by generic abort. // Preserve that protection even if a later batch is accepted or rejected. uncertain(asynchronous && (pending || result.outcomes.some(outcome => outcome.status === "staged"))); pending = hasPending(result, asynchronous) || pending; records = []; }; for (const effect of page) { validatePayloads(effect, stream); const row = JSON.parse(canonicalJson(effect)) as Effect; const operationId = contentHash([ run.scope.syncId, run.scope.logicalRunId, run.scope.configRevision, run.scope.targetIdentity, action, effect.identityHash, contentHash(row), ]); const record = { key: effect.identityHash, row, operationId, sourceSequence: sequence + 1 }; records.push(record); if (Buffer.byteLength(canonicalJson(prepared())) > maxBytes) { records.pop(); await flush(); records.push(record); ensure(Buffer.byteLength(canonicalJson(prepared())) <= maxBytes, "Mirror operation exceeds batch byte limit"); } sequence++; after = effect.identityHash; } await flush(); } // Finish and the next phase must not turn enqueued work into accepted delivery. // Keep the sealed snapshot and receipts; a later attempt polls the saved jobs. ctx.signal.throwIfAborted(); if (pending) return { delivery: "pending" as const, sourceSequence: sequence }; } ctx.signal.throwIfAborted(); await run.delivery.prepareFinish(sequence, ctx.store.snapshot()); uncertain(true); ctx.signal.throwIfAborted(); const result = validateFinishResult(await writer.finish()); if (recovered) await run.core.acknowledgeRecoveredFinish(result, ctx.store.snapshot()); else await run.delivery.acknowledgeFinish(result, ctx.store.snapshot()); if (result.delivery === "accepted") await run.delivery.commitCheckpoint({ sourceSequence: sequence }, ctx.store.snapshot(), true); return { delivery: result.delivery, sourceSequence: sequence }; }