import { createHash } from "node:crypto"; import { afterEach, beforeAll, afterAll, beforeEach, describe, it, expect, vi } from "vitest"; import { GenericContainer, Wait, type StartedTestContainer } from "testcontainers"; import { Client } from "pg"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { execFileSync } from "node:child_process"; import { z } from "zod"; import type { BatchResult, ReverseEtlContext, JsonObject, WriteBatch } from "@jitsu/protocols/reverse-etl"; import { ReverseRunConfig } from "@jitsu/warehouse-query/src/runtime"; import { Database, openPersistence } from "./persistence"; import { execute, type ExecuteOptions } from "./execute"; import type { RuntimeAdapter } from "./adapters"; import { createAdapterRegistry } from "./adapters"; import { Tasks } from "./tasks"; import { googleAudienceStateStream } from "@jitsu/destination-functions/src/functions/google-ads/audience/state"; import { ReverseEtlManualReconciliationError } from "@jitsu/destination-functions/src/reverse-etl/failure"; import { recordKey } from "@jitsu/destination-functions/src/reverse-etl/identity"; import * as http from "node:http"; import { createHmac } from "node:crypto"; import type { AddressInfo } from "node:net"; import { createWarehouseReader } from "@jitsu/warehouse-query"; import { createGuardedRequest } from "@jitsu/destination-functions/src/functions/lib/guarded-request"; import { defaultDeliveryDeps } from "@jitsu/destination-functions/src/functions/webhook/deliver"; import { createWebhookRuntime } from "@jitsu/destination-functions/src/functions/webhook/runtime"; import { MemoryObjects, persisted } from "./artifacts/test-support"; import { gunzipSync } from "node:zlib"; import { Artifacts } from "./artifacts/store"; const objects = new MemoryObjects(); const storage = { objectStorage: { store: objects, signal: new AbortController().signal } }; const durable = () => persisted(admin, objects); afterEach(() => vi.useRealTimers()); describe("Meta Reverse ETL runner integration", () => { const metaResponse = (body: unknown) => new Response(JSON.stringify(body)) as Awaited>; it("logs a missing website user-agent mapping without leaking row data or submitting events", async () => { const f = fixture(); f.input.config.destination = { destinationType: "facebook-conversions", accessToken: "private-meta-token" }; f.input.config.model.cursor = undefined; f.input.config.options = { ...f.input.config.options, stream: "conversions", mode: "upsert", mapping: { email: "id", eventSourceUrl: "url" }, streamOptions: { pixelId: "789", actionSource: "website", eventName: "Lead" }, }; f.input.adapters = createAdapterRegistry(vi.fn()); const rows = [{ id: "private@example.com", url: "https://example.com/private-path" }]; f.setRows(rows); const wire = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected provider request")); try { expect(await execute(f.input)).toBe("FAILED"); expect(wire).not.toHaveBeenCalled(); const errors = (await admin.query("SELECT message FROM newjitsu.task_log WHERE level='ERROR'")).rows; expect(errors).toHaveLength(1); expect(errors[0].message).toContain('Client user agent is required when Action source is "website"'); expect(errors[0].message).toContain("Map a column to Client user agent"); expect(errors[0].message).toContain("This row was not submitted"); expect(errors[0].message).not.toContain("private"); const task = (await admin.query("SELECT error FROM newjitsu.source_task WHERE task_id=$1", [f.input.taskId])) .rows[0]; expect(task.error).toBe(errors[0].message); } finally { wire.mockRestore(); } }); it.each([false, true])( "provisions once and mirrors snapshots with additions before removals (lost receipt: %s)", async lostReceipt => { const f = fixture(); f.input.config.destination = { destinationType: "facebook-conversions", accessToken: "meta-token" }; f.input.config.model.cursor = undefined; f.input.config.options = { ...f.input.config.options, stream: "audience", mode: "mirror", mapping: { email: "id" }, streamOptions: { accountId: "123", audience: { kind: "managed", name: "Test" }, exclusiveManagementConfirmed: true, }, }; const token = vi.fn(async () => "unused-oauth"); f.input.adapters = createAdapterRegistry(token); let marker = ""; let loseResponse = lostReceipt; let lastSession: { session_id: string; num_received: number; num_invalid_entries: number } | undefined; const calls: { method: string; payload?: any }[] = []; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const method = init!.method!; const body = init?.body ? JSON.parse(String(init.body)) : undefined; if (String(url).includes("/customaudiences") && method === "POST") { marker = body.description; calls.push({ method: "create" }); return metaResponse({ id: "456" }); } if (String(url).includes("/456/users")) { calls.push({ method, payload: body.payload }); lastSession = { session_id: String(body.session.session_id), num_received: body.payload.data.length, num_invalid_entries: 0, }; if (loseResponse) { loseResponse = false; throw new Error("Response lost after ingestion"); } return metaResponse({ audience_id: "456", ...lastSession }); } if (String(url).includes("/456/sessions")) { expect(String(url)).toContain(`session_id=${lastSession!.session_id}`); return metaResponse({ data: [lastSession] }); } return metaResponse({ id: "456", account_id: "123", subtype: "CUSTOM", is_value_based: false, description: marker, }); }); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe(lostReceipt ? "FAILED" : "COMPLETE"); if (lostReceipt) { f.input.taskId = "meta-resume"; f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).not.toContain("reader"); } f.input.taskId = "meta-second"; f.setRows([{ id: "b@example.com" }, { id: "c@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); expect(calls.map(c => c.method)).toEqual(["create", "POST", "POST", "DELETE"]); expect(calls.slice(1).map(c => c.payload.data.length)).toEqual([2, 1, 1]); f.input.taskId = "meta-empty"; f.setRows([]); expect(await execute(f.input)).toBe("COMPLETE"); expect(calls.at(-1)).toMatchObject({ method: "DELETE" }); expect(calls.at(-1)!.payload.data).toHaveLength(2); expect((await durable()).members).toHaveLength(0); expect(token).not.toHaveBeenCalled(); } finally { wire.mockRestore(); } } ); it("deduplicates Meta conversion source keys across runs without audience state or OAuth", async () => { const f = fixture(); f.input.config.destination = { destinationType: "facebook-conversions", accessToken: "meta-token" }; f.input.config.model.cursor = undefined; f.input.config.options = { ...f.input.config.options, stream: "conversions", mode: "upsert", mapping: { email: "id" }, streamOptions: { pixelId: "789", actionSource: "physical_store", eventName: "Lead" }, }; const token = vi.fn(async () => "unused-oauth"); f.input.adapters = createAdapterRegistry(token); const uploads: any[][] = []; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { expect(String(url)).toBe("https://graph.facebook.com/v26.0/789/events"); const body = JSON.parse(String(init!.body)); uploads.push(body.data); return metaResponse({ events_received: body.data.length }); }); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); f.input.taskId = "meta-conversions-next"; f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }, { id: "c@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); expect(uploads.map(batch => batch.length)).toEqual([2, 1]); expect(token).not.toHaveBeenCalled(); expect( (await admin.query("SELECT count(*) FROM newjitsu.source_state WHERE stream='_REVERSE_ETL_META_AUDIENCE_' ")) .rows[0].count ).toBe("0"); } finally { wire.mockRestore(); } }); }); describe("OpenAI Ads Reverse ETL runner integration", () => { const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) as Awaited>; const openAiFixture = () => { const f = fixture(); f.input.config.destination = { destinationType: "openai-ads", conversionsApiKey: "openai-key-secret" }; f.input.config.model.cursor = undefined; f.input.config.options = { ...f.input.config.options, stream: "conversions", mode: "upsert", mapping: { email: "id" }, streamOptions: { pixelId: "px_runner", actionSource: "offline", eventType: "lead_created" }, }; f.input.adapters = createAdapterRegistry(vi.fn()); return f; }; it("sends only new source keys to the pixel, with the key as a bearer token and no raw email on the wire", async () => { const f = openAiFixture(); const sent: { url: string; auth: unknown; body: any }[] = []; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const body = JSON.parse(String(init!.body)); sent.push({ url: String(url), auth: (init!.headers as Record).Authorization, body }); return json({ accepted_events: body.events.length }); }); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); f.input.taskId = "openai-conversions-next"; f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }, { id: "c@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); expect(sent.map(s => s.body.events.length)).toEqual([2, 1]); expect(sent.every(s => s.url === "https://bzr.openai.com/v1/events?pid=px_runner")).toBe(true); expect(sent.every(s => s.auth === "Bearer openai-key-secret")).toBe(true); expect(sent.every(s => s.body.integration_source === "jitsu")).toBe(true); const ids = sent.flatMap(s => s.body.events.map((e: any) => e.id)); expect(new Set(ids).size).toBe(3); expect(JSON.stringify(sent)).not.toContain("@example.com"); const logs = (await taskLogs("task")) + (await taskLogs("openai-conversions-next")); expect(logs).not.toContain("openai-key-secret"); expect(logs).not.toContain("@example.com"); } finally { wire.mockRestore(); } }); it("fails the run on a definite rejection with a controlled code and field path, not OpenAI's message", async () => { const f = openAiFixture(); const wire = vi.spyOn(globalThis, "fetch").mockResolvedValue( json( { error: { message: "private detail a@example.com", type: "invalid_request_error", param: "events[0]", code: "invalid_event", errors: [{ message: "private detail", param: "events[0].type", code: "unknown_event_type" }], }, }, 400 ) ); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); expect(wire).toHaveBeenCalledTimes(1); const error = String((await task()).error); const logs = await taskLogs("task"); for (const text of [error, logs]) { expect(text).not.toContain("private detail"); expect(text).not.toContain("@example.com"); expect(text).not.toContain("openai-key-secret"); } expect(error).toContain("OPENAI_INVALID_EVENT"); expect(logs).toContain("2 confirmed submitted in 1 batches; 0 accepted, 0 pending processing, 2 rejected"); } finally { wire.mockRestore(); } }); it("stops on an unconfirmed delivery and never sends the batch again on the next run", async () => { const f = openAiFixture(); const wire = vi .spyOn(globalThis, "fetch") .mockResolvedValue(json({ error: { message: "slow down", type: "rate_limit", code: null, param: null } }, 429)); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); expect(wire).toHaveBeenCalledTimes(1); const error = String((await task()).error); expect(error).toContain("did not confirm a submitted batch"); expect(error).toContain("do not reset sync state"); expect(error).not.toContain("slow down"); f.input.taskId = "openai-after-unconfirmed"; await execute(f.input); expect(wire).toHaveBeenCalledTimes(1); } finally { wire.mockRestore(); } }); }); describe("OpenAI Ads audience runner integration", () => { const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) as Awaited>; const audienceFixture = () => { const f = fixture(); f.input.config.destination = { destinationType: "openai-ads", adsApiKey: "ads-key-secret" }; f.input.config.model.cursor = undefined; f.input.config.options = { ...f.input.config.options, stream: "audience", mode: "mirror", mapping: { email: "id" }, streamOptions: { audience: { kind: "managed", name: "Test audience" }, exclusiveManagementConfirmed: true }, }; f.input.adapters = createAdapterRegistry(vi.fn()); return f; }; /** OpenAI's audience API, answering at once: ready on create, operations succeeded on the first poll. */ const openAi = ( options: { loseAddResponses?: number; failOperations?: boolean; failFrom?: number; refuseSubmit?: number } = {} ) => { const audienceId = `caud_${"0".repeat(31)}1`; let description = ""; let lose = options.loseAddResponses ?? 0; const operations = new Map(); const byKey = new Map(); const requests: { method: string; path: string; key?: string }[] = []; const fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const path = String(url).replace("https://api.ads.openai.com/v1", ""); const headers = init!.headers as Record; requests.push({ method: init!.method!, path, key: headers["Idempotency-Key"] }); expect(headers.Authorization).toBe("Bearer ads-key-secret"); if (path === "/ad_account") return reply({ id: "adacct_runner" }); if (path === "/custom_audiences" && init!.method === "POST") { description = JSON.parse(String(init!.body)).description; return reply({ id: audienceId, status: "ready", description }); } if (path === `/custom_audiences/${audienceId}`) return reply({ id: audienceId, status: "ready", description }); const submit = path.match(new RegExp(`^/custom_audiences/${audienceId}/(add|remove)$`)); // Access revoked or the like: a definite refusal of the request itself, with OpenAI's error envelope. if (submit && options.refuseSubmit) return reply( { error: { message: "private detail", type: "invalid_request_error", code: "permission_denied" } }, options.refuseSubmit ); if (submit) { const key = headers["Idempotency-Key"]; let id = byKey.get(key); if (!id) { id = `caudop_${operations.size + 1}`; byKey.set(key, id); // Whether an operation fails is fixed when it is created, as at OpenAI: a replay under the same key returns // the same failed operation, whatever the fake's options say afterwards. operations.set(id, { id, action: submit[1], identifiers: JSON.parse(String(init!.body)).identifiers, fails: !!options.failOperations || (options.failFrom !== undefined && operations.size + 1 >= options.failFrom), }); } if (lose > 0) { lose--; throw new Error("Response lost after the request was applied"); } return reply({ operation_id: id, custom_audience_id: audienceId, operation: submit[1], status: "processing" }); } const poll = path.match(new RegExp(`^/custom_audiences/${audienceId}/operations/([^/]+)$`)); if (poll) return reply({ operation_id: poll[1], custom_audience_id: audienceId, operation: operations.get(poll[1])?.action, status: operations.get(poll[1])?.fails ? "failed" : "succeeded", ...(operations.get(poll[1])?.fails ? { failure_reason: { code: "audience_too_small", message: "private detail" } } : {}), }); throw new Error(`Unexpected OpenAI request ${init!.method} ${path}`); }); return { fetch, requests, operations, options }; }; const sha = (value: string) => createHash("sha256").update(value).digest("hex"); const emails = (identifiers: any[]) => identifiers.map(i => i.identifier); it("creates the audience once and mirrors snapshots, adding new members before removing missing ones", async () => { const f = audienceFixture(); const wire = openAi(); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); f.input.taskId = "openai-audience-second"; f.setRows([{ id: "b@example.com" }, { id: "c@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); const sent = [...wire.operations.values()]; expect(sent.map(o => o.action)).toEqual(["add", "add", "remove"]); expect(emails(sent[0].identifiers).sort()).toEqual([sha("a@example.com"), sha("b@example.com")].sort()); expect(emails(sent[1].identifiers)).toEqual([sha("c@example.com")]); expect(emails(sent[2].identifiers)).toEqual([sha("a@example.com")]); expect(wire.requests.filter(r => r.method === "POST" && r.path === "/custom_audiences")).toHaveLength(1); f.input.taskId = "openai-audience-empty"; f.setRows([]); expect(await execute(f.input)).toBe("COMPLETE"); expect([...wire.operations.values()].at(-1)).toMatchObject({ action: "remove" }); expect(emails([...wire.operations.values()].at(-1)!.identifiers).sort()).toEqual( [sha("b@example.com"), sha("c@example.com")].sort() ); expect((await durable()).members).toHaveLength(0); const logs = (await taskLogs("task")) + (await taskLogs("openai-audience-second")); expect(logs).not.toContain("ads-key-secret"); expect(logs).not.toContain("@example.com"); expect(JSON.stringify(wire.requests)).not.toContain("@example.com"); } finally { wire.fetch.mockRestore(); } }); it("keeps a member two rows share until both rows are gone: one identifier type means one member", async () => { const f = audienceFixture(); f.input.config.options = { ...f.input.config.options, mapping: { email: "email" } }; const wire = openAi(); try { f.setRows([ { id: "1", email: "shared@example.com" }, { id: "2", email: "shared@example.com" }, ]); expect(await execute(f.input)).toBe("COMPLETE"); const added = [...wire.operations.values()].flatMap(o => emails(o.identifiers)); expect(added).toEqual([sha("shared@example.com")]); f.input.taskId = "openai-audience-shared-one-row-gone"; f.setRows([{ id: "2", email: "shared@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); expect([...wire.operations.values()].map(o => o.action)).toEqual(["add"]); f.input.taskId = "openai-audience-shared-both-gone"; f.setRows([]); expect(await execute(f.input)).toBe("COMPLETE"); expect([...wire.operations.values()].map(o => o.action)).toEqual(["add", "remove"]); } finally { wire.fetch.mockRestore(); } }); it("refuses a mirror that maps two identifier types, before reading the source or calling OpenAI", async () => { const f = audienceFixture(); f.input.config.options = { ...f.input.config.options, mapping: { email: "id", phone: "id" } }; const wire = openAi(); try { f.setRows([{ id: "a@example.com" }]); f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(String((await task()).error)).toContain("one identifier type"); expect(f.calls).not.toContain("reader"); expect(wire.requests).toHaveLength(0); } finally { wire.fetch.mockRestore(); } }); it("recovers a lost submit response by replaying the same key, applying the batch once and not reading the source again", async () => { const f = audienceFixture(); const wire = openAi({ loseAddResponses: 1 }); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); const stopped = String((await task()).error); expect(stopped).toContain("Snapshot mirror stopped during delivery"); expect(stopped).toContain("do not reset sync state"); f.input.taskId = "openai-audience-resume"; f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).not.toContain("reader"); const adds = wire.requests.filter(r => r.path.endsWith("/add")); expect(adds).toHaveLength(2); expect(adds[1].key).toBe(adds[0].key); expect(wire.operations.size).toBe(1); } finally { wire.fetch.mockRestore(); } }); /** OpenAI is healthy again for NEW operations; the same fake keeps its audience, its marker and its failed operations. */ const heal = (wire: ReturnType) => { wire.options.failOperations = false; wire.options.failFrom = undefined; }; const adds = (wire: ReturnType) => [...wire.operations.values()].map(o => `${o.action}:${o.identifiers.length}`); it("fails the run when OpenAI reports an operation failed, with no provider text, key or address in the run", async () => { const f = audienceFixture(); const wire = openAi({ failOperations: true }); try { f.setRows([{ id: "a@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); const error = String((await task()).error); const logs = await taskLogs("task"); for (const text of [error, logs]) { expect(text).not.toContain("private detail"); expect(text).not.toContain("@example.com"); expect(text).not.toContain("ads-key-secret"); } expect(error).toContain("do not reset"); expect(logs).toContain("part of it may have been applied"); } finally { wire.fetch.mockRestore(); } }); it("stays stopped after a failed operation: the next run replays it, reads nothing and creates no operation", async () => { const f = audienceFixture(); const wire = openAi({ failOperations: true }); try { f.setRows([{ id: "a@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); // OpenAI is healthy again and the member has left the model, but the failed Add may have applied part of its // change. The run is not discarded: the next run replays the same request (the same operation comes back, still // failed), reads nothing and sends no removal. heal(wire); f.input.taskId = "openai-audience-after-failed"; f.setRows([]); f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).not.toContain("reader"); expect(adds(wire)).toEqual(["add:1"]); } finally { wire.fetch.mockRestore(); } }); it("stays stopped after a failed Add in a later run, even when the member has left the model since", async () => { const f = audienceFixture(); const wire = openAi(); try { f.setRows([{ id: "a@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); // Run 2 adds b, and OpenAI reports the operation failed: part of it may have been applied. wire.options.failOperations = true; f.input.taskId = "openai-audience-failed-add"; f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); // b has left the model. If b was applied it must not be forgotten: no fresh read of the source, no completion. heal(wire); f.input.taskId = "openai-audience-after-failed-add"; f.setRows([{ id: "a@example.com" }]); f.calls.length = 0; expect({ outcome: await execute(f.input), readSource: f.calls.includes("reader") }).toEqual({ outcome: "FAILED", readSource: false, }); expect(adds(wire)).toEqual(["add:1", "add:1"]); } finally { wire.fetch.mockRestore(); } }); it("stays stopped after a failed Remove: members that came back are not forgotten by a fresh read", async () => { const f = audienceFixture(); const wire = openAi(); try { f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); // Run 2 removes b; OpenAI reports the Remove failed, so b may or may not still be in the audience. wire.options.failFrom = 2; f.input.taskId = "openai-audience-failed-remove"; f.setRows([{ id: "a@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); heal(wire); f.input.taskId = "openai-audience-after-failed-remove"; f.setRows([{ id: "a@example.com" }, { id: "b@example.com" }]); f.calls.length = 0; expect({ outcome: await execute(f.input), readSource: f.calls.includes("reader") }).toEqual({ outcome: "FAILED", readSource: false, }); expect(adds(wire)).toEqual(["add:2", "remove:1"]); } finally { wire.fetch.mockRestore(); } }); it("stays stopped after the second of two batches fails: the first batch was accepted", async () => { const f = audienceFixture(); // A journal record budget of 2 makes three members two Add batches, without thousands of rows (a 3,001-row // version took over 30 s on a loaded CI runner, past the 10 s idle-in-transaction limit, and was cancelled). const bounded = new Database( { host: container.getHost(), port: container.getMappedPort(5432), database: "runner_test", user: "runner_runtime", password: "runtime", }, { ...storage, limits: { batchRecords: 2 } } ); f.input.db = bounded; const wire = openAi({ failFrom: 2 }); try { f.setRows([{ id: "m0@example.com" }, { id: "m1@example.com" }, { id: "m2@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); // The first batch (2 members) was accepted, the second (1 member) failed. expect(adds(wire)).toEqual(["add:2", "add:1"]); heal(wire); f.input.taskId = "openai-audience-after-failed-second-batch"; f.setRows([{ id: "m0@example.com" }]); f.calls.length = 0; expect({ outcome: await execute(f.input), readSource: f.calls.includes("reader") }).toEqual({ outcome: "FAILED", readSource: false, }); expect(adds(wire)).toEqual(["add:2", "add:1"]); } finally { wire.fetch.mockRestore(); await bounded.close(); } }); it("keeps a refused replay uncertain: the original request may have been applied, so the member is still removed later", async () => { const f = audienceFixture(); const wire = openAi({ loseAddResponses: 1 }); try { // Run 1: the Add is applied at OpenAI but its answer is lost, so the batch is unknown. f.setRows([{ id: "a@example.com" }]); expect(await execute(f.input)).toBe("FAILED"); expect(adds(wire)).toEqual(["add:1"]); // Run 2: recovery replays the request and OpenAI refuses it (access revoked, say). That does not show the original // request applied nothing, so the batch must stay unknown, not become a rejection. wire.options.refuseSubmit = 403; f.input.taskId = "openai-audience-replay-refused"; expect(await execute(f.input)).toBe("FAILED"); // Access is back and the member has left the model. Recovery finishes the original run, and the member, applied by // the original Add, is then removed. wire.options.refuseSubmit = undefined; f.setRows([]); f.input.taskId = "openai-audience-replay-recovered"; await execute(f.input); f.input.taskId = "openai-audience-after-recovered"; expect(await execute(f.input)).toBe("COMPLETE"); expect(adds(wire)).toEqual(["add:1", "remove:1"]); } finally { wire.fetch.mockRestore(); } }); it("validates the first snapshot before creating the audience, so a wrong mapping stays correctable", async () => { const f = audienceFixture(); const wire = openAi(); try { // A value that is not an email: the mapping passes save-time validation but the first row cannot be normalized. f.setRows([{ id: "not-an-email" }]); expect(await execute(f.input)).toBe("FAILED"); expect(wire.requests.filter(r => r.method === "POST" && r.path === "/custom_audiences")).toHaveLength(0); const state = await admin.query("SELECT count(*)::int AS n FROM newjitsu.source_state WHERE stream=$1", [ "_REVERSE_ETL_OPENAI_AUDIENCE_", ]); expect(state.rows[0].n).toBe(0); // The user fixes the data (or the mapping): nothing was locked, and the next run creates the audience once. f.input.taskId = "openai-audience-corrected"; f.setRows([{ id: "a@example.com" }]); expect(await execute(f.input)).toBe("COMPLETE"); expect(wire.requests.filter(r => r.method === "POST" && r.path === "/custom_audiences")).toHaveLength(1); } finally { wire.fetch.mockRestore(); } }); }); describe("runner-owned Google audience provisioning", () => { it.each(["invalid-row", "duplicate-key", "source-failure", "identifier-type"])( "validates the whole first snapshot before creating an audience: %s", async scenario => { const f = fixture(); f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", }; f.input.config.options = { ...f.input.config.options, mode: "mirror", mapping: { email: "email" }, streamOptions: { audience: { kind: "managed", displayName: "Test" }, customerMatchTermsAccepted: true, exclusiveManagementConfirmed: true, }, }; f.input.adapters = createAdapterRegistry(async () => "token"); if (scenario === "identifier-type") f.input.config.options.streamOptions.identifierType = "CRM_ID"; const inputRows = [ { id: "a", email: "good@example.com" }, { id: scenario === "duplicate-key" ? "a" : "b", email: scenario === "invalid-row" ? "invalid" : "valid@example.com", }, ]; f.setRows(inputRows); if (scenario === "source-failure") { const reader = f.input.reader; f.input.reader = config => { const original = reader(config); return { ...original, stream: async function* (model, after, signal) { yield* original.stream(model, after, signal); throw new Error("private SQL error"); }, }; }; } const wire = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Unexpected Google call")); try { expect(await execute(f.input)).toBe("FAILED"); expect(wire).not.toHaveBeenCalled(); expect( (await admin.query("SELECT count(*) FROM newjitsu.source_state WHERE stream=$1", [googleAudienceStateStream])) .rows[0].count ).toBe("0"); expect(await control()).toBeUndefined(); expect(await taskLogs("task")).not.toContain("private SQL"); } finally { wire.mockRestore(); } } ); it("does not persist an impossible mobile audience creation intent", async () => { const f = fixture(); f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", }; f.input.config.options = { ...f.input.config.options, mode: "mirror", stream: "audience", mapping: { mobileAdvertisingId: "id" }, streamOptions: { audience: { kind: "managed", displayName: "Mobile" }, identifierType: "MOBILE_ADVERTISING_ID", customerMatchTermsAccepted: true, exclusiveManagementConfirmed: true, }, }; f.input.adapters = createAdapterRegistry(async () => "token"); expect(await execute(f.input)).toBe("FAILED"); expect((await task()).error).toContain("Set the App ID and mobile platform"); expect( (await admin.query("SELECT count(*) FROM newjitsu.source_state WHERE stream=$1", [googleAudienceStateStream])) .rows[0].count ).toBe("0"); expect((await admin.query("SELECT count(*) FROM newjitsu.reverse_sync_control")).rows[0].count).toBe("0"); expect(f.calls).not.toContain("reader"); }); it("stops polling terminal ambiguous conversion results without reopening the model", async () => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); const bind = f.adapter.recovery!; f.adapter.recovery = state => ({ ...bind(state), reconcileBatch: async () => { throw new ReverseEtlManualReconciliationError(); }, }); await admin.query( `UPDATE newjitsu.source_task SET metrics=jsonb_set(metrics,'{reverseRecovery,nextCheckAt}',to_jsonb('2000-01-01T00:00:00.000Z'::text)) WHERE status='PENDING'` ); f.input = { ...f.input, taskId: "terminal-check", recoveryOf: "task", trigger: "recovery" }; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect((await task()).error).toContain("partial or unverified conversion results"); expect(f.calls).not.toContain("reader"); }); it.each(["click-conversions", "call-conversions", "conversion-adjustments"])( "runs %s and skips previously submitted primary keys on subsequent extraction", async stream => { const f = fixture(); f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", developerToken: "test-token", }; f.input.config.model.cursor = undefined; f.input.config.options = { ...f.input.config.options, mode: "upsert", stream, mapping: stream === "click-conversions" ? { gclid: "id", conversionTimestamp: "time" } : stream === "call-conversions" ? { callerId: "caller", callTimestamp: "time", conversionTimestamp: "time" } : { orderId: "id", adjustmentTimestamp: "time" }, streamOptions: { conversionActionId: "123", ...(stream === "conversion-adjustments" ? { adjustmentType: "RETRACTION" } : {}), }, }; f.input.adapters = createAdapterRegistry(async () => "token"); const rows = (ids: string[]) => ids.map(id => ({ id, time: "2026-09-20T12:30:00Z", caller: "+14155552671" })); f.setRows(rows(["a", "b"])); const uploads: any[] = []; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { if (String(_url).includes("requestStatus:retrieve")) return new Response( JSON.stringify({ requestStatusPerDestination: [ { destination: { operatingAccount: { accountType: "GOOGLE_ADS", accountId: "1234567890" }, productDestinationId: "123", }, requestStatus: "SUCCESS", eventsIngestionStatus: { recordCount: "2" }, }, ], }) ) as Awaited>; const body = JSON.parse(init!.body as string); uploads.push(body); return new Response( JSON.stringify( stream === "click-conversions" ? { requestId: `req-${uploads.length}` } : { results: (body.conversions ?? body.conversionAdjustments).map(() => ({ conversionAction: "customers/1234567890/conversionActions/123", })), } ) ) as Awaited>; }); try { expect(await execute(f.input)).toBe(stream === "click-conversions" ? "PENDING" : "COMPLETE"); if (stream === "click-conversions") { await admin.query( `UPDATE newjitsu.source_task SET metrics=jsonb_set(metrics,'{reverseRecovery,nextCheckAt}',to_jsonb('2000-01-01T00:00:00.000Z'::text)) WHERE task_id='task'` ); expect(await execute({ ...f.input, taskId: "accepted-check", trigger: "recovery", recoveryOf: "task" })).toBe( "COMPLETE" ); } f.input.taskId = "second-event-run"; // Different payload, same primary key: insert-only must still omit a/b. f.setRows(rows(["a", "b", "c"]).map(row => ({ ...row, time: "2026-09-20T13:30:00Z" }))); expect(await execute(f.input)).toBe(stream === "click-conversions" ? "PENDING" : "COMPLETE"); expect(uploads).toHaveLength(2); const key = stream === "click-conversions" ? "events" : stream === "call-conversions" ? "conversions" : "conversionAdjustments"; expect(uploads[0][key]).toHaveLength(2); expect(uploads[1][key]).toHaveLength(1); expect(await taskLogs("second-event-run")).toContain("2 previously submitted events omitted"); expect( (await admin.query("SELECT count(*) FROM newjitsu.source_state WHERE stream=$1", [googleAudienceStateStream])) .rows[0].count ).toBe("0"); } finally { wire.mockRestore(); } } ); it.each(["normal", "lost-response", "empty-discovery", "oauth-failure"])( "persists first-run provisioning and does not create twice: %s", async scenario => { const f = fixture(); f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", }; f.input.config.options = { ...f.input.config.options, mode: "mirror", mapping: { email: "id" }, streamOptions: { audience: { kind: "managed", displayName: "Test" }, customerMatchTermsAccepted: true, exclusiveManagementConfirmed: true, mirrorStrategy: "snapshot-diff", }, }; f.setRows([]); let tokenFails = scenario === "oauth-failure", creates = 0, remote: any; f.input.adapters = createAdapterRegistry(async () => { if (tokenFails) throw new Error("private-token"); return "token"; }); const saved = async () => ( await admin.query("SELECT state FROM newjitsu.source_state WHERE sync_id='sync' AND stream=$1", [ googleAudienceStateStream, ]) ).rows[0]?.state; const response = (body: unknown) => new Response(JSON.stringify(body)) as Awaited>; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { expect(f.calls).toContain("lease"); if (init?.method === "POST") { creates++; expect((await saved()).phase).toBe("submitting"); expect((await admin.query("SELECT count(*) FROM newjitsu.reverse_sync_control")).rows[0].count).toBe("0"); remote = { ...JSON.parse(init.body as string), id: "123", name: "accountTypes/GOOGLE_ADS/accounts/1234567890/userLists/123", accessReason: "OWNED", }; if (scenario === "lost-response" || scenario === "empty-discovery") throw new Error("lost response"); return response(remote); } if (String(url).includes("?")) return response({ userLists: scenario === "empty-discovery" ? [] : [remote] }); return response(remote); }); try { expect(await execute(f.input)).toBe(scenario === "normal" ? "COMPLETE" : "FAILED"); expect(f.calls.filter(call => call === "reader")).toHaveLength(1); expect((await saved()).phase).toBe( scenario === "normal" ? "ready" : scenario === "oauth-failure" ? "prepared" : "submitting" ); tokenFails = false; f.input.taskId = "next"; expect(await execute(f.input)).toBe(scenario === "empty-discovery" ? "FAILED" : "COMPLETE"); expect(creates).toBe(1); if (scenario !== "empty-discovery") { expect((await saved()).audienceId).toBe("123"); expect((await control()).revision).toBe(f.input.config.configRevision); expect((await control()).target_hash).toBeDefined(); } expect(f.input.config.options.streamOptions.audienceId).toBeUndefined(); } finally { wire.mockRestore(); } } ); }); let container: StartedTestContainer; let admin: Client; let db: Database; beforeAll(async () => { container = await new GenericContainer("postgres:18-alpine") .withEnvironment({ POSTGRES_PASSWORD: "test", POSTGRES_DB: "runner_test" }) .withExposedPorts(5432) .withWaitStrategy(Wait.forLogMessage(/database system is ready to accept connections/, 2)) .start(); const config = { host: container.getHost(), port: container.getMappedPort(5432), database: "runner_test", user: "postgres", password: "test", }; admin = new Client(config); await admin.connect(); execFileSync( process.execPath, [ createRequire(import.meta.url).resolve("prisma/build/index.js"), "db", "push", `--schema=${fileURLToPath(new URL("../../../webapps/console/prisma/schema.prisma", import.meta.url))}`, "--skip-generate", ], { env: { // eslint-disable-next-line no-restricted-properties -- disposable test database only. ...process.env, DATABASE_URL: `postgresql://postgres:test@${config.host}:${config.port}/${config.database}?schema=newjitsu`, }, stdio: "inherit", } ); await admin.query( "CREATE ROLE runner_runtime LOGIN PASSWORD 'runtime'; GRANT USAGE ON SCHEMA newjitsu TO runner_runtime" ); for (const table of ["control", "target_owner"]) await admin.query(`GRANT SELECT,INSERT,UPDATE,DELETE ON newjitsu.reverse_sync_${table} TO runner_runtime`); await admin.query( "GRANT SELECT,INSERT,UPDATE ON newjitsu.source_state,newjitsu.source_task TO runner_runtime; GRANT INSERT ON newjitsu.task_log TO runner_runtime" ); db = new Database({ ...config, user: "runner_runtime", password: "runtime" }, storage); }, 60_000); afterAll(async () => { await db?.close(); await admin?.end(); await container?.stop(); }); beforeEach(async () => { await admin.query( "TRUNCATE newjitsu.reverse_sync_control,newjitsu.reverse_sync_target_owner,newjitsu.source_state,newjitsu.source_task,newjitsu.task_log" ); }); const config = () => ReverseRunConfig.parse({ version: 1, kind: "reverse", id: "sync", workspaceId: "workspace", fromId: "model", toId: "destination", configRevision: "a".repeat(64), updatedAt: new Date().toISOString(), model: { warehouseId: "warehouse", query: "SELECT id FROM users", primaryKey: ["id"] }, warehouse: { destinationType: "postgres" }, destination: { destinationType: "test" }, options: { stream: "audience", mode: "upsert", mapping: { id: "id" } }, }); const accepted = (batch: WriteBatch) => ({ outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "accepted" as const })), }); function fixture() { const calls: string[] = []; const writes: WriteBatch[] = []; let pending = false; let failInit = false; let failBatch = false; let rows: { id: string; email?: string }[] = [{ id: "a" }, { id: "b" }]; const writer = (ctx: ReverseEtlContext) => ({ init: async () => { calls.push("init"); if (failInit) throw new Error("private-token"); await ctx.delivery.saveProviderState({ session: "existing" }); }, upsert: async (batch: WriteBatch) => { calls.push("upsert"); writes.push(batch); if (failBatch) throw new Error("lost-response"); return accepted(batch); }, remove: async (batch: WriteBatch) => { calls.push("remove"); return accepted(batch); }, finish: async () => { calls.push("finish"); return pending ? { delivery: "pending" as const, remoteJobIds: ["job"] } : { delivery: "accepted" as const }; }, abort: async () => { calls.push("abort"); }, }); const adapter: RuntimeAdapter = { credentials: {}, targetIdentity: "test/account/audience", project: (_action, row: any) => [{ identity: String(row.id), upsert: { id: row.id }, remove: { id: row.id } }], stream: { name: "audience", displayName: "Audience", rowType: z.object({ id: z.string() }), removeRowType: z.object({ id: z.string() }), options: z.object({}), batchSize: 2, capabilities: { supportsUpsert: true, supportsExplicitRemove: true, mirror: "snapshot-diff", replay: "idempotent-operation", }, createWriter: async ctx => { calls.push("create"); return writer(ctx); }, }, verifyMirrorBaseline: async () => "tracked", recovery: () => ({ attachWriter: async ctx => { calls.push("attach"); return writer(ctx); }, reconcileBatch: async batch => { calls.push("reconcile"); expect(batch).toEqual(writes.at(-1)); return accepted(batch); }, reconcileFinish: async () => { calls.push("reconcileFinish"); return { delivery: "accepted" }; }, reconcileInit: async () => { calls.push("reconcileInit"); return "absent"; }, reconcileAbort: async () => { calls.push("reconcileAbort"); }, }), }; adapter.mirror = { stream: adapter.stream, batchDelivery: "accepted", projection: { rowType: adapter.stream.rowType, project: row => adapter.project("upsert", row) }, }; const input: ExecuteOptions = { config: config(), db, taskId: "task", trigger: "scheduled", adapters: new Map([["test", () => adapter]]), controller: new AbortController(), lease: { acquire: async () => { calls.push("lease"); }, renew: async () => { calls.push("renew"); }, release: async () => { calls.push("release"); }, }, admit: async () => input.config, reader: () => { calls.push("reader"); return { sql: {} as any, columns: async () => [], preview: async () => ({ rows: [], columns: [], truncated: false }), close: async () => { calls.push("close"); }, stream: async function* (_model, after, signal) { calls.push("source"); expect(signal).toBe(input.controller.signal); for (const row of rows) yield { row, deleted: false, ...(input.config.model.cursor ? { checkpoint: { value: row.id, primaryKeyValues: [row.id] } } : {}), }; }, }; }, }; return { input, calls, adapter, writes, setPending: () => { pending = true; }, setFailInit: () => { failInit = true; }, setFailBatch: () => { failBatch = true; }, setRows: (value: typeof rows) => { rows = value; }, }; } async function task(id = "task") { return (await admin.query("SELECT * FROM newjitsu.source_task WHERE task_id=$1", [id])).rows[0]; } async function taskLogs(id: string) { return (await admin.query("SELECT message FROM newjitsu.task_log WHERE task_id=$1 ORDER BY timestamp", [id])).rows .map(row => row.message) .join("\n"); } async function control() { return (await admin.query("SELECT * FROM newjitsu.reverse_sync_control ORDER BY run_order DESC LIMIT 1")).rows[0]; } function asynchronousFixture() { const f = fixture(); const receipts = new Map(); f.adapter.stream.batchDelivery = "asynchronous"; const create = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => ({ ...(await create(ctx)), upsert: async batch => { f.calls.push("upsert"); f.writes.push(batch); const result: BatchResult = { outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "staged" })), remoteJobIds: [batch.batchId], }; receipts.set(batch.batchId, result); return result; }, }); const recovery = f.adapter.recovery!; f.adapter.recovery = state => ({ ...recovery(state), reconcileBatch: async batch => { f.calls.push("reconcile"); return receipts.get(batch.batchId)!; }, }); return { ...f, receipts }; } describe("retention bucket routing through the run loop", () => { // With a retention bucket the projected effects, which stay in the main store, must hold no row data (as for webhook). const payloadFree = (f: ReturnType) => { f.adapter.project = (_action, row: any) => [ { identity: createHash("sha256").update(String(row.id)).digest("hex"), upsert: {}, remove: {} }, ]; }; const decoded = (store: MemoryObjects) => [...store.objects.entries()].map(([key, value]) => ({ key, value: JSON.parse(gunzipSync(value).toString()).value as any, })); // A batch carries rows either inline (no retention bucket) or as a separate artifact; the manifest has null rows. const carriesRows = (value: any) => (Array.isArray(value?.rows) && value.rows.length > 0) || !!value?.batch?.records?.some((record: any) => record.row !== null && record.row !== undefined); function retentionDb(main: MemoryObjects, retention: MemoryObjects) { return new Database( { host: container.getHost(), port: container.getMappedPort(5432), database: "runner_test", user: "runner_runtime", password: "runtime", }, { objectStorage: { store: main, signal: new AbortController().signal, retention } } ); } it("stores the rows, and only the rows, in the retention bucket", async () => { const main = new MemoryObjects(), retention = new MemoryObjects(), rdb = retentionDb(main, retention); try { const f = fixture(); payloadFree(f); f.input.db = rdb; expect(await execute(f.input)).toBe("COMPLETE"); const kept = decoded(retention); expect(kept.length).toBeGreaterThan(0); expect(kept.every(object => object.key.startsWith("r1/") && Array.isArray(object.value.rows))).toBe(true); expect(kept.flatMap(object => object.value.rows.map((row: any) => row.id)).sort()).toEqual(["a", "b"]); const rest = decoded(main); expect(rest.length).toBeGreaterThan(0); expect(rest.every(object => object.key.startsWith("v1/") && !carriesRows(object.value))).toBe(true); // The batch manifest stays in the main store, with its rows replaced by a reference. const manifests = rest.filter(object => object.value?.batch?.records); expect(manifests.length).toBeGreaterThan(0); expect( manifests.every(object => object.value.rows && object.value.batch.records.every((r: any) => r.row === null)) ).toBe(true); } finally { await rdb.close(); } }); it("a destination whose projections hold row data is refused with a readable message when retention is configured", async () => { const main = new MemoryObjects(), retention = new MemoryObjects(), rdb = retentionDb(main, retention); try { const f = fixture(); // the default projection keeps the row in the effects, which would never expire f.input.db = rdb; expect(await execute(f.input)).toBe("FAILED"); expect((await task()).error).toContain("cannot expire"); expect(retention.objects.size).toBe(0); expect(f.calls).not.toContain("upsert"); } finally { await rdb.close(); } }); it("a run without a retention bucket stores everything in the main bucket, as before", async () => { const main = new MemoryObjects(), rdb = retentionDb(main, undefined as any); try { const f = fixture(); payloadFree(f); f.input.db = rdb; expect(await execute(f.input)).toBe("COMPLETE"); const all = decoded(main); expect(all.every(object => object.key.startsWith("v1/"))).toBe(true); expect(all.some(object => carriesRows(object.value))).toBe(true); } finally { await rdb.close(); } }); it("after the retention window the next run still works: only an unresolved batch needs its rows", async () => { const main = new MemoryObjects(), retention = new MemoryObjects(), rdb = retentionDb(main, retention); try { const f = fixture(); payloadFree(f); f.input.db = rdb; expect(await execute(f.input)).toBe("COMPLETE"); expect(retention.objects.size).toBeGreaterThan(0); retention.objects.clear(); // the bucket's lifecycle rule has deleted every expired row object f.input = { ...f.input, taskId: "task-after-expiry" }; expect(await execute(f.input)).toBe("COMPLETE"); // The new run wrote its own rows; the expired objects were never needed. expect(retention.objects.size).toBeGreaterThan(0); } finally { await rdb.close(); } }); it("a poison record stops the run; after its rows expire and the endpoint is fixed the next run delivers everything", async () => { const main = new MemoryObjects(), retention = new MemoryObjects(), rdb = retentionDb(main, retention); try { const f = fixture(); payloadFree(f); f.input.db = rdb; // First run: the destination rejects one record, which stops the run (a rejected batch stays in the head). const createWriter = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { const writer = await createWriter(ctx); return { ...writer, upsert: async batch => ({ outcomes: batch.records.map((record, i) => i === 0 ? { operationId: record.operationId, status: "rejected" as const, code: "invalid", safeReason: "Invalid", } : { operationId: record.operationId, status: "accepted" as const } ), }), }; }; expect(await execute(f.input)).toBe("FAILED"); expect(retention.objects.size).toBeGreaterThan(0); // The window passes before anyone fixes the endpoint: the bucket's lifecycle rule deletes the rows. retention.objects.clear(); // Endpoint fixed. The next scheduled run must not need the expired rows. f.adapter.stream.createWriter = createWriter; f.writes.length = 0; f.input = { ...f.input, taskId: "task-after-fix" }; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.writes.flatMap(batch => batch.records.map(record => (record.row as any).id)).sort()).toEqual(["a", "b"]); } finally { await rdb.close(); } }); it("replay refuses rows that do not match the batch manifest instead of sending them", async () => { const main = new MemoryObjects(), retention = new MemoryObjects(), rdb = retentionDb(main, retention); // A writer bug: the stored rows artifact is internally valid but is not the batch the manifest describes. const original = Artifacts.prototype.put; const spy = vi .spyOn(Artifacts.prototype, "put") .mockImplementation(function (this: Artifacts, value: any, kind?: any) { return original.call( this, kind === "rows" ? { rows: value.rows.map(() => ({ id: "not-the-batch" })) } : value, kind ); }); try { const f = fixture(); payloadFree(f); f.input.db = rdb; f.setFailBatch(); // leaves the batch unknown, so the next run must replay it expect(await execute(f.input)).toBe("FAILED"); spy.mockRestore(); f.input = { ...f.input, taskId: "task-replay" }; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); // Stopped before the provider was asked to replay anything. expect(f.calls).not.toContain("reconcile"); expect(f.writes.every(batch => batch.records.every(record => (record.row as any).id !== "not-the-batch"))).toBe( true ); } finally { spy.mockRestore(); await rdb.close(); } }); it("a batch that is still unresolved when its rows expire blocks delivery with the recovery message", async () => { const main = new MemoryObjects(), retention = new MemoryObjects(), rdb = retentionDb(main, retention); try { const f = fixture(); payloadFree(f); f.input.db = rdb; f.setFailBatch(); // the destination loses the response: the batch is left unknown expect(await execute(f.input)).toBe("FAILED"); retention.objects.clear(); f.input = { ...f.input, taskId: "task-after-expiry" }; // the next scheduled run starts recovery, as in production const result = await execute(f.input); expect(result).toBe("FAILED"); expect((await task("task-after-expiry")).error).toContain("Saved sync data is missing or unreadable"); } finally { await rdb.close(); } }); }); describe("executable runner", () => { it("records actionable duplicate-identity failures in task details and logs without member values", async () => { const f = asynchronousFixture(); // Distinct model primary keys can normalize to the same destination identity. f.adapter.project = () => [ { identity: "private@example.com", upsert: { email: "private@example.com" }, remove: { email: "private@example.com" }, }, ]; expect(await execute(f.input)).toBe("FAILED"); const failed = await task(); expect(failed.error).toContain("Multiple source rows identify the same audience member"); expect(failed.error).toContain("Update the model"); expect(failed.error).toContain("Run ID: task"); expect(failed.description).toBe(failed.error); expect(failed.error).not.toContain("private@example.com"); expect((await admin.query("SELECT message FROM newjitsu.task_log WHERE level='ERROR'")).rows).toEqual([ { message: failed.error }, ]); expect(f.writes).toHaveLength(0); }); it("explains audience ownership conflicts before opening the warehouse", async () => { const mirror = fixture(); mirror.input.config.options.mode = "mirror"; expect(await execute(mirror.input)).toBe("COMPLETE"); const f = fixture(); f.input.config.id = "another-sync"; f.input.taskId = "another-task"; expect(await execute(f.input)).toBe("FAILED"); expect((await task("another-task")).error).toContain("This audience is reserved by another sync"); expect(f.calls).not.toContain("source"); }); it("gives a support reference instead of exposing unexpected provider errors", async () => { const f = fixture(); f.setFailInit(); expect(await execute(f.input)).toBe("FAILED"); const failed = await task(); expect(failed.error).toContain("Contact support or your Jitsu administrator"); expect(failed.error).toContain("Run ID: task"); expect(failed.error).not.toMatch(/private-token|inspect.*state/); }); const makeDue = () => admin.query( `UPDATE newjitsu.source_task SET metrics=jsonb_set(metrics,'{reverseRecovery,nextCheckAt}',to_jsonb('2000-01-01T00:00:00.000Z'::text)) WHERE status='PENDING'` ); async function refresh(f: { input: ExecuteOptions }, worker: string, parent = "task") { await makeDue(); f.input = { ...f.input, taskId: worker, recoveryOf: parent, trigger: "recovery" }; } it("refreshes paused delivery without extraction and rejects new manual or scheduled runs", async () => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); f.input.config.options.disabled = true; for (const trigger of ["manual", "scheduled"] as const) { f.calls.length = 0; expect(await execute({ ...f.input, taskId: trigger, trigger })).toBe("FAILED"); expect(f.calls).not.toContain("reader"); expect(f.calls).not.toContain("upsert"); } for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "paused-check"); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).toContain("reconcile"); expect(f.calls).not.toContain("reader"); }); it("refuses an automatic refresh queued before the sync was paused", async () => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); await refresh(f, "stale-auto-check"); f.input.admit = async () => ({ ...f.input.config, options: { ...f.input.config.options, disabled: true } }); f.calls.length = 0; expect(await execute(f.input)).toBe("PENDING"); expect(f.calls).not.toContain("reader"); expect(f.calls).not.toContain("reconcile"); expect(await taskLogs("task")).toContain("Status refresh failed without updating delivery outcomes"); }); it("retries rejected conversion keys after explicit cleanup without replaying accepted keys", async () => { const f = asynchronousFixture(); f.input.config.model.cursor = undefined; f.adapter.insertOnly = true; f.adapter.project = (_action, row: any) => [ { identity: { eventKey: recordKey([row.id]) }, upsert: row, remove: {} }, ]; expect(await execute(f.input)).toBe("PENDING"); const batch = f.writes[0]; f.input.taskId = "overlap"; expect(await execute(f.input)).toBe("FAILED"); expect(f.writes).toHaveLength(1); f.receipts.set(batch.batchId, { outcomes: batch.records.map((row, i) => i === 0 ? { operationId: row.operationId, status: "accepted" } : { operationId: row.operationId, status: "rejected", code: "invalid", safeReason: "Invalid" } ), }); await refresh(f, "rejected-check"); expect(await execute(f.input)).toBe("FAILED"); expect((await control()).phase).toBe("batches_pending"); // Explicit retry cleans up the failed saved run, but does not re-upload it. await admin.query("UPDATE newjitsu.source_task SET status='PENDING' WHERE task_id='task'"); await refresh(f, "cleanup-check"); expect(await execute(f.input)).toBe("FAILED"); expect((await control()).phase).toBe("aborted"); f.input = { ...f.input, taskId: "retry", trigger: "manual", recoveryOf: undefined }; expect(await execute(f.input)).toBe("PENDING"); expect(f.writes).toHaveLength(2); expect(f.writes[1].records.map(row => row.row.id)).toEqual(["b"]); }); it.each([false, true])( "blocks insert-only overlap until acceptance without advancing the cursor (saved cursor: %s)", async seeded => { const f = asynchronousFixture(); f.input.config.model.cursor = { column: "id", type: "string" }; f.adapter.insertOnly = true; f.adapter.project = (_action, row: any) => [ { identity: { eventKey: recordKey([row.id]) }, upsert: row, remove: {}, }, ]; const createWriter = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { const writer = await createWriter(ctx); return { ...writer, init: async () => { await writer.init(); ctx.store.set("latestRun", ctx.logicalRunId); }, }; }; const reader = f.input.reader; const afters: unknown[] = []; f.input.reader = config => { const original = reader(config); return { ...original, stream: async function* (model, after, signal) { afters.push(after); for await (const row of original.stream(model, after, signal)) if (!after || row.checkpoint!.value > after.value) yield row; }, }; }; const saved = async () => { const envelope = (await admin.query("SELECT state FROM newjitsu.source_state WHERE stream='_REVERSE_ETL_'")) .rows[0]?.state; return envelope && { ...envelope, decoded: JSON.parse(envelope.value).value }; }; if (seeded) { f.setRows([{ id: "0" }]); f.input.taskId = "seed"; expect(await execute(f.input)).toBe("PENDING"); for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "seed-check", "seed"); expect(await execute(f.input)).toBe("COMPLETE"); // Older version-2 state has only runOrder; upgrades must preserve its fence. await admin.query( "UPDATE newjitsu.source_state SET state=state-'checkpointRunOrder' WHERE stream='_REVERSE_ETL_'" ); } const before = await saved(); f.setRows([{ id: "a" }, { id: "b" }]); f.input = { ...f.input, taskId: "task", trigger: "manual", recoveryOf: undefined }; const firstWrite = f.writes.length; expect(await execute(f.input)).toBe("PENDING"); const runOrder = (await control()).run_order; const count = f.writes.length; for (const taskId of ["noop-1", "noop-2"]) { f.input.taskId = taskId; expect(await execute(f.input)).toBe("FAILED"); expect(f.writes).toHaveLength(count); expect(await saved()).toEqual(before); expect((await task(taskId)).error).toContain("previous conversion run is still unresolved"); expect((await task()).status).toBe("PENDING"); } expect((await control()).run_order).toBe(runOrder); for (const batch of f.writes.slice(firstWrite)) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "accepted-check"); expect(await execute(f.input)).toBe("COMPLETE"); const acceptedState = await saved(); expect(acceptedState.checkpointRunOrder).toBe(runOrder); expect(acceptedState.decoded.store.latestRun).toBe((await control()).run_id); expect(acceptedState.decoded.point.cursor).toEqual({ value: "b", primaryKeyValues: ["b"] }); f.input = { ...f.input, taskId: "after-acceptance", trigger: "manual", recoveryOf: undefined }; expect(await execute(f.input)).toBe("COMPLETE"); expect(afters.at(-1)).toEqual({ value: "b", primaryKeyValues: ["b"] }); expect(f.writes).toHaveLength(count); } ); it.each(["upsert", "mirror"] as const)( "allows a new %s upload while an older run is pending and isolates late refresh", async mode => { const f = asynchronousFixture(); f.input.config.options.mode = mode; if (mode === "mirror") f.adapter.mirror!.batchDelivery = "asynchronous"; expect(await execute(f.input)).toBe("PENDING"); const firstRun = (await control()).run_id; const firstBatches = [...f.writes]; f.input.taskId = "second"; f.setRows([{ id: "b" }, { id: "c" }]); expect(await execute(f.input)).toBe("PENDING"); const secondRun = (await control()).run_id; expect(secondRun).not.toBe(firstRun); expect((await admin.query("SELECT count(*) FROM newjitsu.reverse_sync_control")).rows[0].count).toBe("2"); expect((await task()).status).toBe("PENDING"); expect((await task("second")).status).toBe("PENDING"); const secondBatches = f.writes.slice(firstBatches.length); expect(secondBatches.flatMap(batch => batch.records)).toHaveLength(2); for (const batch of secondBatches) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "second-check", "second"); expect(await execute(f.input)).toBe("COMPLETE"); const checkpoint = (await admin.query("SELECT state FROM newjitsu.source_state")).rows[0].state; expect(checkpoint.runOrder).toBe("1"); expect((await task()).status).toBe("PENDING"); for (const batch of firstBatches) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "first-check"); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).not.toContain("reader"); expect((await admin.query("SELECT state FROM newjitsu.source_state")).rows[0].state).toEqual(checkpoint); expect((await task()).status).toBe("COMPLETE"); expect((await task("second")).status).toBe("COMPLETE"); expect((await admin.query("SELECT count(*) FROM newjitsu.source_task")).rows[0].count).toBe("2"); expect((await durable()).members.map(m => m.effect.identity).sort()).toEqual( mode === "mirror" ? ["b", "c"] : ["a", "b", "c"] ); } ); it("persists WAITING without errors, and a separate recovery trigger polls without opening SQL", async () => { const f = asynchronousFixture(); const before = Date.now(); expect(await execute(f.input)).toBe("PENDING"); const waiting = await task(); expect(waiting.error).toBeNull(); expect(waiting.metrics.reverseDelivery).toMatchObject({ upsert: { total: 1, pending: 1 }, records: { accepted: 0, pending: 2 }, }); expect(waiting.started_by.workspaceId).toBe("workspace"); expect(waiting.metrics.reverseRecovery).toMatchObject({ runId: (await control()).run_id, revision: f.input.config.configRevision, attempt: 0, }); expect(Date.parse(waiting.metrics.reverseRecovery.nextCheckAt)).toBeGreaterThanOrEqual(before + 30 * 60_000); expect((await admin.query("SELECT 1 FROM newjitsu.task_log WHERE level='ERROR'")).rowCount).toBe(0); await makeDue(); f.input = { ...f.input, taskId: "automatic-check", trigger: "recovery", recoveryOf: "task" }; f.calls.length = 0; expect(await execute(f.input)).toBe("PENDING"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "release"]); expect((await task()).status).toBe("PENDING"); const polled = await task(); expect(polled.started_by).toEqual(waiting.started_by); expect(polled.started_at).toEqual(waiting.started_at); expect(polled.metrics.reverseRecovery.attempt).toBe(1); expect(polled.metrics.reverseDelivery).toMatchObject({ upsert: { total: 1, pending: 1 } }); expect(polled.metrics.reverseRecovery.deadline).toBe(waiting.metrics.reverseRecovery.deadline); for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); await makeDue(); f.input.taskId = "automatic-complete"; f.input.recoveryOf = "task"; expect(await execute(f.input)).toBe("COMPLETE"); expect((await task()).status).toBe("COMPLETE"); expect((await task()).metrics.reverseDelivery).toMatchObject({ upsert: { total: 1, accepted: 1, pending: 0 }, records: { accepted: 2, pending: 0 }, }); expect((await admin.query("SELECT count(*) FROM newjitsu.source_task")).rows[0].count).toBe("1"); expect((await control()).phase).toBe("complete"); expect(f.calls).not.toContain("reader"); const waitingLogs = (await admin.query("SELECT message FROM newjitsu.task_log WHERE task_id='task'")).rows .map(r => r.message) .join("\n"); expect(waitingLogs).toContain("2 confirmed submitted in 1 batches; 0 accepted, 2 pending"); expect(waitingLogs).toContain("2 rows read in this attempt, 2 upsert rows, 0 explicit removal rows"); expect(waitingLogs.match(/Delivery totals/g)).toHaveLength(3); expect(waitingLogs).toContain("Preparing 2 additions/upserts."); expect(waitingLogs).toContain("Submitted 2 additions/upserts; 0 accepted, 2 pending processing"); expect(waitingLogs).not.toContain("may have reached the destination"); const recoveryLogs = waitingLogs; expect(recoveryLogs).toContain("warehouse SQL is not re-read"); expect(recoveryLogs).toContain("2 confirmed submitted in 1 batches; 2 accepted, 0 pending"); expect(recoveryLogs.match(/Delivery totals/g)).toHaveLength(3); expect(recoveryLogs).toContain("Status updated for 2 additions/upserts: 2 accepted"); expect(recoveryLogs.match(/Submitted 2 additions\/upserts/g)).toHaveLength(1); const unchangedLogs = await taskLogs("automatic-check"); expect(unchangedLogs).toBe(""); }); it.each(["early", "cancelled", "revision", "run", "complete", "foreign", "duplicate"])( "does not start an obsolete or unauthorized recovery (%s)", async reason => { const f = asynchronousFixture(); await execute(f.input); if (reason !== "early") await makeDue(); if (reason === "cancelled") await admin.query("UPDATE newjitsu.source_task SET status='CANCELLED'"); if (reason === "revision") f.input.config.configRevision = "b".repeat(64); if (reason === "run") await admin.query("UPDATE newjitsu.reverse_sync_control SET run_id='new-run'"); if (reason === "complete") await admin.query("UPDATE newjitsu.reverse_sync_control SET phase='complete'"); if (reason === "foreign") f.input.config.workspaceId = "foreign"; f.input.trigger = "recovery"; f.input.recoveryOf = "task"; f.input.taskId = "automatic"; if (reason === "duplicate") { expect(await execute(f.input)).toBe("PENDING"); f.input.taskId = "duplicate"; } f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "release"]); expect(await task(f.input.taskId)).toBeUndefined(); if (reason === "cancelled") expect((await task()).status).toBe("CANCELLED"); else if (reason !== "duplicate") expect((await task()).status).toBe("PENDING"); } ); it("keeps real provider rejection FAILED without scheduling another check", async () => { const f = asynchronousFixture(); await execute(f.input); await makeDue(); const batch = f.writes[0]; f.receipts.set(batch.batchId, { outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "rejected", code: "invalid", safeReason: "Invalid", })), }); f.input.trigger = "recovery"; f.input.recoveryOf = "task"; f.input.taskId = "rejected-check"; expect(await execute(f.input)).toBe("FAILED"); const rejectedMetrics = (await task()).metrics; expect(rejectedMetrics.reverseRecovery.runId).toBe((await control()).run_id); expect(rejectedMetrics.reverseDelivery).toMatchObject({ upsert: { total: 1, rejected: 1 }, records: { rejected: 2 }, }); expect((await admin.query("SELECT 1 FROM newjitsu.source_task WHERE status='PENDING'")).rowCount).toBe(0); expect((await control()).phase).toBe("batches_pending"); expect(await taskLogs("task")).toContain( "2 confirmed submitted in 1 batches; 0 accepted, 0 pending processing, 2 rejected" ); }); it("retries a transient refresh failure on the original task without reopening SQL", async () => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); const bind = f.adapter.recovery!; let fail = true; f.adapter.recovery = state => ({ ...bind(state), reconcileBatch: async batch => { if (fail) throw new Error("private provider response"); return accepted(batch); }, }); await refresh(f, "failed-poll"); f.calls.length = 0; expect(await execute(f.input)).toBe("PENDING"); expect((await task()).metrics.reverseRecovery.attempt).toBe(1); expect(await taskLogs("task")).not.toContain("private provider response"); fail = false; await refresh(f, "next-poll"); expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).not.toContain("reader"); expect((await admin.query("SELECT count(*) FROM newjitsu.source_task")).rows[0].count).toBe("1"); }); it.each(["manual", "scheduled"] as const)( "does not create another task when %s admission encounters a pending non-detached run", async trigger => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); // Represents an interrupted extraction or migrated WAITING run. await admin.query("UPDATE newjitsu.reverse_sync_control SET detached=false"); const before = await task(); f.input = { ...f.input, trigger, taskId: "new-attempt" }; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect((await admin.query("SELECT task_id,status FROM newjitsu.source_task")).rows).toEqual([ { task_id: "task", status: "PENDING" }, ]); expect((await task()).metrics).toEqual(before.metrics); expect(f.calls).not.toContain("reader"); for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "continue-original"); expect(await execute(f.input)).toBe("COMPLETE"); expect((await task()).status).toBe("COMPLETE"); expect((await admin.query("SELECT count(*) FROM newjitsu.source_task")).rows[0].count).toBe("1"); } ); it.each(["FAILED", "CANCELLED"])("retains a %s detached run across failed explicit refresh", async previousStatus => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); const initial = await task(); expect((await control()).detached).toBe(true); const bind = f.adapter.recovery!; f.adapter.recovery = state => ({ ...bind(state), reconcileBatch: async () => { throw new ReverseEtlManualReconciliationError(); }, }); await refresh(f, "failed-check"); expect(await execute(f.input)).toBe("FAILED"); expect((await task()).metrics.reverseRecovery.runId).toBe(initial.metrics.reverseRecovery.runId); const error = (await task()).error; // The controller's explicit-refresh transition: same task, new worker. await admin.query( ` UPDATE newjitsu.source_task SET status='PENDING',metrics=jsonb_set( jsonb_set(metrics,'{reverseRecovery,nextCheckAt}',to_jsonb('2000-01-01T00:00:00.000Z'::text)), '{reverseRecovery,previousStatus}',to_jsonb($1::text)) WHERE task_id='task'`, [previousStatus] ); f.input.taskId = "manual-failed-check"; f.adapter.recovery = state => ({ ...bind(state), reconcileBatch: async () => { throw new Error("temporary lookup failure"); }, }); f.calls.length = 0; expect(await execute(f.input)).toBe(previousStatus); expect((await task()).status).toBe(previousStatus); expect((await task()).metrics.reverseWorker.active).toBe(false); expect((await task()).error).toBe(error); expect((await task()).metrics.reverseRecovery.runId).toBe(initial.metrics.reverseRecovery.runId); await admin.query(` UPDATE newjitsu.source_task SET status='PENDING',metrics=jsonb_set( metrics,'{reverseRecovery,nextCheckAt}',to_jsonb('2000-01-01T00:00:00.000Z'::text)) WHERE task_id='task'`); f.input.taskId = "manual-successful-check"; f.adapter.recovery = bind; for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); expect(await execute(f.input)).toBe("COMPLETE"); expect((await task()).error).toBeNull(); expect(f.calls).not.toContain("reader"); expect(f.calls).not.toContain("upsert"); expect((await admin.query("SELECT count(*) FROM newjitsu.source_task")).rows[0].count).toBe("1"); }); it("does not let a queued refresh bypass suspension", async () => { const f = asynchronousFixture(); expect(await execute(f.input)).toBe("PENDING"); await refresh(f, "late-queued-check"); await admin.query( "UPDATE newjitsu.source_task SET metrics=jsonb_set(metrics,'{reverseRecovery,suspended}','true')" ); const before = await task(); f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(await task()).toEqual(before); expect(f.calls).not.toContain("reconcile"); expect(f.calls).not.toContain("reader"); }); it.each([ "Google replacement cleanup failed or is unverified; manual reconciliation required, no automatic replay", "Malformed Google replacement status; manual reconciliation required", "Google replacement status target mismatch", "Google replacement cleanup receipt unavailable; manual reconciliation required, no automatic replay", ])("stops polling a terminal native-replacement result: %s", async reason => { const f = fixture(); f.input.config.options.mode = "mirror"; f.adapter.stream.capabilities.mirror = "native-replace"; f.adapter.verifyMirrorBaseline = async () => "replace"; f.setPending(); expect(await execute(f.input)).toBe("PENDING"); const bind = f.adapter.recovery!; f.adapter.recovery = state => ({ ...bind(state), reconcileFinish: async () => { throw new Error(reason); }, }); const before = await task(); await refresh(f, "replacement-failed"); f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); const failed = await task(); expect(failed.error).toContain("Google audience replacement could not be confirmed"); expect(failed.metrics.reverseRecovery.runId).toBe(before.metrics.reverseRecovery.runId); expect(failed.metrics.reverseRecovery.attempt).toBe(before.metrics.reverseRecovery.attempt); expect(f.calls).not.toContain("reader"); expect(f.calls).not.toContain("finish"); }); it("stops automatic polling at its original deadline without losing receipts", async () => { const f = asynchronousFixture(); await execute(f.input); await makeDue(); await admin.query( `UPDATE newjitsu.source_task SET metrics=jsonb_set(jsonb_set(metrics,'{reverseRecovery,startedAt}',to_jsonb('2000-01-01T00:00:00.000Z'::text)),'{reverseRecovery,deadline}',to_jsonb('2000-01-03T00:00:00.000Z'::text))` ); f.input.trigger = "recovery"; f.input.recoveryOf = "task"; f.input.taskId = "last-check"; expect(await execute(f.input)).toBe("PENDING"); expect((await task()).description).toContain("after 48 hours"); expect((await task()).error).toBeNull(); expect((await task()).metrics.reverseRecovery.suspended).toBe(true); expect((await control()).phase).toBe("batches_pending"); expect((await durable()).batches.length).toBe(1); expect((await admin.query("SELECT 1 FROM newjitsu.source_task WHERE status='PENDING'")).rowCount).toBe(1); const expiredSchedule = (await task()).metrics.reverseRecovery; await admin.query( `UPDATE newjitsu.source_task SET metrics=jsonb_set(metrics,'{reverseRecovery,suspended}','false') WHERE task_id='task'` ); f.input.taskId = "explicit-after-deadline"; expect(await execute(f.input)).toBe("PENDING"); expect((await task()).metrics.reverseRecovery).toEqual(expiredSchedule); expect(f.writes).toHaveLength(1); // A new extraction has its own deadline; it does not reopen this expired run. f.input.trigger = "manual"; f.input.taskId = "manual-after-timeout"; delete f.input.recoveryOf; expect(await execute(f.input)).toBe("PENDING"); expect((await task()).status).toBe("PENDING"); }); it("still cleans up incomplete legacy finish-staged sessions through verified abort", async () => { const f = asynchronousFixture(); f.adapter.stream.batchDelivery = undefined; const create = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { const writer = await create(ctx); return { ...writer, upsert: async batch => { await writer.upsert(batch); throw new Error("lost staged response"); }, }; }; expect(await execute(f.input)).toBe("FAILED"); expect((await control()).phase).toBe("running"); f.input.taskId = "legacy-cleanup"; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "reconcileAbort", "release"]); expect((await control()).phase).toBe("aborted"); expect([...new Set((await durable()).operations.map(row => row.status))].map(status => ({ status }))).toEqual([ { status: "cancelled" }, ]); }); it("blocks direct batch acknowledgement, cleanup and finish while sealed jobs remain pending", async () => { const f = asynchronousFixture(); await execute(f.input); const run = await openPersistence( db, { workspaceId: "workspace", syncId: "sync", logicalRunId: (await control()).run_id, taskId: "probe", configRevision: f.input.config.configRevision, targetIdentity: f.adapter.targetIdentity, mode: "upsert", extraction: "full", }, f.adapter.project ); await expect(run.delivery.prepareFinish(2, {})).rejects.toThrow(); await expect(run.delivery.prepareAbort()).rejects.toThrow(); await expect(run.delivery.acknowledge(f.writes[0].batchId, accepted(f.writes[0]), {})).rejects.toThrow(); expect((await control()).phase).toBe("batches_pending"); }); it.each([false, true])("settles independent batches without re-extraction (cursor=%s)", async cursor => { const f = asynchronousFixture(); if (cursor) f.input.config.model.cursor = { column: "id", type: "string" }; f.setRows([{ id: "a" }, { id: "b" }, { id: "c" }]); expect(await execute(f.input)).toBe("PENDING"); expect((await control()).phase).toBe("batches_pending"); expect(f.calls).not.toContain("finish"); expect((await admin.query("SELECT count(*) FROM newjitsu.source_state")).rows[0].count).toBe("0"); await refresh(f, "poll"); f.calls.length = 0; expect(await execute(f.input)).toBe("PENDING"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "reconcile", "release"]); expect((await control()).phase).toBe("batches_pending"); for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "complete"); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "reconcile", "attach", "finish", "release"]); expect((await control()).phase).toBe("complete"); const state = (await admin.query("SELECT state FROM newjitsu.source_state")).rows[0].state; expect(JSON.parse(state.value).value.point).toEqual({ sourceSequence: 3, ...(cursor ? { cursor: { value: "c", primaryKeyValues: ["c"] } } : {}), }); expect(f.writes).toHaveLength(2); }); it("retains partial accepted effects after independent jobs reject rows, without finalizing", async () => { const f = asynchronousFixture(); await execute(f.input); const batch = f.writes[0]; f.receipts.set(batch.batchId, { outcomes: [ { operationId: batch.records[0].operationId, status: "accepted" }, { operationId: batch.records[1].operationId, status: "rejected", code: "invalid", safeReason: "Invalid" }, ], }); await refresh(f, "rejected"); f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "release"]); expect((await control()).phase).toBe("batches_pending"); expect(String((await durable()).members.length)).toBe("1"); expect((await task()).status).toBe("FAILED"); expect(f.calls).not.toContain("finish"); }); it("rejects overlapping async identities before the conflicting provider request", async () => { const f = asynchronousFixture(); f.adapter.stream.batchSize = 1; f.adapter.project = () => [{ identity: "shared", upsert: { id: "shared" }, remove: { id: "shared" } }]; expect(await execute(f.input)).toBe("FAILED"); expect(f.writes).toHaveLength(1); expect((await control()).phase).toBe("running"); expect(f.calls).not.toContain("abort"); f.input.taskId = "still-pending"; f.calls.length = 0; expect(await execute(f.input)).toBe("PENDING"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "release"]); expect((await control()).phase).toBe("running"); f.input.taskId = "do-not-duplicate-pending"; expect(await execute(f.input)).toBe("FAILED"); expect(await task("do-not-duplicate-pending")).toBeUndefined(); }); it("records lease contention without opening delivery persistence", async () => { const f = fixture(); f.input.lease.acquire = async () => { throw new Error("Reverse sync already running"); }; expect(await execute(f.input)).toBe("FAILED"); expect(await control()).toBeUndefined(); expect(await task()).toMatchObject({ status: "FAILED" }); const logs = (await admin.query("SELECT message FROM newjitsu.task_log WHERE task_id=$1", [f.input.taskId])).rows; expect(logs).toHaveLength(1); expect(logs[0].message).toContain("Another worker is running this sync"); expect(f.calls).toEqual([]); }); it("does not overwrite an existing task when a duplicate worker loses admission", async () => { const f = fixture(); expect(await execute(f.input)).toBe("COMPLETE"); const before = await task(); f.input.lease.acquire = async () => { throw new Error("Reverse sync already running"); }; expect(await execute(f.input)).toBe("FAILED"); expect(await task()).toEqual(before); }); it("shares an in-flight heartbeat renewal when slow provisioning finishes", async () => { const f = fixture(); let ready!: () => void; const heartbeatStarted = new Promise(resolve => { ready = resolve; }); let inFlight = 0; let maximum = 0; f.input.heartbeatMs = 5; f.input.lease.renew = async () => { maximum = Math.max(maximum, ++inFlight); ready(); await new Promise(resolve => setTimeout(resolve, 40)); inFlight--; }; f.input.adapters = new Map([ [ String(f.input.config.destination.destinationType), async () => { await heartbeatStarted; return f.adapter; }, ], ]); expect(await execute(f.input)).toBe("COMPLETE"); expect(maximum).toBe(1); }); it("runs upsert with restricted DB grants, task logs and Kubernetes admission", async () => { const f = fixture(); expect(await execute(f.input)).toBe("COMPLETE"); expect((await task()).status).toBe("COMPLETE"); expect((await control()).phase).toBe("complete"); expect(f.calls).toEqual([ "lease", "renew", "create", "init", "reader", "source", "upsert", "finish", "close", "release", ]); expect((await admin.query("SELECT message FROM newjitsu.task_log")).rows).toContainEqual({ message: "Reverse ETL delivery committed", }); }); it("runs mirror including empty generations", async () => { const f = fixture(); f.input.config.options.mode = "mirror"; expect(await execute(f.input)).toBe("COMPLETE"); const logs = (await admin.query("SELECT message FROM newjitsu.task_log ORDER BY timestamp")).rows.map( r => r.message ); expect(logs).toContain("Extracted 2 source rows into snapshot; no audience changes submitted yet"); expect(logs).toContain( "Snapshot complete: 2 source rows, 2 projected audience members, 2 unique audience members, 0 duplicates collapsed. Comparing audience membership and submitting changes." ); f.input.taskId = "empty"; f.setRows([]); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).toContain("remove"); expect(String((await durable()).members.length)).toBe("0"); expect((await admin.query("SELECT message FROM newjitsu.task_log WHERE task_id='empty'")).rows).toContainEqual({ message: "Snapshot complete: 0 source rows, 0 projected audience members, 0 unique audience members, 0 duplicates collapsed. Comparing audience membership and submitting changes.", }); }); it("logs deduplication of projected members rather than subtracting source rows", async () => { const f = fixture(); f.input.config.options.mode = "mirror"; f.setRows([{ id: "a" }, { id: "b" }, { id: "excluded" }]); f.adapter.project = (_action, row: any) => row.id === "excluded" ? [] : ["private-member-1", "private-member-2"].map(id => ({ identity: id, upsert: { id }, remove: { id } })); expect(await execute(f.input)).toBe("COMPLETE"); const logs = (await admin.query("SELECT message FROM newjitsu.task_log")).rows; expect(logs).toContainEqual({ message: "Snapshot complete: 3 source rows, 4 projected audience members, 2 unique audience members, 2 duplicates collapsed. Comparing audience membership and submitting changes.", }); expect(JSON.stringify(logs)).not.toContain("private-member"); expect(JSON.stringify(logs)).toContain("2 duplicates collapsed, 1 source rows excluded by projection"); expect(f.writes.flatMap(batch => batch.records)).toHaveLength(2); }); it("preserves the original mirror comparison while a later attempt accepts additions and removes old members", async () => { const baseline = fixture(); baseline.input.config.options.mode = "mirror"; expect(await execute(baseline.input)).toBe("COMPLETE"); const f = asynchronousFixture(); f.input.config.options.mode = "mirror"; f.adapter.mirror!.batchDelivery = "asynchronous"; f.input.taskId = "diff"; f.setRows([{ id: "b" }, { id: "c" }, { id: "duplicate-c" }]); f.adapter.project = (_action, row: any) => { const id = row.id === "duplicate-c" ? "c" : row.id; return [{ identity: id, upsert: { id }, remove: { id } }]; }; expect(await execute(f.input)).toBe("PENDING"); const initialLogs = await taskLogs("diff"); expect(initialLogs).toContain( "2 baseline members; 1 new, 0 changed, 0 unchanged due for expiry refresh, 0 unconfirmed requiring refresh, 1 unchanged skipped, 1 to remove" ); expect(initialLogs).toContain("3 source rows, 2 unique members, 3 projected members, 1 duplicates collapsed"); expect(initialLogs).toContain("1 confirmed submitted in 1 batches; 0 accepted, 1 pending"); expect(initialLogs).toContain("Removals are blocked until all additions/updates/refreshes are accepted"); expect(initialLogs.match(/Delivery totals/g)).toHaveLength(1); expect(f.calls).not.toContain("remove"); for (const batch of f.writes) f.receipts.set(batch.batchId, accepted(batch)); await refresh(f, "diff-status-check", "diff"); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); const resumedLogs = await taskLogs("diff"); expect(resumedLogs).toContain("warehouse SQL is not re-read"); expect(resumedLogs).toContain("2 baseline members; 1 new"); expect(resumedLogs).toContain("Removals: 1 confirmed submitted in 1 batches; 1 accepted, 0 pending"); expect(resumedLogs.match(/Delivery totals/g)).toHaveLength(2); expect(resumedLogs).toContain("Preparing 1 removals."); expect(resumedLogs).toContain("Submitted 1 removals; 1 accepted"); expect(f.calls).toContain("remove"); expect(f.calls).not.toContain("reader"); expect((await durable()).members).toHaveLength(2); }); it("reports the failing mirror stage and counters without exposing warehouse errors", async () => { const f = fixture(); f.input.config.options.mode = "mirror"; const reader = f.input.reader(f.input.config.warehouse); f.input.reader = () => ({ ...reader, stream: async function* () { yield { row: { id: "one" }, deleted: false }; throw new Error("private-user@example.com secret-token"); }, }); expect(await execute(f.input)).toBe("FAILED"); const failed = await task(); expect(failed.error).toContain("during extraction (read 1 rows, saved 0)"); expect(failed.error).toContain("Check warehouse connectivity and query timeouts"); expect(failed.error).not.toMatch(/private-user|secret-token/); expect(f.writes).toHaveLength(0); }); it("caps provider batches to the journal record budget", async () => { const f = fixture(); const bounded = new Database( { host: container.getHost(), port: container.getMappedPort(5432), database: "runner_test", user: "runner_runtime", password: "runtime", }, { ...storage, limits: { batchRecords: 2 } } ); f.input.db = bounded; f.adapter.stream.batchSize = 1000; f.setRows([{ id: "a" }, { id: "b" }, { id: "c" }]); try { expect(await execute(f.input)).toBe("COMPLETE"); expect(f.writes.map(batch => batch.records.length)).toEqual([2, 1]); } finally { await bounded.close(); } }); it("rejects stale admission before constructing provider or reader", async () => { const f = fixture(); f.input.admit = async () => ({ ...f.input.config, configRevision: "b".repeat(64) }); expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "release"]); expect((await task()).status).toBe("FAILED"); }); describe("retention changed between startup and admission", () => { // The object storage is fixed from the startup configuration, but retention is outside the revision hash, so an // admitted configuration with a different retention bucket must not run with the old storage choice. const admitting = (startup: { bucket: string } | undefined, admitted: { bucket: string } | undefined) => { const f = fixture(); f.input.config = { ...f.input.config, ...(startup ? { retention: startup } : {}) }; f.input.admit = async () => ({ ...f.input.config, ...(admitted ? { retention: admitted } : { retention: undefined }), }); return f; }; it.each([ // The only bucket a run may name is its own workspace's (the fixture's workspace is "workspace"). ["enabled after startup", undefined, { bucket: "jitsu-retl-workspace" }], ["disabled after startup", { bucket: "jitsu-retl-workspace" }, undefined], ])( "refuses a run when retention was %s, before anything is constructed or sent", async (_name, startup, admitted) => { const f = admitting(startup, admitted); expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "release"]); expect((await task()).error).toContain("retention settings changed"); } ); it("refuses retention on a mirror run at admission, before anything is extracted or stored", async () => { // A mirror seals its full snapshot, with the projected provider data, before its first batch, so the per-batch guard // would be too late: the combination is refused up front. const f = fixture(); f.input.config = { ...f.input.config, options: { ...f.input.config.options, mode: "mirror" }, retention: { bucket: "jitsu-retl-workspace" }, }; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "release"]); expect((await task()).error).toContain("snapshot of its data that cannot expire"); }); it("admits a run whose retention is unchanged, or absent on both sides", async () => { expect(await execute(admitting(undefined, undefined).input)).toBe("COMPLETE"); }); }); it("does not admit missing provider bindings", async () => { const f = fixture(); f.input.adapters = new Map(); expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).not.toContain("reader"); }); it("does not overwrite a task cancelled before startup", async () => { const f = fixture(); await new Tasks(db, "sync", "task").start("manual"); await new Tasks(db, "sync", "task").finish("CANCELLED", "Cancelled"); expect(await execute(f.input)).toBe("FAILED"); expect((await task()).status).toBe("CANCELLED"); expect(f.calls).toEqual(["lease", "release"]); }); it("reconciles pending finish without a fresh source or session", async () => { const f = fixture(); f.setPending(); expect(await execute(f.input)).toBe("PENDING"); const logical = (await control()).run_id; await refresh(f, "recovery"); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).toEqual(["lease", "renew", "reconcileFinish", "release"]); expect((await control()).run_id).toBe(logical); expect((await task()).status).toBe("COMPLETE"); }); it("does not restore pending after committed delivery when task completion bookkeeping fails", async () => { const f = fixture(); f.setPending(); expect(await execute(f.input)).toBe("PENDING"); await refresh(f, "completion-check"); const finish = vi .spyOn(Tasks.prototype, "finish") .mockRejectedValueOnce(new Error("database temporarily unavailable")); try { expect(await execute(f.input)).toBe("FAILED"); expect((await control()).phase).toBe("complete"); const saved = await task(); expect(saved.status).toBe("FAILED"); expect(saved.metrics.reverseWorker.active).toBe(false); expect(finish).toHaveBeenCalledTimes(2); } finally { finish.mockRestore(); } }); it("executes managed Google mirror, resumes exact wire payloads and refreshes only when due", async () => { const f = fixture(); const managed = { id: `retl-google-${"b".repeat(64)}`, syncId: "sync", customerId: "1234567890", audienceId: "123", integrationCode: `jitsu-retl-${"b".repeat(64)}`, displayName: "Managed", membershipDays: 540, }; f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", reverseManagedAudience: managed, }; f.input.config.options.mode = "mirror"; f.input.config.options.mapping = { email: "id", adUserData: "consent", adPersonalization: "consent" }; f.input.config.options.streamOptions = { audienceId: "123", customerMatchTermsAccepted: true, managedAudienceId: managed.id, }; const originalReader = f.input.reader; f.input.reader = connection => ({ ...originalReader(connection), stream: async function* () { f.calls.push("google-source"); yield { row: { id: "Private.Person+tag@gmail.com", consent: "GRANTED" }, deleted: false }; }, }); const response = (body: unknown) => new Response(JSON.stringify(body)) as Awaited>; let submits = 0; const bodies: unknown[] = []; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (String(url).endsWith("/userLists/123")) return response({ name: "accountTypes/GOOGLE_ADS/accounts/1234567890/userLists/123", id: "123", displayName: managed.displayName, integrationCode: managed.integrationCode, membershipDuration: "46656000s", membershipStatus: "OPEN", accessReason: "OWNED", ingestedUserListInfo: { uploadKeyTypes: ["CONTACT_ID"], contactIdInfo: { dataSourceType: "DATA_SOURCE_TYPE_FIRST_PARTY" }, }, }); if (String(url).includes("audienceMembers:ingest")) { bodies.push(JSON.parse(init!.body as string)); return response({ requestId: `job-${++submits}` }); } expect(String(url)).toContain("requestStatus:retrieve?requestId=job-"); return response({ requestStatusPerDestination: [ { destination: { operatingAccount: { accountType: "GOOGLE_ADS", accountId: "1234567890" }, productDestinationId: "123", }, requestStatus: "SUCCESS", audienceMembersIngestionStatus: { userDataIngestionStatus: { recordCount: "1" } }, }, ], }); }); try { f.input.adapters = createAdapterRegistry(async () => "access-token"); expect(await execute(f.input)).toBe("PENDING"); expect(submits).toBe(1); await refresh(f, "resume"); f.input.adapters = createAdapterRegistry(async () => "access-token"); expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls.filter(c => c === "google-source")).toHaveLength(1); f.input.taskId = "fresh"; f.input.trigger = "manual"; delete f.input.recoveryOf; expect(await execute(f.input)).toBe("COMPLETE"); expect(submits).toBe(1); expect(await taskLogs("fresh")).toContain("1 unchanged skipped"); expect(await taskLogs("fresh")).toContain("No audience changes or expiry refreshes needed"); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(Date.now() + 31 * 86400_000); f.input.taskId = "refresh"; expect(await execute(f.input)).toBe("PENDING"); expect(submits).toBe(2); expect(await taskLogs("refresh")).toContain( "1 unchanged due for expiry refresh, 0 unconfirmed requiring refresh, 0 unchanged skipped" ); expect(bodies[1]).toEqual(bodies[0]); expect(JSON.stringify(bodies)).not.toContain("Private.Person"); await refresh(f, "refresh-resume", "refresh"); expect(await execute(f.input)).toBe("COMPLETE"); expect(await taskLogs("refresh")).toContain( "1 unchanged due for expiry refresh, 0 unconfirmed requiring refresh, 0 unchanged skipped" ); expect(await taskLogs("refresh")).toContain("1 confirmed submitted in 1 batches; 1 accepted, 0 pending"); } finally { wire.mockRestore(); } }); it("replaces an existing Google audience across upload and cleanup status checks without replay or re-extraction", async () => { const f = fixture(); f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", }; f.input.config.options.mode = "mirror"; f.input.config.options.mapping = { email: "id" }; f.input.config.options.streamOptions = { audienceId: "123", customerMatchTermsAccepted: true, mirrorStrategy: "full-replace", exclusiveManagementConfirmed: true, }; f.setRows([{ id: "one@example.com" }, { id: "two@example.com" }]); f.input.adapters = createAdapterRegistry(async () => "access-token"); const cutoff = "2026-09-20T07:00:00.000Z"; let uploads = 0, cleanups = 0, uploadsAccepted = false, cleanupAccepted = false; const response = (body: unknown) => new Response(JSON.stringify(body), { headers: { Date: new Date(cutoff).toUTCString() } }) as Awaited< ReturnType >; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (String(url).endsWith("/userLists/123")) return response({ id: "123", name: "accountTypes/GOOGLE_ADS/accounts/1234567890/userLists/123", displayName: "Existing", membershipDuration: "46656000s", membershipStatus: "OPEN", accessReason: "OWNED", ingestedUserListInfo: { uploadKeyTypes: ["CONTACT_ID"], contactIdInfo: { dataSourceType: "DATA_SOURCE_TYPE_FIRST_PARTY" }, }, }); if (String(url).endsWith("audienceMembers:ingest")) { expect(JSON.parse((await control()).store.toString()).value.googleAudienceReplacement.cutoff).toBe(cutoff); expect(JSON.parse(init!.body as string).audienceMembers).toHaveLength(2); return response({ requestId: `upload-${++uploads}` }); } if (String(url).endsWith("audienceMembers:removeAll")) { expect(uploadsAccepted).toBe(true); expect((await control()).phase).toBe("finish_prepared"); expect(JSON.parse(init!.body as string)).toEqual({ destinations: [ { operatingAccount: { accountType: "GOOGLE_ADS", accountId: "1234567890" }, productDestinationId: "123" }, ], removeAsOfTime: cutoff, }); return response({ requestId: `cleanup-${++cleanups}` }); } expect(String(url)).toContain("requestStatus:retrieve?requestId="); expect(init?.method).toBe("GET"); const cleanup = String(url).includes("requestId=cleanup-"); return response({ requestStatusPerDestination: [ { destination: { operatingAccount: { accountType: "GOOGLE_ADS", accountId: "1234567890" }, productDestinationId: "123", }, requestStatus: (cleanup ? cleanupAccepted : uploadsAccepted) ? "SUCCESS" : "PROCESSING", ...(cleanup ? { removeAllAudienceMembersStatus: {} } : { audienceMembersIngestionStatus: { userDataIngestionStatus: { recordCount: "2" } } }), }, ], }); }); try { expect(await execute(f.input)).toBe("PENDING"); expect(uploads).toBe(1); expect(cleanups).toBe(0); expect(await taskLogs("task")).toContain("All 2 members will be uploaded"); expect(await taskLogs("task")).not.toContain("unchanged skipped"); await makeDue(); f.input = { ...f.input, taskId: "upload-check", trigger: "recovery", recoveryOf: "task" }; expect(await execute(f.input)).toBe("PENDING"); uploadsAccepted = true; await makeDue(); f.input = { ...f.input, taskId: "start-cleanup", recoveryOf: "task" }; expect(await execute(f.input)).toBe("PENDING"); expect(cleanups).toBe(1); expect(await taskLogs("task")).toContain("Google is processing full-audience cleanup"); expect(await taskLogs("task")).toContain("Full-audience cleanup: pending"); expect(await taskLogs("task")).not.toContain("Removals: 0"); await makeDue(); f.input = { ...f.input, taskId: "cleanup-check", recoveryOf: "task" }; expect(await execute(f.input)).toBe("PENDING"); cleanupAccepted = true; await makeDue(); f.input = { ...f.input, taskId: "complete-cleanup", recoveryOf: "task" }; expect(await execute(f.input)).toBe("COMPLETE"); expect(await taskLogs("task")).toContain("Full-audience cleanup: accepted"); expect((await taskLogs("task")).match(/Delivery totals/g)).toHaveLength(5); expect(uploads).toBe(1); expect(cleanups).toBe(1); expect(f.calls.filter(c => c === "source")).toHaveLength(1); expect((await control()).phase).toBe("complete"); } finally { wire.mockRestore(); } }); it("rejects Google mirroring without an exact sync/account/audience binding", () => { const cfg = config(); cfg.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", }; cfg.options.mode = "mirror"; cfg.options.streamOptions = { audienceId: "123", customerMatchTermsAccepted: true }; const create = createAdapterRegistry(async () => "token").get("google-ads")!; expect(() => create(cfg)).toThrow("cannot be mirrored"); cfg.options.streamOptions.managedAudienceId = `retl-google-${"a".repeat(64)}`; cfg.destination.reverseManagedAudience = { id: cfg.options.streamOptions.managedAudienceId, syncId: "other", customerId: "1234567890", audienceId: "123", integrationCode: `jitsu-retl-${"a".repeat(64)}`, displayName: "Managed", membershipDays: 540, }; expect(() => create(cfg)).toThrow("binding mismatch"); }); it("recovers a Google request after restart using the durable normalized receipt, without source replay", async () => { const f = fixture(); f.input.config.destination = { destinationType: "google-ads", authorized: true, oauthConnectionId: "destination.destination", customerId: "1234567890", }; f.input.config.options.mapping = { email: "id", adUserData: "consent", adPersonalization: "consent" }; f.input.config.options.streamOptions = { audienceId: "123", customerMatchTermsAccepted: true }; const originalReader = f.input.reader; f.input.reader = connection => ({ ...originalReader(connection), stream: async function* () { f.calls.push("google-source"); yield { row: { id: "Private.Person+tag@gmail.com", consent: "GRANTED" }, deleted: false }; }, }); let polls = 0; // Node's Response constructor and the workspace's ambient fetch declaration // differ only in json()'s generic signature; runtime responses are native. const response = (body: unknown) => new Response(JSON.stringify(body)) as Awaited>; const wire = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { expect(init?.headers).toEqual({ Authorization: "Bearer access-token", "Content-Type": "application/json" }); if (String(url).includes("audienceMembers:ingest")) return response({ requestId: "durable-job" }); expect(String(url)).toContain("requestStatus:retrieve?requestId=durable-job"); polls++; return response({ requestStatusPerDestination: [ { destination: { operatingAccount: { accountType: "GOOGLE_ADS", accountId: "1234567890" }, productDestinationId: "123", }, requestStatus: polls === 1 ? "PROCESSING" : "SUCCESS", ...(polls === 1 ? {} : { audienceMembersIngestionStatus: { userDataIngestionStatus: { recordCount: "1" } } }), }, ], }); }); try { f.input.adapters = createAdapterRegistry(async () => "access-token"); expect(await execute(f.input)).toBe("PENDING"); expect((await control()).phase).toBe("batches_pending"); const logicalRun = (await control()).run_id; const saved = (await durable()).batches; expect(saved).toHaveLength(1); const serialized = JSON.stringify(saved); expect(serialized).toContain("durable-job"); expect(serialized).not.toContain("Private.Person"); expect(serialized).not.toContain("access-token"); // New registry/client instances simulate a different worker process. f.input.adapters = createAdapterRegistry(async () => "access-token"); await refresh(f, "google-pending"); expect(await execute(f.input)).toBe("PENDING"); f.input.adapters = createAdapterRegistry(async () => "access-token"); await refresh(f, "google-accepted"); expect(await execute(f.input)).toBe("COMPLETE"); expect((await control()).run_id).toBe(logicalRun); expect((await control()).checkpoint_sequence).toBe("1"); expect(f.calls.filter(call => call === "google-source")).toHaveLength(1); expect(wire.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1); expect(String((await durable()).members.length)).toBe("1"); } finally { wire.mockRestore(); } }); it("preserves cursor when recovered finish commits the exact last manifest", async () => { const f = fixture(); f.input.config.model.cursor = { column: "id", type: "string" }; f.setPending(); expect(await execute(f.input)).toBe("PENDING"); await refresh(f, "recovery"); expect(await execute(f.input)).toBe("COMPLETE"); expect((await control()).checkpoint_sequence).toBe("2"); }); it("recovers an empty cursor run from its plain JSON checkpoint without keys", async () => { const f = fixture(); f.input.config.model.cursor = { column: "id", type: "string" }; f.setRows([{ id: "a\u0000b" }]); expect(await execute(f.input)).toBe("COMPLETE"); const saved = (await admin.query("SELECT state FROM newjitsu.source_state")).rows[0].state; expect(saved.version).toBe(2); const point = JSON.parse(saved.value).value.point; expect(point.cursor).toEqual({ value: "a\u0000b", primaryKeyValues: ["a\u0000b"] }); f.setRows([]); f.setPending(); f.input.taskId = "empty"; expect(await execute(f.input)).toBe("PENDING"); await refresh(f, "recovery", "empty"); f.calls.length = 0; expect(await execute(f.input)).toBe("COMPLETE"); expect(f.calls).toEqual(["lease", "renew", "reconcileFinish", "release"]); const recovered = (await admin.query("SELECT state FROM newjitsu.source_state")).rows[0].state; expect(JSON.parse(recovered.value).value.point).toEqual(point); }); it("blocks unknown initialization without proof, then resets with explicit reconciliation", async () => { const f = fixture(); f.setFailInit(); expect(await execute(f.input)).toBe("FAILED"); const recovery = f.adapter.recovery; f.adapter.recovery = undefined; f.input.taskId = "blocked"; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "renew", "release"]); expect((await control()).phase).toBe("init_prepared"); f.adapter.recovery = recovery; f.input.taskId = "reset"; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toContain("reconcileInit"); expect((await control()).phase).toBe("new"); }); it("reconciles an ambiguous upsert then cleans up before any fresh extraction", async () => { const f = fixture(); f.setFailBatch(); expect(await execute(f.input)).toBe("FAILED"); const failedLogs = await taskLogs("task"); expect(failedLogs.match(/Delivery totals/g)).toHaveLength(1); expect(failedLogs).toContain("Confirmation missing for 2 additions/upserts"); expect(failedLogs).toContain("2 prepared/unconfirmed"); expect(failedLogs).toContain("may have reached the destination"); f.input.taskId = "recovery"; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toEqual(["lease", "renew", "reconcile", "reconcileAbort", "release"]); expect((await control()).phase).toBe("aborted"); expect(String((await durable()).members.length)).toBe("2"); }); it("does not release ownership until an aborted in-flight callback settles", async () => { const f = fixture(); f.input.heartbeatMs = 5; let unblock!: () => void; let entered!: () => void; const enteredPromise = new Promise(resolve => { entered = resolve; }); const barrier = new Promise(resolve => { unblock = resolve; }); const create = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { const writer = await create(ctx); return { ...writer, upsert: async batch => { entered(); await barrier; return accepted(batch); }, }; }; const running = execute(f.input); await enteredPromise; f.input.controller.abort(); expect(f.calls).not.toContain("release"); expect((await task()).status).toBe("RUNNING"); unblock(); expect(await running).toBe("CANCELLED"); expect(f.calls.at(-1)).toBe("release"); }); it("aborts a live run when Kubernetes renewal fails", async () => { const f = fixture(); f.input.heartbeatMs = 5; let constructing = false; f.input.lease.renew = async () => { if (constructing) throw new Error("lease lost"); }; const create = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { constructing = true; await new Promise(resolve => ctx.signal.addEventListener("abort", () => resolve(), { once: true })); return create(ctx); }; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).not.toContain("init"); expect((await control()).phase).toBe("init_prepared"); expect((await task()).error).toContain("ownership"); }); }); describe("rejection reporting", () => { const rejectEverything = ( f: ReturnType, code: string, details?: Array<{ reason: string; field?: string }> ) => { const create = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { const writer = await create(ctx); writer.upsert = async batch => ({ outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "rejected" as const, code, safeReason: "HTTP 422 with private-token in the body", ...(details ? { details } : {}), })), }); return writer; }; }; it("persists structured provider details in both the task error and ERROR log", async () => { const f = fixture(); rejectEverything(f, "GOOGLE_HTTP_400_INVALID_ARGUMENT", [ { reason: "INVALID_CURRENCY_CODE", field: "events[0].currency" }, ]); expect(await execute(f.input)).toBe("FAILED"); const error = (await task()).error as string; expect(error).toContain("events[0].currency: INVALID_CURRENCY_CODE"); const logs = await admin.query("SELECT message FROM newjitsu.task_log WHERE task_id=$1 AND level='ERROR'", [ f.input.taskId, ]); expect(logs.rows.some(row => row.message === error)).toBe(true); expect(await taskLogs("task")).not.toContain("private-token"); }); it("shows the rejection code in the task error and never the provider's text", async () => { const f = fixture(); rejectEverything(f, "http_422"); expect(await execute(f.input)).toBe("FAILED"); const error = (await task()).error as string; expect(error).toContain("The destination rejected a row, so the sync stopped."); expect(error).toContain("Reason code: http_422."); expect(error).not.toContain("identifier mappings"); expect(error).not.toContain("private-token"); expect(await taskLogs("task")).not.toContain("private-token"); }); it("shows the rejection code when recovery of an unresolved batch finds a rejected row", async () => { const f = fixture(); f.setFailBatch(); // the response was lost: the batch is left unresolved expect(await execute(f.input)).toBe("FAILED"); const recovery = f.adapter.recovery!; // reconciliation learns the destination's answer: the row was rejected f.adapter.recovery = providerState => ({ ...recovery(providerState), reconcileBatch: async batch => ({ outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "rejected" as const, code: "http_422", safeReason: "HTTP 422 with private-token in the body", })), }), }); f.input = { ...f.input, taskId: "recovery" }; expect(await execute(f.input)).toBe("FAILED"); const error = (await task("recovery")).error as string; expect(error).toContain("The destination rejected a row, so the sync stopped."); expect(error).toContain("Reason code: http_422."); expect(error).not.toContain("private-token"); }); it("shows the rejection code when a mirror run's recovery finds a rejected row", async () => { const f = fixture(); f.input.config.options = { ...f.input.config.options, mode: "mirror" }; f.setFailBatch(); expect(await execute(f.input)).toBe("FAILED"); const recovery = f.adapter.recovery!; f.adapter.recovery = providerState => ({ ...recovery(providerState), reconcileBatch: async batch => ({ outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "rejected" as const, code: "http_422", safeReason: "HTTP 422 with private-token in the body", })), }), }); f.input = { ...f.input, taskId: "recovery" }; expect(await execute(f.input)).toBe("FAILED"); const error = (await task("recovery")).error as string; expect(error).toContain("The destination rejected a row, so the sync stopped."); expect(error).toContain("Reason code: http_422."); expect(error).not.toContain("private-token"); }); it("the run that cleans up after a rejected batch was acknowledged still shows its reason code", async () => { const f = fixture(); f.setFailBatch(); // unresolved batch expect(await execute(f.input)).toBe("FAILED"); const recovery = f.adapter.recovery!; f.adapter.recovery = providerState => ({ ...recovery(providerState), reconcileBatch: async batch => ({ outcomes: batch.records.map(row => ({ operationId: row.operationId, status: "rejected" as const, code: "http_422", safeReason: "HTTP 422 with private-token in the body", details: [{ reason: "INVALID_CURRENCY_CODE", field: "events[0].currency" }], })), }), }); f.input = { ...f.input, taskId: "recovery-1" }; expect(await execute(f.input)).toBe("FAILED"); // the rejection is acknowledged durably, with its code shown // The next run finds the rejection already saved: it cleans up and restarts, and must not lose the code. Raising here // again would make recovery fail every time, so the code is added to the cleanup message instead. f.input = { ...f.input, taskId: "recovery-2" }; f.calls.length = 0; expect(await execute(f.input)).toBe("FAILED"); expect(f.calls).toContain("reconcileAbort"); const error = (await task("recovery-2")).error as string; expect(error).toContain("Recovery completed cleanup; next run will restart extraction"); expect(error).toContain("Reason code: http_422."); expect(error).toContain("events[0].currency: INVALID_CURRENCY_CODE"); expect(error).not.toContain("private-token"); // and the sync is not stuck: the run after that completes (the destination is healthy again) const create = f.adapter.stream.createWriter; f.adapter.stream.createWriter = async ctx => { const writer = await create(ctx); writer.upsert = async batch => accepted(batch); return writer; }; f.input = { ...f.input, taskId: "recovery-3" }; expect(await execute(f.input)).toBe("COMPLETE"); }); it("shows the rejection code for a mirror run too", async () => { const f = fixture(); rejectEverything(f, "http_422"); f.input.config.options = { ...f.input.config.options, mode: "mirror" }; expect(await execute(f.input)).toBe("FAILED"); const error = (await task()).error as string; expect(error).toContain("The destination rejected a row, so the sync stopped."); expect(error).toContain("Reason code: http_422."); expect(error).not.toContain("private-token"); }); it("drops a malformed code instead of showing it", async () => { const f = fixture(); rejectEverything(f, "Bearer private-token"); expect(await execute(f.input)).toBe("FAILED"); const error = (await task()).error as string; expect(error).toContain("The destination rejected a row, so the sync stopped."); expect(error).not.toContain("Reason code"); expect(error).not.toContain("private-token"); }); it("describes delivery counts without Google-specific wording", async () => { const f = fixture(); expect(await execute(f.input)).toBe("COMPLETE"); const logs = await taskLogs("task"); expect(logs).toContain("Delivery counts describe records the destination's API accepted"); expect(logs).not.toContain("Google"); }); }); describe("webhook destination through the runner", () => { let server: http.Server; let received: any[]; let status: number; beforeEach(async () => { received = []; status = 200; server = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", chunk => chunks.push(chunk)); req.on("end", () => { received.push({ headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString()) }); res.statusCode = status; res.end(); }); }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); }); afterEach(() => new Promise(resolve => server.close(() => resolve()))); function webhookFixture() { const f = fixture(); const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/hook`; f.input.config.destination = { destinationType: "webhook", url, method: "POST" }; f.input.config.options = { ...f.input.config.options, stream: "rows", mode: "upsert", mapping: { id: "id" }, streamOptions: { deliveryAttested: true, allowInsecureHttp: true }, }; f.setRows([{ id: "a" }, { id: "b" }, { id: "c" }]); // The test server is on loopback, which the real guard refuses; everything else is the production delivery path. const deps = { ...defaultDeliveryDeps, send: createGuardedRequest({ isBlocked: () => false }), sleep: async () => {}, }; f.input.adapters = new Map([["webhook", cfg => createWebhookRuntime(cfg as any, undefined, deps)]]); return f; } it("delivers every row once, in the documented envelope", async () => { const f = webhookFixture(); expect(await execute(f.input)).toBe("COMPLETE"); const records = received.flatMap(r => r.body.records); expect(records.map((r: any) => r.data.id).sort()).toEqual(["a", "b", "c"]); expect(received[0].body).toMatchObject({ syncId: "sync" }); expect(records.every((r: any) => r.operation === "upsert" && r.idempotencyKey)).toBe(true); }); it("a long URL (for example one that carries a token in its query string) does not stop the run from starting", async () => { const f = webhookFixture(); const long = `${f.input.config.destination.url}?token=${"t".repeat(700)}`; f.input.config.destination = { ...f.input.config.destination, url: long }; expect(await execute(f.input)).toBe("COMPLETE"); expect(received.flatMap(r => r.body.records).length).toBe(3); }); it("stops at a rejected record with its reason code, then re-sends the same keys on the next run", async () => { const f = webhookFixture(); status = 400; expect(await execute(f.input)).toBe("FAILED"); expect((await task()).error).toContain("Reason code: http_400"); const firstKeys = received.flatMap(r => r.body.records.map((x: any) => x.idempotencyKey)).sort(); received.length = 0; status = 200; f.input = { ...f.input, taskId: "task-2" }; expect(await execute(f.input)).toBe("COMPLETE"); const secondKeys = received.flatMap(r => r.body.records.map((x: any) => x.idempotencyKey)).sort(); expect([...new Set(secondKeys)]).toEqual([...new Set(firstKeys)]); }); it("an unreachable endpoint fails the run with a readable code and no thrown crash", async () => { const f = webhookFixture(); await new Promise(resolve => server.close(() => resolve())); server = http.createServer(); expect(await execute(f.input)).toBe("FAILED"); expect((await task()).error).toMatch(/Reason code: (unconfirmed|connection_error)/); }); describe("Postgres column types arrive as plain JSON", () => { // [name, column type, value expression, expected JSON value as received by the endpoint] // Expectations were reviewed one by one against the plan's type table (H11): types that fail a run without // conversion (float NaN/Infinity, interval, bytea) must be converted. The warehouse reader also // preserves exact numeric arrays; JSON payloads intentionally use native JS values. const cases: Array<[string, string, string, unknown]> = [ ["int4", "int4", "42", 42], ["int8", "int8", "123456789012345678", "123456789012345678"], ["numeric", "numeric", "1.50", "1.50"], ["float8", "float8", "1.25", 1.25], ["float4", "float4", "1.5", 1.5], ["float8 NaN", "float8", "'NaN'", "NaN"], ["float8 Infinity", "float8", "'Infinity'", "Infinity"], ["float8 -Infinity", "float8", "'-Infinity'", "-Infinity"], ["float4 NaN", "float4", "'NaN'", "NaN"], ["numeric NaN", "numeric", "'NaN'", "NaN"], ["bool", "bool", "true", true], ["text", "text", "'héllo'", "héllo"], ["char(3)", "char(3)", "'a'", "a "], ["null", "text", "NULL", null], ["date", "date", "'2026-01-01'", "2026-01-01"], ["timestamp", "timestamp", "'2026-01-01 12:00:00.123456'", "2026-01-01 12:00:00.123456"], ["timestamptz", "timestamptz", "'2026-01-01 12:00:00+00'", "2026-01-01 12:00:00+00"], ["timestamptz infinity", "timestamptz", "'infinity'", "infinity"], ["time", "time", "'12:34:56'", "12:34:56"], ["interval", "interval", "'1 day 02:03:04'", "P0Y0M1DT2H3M4S"], ["uuid", "uuid", "'11111111-2222-3333-4444-555555555555'", "11111111-2222-3333-4444-555555555555"], ["json", "json", `'{"a":[1,2]}'`, { a: [1, 2] }], ["jsonb", "jsonb", `'{"a":[1,2]}'`, { a: [1, 2] }], ["json JS number", "json", `'{"id":9007199254740993}'`, { id: 9007199254740992 }], ["jsonb JS number", "jsonb", `'{"id":9007199254740993}'`, { id: 9007199254740992 }], ["int[]", "int[]", "ARRAY[1,2,3]", [1, 2, 3]], ["text[]", "text[]", "ARRAY['a','b']", ["a", "b"]], ["int8[]", "int8[]", "ARRAY[9007199254740993]", ["9007199254740993"]], ["numeric[]", "numeric[]", "ARRAY[1.5,2.5]", ["1.5", "2.5"]], ["bytea", "bytea", "'\\xdeadbeef'", "3q2+7w=="], ["bytea[]", "bytea[]", "ARRAY['\\xde'::bytea]", ["3g=="]], ["interval[]", "interval[]", "ARRAY['1 day'::interval]", ["P0Y0M1DT0H0M0S"]], ["float8[] with NaN", "float8[]", "ARRAY['NaN'::float8, 1]", ["NaN", 1]], ["jsonb[]", "jsonb[]", `ARRAY['{"a":1}'::jsonb]`, [{ a: 1 }]], [ "2-D int[]", "int[]", "ARRAY[[1,2],[3,4]]", [ [1, 2], [3, 4], ], ], ["inet", "inet", "'10.0.0.1'", "10.0.0.1"], ["cidr", "cidr", "'10.0.0.0/8'", "10.0.0.0/8"], ["macaddr", "macaddr", "'08:00:2b:01:02:03'", "08:00:2b:01:02:03"], ["money", "money", "'$1.50'", "$1.50"], ["bit(3)", "bit(3)", "B'101'", "101"], ["xml", "xml", "''", ""], ["tsvector", "tsvector", "'a:1 b:2'", "'a':1 'b':2"], ["int4range", "int4range", "'[1,5)'", "[1,5)"], ["point", "point", "'(1,2)'", { x: 1, y: 2 }], ["box", "box", "'((1,1),(2,2))'", "(2,2),(1,1)"], ]; let n = 0; it.each(cases)("%s", async (name, type, expr, expected) => { const table = `webhook_type_${++n}`; await admin.query( `CREATE TABLE ${table} (id int PRIMARY KEY, v ${type}); INSERT INTO ${table} VALUES (1, ${expr})` ); const f = webhookFixture(); f.input.config.warehouse = { destinationType: "postgres", host: container.getHost(), port: container.getMappedPort(5432), database: "runner_test", username: "postgres", password: "test", sslMode: "disable", }; f.input.reader = createWarehouseReader as any; f.input.config.model = { ...f.input.config.model, query: `SELECT id, v FROM ${table}`, primaryKey: ["id"] }; f.input.config.options = { ...f.input.config.options, mapping: { id: "id", v: "v" } }; expect(await execute(f.input)).toBe("COMPLETE"); const record = received.flatMap(r => r.body.records)[0]; expect(record.data.id).toBe(1); // Whatever arrives must survive a JSON round trip unchanged, i.e. be plain JSON. expect(JSON.parse(JSON.stringify(record.data))).toEqual(record.data); expect(record.data.v).toEqual(expected); }); }); describe("ClickHouse column types arrive as plain JSON", () => { let ch: StartedTestContainer; const chRun = async (query: string) => { const response = await fetch(`http://${ch.getHost()}:${ch.getMappedPort(8123)}/?user=default&password=pw`, { method: "POST", body: query, }); if (!response.ok) throw new Error(await response.text()); }; beforeAll(async () => { ch = await new GenericContainer("clickhouse/clickhouse-server:25.4-alpine") .withExposedPorts(8123) .withEnvironment({ CLICKHOUSE_DB: "default", CLICKHOUSE_USER: "default", CLICKHOUSE_PASSWORD: "pw" }) .withWaitStrategy(Wait.forHttp("/ping", 8123).forStatusCode(200)) .withStartupTimeout(120_000) .start(); }, 150_000); afterAll(async () => { await ch?.stop(); }); // [name, column type, value expression, expected JSON value as received by the endpoint] const cases: Array<[string, string, string, unknown]> = [ ["Int64", "Int64", "9223372036854775807", "9223372036854775807"], ["UInt64", "UInt64", "18446744073709551615", "18446744073709551615"], ["Int128", "Int128", "170141183460469231731687303715884105727", "170141183460469231731687303715884105727"], [ "Int256", "Int256", "57896044618658097711785492504343953926634992332820282019728792003956564819967", "57896044618658097711785492504343953926634992332820282019728792003956564819967", ], ["Decimal(18,4)", "Decimal(18,4)", "1.2345", "1.2345"], ["Decimal256(10)", "Decimal256(10)", "123.456", "123.456"], ["Float64", "Float64", "1.5", 1.5], ["Float64 nan", "Float64", "nan", "nan"], ["Float64 inf", "Float64", "inf", "inf"], ["Date", "Date", "'2026-01-01'", "2026-01-01"], ["Date32", "Date32", "'2026-01-01'", "2026-01-01"], ["DateTime", "DateTime('UTC')", "'2026-01-01 12:00:00'", "2026-01-01T12:00:00Z"], ["DateTime64(6)", "DateTime64(6,'UTC')", "'2026-01-01 12:00:00.123456'", "2026-01-01T12:00:00.123456Z"], ["String", "String", "'héllo'", "héllo"], ["FixedString(3)", "FixedString(3)", "'abc'", "abc"], ["UUID", "UUID", "'11111111-2222-3333-4444-555555555555'", "11111111-2222-3333-4444-555555555555"], ["Bool", "Bool", "true", true], ["Enum8", "Enum8('a'=1,'b'=2)", "'a'", "a"], ["Enum16", "Enum16('x'=1000,'y'=2)", "'x'", "x"], ["LowCardinality(String)", "LowCardinality(String)", "'lc'", "lc"], ["Nullable(String) NULL", "Nullable(String)", "NULL", null], ["Array(Int64)", "Array(Int64)", "[1,2,9223372036854775807]", ["1", "2", "9223372036854775807"]], ["Array(String)", "Array(String)", "['x']", ["x"]], ["Array(Nullable(Int64))", "Array(Nullable(Int64))", "[1,NULL]", ["1", null]], ["Tuple(Int64,String)", "Tuple(Int64, String)", "(7,'t')", ["7", "t"]], ["named Tuple", "Tuple(a Int64, b String)", "(1,'t')", { a: "1", b: "t" }], ["Map(String,Int64)", "Map(String, Int64)", "map('k', 5)", { k: "5" }], ["IPv4", "IPv4", "'10.0.0.1'", "10.0.0.1"], ["IPv6", "IPv6", "'::1'", "::1"], ["Variant", "Variant(Int64, String)", "5", "5"], ]; let n = 0; it.each(cases)("%s", async (name, type, expr, expected) => { const table = `webhook_type_${++n}`; await chRun(`CREATE TABLE ${table} (id UInt32, v ${type}) ENGINE = MergeTree ORDER BY id`); await chRun(`INSERT INTO ${table} VALUES (1, ${expr})`); const f = webhookFixture(); f.input.config.warehouse = { destinationType: "clickhouse", protocol: "http", hosts: [`${ch.getHost()}:${ch.getMappedPort(8123)}`], database: "default", username: "default", password: "pw", }; f.input.reader = createWarehouseReader as any; f.input.config.model = { ...f.input.config.model, query: `SELECT id, v FROM ${table}`, primaryKey: ["id"] }; f.input.config.options = { ...f.input.config.options, mapping: { id: "id", v: "v" } }; expect(await execute(f.input)).toBe("COMPLETE"); const record = received.flatMap(r => r.body.records)[0]; expect(JSON.parse(JSON.stringify(record.data))).toEqual(record.data); expect(record.data.v).toEqual(expected); }); it("Nested columns arrive as arrays when aliased in the model SQL", async () => { await chRun( `CREATE TABLE webhook_nested (id UInt32, n Nested(a Int64, b String)) ENGINE = MergeTree ORDER BY id` ); await chRun("INSERT INTO webhook_nested (id, `n.a`, `n.b`) VALUES (1, [1,2], ['p','q'])"); const f = webhookFixture(); f.input.config.warehouse = { destinationType: "clickhouse", protocol: "http", hosts: [`${ch.getHost()}:${ch.getMappedPort(8123)}`], database: "default", username: "default", password: "pw", }; f.input.reader = createWarehouseReader as any; f.input.config.model = { ...f.input.config.model, query: "SELECT id, `n.a` AS n_a, `n.b` AS n_b FROM webhook_nested", primaryKey: ["id"], }; f.input.config.options = { ...f.input.config.options, mapping: { id: "id", n_a: "n_a", n_b: "n_b" } }; expect(await execute(f.input)).toBe("COMPLETE"); expect(received.flatMap(r => r.body.records)[0].data).toEqual({ id: 1, n_a: ["1", "2"], n_b: ["p", "q"] }); }); }); }); describe("webhook delivery under faults", () => { const ROWS = Number(process.env.WEBHOOK_SOAK_ROWS ?? 3000); // The suite's console output is swallowed; WEBHOOK_REPORT= collects the measurements instead. const report = (line: string) => { if (process.env.WEBHOOK_REPORT) require("node:fs").appendFileSync(process.env.WEBHOOK_REPORT, `${line}\n`); }; const secret = "soak-secret"; type Action = | { kind: "ok" } | { kind: "status"; status: number; retryAfter?: string } | { kind: "reset"; processed: boolean } | { kind: "slow"; ms: number } | { kind: "hang"; processed: boolean }; interface Info { ids: number[]; request: number; } let server: http.Server; let sockets: Set; const state = { delivered: new Map(), ids: new Map(), requests: 0, badSignatures: 0, behaviour: (_info: Info): Action => ({ kind: "ok" }), onProcessed: (_uniqueIds: number) => {}, }; const reset = () => { state.delivered.clear(); state.ids.clear(); state.requests = 0; state.badSignatures = 0; state.behaviour = () => ({ kind: "ok" }); state.onProcessed = () => {}; }; // Small deterministic generator so a failing run can be reproduced. const seeded = (seed: number) => () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; beforeEach(async () => { reset(); sockets = new Set(); server = http.createServer((req, res) => { const chunks: Buffer[] = []; req.on("data", chunk => chunks.push(chunk)); req.on("end", () => { const text = Buffer.concat(chunks).toString(); const timestamp = String(req.headers["jitsu-signature-timestamp"]); const expected = createHmac("sha256", secret).update(`${timestamp}.${text}`).digest("hex"); if (req.headers["jitsu-signature"] !== expected) state.badSignatures++; const records = JSON.parse(text).records as { key: string; idempotencyKey: string; data: { id: number } }[]; const action = state.behaviour({ ids: records.map(r => r.data.id), request: ++state.requests }); const process = () => { for (const record of records) { state.delivered.set(record.idempotencyKey, (state.delivered.get(record.idempotencyKey) ?? 0) + 1); state.ids.set(record.data.id, (state.ids.get(record.data.id) ?? 0) + 1); } state.onProcessed(state.ids.size); }; switch (action.kind) { case "ok": process(); res.statusCode = 200; res.end(); break; case "slow": process(); setTimeout(() => { res.statusCode = 200; res.end(); }, action.ms); break; case "status": res.statusCode = action.status; if (action.retryAfter) res.setHeader("retry-after", action.retryAfter); res.end(); break; case "reset": if (action.processed) process(); req.socket.destroy(); break; case "hang": if (action.processed) process(); break; } }); }); server.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); }); afterEach(async () => { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); }); function soak(rows = ROWS) { const f = fixture(); const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/hook`; f.input.config.destination = { destinationType: "webhook", url, method: "POST", signatureMethod: "hmac", signatureSecret: secret, }; f.input.config.model = { ...f.input.config.model, query: "SELECT id, name FROM source", primaryKey: ["id"], cursor: { column: "id", type: "number" }, } as any; f.input.config.options = { ...f.input.config.options, stream: "rows", mode: "upsert", mapping: { id: "id", name: "name" }, checkpointEvery: 5000, streamOptions: { deliveryAttested: true, allowInsecureHttp: true, recordsPerRequest: 50, concurrency: 2 }, }; const deps = { ...defaultDeliveryDeps, send: createGuardedRequest({ isBlocked: () => false }), sleep: async () => {}, }; f.input.adapters = new Map([["webhook", cfg => createWebhookRuntime(cfg as any, undefined, deps)]]); f.input.reader = () => ({ sql: {} as any, columns: async () => [], preview: async () => ({ rows: [], columns: [], truncated: false }), close: async () => {}, stream: async function* (_model: any, after: any, signal: AbortSignal) { for (let id = after ? Number(after.value) + 1 : 1; id <= rows; id++) { signal.throwIfAborted(); yield { row: { id, name: `user-${id}` }, deleted: false, checkpoint: { value: String(id), primaryKeyValues: [String(id)] }, }; } }, } as any); let run = 0; const next = () => { run++; f.input = { ...f.input, taskId: `soak-${run}`, controller: new AbortController() }; return f.input; }; return { f, next, rows }; } const deliveredEverything = (rows: number) => { const missing: number[] = []; for (let id = 1; id <= rows && missing.length < 5; id++) if (!state.ids.has(id)) missing.push(id); return missing; }; const duplicates = () => [...state.delivered.values()].reduce((sum, count) => sum + count - 1, 0); // After a run is interrupted mid-request the batch is left uncertain. The next scheduled run fails once with the // core's "reconcile before retrying" error and the one after it recovers (no human step). Returns each run's result // and the failed runs' task errors. const continueAfterInterruption = async (next: () => ExecuteOptions) => { const results: string[] = []; const errors: string[] = []; while (results.at(-1) !== "COMPLETE" && results.length < 4) { const input = next(); results.push(await execute(input)); if (results.at(-1) === "FAILED") errors.push(String((await task(input.taskId)).error)); } return { results, errors }; }; it("delivers every row exactly once-or-more under 503, 429, resets and slow responses, with valid signatures", async () => { const random = seeded(7); state.behaviour = () => { const r = random(); if (r < 0.015) return { kind: "status", status: 503 }; if (r < 0.02) return { kind: "status", status: 429, retryAfter: "0" }; if (r < 0.025) return { kind: "reset", processed: false }; if (r < 0.035) return { kind: "reset", processed: true }; if (r < 0.045) return { kind: "slow", ms: 30 }; return { kind: "ok" }; }; const { f, rows } = soak(); expect(await execute(f.input)).toBe("COMPLETE"); expect(deliveredEverything(rows)).toEqual([]); expect(state.ids.size).toBe(rows); expect(state.badSignatures).toBe(0); // Resets after processing show up as repeats with the same idempotency key, never as different keys for one row. expect(new Set([...state.delivered.keys()]).size).toBe(rows); report(`SOAK rows=${rows} requests=${state.requests} duplicates=${duplicates()}`); }, 600_000); it("a poison record stops the run with its code; after the endpoint is fixed the next run delivers everything and re-sends at most one checkpoint", async () => { const { f, next, rows } = soak(); const poison = Math.floor(rows * 0.6); state.behaviour = ({ ids }) => (ids.includes(poison) ? { kind: "status", status: 400 } : { kind: "ok" }); expect(await execute(next())).toBe("FAILED"); expect((await task("soak-1")).error).toContain("Reason code: http_400"); expect(state.ids.has(poison)).toBe(false); const deliveredBefore = state.ids.size; state.behaviour = () => ({ kind: "ok" }); expect(await execute(next())).toBe("COMPLETE"); expect(deliveredEverything(rows)).toEqual([]); expect(state.badSignatures).toBe(0); const resent = duplicates(); report(`POISON rows=${rows} poison=${poison} deliveredBeforeFix=${deliveredBefore} resent=${resent}`); expect(resent).toBeLessThanOrEqual(5000 + 4 * 50); }, 600_000); it("cancelling mid-run and running again delivers every row", async () => { const { next, rows } = soak(); const run = next(); const cutoff = Math.floor(rows * 0.3); state.onProcessed = unique => { if (unique >= cutoff) run.controller.abort(); }; expect(await execute(run)).toBe("CANCELLED"); expect(state.ids.size).toBeLessThan(rows); state.onProcessed = () => {}; const { results, errors } = await continueAfterInterruption(next); report(`CANCEL results=${results.join(",")} errors=${JSON.stringify([...new Set(errors)])}`); expect(results.at(-1)).toBe("COMPLETE"); expect(results.filter(result => result === "FAILED").length).toBeLessThanOrEqual(1); expect(deliveredEverything(rows)).toEqual([]); report(`CANCEL rows=${rows} cutoff=${cutoff} duplicates=${duplicates()}`); }, 600_000); it("an abort while a request is in flight (endpoint processed it, never answered) is replayed with the same keys and delivers every row", async () => { const { next, rows } = soak(); const run = next(); const hangAt = Math.floor(rows / 2); let hung = false; state.behaviour = ({ ids }) => { if (!hung && ids.includes(hangAt)) { hung = true; setTimeout(() => run.controller.abort(), 100); return { kind: "hang", processed: true }; } return { kind: "ok" }; }; expect(await execute(run)).not.toBe("COMPLETE"); expect(state.ids.has(hangAt)).toBe(true); const { results, errors } = await continueAfterInterruption(next); report(`INFLIGHT results=${results.join(",")} errors=${JSON.stringify([...new Set(errors)])}`); expect(results.at(-1)).toBe("COMPLETE"); expect(results.filter(result => result === "FAILED").length).toBeLessThanOrEqual(1); expect(deliveredEverything(rows)).toEqual([]); const repeated = [...state.delivered.entries()].filter(([, count]) => count > 1); expect(repeated.length).toBeGreaterThan(0); report(`INFLIGHT rows=${rows} hangAt=${hangAt} duplicates=${duplicates()}`); }, 600_000); }); describe("webhook SSRF protection through the runner (real address guard, nothing bypassed)", () => { let server: http.Server; let hits: string[]; let redirectTo: string | undefined; beforeEach(async () => { hits = []; redirectTo = undefined; server = http.createServer((req, res) => { hits.push(`${req.method} ${req.url}`); req.resume(); if (redirectTo) { res.statusCode = 302; res.setHeader("location", redirectTo); } res.end(); }); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); }); afterEach(() => new Promise(resolve => server.close(() => resolve()))); function syncTo(url: string, realGuard = true) { const f = fixture(); f.input.config.destination = { destinationType: "webhook", url, method: "POST" }; f.input.config.options = { ...f.input.config.options, stream: "rows", mode: "upsert", mapping: { id: "id" }, streamOptions: { deliveryAttested: true, allowInsecureHttp: true }, }; f.setRows([{ id: "a" }]); // Production deliver path with its real guard; only the wait between retries is skipped. const deps = { ...defaultDeliveryDeps, sleep: async () => {}, ...(realGuard ? {} : { send: createGuardedRequest({ isBlocked: (address: string) => address !== "127.0.0.1" }) }), }; f.input.adapters = new Map([["webhook", cfg => createWebhookRuntime(cfg as any, undefined, deps)]]); return f; } const blocked = (port: number) => [ `http://127.0.0.1:${port}/hook`, `http://localhost:${port}/hook`, `http://[::1]:${port}/hook`, `http://[::ffff:127.0.0.1]:${port}/hook`, `http://[::ffff:7f00:1]:${port}/hook`, `http://2130706433:${port}/hook`, `http://0x7f.1:${port}/hook`, `http://0.0.0.0:${port}/hook`, `http://[::]:${port}/hook`, "http://169.254.169.254/latest/meta-data/", "http://[fe80::1]/", "http://10.0.0.1/", "http://172.16.0.1/", "http://192.168.1.1/", "http://100.64.0.1/", "http://224.0.0.1/", "http://[64:ff9b::7f00:1]/", "http://[fd00::1]/", ]; it("refuses loopback, link-local, private, CGNAT, multicast, mapped and NAT64 targets with a clear code and no request", async () => { const port = (server.address() as AddressInfo).port; for (const url of blocked(port)) { const f = syncTo(url); f.input.taskId = `ssrf-${Math.random().toString(36).slice(2)}`; expect(await execute(f.input), url).toBe("FAILED"); const error = String((await task(f.input.taskId)).error); expect(error, url).toContain("Reason code: blocked_address"); expect(error, url).not.toContain("127.0.0.1"); await admin.query( "TRUNCATE newjitsu.reverse_sync_control,newjitsu.reverse_sync_target_owner,newjitsu.source_state,newjitsu.source_task,newjitsu.task_log" ); } expect(hits).toEqual([]); }, 300_000); it("does not follow a redirect to an internal address", async () => { redirectTo = "http://169.254.169.254/latest/meta-data/"; const f = syncTo(`http://127.0.0.1:${(server.address() as AddressInfo).port}/hook`, false); expect(await execute(f.input)).toBe("FAILED"); expect(String((await task()).error)).toContain("Reason code: redirect_refused"); expect(hits).toEqual(["POST /hook"]); }); });