@voyant-travel/workflows-orchestrator 0.107.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +52 -0
- package/README.md +76 -0
- package/dist/abort-registry.d.ts +6 -0
- package/dist/abort-registry.d.ts.map +1 -0
- package/dist/abort-registry.js +37 -0
- package/dist/concurrency.d.ts +31 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +145 -0
- package/dist/drive.d.ts +67 -0
- package/dist/drive.d.ts.map +1 -0
- package/dist/drive.js +373 -0
- package/dist/driver-inmemory.d.ts +30 -0
- package/dist/driver-inmemory.d.ts.map +1 -0
- package/dist/driver-inmemory.js +394 -0
- package/dist/event-router.d.ts +51 -0
- package/dist/event-router.d.ts.map +1 -0
- package/dist/event-router.js +68 -0
- package/dist/http-step-handler.d.ts +25 -0
- package/dist/http-step-handler.d.ts.map +1 -0
- package/dist/http-step-handler.js +78 -0
- package/dist/in-memory-store.d.ts +5 -0
- package/dist/in-memory-store.d.ts.map +1 -0
- package/dist/in-memory-store.js +41 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/journal-helpers.d.ts +3 -0
- package/dist/journal-helpers.d.ts.map +1 -0
- package/dist/journal-helpers.js +9 -0
- package/dist/orchestrator.d.ts +116 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +411 -0
- package/dist/resume-run.d.ts +40 -0
- package/dist/resume-run.d.ts.map +1 -0
- package/dist/resume-run.js +119 -0
- package/dist/schedule.d.ts +51 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +243 -0
- package/dist/testing/driver-compliance.d.ts +58 -0
- package/dist/testing/driver-compliance.d.ts.map +1 -0
- package/dist/testing/driver-compliance.js +667 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +51 -0
- package/src/__tests__/orchestrator-test-support.ts +18 -0
- package/src/abort-registry.ts +41 -0
- package/src/concurrency.ts +217 -0
- package/src/drive.ts +477 -0
- package/src/driver-inmemory.ts +511 -0
- package/src/event-router.ts +120 -0
- package/src/http-step-handler.ts +112 -0
- package/src/in-memory-store.ts +44 -0
- package/src/index.ts +73 -0
- package/src/journal-helpers.ts +11 -0
- package/src/orchestrator.ts +527 -0
- package/src/resume-run.ts +162 -0
- package/src/schedule.ts +310 -0
- package/src/testing/driver-compliance.ts +800 -0
- package/src/types.ts +201 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
// Driver compliance suite — the contract every WorkflowDriver must satisfy.
|
|
2
|
+
//
|
|
3
|
+
// `runDriverComplianceSuite(name, makeDriver)` is parameterized over a
|
|
4
|
+
// driver factory. It runs identical assertions against every implementation
|
|
5
|
+
// we ship: InMemory, Mode 2 / Postgres, Mode 1 / CF edge.
|
|
6
|
+
//
|
|
7
|
+
// Importable from a regular `.ts` file so downstream packages
|
|
8
|
+
// (`@voyant-travel/workflows-orchestrator-node`, `-cloudflare`) can run the
|
|
9
|
+
// same suite against their own driver factories without duplicating the
|
|
10
|
+
// assertions. Vitest globals are imported explicitly because this file
|
|
11
|
+
// isn't a `.test.ts` (no auto-injection).
|
|
12
|
+
//
|
|
13
|
+
// Tests that depend on machinery not yet wired (filter matching,
|
|
14
|
+
// time-wheel resume of DATETIME waitpoints) are added as those
|
|
15
|
+
// capabilities land. PR1 covers: register/get manifest, trigger,
|
|
16
|
+
// idempotency dedup, ingestEvent's manifest-not-registered + no-filters
|
|
17
|
+
// paths, ctx.services, basic admin reads, shutdown.
|
|
18
|
+
//
|
|
19
|
+
// Architecture: docs/architecture/workflows-runtime-architecture.md §6.4.
|
|
20
|
+
import { __resetRegistry, workflow } from "@voyant-travel/workflows";
|
|
21
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
22
|
+
import { WorkflowConcurrencyRejectedError } from "../concurrency.js";
|
|
23
|
+
/**
|
|
24
|
+
* Tiny in-memory ServiceResolver builder for compliance tests. Lets a test
|
|
25
|
+
* register named services then assert workflow bodies can resolve them via
|
|
26
|
+
* `ctx.services.resolve(...)`.
|
|
27
|
+
*/
|
|
28
|
+
export function makeServiceResolver(entries = {}) {
|
|
29
|
+
const map = new Map(Object.entries(entries));
|
|
30
|
+
return {
|
|
31
|
+
resolve(name) {
|
|
32
|
+
if (!map.has(name)) {
|
|
33
|
+
throw new Error(`compliance harness: no service registered under "${name}"`);
|
|
34
|
+
}
|
|
35
|
+
return map.get(name);
|
|
36
|
+
},
|
|
37
|
+
has(name) {
|
|
38
|
+
return map.has(name);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a `DriverFactoryDeps` value suitable for compliance tests. Captures
|
|
44
|
+
* log lines so individual tests can assert on them when needed. Pass
|
|
45
|
+
* `services` to register specific entries; defaults to an empty resolver.
|
|
46
|
+
*/
|
|
47
|
+
export function testFactoryDeps(services = makeServiceResolver()) {
|
|
48
|
+
const logs = [];
|
|
49
|
+
return {
|
|
50
|
+
services,
|
|
51
|
+
logger: (level, msg, data) => logs.push([level, msg, data]),
|
|
52
|
+
logs,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build a minimal manifest for tests. The shape matches `WorkflowManifest`;
|
|
57
|
+
* filter-related fields stay empty until the event-router (PR2) lands.
|
|
58
|
+
*/
|
|
59
|
+
export function buildTestManifest(versionId = "v_test_001") {
|
|
60
|
+
return {
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
projectId: "default",
|
|
63
|
+
versionId,
|
|
64
|
+
builtAt: 1_700_000_000_000,
|
|
65
|
+
builderVersion: "test-0.0.0",
|
|
66
|
+
capabilities: testReleaseCapabilities(),
|
|
67
|
+
workflows: [],
|
|
68
|
+
eventFilters: [],
|
|
69
|
+
diagnostics: [],
|
|
70
|
+
bindings: {},
|
|
71
|
+
environments: { production: {}, preview: {}, development: {} },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function testReleaseCapabilities() {
|
|
75
|
+
return {
|
|
76
|
+
trigger: true,
|
|
77
|
+
events: true,
|
|
78
|
+
schedules: true,
|
|
79
|
+
rerun: true,
|
|
80
|
+
resume: true,
|
|
81
|
+
cancel: true,
|
|
82
|
+
humanApproval: false,
|
|
83
|
+
stepRerun: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// Per-test counter so workflow ids stay unique across tests in a suite —
|
|
87
|
+
// some persistent stores (Mode 2's Postgres) carry state between tests in
|
|
88
|
+
// the same run, and a duplicate id would HMR-warn at minimum and
|
|
89
|
+
// pollute results at worst.
|
|
90
|
+
let suiteCounter = 0;
|
|
91
|
+
function uniqueId(prefix) {
|
|
92
|
+
return `${prefix}-${++suiteCounter}`;
|
|
93
|
+
}
|
|
94
|
+
const DATETIME_WAKEUP_TEST_TIMEOUT_MS = 5_000;
|
|
95
|
+
// ---- The parameterized contract ----
|
|
96
|
+
export function runDriverComplianceSuite(name, makeFactory, capabilities = {}) {
|
|
97
|
+
const servicesThreading = capabilities.servicesThreading ?? true;
|
|
98
|
+
const crossRunQueries = capabilities.crossRunQueries ?? true;
|
|
99
|
+
const autoDatetimeWakeups = capabilities.autoDatetimeWakeups ?? false;
|
|
100
|
+
const workflowConcurrency = capabilities.workflowConcurrency ?? true;
|
|
101
|
+
describe(`${name} driver compliance`, () => {
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
__resetRegistry();
|
|
104
|
+
});
|
|
105
|
+
describe("registerManifest / getManifest", () => {
|
|
106
|
+
test("returns versionId from the supplied manifest", async () => {
|
|
107
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
108
|
+
const manifest = buildTestManifest(uniqueId("v"));
|
|
109
|
+
const result = await driver.registerManifest({
|
|
110
|
+
environment: "production",
|
|
111
|
+
manifest,
|
|
112
|
+
});
|
|
113
|
+
expect(result.versionId).toBe(manifest.versionId);
|
|
114
|
+
});
|
|
115
|
+
test("getManifest returns the registered manifest for the right environment", async () => {
|
|
116
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
117
|
+
const manifest = buildTestManifest(uniqueId("v"));
|
|
118
|
+
await driver.registerManifest({ environment: "production", manifest });
|
|
119
|
+
const got = await driver.getManifest({ environment: "production" });
|
|
120
|
+
expect(got).toBeTruthy();
|
|
121
|
+
expect(got?.versionId).toBe(manifest.versionId);
|
|
122
|
+
});
|
|
123
|
+
test("getManifest returns null when no manifest is registered for an environment", async () => {
|
|
124
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
125
|
+
const got = await driver.getManifest({ environment: "preview" });
|
|
126
|
+
expect(got).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
test("registerManifest is idempotent: same versionId returns the same value", async () => {
|
|
129
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
130
|
+
const manifest = buildTestManifest(uniqueId("v"));
|
|
131
|
+
const first = await driver.registerManifest({ environment: "production", manifest });
|
|
132
|
+
const second = await driver.registerManifest({ environment: "production", manifest });
|
|
133
|
+
expect(first.versionId).toBe(second.versionId);
|
|
134
|
+
expect(first.versionId).toBe(manifest.versionId);
|
|
135
|
+
});
|
|
136
|
+
test("manifests are environment-scoped (production vs preview don't bleed)", async () => {
|
|
137
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
138
|
+
const prodManifest = buildTestManifest(uniqueId("v_prod"));
|
|
139
|
+
const prevManifest = buildTestManifest(uniqueId("v_prev"));
|
|
140
|
+
await driver.registerManifest({ environment: "production", manifest: prodManifest });
|
|
141
|
+
await driver.registerManifest({ environment: "preview", manifest: prevManifest });
|
|
142
|
+
const prod = await driver.getManifest({ environment: "production" });
|
|
143
|
+
const prev = await driver.getManifest({ environment: "preview" });
|
|
144
|
+
expect(prod?.versionId).toBe(prodManifest.versionId);
|
|
145
|
+
expect(prev?.versionId).toBe(prevManifest.versionId);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe("trigger", () => {
|
|
149
|
+
test("creates a run that completes for a trivial workflow body", async () => {
|
|
150
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
151
|
+
const wfId = uniqueId("compliance-double");
|
|
152
|
+
const wf = workflow({
|
|
153
|
+
id: wfId,
|
|
154
|
+
async run(input) {
|
|
155
|
+
return { doubled: input.n * 2 };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const run = await driver.trigger(wf, { n: 21 });
|
|
159
|
+
expect(run.workflowId).toBe(wfId);
|
|
160
|
+
expect(run.status).toBe("completed");
|
|
161
|
+
});
|
|
162
|
+
test("respects environment from TriggerOptions", async () => {
|
|
163
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
164
|
+
const wf = workflow({
|
|
165
|
+
id: uniqueId("compliance-env"),
|
|
166
|
+
async run() { },
|
|
167
|
+
});
|
|
168
|
+
const run = await driver.trigger(wf, {}, { environment: "preview" });
|
|
169
|
+
expect(run.status).toBe("completed");
|
|
170
|
+
});
|
|
171
|
+
test("delay parks the run instead of invoking the workflow immediately", async () => {
|
|
172
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
173
|
+
let invocationCount = 0;
|
|
174
|
+
const wf = workflow({
|
|
175
|
+
id: uniqueId("compliance-delay"),
|
|
176
|
+
async run() {
|
|
177
|
+
invocationCount++;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
const run = await driver.trigger(wf, {}, { delay: "1s" });
|
|
181
|
+
expect(run.status).toBe("waiting");
|
|
182
|
+
expect(invocationCount).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
test.runIf(autoDatetimeWakeups)("delay continues scheduling later DATETIME waits after the trigger delay fires", async () => {
|
|
185
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
186
|
+
const completed = vi.fn();
|
|
187
|
+
const wf = workflow({
|
|
188
|
+
id: uniqueId("compliance-delay-then-sleep"),
|
|
189
|
+
async run(_, ctx) {
|
|
190
|
+
await ctx.sleep("10ms");
|
|
191
|
+
completed();
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
const run = await driver.trigger(wf, {}, { delay: "10ms" });
|
|
195
|
+
expect(run.status).toBe("waiting");
|
|
196
|
+
await vi.waitFor(() => expect(completed).toHaveBeenCalledTimes(1), {
|
|
197
|
+
timeout: DATETIME_WAKEUP_TEST_TIMEOUT_MS,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
test("idempotencyKey produces a stable run across retries (same key → same run)", async () => {
|
|
201
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
202
|
+
const wf = workflow({
|
|
203
|
+
id: uniqueId("compliance-idem"),
|
|
204
|
+
async run(input) {
|
|
205
|
+
return { n: input.n };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
const key = `key-${suiteCounter}`;
|
|
209
|
+
const a = await driver.trigger(wf, { n: 1 }, { idempotencyKey: key });
|
|
210
|
+
const b = await driver.trigger(wf, { n: 999 }, { idempotencyKey: key });
|
|
211
|
+
expect(b.id).toBe(a.id);
|
|
212
|
+
});
|
|
213
|
+
test("concurrent triggers with the same idempotencyKey only run once", async () => {
|
|
214
|
+
// Closes the get-then-save race window. A counter inside the
|
|
215
|
+
// workflow body lets us assert exactly-once side effects across
|
|
216
|
+
// 8 parallel triggers — without `tryInsert`'s atomicity, the
|
|
217
|
+
// counter would tick more than once.
|
|
218
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
219
|
+
let invocationCount = 0;
|
|
220
|
+
const wf = workflow({
|
|
221
|
+
id: uniqueId("compliance-race"),
|
|
222
|
+
async run() {
|
|
223
|
+
invocationCount++;
|
|
224
|
+
return { invoked: invocationCount };
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const key = `race-${suiteCounter}`;
|
|
228
|
+
const triggers = Array.from({ length: 8 }, (_, i) => driver.trigger(wf, { tag: `caller-${i}` }, { idempotencyKey: key }));
|
|
229
|
+
const results = await Promise.all(triggers);
|
|
230
|
+
// All 8 callers receive the same runId.
|
|
231
|
+
const ids = new Set(results.map((r) => r.id));
|
|
232
|
+
expect(ids.size).toBe(1);
|
|
233
|
+
// The body runs at most once. (The first writer wins; later
|
|
234
|
+
// callers return the existing record without re-driving.)
|
|
235
|
+
expect(invocationCount).toBeLessThanOrEqual(1);
|
|
236
|
+
});
|
|
237
|
+
test.skipIf(!workflowConcurrency)("concurrency queue strategy serializes triggers with the same key", async () => {
|
|
238
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
239
|
+
const gate = deferred();
|
|
240
|
+
const started = [];
|
|
241
|
+
const wf = workflow({
|
|
242
|
+
id: uniqueId("compliance-concurrency-queue"),
|
|
243
|
+
concurrency: {
|
|
244
|
+
key: (input) => input.key,
|
|
245
|
+
limit: 1,
|
|
246
|
+
strategy: "queue",
|
|
247
|
+
},
|
|
248
|
+
async run(input) {
|
|
249
|
+
started.push(input.key);
|
|
250
|
+
if (started.length === 1)
|
|
251
|
+
await gate.promise;
|
|
252
|
+
return input.key;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
const first = driver.trigger(wf, { key: "same" });
|
|
256
|
+
await vi.waitFor(() => expect(started).toEqual(["same"]));
|
|
257
|
+
const second = driver.trigger(wf, { key: "same" });
|
|
258
|
+
await Promise.resolve();
|
|
259
|
+
expect(started).toEqual(["same"]);
|
|
260
|
+
gate.resolve();
|
|
261
|
+
await Promise.all([first, second]);
|
|
262
|
+
expect(started).toEqual(["same", "same"]);
|
|
263
|
+
});
|
|
264
|
+
test.skipIf(!workflowConcurrency)("concurrency cancel-newest strategy rejects overflow triggers", async () => {
|
|
265
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
266
|
+
const gate = deferred();
|
|
267
|
+
const wf = workflow({
|
|
268
|
+
id: uniqueId("compliance-concurrency-cancel-newest"),
|
|
269
|
+
concurrency: {
|
|
270
|
+
key: "shared",
|
|
271
|
+
limit: 1,
|
|
272
|
+
strategy: "cancel-newest",
|
|
273
|
+
},
|
|
274
|
+
async run(input) {
|
|
275
|
+
await gate.promise;
|
|
276
|
+
return input.n;
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const first = driver.trigger(wf, { n: 1 });
|
|
280
|
+
await expect(driver.trigger(wf, { n: 2 })).rejects.toBeInstanceOf(WorkflowConcurrencyRejectedError);
|
|
281
|
+
gate.resolve();
|
|
282
|
+
await expect(first).resolves.toMatchObject({ status: "completed" });
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
describe("ingestEvent", () => {
|
|
286
|
+
test("returns ok=false when no manifest is registered", async () => {
|
|
287
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
288
|
+
const result = await driver.ingestEvent({
|
|
289
|
+
environment: "production",
|
|
290
|
+
envelope: {
|
|
291
|
+
name: "promotion.changed",
|
|
292
|
+
data: { kind: "all" },
|
|
293
|
+
metadata: { eventId: `evt_${uniqueId("not-registered")}` },
|
|
294
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
// Drivers that registered a manifest earlier in the suite may
|
|
298
|
+
// accept this — gate on the fresh-driver case below instead.
|
|
299
|
+
if (result.ok) {
|
|
300
|
+
expect(result.matches).toEqual([]);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
expect(result.reason).toBe("manifest_not_registered");
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
test("returns ok=true matches=[] when the manifest has no event filters", async () => {
|
|
307
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
308
|
+
await driver.registerManifest({
|
|
309
|
+
environment: "production",
|
|
310
|
+
manifest: buildTestManifest(uniqueId("v_empty")),
|
|
311
|
+
});
|
|
312
|
+
const eventId = `evt_${uniqueId("none")}`;
|
|
313
|
+
const result = await driver.ingestEvent({
|
|
314
|
+
environment: "production",
|
|
315
|
+
envelope: {
|
|
316
|
+
name: "promotion.changed",
|
|
317
|
+
data: {},
|
|
318
|
+
metadata: { eventId },
|
|
319
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
expect(result.ok).toBe(true);
|
|
323
|
+
if (result.ok) {
|
|
324
|
+
expect(result.eventId).toBe(eventId);
|
|
325
|
+
expect(result.matches).toEqual([]);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
test("derives eventId when metadata.eventId is absent", async () => {
|
|
329
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
330
|
+
await driver.registerManifest({
|
|
331
|
+
environment: "production",
|
|
332
|
+
manifest: buildTestManifest(uniqueId("v_noid")),
|
|
333
|
+
});
|
|
334
|
+
const result = await driver.ingestEvent({
|
|
335
|
+
environment: "production",
|
|
336
|
+
envelope: {
|
|
337
|
+
name: "promotion.changed",
|
|
338
|
+
data: {},
|
|
339
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
expect(result.ok).toBe(true);
|
|
343
|
+
if (result.ok) {
|
|
344
|
+
expect(result.eventId).toMatch(/^evt_/);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
test("eventId fallback is stable across calls (content-derived)", async () => {
|
|
348
|
+
// Same envelope content → same derived eventId. External HTTP
|
|
349
|
+
// retries that don't stamp metadata.eventId still dedupe via the
|
|
350
|
+
// driver's `${filterId}:${eventId}` idempotency key.
|
|
351
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
352
|
+
const wfId = uniqueId("compliance-stable-id");
|
|
353
|
+
const wf = workflow({
|
|
354
|
+
id: wfId,
|
|
355
|
+
async run() { },
|
|
356
|
+
});
|
|
357
|
+
const filterId = `ef_${uniqueId("ef-stable")}`;
|
|
358
|
+
const manifest = {
|
|
359
|
+
...buildTestManifest(uniqueId("v_stable")),
|
|
360
|
+
eventFilters: [
|
|
361
|
+
{
|
|
362
|
+
id: filterId,
|
|
363
|
+
eventType: "evt.stable",
|
|
364
|
+
payloadHash: filterId,
|
|
365
|
+
targetWorkflowId: wfId,
|
|
366
|
+
},
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
await driver.registerManifest({ environment: "production", manifest });
|
|
370
|
+
const envelope = {
|
|
371
|
+
name: "evt.stable",
|
|
372
|
+
data: { k: "v" },
|
|
373
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
374
|
+
};
|
|
375
|
+
const a = await driver.ingestEvent({ environment: "production", envelope });
|
|
376
|
+
const b = await driver.ingestEvent({ environment: "production", envelope });
|
|
377
|
+
if (!a.ok || !b.ok)
|
|
378
|
+
throw new Error("expected ok=true on both");
|
|
379
|
+
expect(a.eventId).toBe(b.eventId);
|
|
380
|
+
const ma = a.matches[0];
|
|
381
|
+
const mb = b.matches[0];
|
|
382
|
+
if (ma?.status !== "queued" || mb?.status !== "queued") {
|
|
383
|
+
throw new Error("expected queued matches");
|
|
384
|
+
}
|
|
385
|
+
// Same eventId → same derived idempotencyKey → same run.
|
|
386
|
+
expect(mb.runId).toBe(ma.runId);
|
|
387
|
+
void wf;
|
|
388
|
+
});
|
|
389
|
+
test("matches a where predicate and triggers a run", async () => {
|
|
390
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
391
|
+
const wfId = uniqueId("compliance-ingest-match");
|
|
392
|
+
const wf = workflow({
|
|
393
|
+
id: wfId,
|
|
394
|
+
async run() { },
|
|
395
|
+
});
|
|
396
|
+
const filterId = `ef_${uniqueId("ef")}`;
|
|
397
|
+
const manifest = {
|
|
398
|
+
...buildTestManifest(uniqueId("v_match")),
|
|
399
|
+
eventFilters: [
|
|
400
|
+
{
|
|
401
|
+
id: filterId,
|
|
402
|
+
eventType: "promotion.changed",
|
|
403
|
+
where: { eq: [{ path: "data.kind" }, { lit: "all" }] },
|
|
404
|
+
payloadHash: filterId,
|
|
405
|
+
targetWorkflowId: wfId,
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
};
|
|
409
|
+
await driver.registerManifest({ environment: "production", manifest });
|
|
410
|
+
const result = await driver.ingestEvent({
|
|
411
|
+
environment: "production",
|
|
412
|
+
envelope: {
|
|
413
|
+
name: "promotion.changed",
|
|
414
|
+
data: { kind: "all" },
|
|
415
|
+
metadata: { eventId: `evt_${uniqueId("match")}` },
|
|
416
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
expect(result.ok).toBe(true);
|
|
420
|
+
if (!result.ok)
|
|
421
|
+
return;
|
|
422
|
+
expect(result.matches).toHaveLength(1);
|
|
423
|
+
const m = result.matches[0];
|
|
424
|
+
expect(m?.status).toBe("queued");
|
|
425
|
+
if (m?.status === "queued") {
|
|
426
|
+
expect(m.filterId).toBe(filterId);
|
|
427
|
+
expect(m.targetWorkflowId).toBe(wfId);
|
|
428
|
+
const detail = await driver.admin?.getRun?.(m.runId);
|
|
429
|
+
expect(detail?.workflowId).toBe(wfId);
|
|
430
|
+
}
|
|
431
|
+
void wf;
|
|
432
|
+
});
|
|
433
|
+
test("predicate failure on one filter doesn't block another", async () => {
|
|
434
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
435
|
+
const wfId = uniqueId("compliance-ingest-mixed");
|
|
436
|
+
const wf = workflow({
|
|
437
|
+
id: wfId,
|
|
438
|
+
async run() { },
|
|
439
|
+
});
|
|
440
|
+
const goodId = `ef_${uniqueId("ok")}`;
|
|
441
|
+
const manifest = {
|
|
442
|
+
...buildTestManifest(uniqueId("v_mixed")),
|
|
443
|
+
eventFilters: [
|
|
444
|
+
{
|
|
445
|
+
id: `ef_${uniqueId("bad")}`,
|
|
446
|
+
eventType: "evt.x",
|
|
447
|
+
where: { wat: "huh" },
|
|
448
|
+
payloadHash: "h",
|
|
449
|
+
targetWorkflowId: wfId,
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
id: goodId,
|
|
453
|
+
eventType: "evt.x",
|
|
454
|
+
where: { eq: [{ path: "data.k" }, { lit: "v" }] },
|
|
455
|
+
payloadHash: "h",
|
|
456
|
+
targetWorkflowId: wfId,
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
};
|
|
460
|
+
await driver.registerManifest({ environment: "production", manifest });
|
|
461
|
+
const result = await driver.ingestEvent({
|
|
462
|
+
environment: "production",
|
|
463
|
+
envelope: {
|
|
464
|
+
name: "evt.x",
|
|
465
|
+
data: { k: "v" },
|
|
466
|
+
metadata: { eventId: `evt_${uniqueId("mixed")}` },
|
|
467
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
expect(result.ok).toBe(true);
|
|
471
|
+
if (!result.ok)
|
|
472
|
+
return;
|
|
473
|
+
const skipped = result.matches.find((m) => m.status === "skipped");
|
|
474
|
+
const queued = result.matches.find((m) => m.status === "queued");
|
|
475
|
+
expect(skipped?.status).toBe("skipped");
|
|
476
|
+
if (skipped?.status === "skipped") {
|
|
477
|
+
expect(skipped.reason).toBe("where_eval_error");
|
|
478
|
+
}
|
|
479
|
+
expect(queued?.status).toBe("queued");
|
|
480
|
+
void wf;
|
|
481
|
+
});
|
|
482
|
+
test("input mapper projects correctly through to the workflow", async () => {
|
|
483
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
484
|
+
const wfId = uniqueId("compliance-ingest-input");
|
|
485
|
+
const wf = workflow({
|
|
486
|
+
id: wfId,
|
|
487
|
+
async run(input) {
|
|
488
|
+
return input;
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
const filterId = `ef_${uniqueId("ef-input")}`;
|
|
492
|
+
const manifest = {
|
|
493
|
+
...buildTestManifest(uniqueId("v_input")),
|
|
494
|
+
eventFilters: [
|
|
495
|
+
{
|
|
496
|
+
id: filterId,
|
|
497
|
+
eventType: "promotion.changed",
|
|
498
|
+
input: {
|
|
499
|
+
object: {
|
|
500
|
+
kind: { path: "data.affected.kind" },
|
|
501
|
+
offer: { path: "data.offerId" },
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
payloadHash: filterId,
|
|
505
|
+
targetWorkflowId: wfId,
|
|
506
|
+
},
|
|
507
|
+
],
|
|
508
|
+
};
|
|
509
|
+
await driver.registerManifest({ environment: "production", manifest });
|
|
510
|
+
const result = await driver.ingestEvent({
|
|
511
|
+
environment: "production",
|
|
512
|
+
envelope: {
|
|
513
|
+
name: "promotion.changed",
|
|
514
|
+
data: { affected: { kind: "all" }, offerId: "pofr_42" },
|
|
515
|
+
metadata: { eventId: `evt_${uniqueId("input")}` },
|
|
516
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
expect(result.ok).toBe(true);
|
|
520
|
+
if (!result.ok)
|
|
521
|
+
return;
|
|
522
|
+
const m = result.matches[0];
|
|
523
|
+
if (m?.status !== "queued")
|
|
524
|
+
throw new Error("expected queued match");
|
|
525
|
+
const detail = await driver.admin?.getRun?.(m.runId);
|
|
526
|
+
expect(detail?.output).toEqual({ kind: "all", offer: "pofr_42" });
|
|
527
|
+
void wf;
|
|
528
|
+
});
|
|
529
|
+
test("metadata.eventId dedupes across retries (same event → same run)", async () => {
|
|
530
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
531
|
+
const wfId = uniqueId("compliance-ingest-dedup");
|
|
532
|
+
const wf = workflow({
|
|
533
|
+
id: wfId,
|
|
534
|
+
async run() { },
|
|
535
|
+
});
|
|
536
|
+
const filterId = `ef_${uniqueId("ef-dedup")}`;
|
|
537
|
+
const manifest = {
|
|
538
|
+
...buildTestManifest(uniqueId("v_dedup")),
|
|
539
|
+
eventFilters: [
|
|
540
|
+
{
|
|
541
|
+
id: filterId,
|
|
542
|
+
eventType: "evt.dedup",
|
|
543
|
+
payloadHash: filterId,
|
|
544
|
+
targetWorkflowId: wfId,
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
};
|
|
548
|
+
await driver.registerManifest({ environment: "production", manifest });
|
|
549
|
+
const sharedEventId = `evt_${uniqueId("shared")}`;
|
|
550
|
+
const envelope = {
|
|
551
|
+
name: "evt.dedup",
|
|
552
|
+
data: {},
|
|
553
|
+
metadata: { eventId: sharedEventId },
|
|
554
|
+
emittedAt: new Date(1_700_000_000_000).toISOString(),
|
|
555
|
+
};
|
|
556
|
+
const a = await driver.ingestEvent({ environment: "production", envelope });
|
|
557
|
+
const b = await driver.ingestEvent({ environment: "production", envelope });
|
|
558
|
+
if (!a.ok || !b.ok)
|
|
559
|
+
throw new Error("expected ok=true");
|
|
560
|
+
const ma = a.matches[0];
|
|
561
|
+
const mb = b.matches[0];
|
|
562
|
+
if (ma?.status !== "queued" || mb?.status !== "queued") {
|
|
563
|
+
throw new Error("expected queued matches");
|
|
564
|
+
}
|
|
565
|
+
expect(mb.runId).toBe(ma.runId);
|
|
566
|
+
void wf;
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
describe("admin (when implemented)", () => {
|
|
570
|
+
test("getRun returns a run by id, null for missing", async () => {
|
|
571
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
572
|
+
const wfId = uniqueId("compliance-admin");
|
|
573
|
+
const wf = workflow({
|
|
574
|
+
id: wfId,
|
|
575
|
+
async run() { },
|
|
576
|
+
});
|
|
577
|
+
const created = await driver.trigger(wf, {});
|
|
578
|
+
const detail = await driver.admin?.getRun?.(created.id);
|
|
579
|
+
expect(detail?.id).toBe(created.id);
|
|
580
|
+
expect(detail?.workflowId).toBe(wfId);
|
|
581
|
+
const missing = await driver.admin?.getRun?.("nonexistent_run_id");
|
|
582
|
+
expect(missing).toBeNull();
|
|
583
|
+
});
|
|
584
|
+
test.skipIf(!crossRunQueries)("listRuns surfaces created runs (admin probes its own existence)", async () => {
|
|
585
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
586
|
+
if (!driver.admin?.listRuns)
|
|
587
|
+
return;
|
|
588
|
+
const wfId = uniqueId("compliance-list");
|
|
589
|
+
const wf = workflow({
|
|
590
|
+
id: wfId,
|
|
591
|
+
async run() { },
|
|
592
|
+
});
|
|
593
|
+
await driver.trigger(wf, {});
|
|
594
|
+
await driver.trigger(wf, {});
|
|
595
|
+
const result = await driver.admin.listRuns({ workflowId: wfId });
|
|
596
|
+
expect(result.runs.length).toBeGreaterThanOrEqual(2);
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
describe("shutdown", () => {
|
|
600
|
+
test("subsequent operations after shutdown are refused", async () => {
|
|
601
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
602
|
+
if (!driver.shutdown)
|
|
603
|
+
return;
|
|
604
|
+
await driver.shutdown();
|
|
605
|
+
const wf = workflow({
|
|
606
|
+
id: uniqueId("compliance-shutdown"),
|
|
607
|
+
async run() { },
|
|
608
|
+
});
|
|
609
|
+
await expect(driver.trigger(wf, {})).rejects.toThrow();
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
describe.skipIf(!servicesThreading)("ctx.services (container threading)", () => {
|
|
613
|
+
test("workflow body resolves a service registered on the harness", async () => {
|
|
614
|
+
const greeter = { hello: (n) => `hi ${n}` };
|
|
615
|
+
const services = makeServiceResolver({ greeter });
|
|
616
|
+
const driver = makeFactory()(testFactoryDeps(services));
|
|
617
|
+
const wf = workflow({
|
|
618
|
+
id: uniqueId("compliance-services-resolve"),
|
|
619
|
+
async run(input, ctx) {
|
|
620
|
+
const g = ctx.services.resolve("greeter");
|
|
621
|
+
return { greeting: g.hello(input.name) };
|
|
622
|
+
},
|
|
623
|
+
});
|
|
624
|
+
const run = await driver.trigger(wf, { name: "world" });
|
|
625
|
+
const detail = await driver.admin?.getRun?.(run.id);
|
|
626
|
+
expect(detail?.output).toEqual({ greeting: "hi world" });
|
|
627
|
+
});
|
|
628
|
+
test("ctx.services.has returns true for registered, false for missing", async () => {
|
|
629
|
+
const services = makeServiceResolver({ db: {} });
|
|
630
|
+
const driver = makeFactory()(testFactoryDeps(services));
|
|
631
|
+
const wf = workflow({
|
|
632
|
+
id: uniqueId("compliance-services-has"),
|
|
633
|
+
async run(_input, ctx) {
|
|
634
|
+
return {
|
|
635
|
+
hasDb: ctx.services.has("db"),
|
|
636
|
+
hasMissing: ctx.services.has("missing"),
|
|
637
|
+
};
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
const run = await driver.trigger(wf, {});
|
|
641
|
+
const detail = await driver.admin?.getRun?.(run.id);
|
|
642
|
+
expect(detail?.output).toEqual({ hasDb: true, hasMissing: false });
|
|
643
|
+
});
|
|
644
|
+
test("ctx.services.resolve on an unregistered key surfaces a step error", async () => {
|
|
645
|
+
const driver = makeFactory()(testFactoryDeps());
|
|
646
|
+
const wf = workflow({
|
|
647
|
+
id: uniqueId("compliance-services-missing"),
|
|
648
|
+
async run(_input, ctx) {
|
|
649
|
+
ctx.services.resolve("nope");
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
const run = await driver.trigger(wf, {});
|
|
653
|
+
const detail = await driver.admin?.getRun?.(run.id);
|
|
654
|
+
expect(detail?.status).toBe("failed");
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function deferred() {
|
|
660
|
+
let resolve;
|
|
661
|
+
let reject;
|
|
662
|
+
const promise = new Promise((res, rej) => {
|
|
663
|
+
resolve = res;
|
|
664
|
+
reject = rej;
|
|
665
|
+
});
|
|
666
|
+
return { promise, resolve, reject };
|
|
667
|
+
}
|