@geonosis/workflows 0.2.0

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/dist/index.cjs ADDED
@@ -0,0 +1,1751 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ABANDONED_SWEEP_LIMIT: () => ABANDONED_SWEEP_LIMIT,
24
+ COMPENSATION_PREFIX: () => COMPENSATION_PREFIX,
25
+ DEFAULT_STEP_BUDGET: () => DEFAULT_STEP_BUDGET,
26
+ EVENT_BATCH_LIMIT: () => EVENT_BATCH_LIMIT,
27
+ EVENT_SWEEP_GRACE_MS: () => EVENT_SWEEP_GRACE_MS,
28
+ EVENT_SWEEP_LIMIT: () => EVENT_SWEEP_LIMIT,
29
+ IdempotencyKeyHeldError: () => IdempotencyKeyHeldError,
30
+ LIFECYCLE_EVENTS: () => LIFECYCLE_EVENTS,
31
+ RESERVED_STEP_NAMES: () => RESERVED_STEP_NAMES,
32
+ RUN_OUTCOMES: () => RUN_OUTCOMES,
33
+ RUN_STATUSES: () => RUN_STATUSES,
34
+ RunCancelledError: () => RunCancelledError,
35
+ RunFailedError: () => RunFailedError,
36
+ STEP_STATUSES: () => STEP_STATUSES,
37
+ WorkflowsError: () => WorkflowsError,
38
+ compensationIdempotencyKey: () => compensationIdempotencyKey,
39
+ compensationStepName: () => compensationStepName,
40
+ createInProcessSink: () => createInProcessSink,
41
+ createInlineRunner: () => createInlineRunner,
42
+ createMemoryJournal: () => createMemoryJournal,
43
+ createMemoryPlatform: () => createMemoryPlatform,
44
+ createMemorySink: () => createMemorySink,
45
+ createRunEmitter: () => createRunEmitter,
46
+ createRuntime: () => createRuntime,
47
+ createStep: () => createStep,
48
+ createStepPrimitive: () => createStepPrimitive,
49
+ createWorkflow: () => createWorkflow,
50
+ dispatchEvents: () => dispatchEvents,
51
+ entrypointFor: () => entrypointFor,
52
+ envelopeId: () => envelopeId,
53
+ executeDurable: () => executeDurable,
54
+ executeRun: () => executeRun,
55
+ handleQueue: () => handleQueue,
56
+ handleScheduled: () => handleScheduled,
57
+ journalConformance: () => journalConformance,
58
+ lifecycleEnvelopeId: () => lifecycleEnvelopeId,
59
+ messageOf: () => messageOf,
60
+ parallelize: () => parallelize,
61
+ stepDefinitionOf: () => stepDefinitionOf,
62
+ stepIdempotencyKey: () => stepIdempotencyKey,
63
+ sweepAbandonedRuns: () => sweepAbandonedRuns,
64
+ sweepEventOutbox: () => sweepEventOutbox,
65
+ workflowDefinitionOf: () => workflowDefinitionOf
66
+ });
67
+ module.exports = __toCommonJS(index_exports);
68
+
69
+ // src/contract.ts
70
+ var LIFECYCLE_EVENTS = {
71
+ compensated: "workflow.compensated",
72
+ completed: "workflow.completed"
73
+ };
74
+ var RUN_STATUSES = ["cancelled", "compensated", "completed", "failed", "running"];
75
+ var RUN_OUTCOMES = ["cancelled", "compensated", "completed", "failed"];
76
+ var STEP_STATUSES = ["compensated", "completed", "failed"];
77
+ var RESERVED_STEP_NAMES = {
78
+ emitEvents: "emit-events",
79
+ finishRun: "finish-run"
80
+ };
81
+ var COMPENSATION_PREFIX = "compensate:";
82
+ var compensationStepName = (stepName) => `${COMPENSATION_PREFIX}${stepName}`;
83
+ var DEFAULT_STEP_BUDGET = {
84
+ retry: { backoff: "exponential", delay: "10 seconds", limit: 3 },
85
+ timeout: "2 minutes"
86
+ };
87
+
88
+ // src/duration.ts
89
+ var UNIT_MILLISECONDS = {
90
+ day: 864e5,
91
+ days: 864e5,
92
+ hour: 36e5,
93
+ hours: 36e5,
94
+ millisecond: 1,
95
+ milliseconds: 1,
96
+ minute: 6e4,
97
+ minutes: 6e4,
98
+ ms: 1,
99
+ second: 1e3,
100
+ seconds: 1e3
101
+ };
102
+ var DURATION = /^\s*(\d+(?:\.\d+)?)\s*([a-z]+)\s*$/i;
103
+ var millisecondsOf = (duration) => {
104
+ if (typeof duration === "number") return duration;
105
+ const match = DURATION.exec(duration);
106
+ const unit = match?.[2] === void 0 ? void 0 : UNIT_MILLISECONDS[match[2].toLowerCase()];
107
+ if (match?.[1] === void 0 || unit === void 0) {
108
+ throw new Error(`"${duration}" is not a duration this can read`);
109
+ }
110
+ return Number(match[1]) * unit;
111
+ };
112
+
113
+ // src/inline-runner.ts
114
+ var delayBefore = (retry, attempt) => {
115
+ const base = millisecondsOf(retry.delay ?? 0);
116
+ if (retry.backoff === "linear") return base * attempt;
117
+ if (retry.backoff === "exponential") return base * 2 ** (attempt - 1);
118
+ return base;
119
+ };
120
+ var createInlineRunner = (options = {}) => {
121
+ const sleep = options.sleep ?? ((milliseconds) => milliseconds <= 0 ? Promise.resolve() : new Promise((resolve2) => setTimeout(resolve2, milliseconds)));
122
+ return async (_name, budget, run) => {
123
+ const retry = budget === DEFAULT_STEP_BUDGET ? void 0 : budget.retry;
124
+ const attempts = (retry?.limit ?? 0) + 1;
125
+ let refusal;
126
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
127
+ try {
128
+ return await run({ attempt });
129
+ } catch (error) {
130
+ refusal = error;
131
+ if (attempt === attempts || !retry) break;
132
+ await sleep(delayBefore(retry, attempt));
133
+ }
134
+ }
135
+ throw refusal;
136
+ };
137
+ };
138
+
139
+ // src/emit-door.ts
140
+ var import_events = require("@geonosis/events");
141
+
142
+ // src/identity.ts
143
+ var stepIdempotencyKey = (runId, seq) => `${runId}:${seq}`;
144
+ var compensationIdempotencyKey = (runId, seq) => `${runId}:${seq}:undo`;
145
+ var envelopeId = (runId, ordinal) => `${runId}:${ordinal}`;
146
+ var lifecycleEnvelopeId = (runId, closure) => `${runId}:${closure}`;
147
+
148
+ // src/emit-door.ts
149
+ var createRunEmitter = (config, run) => {
150
+ const carried = config.extensionsOf?.(run) ?? {};
151
+ const extensions = Object.keys(carried);
152
+ const now = config.now ?? Date.now;
153
+ const doorMinting = (newId) => {
154
+ const door = (0, import_events.createEmitDoor)({ extensions, newId, now, source: config.source });
155
+ return (type, payload) => door.emit(type, payload, carried);
156
+ };
157
+ let ordinal = 0;
158
+ const emission = doorMinting(() => {
159
+ const id = envelopeId(run.runId, ordinal);
160
+ ordinal += 1;
161
+ return id;
162
+ });
163
+ return {
164
+ closure: (closure, type, payload) => doorMinting(() => lifecycleEnvelopeId(run.runId, closure))(type, payload),
165
+ emission
166
+ };
167
+ };
168
+
169
+ // src/outbox.ts
170
+ var EVENT_BATCH_LIMIT = 100;
171
+ var dispatchEvents = async (options) => {
172
+ let delivered = 0;
173
+ for (let index = 0; index < options.envelopes.length; index += EVENT_BATCH_LIMIT) {
174
+ const batch = options.envelopes.slice(index, index + EVENT_BATCH_LIMIT);
175
+ await options.sink.sendBatch(batch.map((body) => ({ body })));
176
+ await options.markDispatched(batch.map((message) => message.id));
177
+ delivered += batch.length;
178
+ }
179
+ return delivered;
180
+ };
181
+ var EVENT_SWEEP_LIMIT = 500;
182
+ var EVENT_SWEEP_GRACE_MS = 6e4;
183
+ var sweepEventOutbox = async (options) => {
184
+ const stranded = await options.journal.listUndispatchedEvents({
185
+ before: (options.now ?? Date.now()) - (options.olderThanMs ?? EVENT_SWEEP_GRACE_MS),
186
+ limit: options.limit ?? EVENT_SWEEP_LIMIT
187
+ });
188
+ const byTenant = /* @__PURE__ */ new Map();
189
+ for (const row of stranded) {
190
+ const carried = byTenant.get(row.tenantId) ?? [];
191
+ carried.push(row.envelope);
192
+ byTenant.set(row.tenantId, carried);
193
+ }
194
+ let delivered = 0;
195
+ for (const [tenantId, envelopes] of byTenant) {
196
+ delivered += await dispatchEvents({
197
+ envelopes,
198
+ markDispatched: (ids) => options.journal.markEventsDispatched({ ids, tenantId }),
199
+ sink: options.sink
200
+ });
201
+ }
202
+ return delivered;
203
+ };
204
+ var ABANDONED_SWEEP_LIMIT = 200;
205
+ var sweepAbandonedRuns = async (options) => {
206
+ const error = `abandoned: no finish after ${options.olderThanMs}ms`;
207
+ const abandoned = await options.journal.listAbandonedRuns({
208
+ execution: "inline",
209
+ limit: options.limit ?? ABANDONED_SWEEP_LIMIT,
210
+ startedBefore: (options.now ?? Date.now()) - options.olderThanMs
211
+ });
212
+ for (const run of abandoned) {
213
+ const announcement = options.emit ? createRunEmitter(options.emit, { actor: null, ...run }).closure(
214
+ "swept",
215
+ LIFECYCLE_EVENTS.compensated,
216
+ { error, name: run.name, outcome: "failed", runId: run.runId }
217
+ ) : void 0;
218
+ await options.journal.finishRun({
219
+ error,
220
+ ...announcement === void 0 ? {} : { events: [announcement] },
221
+ runId: run.runId,
222
+ status: "failed",
223
+ tenantId: run.tenantId
224
+ });
225
+ }
226
+ return abandoned.length;
227
+ };
228
+
229
+ // src/parallelize.ts
230
+ var parallelize = (...calls) => Promise.all(calls);
231
+
232
+ // src/errors.ts
233
+ var messageOf = (error) => error instanceof Error ? error.message : String(error);
234
+ var WorkflowsError = class extends Error {
235
+ constructor(message, options) {
236
+ super(message, options);
237
+ this.name = "WorkflowsError";
238
+ }
239
+ };
240
+ var describeFailure = (params) => {
241
+ const where = params.failedStep ?? "its body";
242
+ if (params.outcome === "cancelled") {
243
+ return `workflow ${params.workflowName} was cancelled at ${where} and was fully undone`;
244
+ }
245
+ const undone = params.outcome === "compensated" ? "was compensated" : "could not be fully compensated";
246
+ return `workflow ${params.workflowName} failed at ${where} and ${undone}: ${messageOf(params.cause)}`;
247
+ };
248
+ var RunFailedError = class extends WorkflowsError {
249
+ /** A literal tag beside `name`, so this slots into tagged-union error handling with no adapter. */
250
+ _tag = "RunFailedError";
251
+ /** The steps whose undo came back, in the order they were undone. */
252
+ compensated;
253
+ /** The steps whose undo refused. Non-empty means something is still standing. */
254
+ failedCompensations;
255
+ failedStep;
256
+ outcome;
257
+ runId;
258
+ workflowName;
259
+ constructor(params) {
260
+ super(describeFailure(params), { cause: params.cause });
261
+ this.name = "RunFailedError";
262
+ this.runId = params.runId;
263
+ this.workflowName = params.workflowName;
264
+ this.failedStep = params.failedStep;
265
+ this.outcome = params.outcome;
266
+ this.compensated = params.compensated;
267
+ this.failedCompensations = params.failedCompensations;
268
+ }
269
+ };
270
+ var RunCancelledError = class extends WorkflowsError {
271
+ _tag = "RunCancelledError";
272
+ runId;
273
+ constructor(runId) {
274
+ super(`run ${runId} was asked to stop`);
275
+ this.name = "RunCancelledError";
276
+ this.runId = runId;
277
+ }
278
+ };
279
+ var IdempotencyKeyHeldError = class extends WorkflowsError {
280
+ idempotencyKey;
281
+ tenantId;
282
+ constructor(params) {
283
+ super(`the idempotency key "${params.idempotencyKey}" is already held`);
284
+ this.name = "IdempotencyKeyHeldError";
285
+ this.tenantId = params.tenantId;
286
+ this.idempotencyKey = params.idempotencyKey;
287
+ }
288
+ };
289
+
290
+ // src/runtime.ts
291
+ var UNSCOPED_TENANT = "default";
292
+ var isWorkflow = (candidate) => typeof candidate === "object" && candidate !== null && typeof candidate.name === "string" && typeof candidate.run === "function";
293
+ var createRuntime = (config) => {
294
+ const runtime = {
295
+ journal: config.journal,
296
+ tenantId: config.tenantId ?? UNSCOPED_TENANT,
297
+ ...config.actor === void 0 ? {} : { actor: config.actor },
298
+ ...config.context === void 0 ? {} : { context: config.context },
299
+ ...config.emit === void 0 ? {} : { emit: config.emit },
300
+ ...config.events === void 0 ? {} : { events: config.events },
301
+ ...config.observer === void 0 ? {} : { observer: config.observer },
302
+ ...config.scope === void 0 ? {} : { scope: config.scope }
303
+ };
304
+ return {
305
+ bind: (definitions) => {
306
+ const bound = {};
307
+ for (const [key, definition] of Object.entries(definitions)) {
308
+ if (!isWorkflow(definition)) {
309
+ throw new WorkflowsError(
310
+ `"${key}" is not a workflow: it did not come from createWorkflow, so there is nothing to open a run for. Bind the definition rather than a wrapper around it.`
311
+ );
312
+ }
313
+ bound[key] = {
314
+ name: definition.name,
315
+ run: ((given) => definition.run({ ...given, runtime }))
316
+ };
317
+ }
318
+ return bound;
319
+ },
320
+ for: (narrower) => createRuntime({
321
+ ...config,
322
+ actor: narrower.actor === void 0 ? config.actor : narrower.actor,
323
+ // A worker knows its bindings and nothing about who is asking; a request knows the tenant
324
+ // and nothing about bindings. So a layer ADDS what it knows: replacing would make the last
325
+ // caller responsible for everything every earlier one had put there.
326
+ context: { ...config.context, ...narrower.context },
327
+ tenantId: narrower.tenantId ?? config.tenantId
328
+ }),
329
+ runtime
330
+ };
331
+ };
332
+
333
+ // src/run-context.ts
334
+ var import_node_async_hooks = require("async_hooks");
335
+ var frames = new import_node_async_hooks.AsyncLocalStorage();
336
+ var runInFrame = (frame, body) => frames.run(frame, body);
337
+ var frameFor = (stepName) => {
338
+ const frame = frames.getStore();
339
+ if (!frame) {
340
+ throw new WorkflowsError(
341
+ `the step "${stepName}" was called outside a workflow run. A step is journalled, retried and compensated as part of a run, so there is nowhere for this call to be recorded \u2014 call it inside a createWorkflow body.`
342
+ );
343
+ }
344
+ return frame;
345
+ };
346
+
347
+ // src/engine.ts
348
+ var watch = (hook, fact) => {
349
+ if (!hook) return;
350
+ try {
351
+ hook(fact());
352
+ } catch {
353
+ }
354
+ };
355
+ var UNSCOPED = (body) => body();
356
+ var executeRun = async (execution) => {
357
+ const { name, runId, runner, runtime } = execution;
358
+ const held = [];
359
+ const emitter = runtime.emit ? createRunEmitter(runtime.emit, {
360
+ actor: runtime.actor ?? null,
361
+ name,
362
+ runId,
363
+ tenantId: runtime.tenantId
364
+ }) : void 0;
365
+ const undos = [];
366
+ const inflight = [];
367
+ const namesUsed = /* @__PURE__ */ new Map();
368
+ const compensated = [];
369
+ const failedCompensations = [];
370
+ let seq = 0;
371
+ let failedStep = null;
372
+ let cancelledAfter = null;
373
+ const startedAt = Date.now();
374
+ watch(runtime.observer?.onRunStart, () => ({ name, runId, tenantId: runtime.tenantId }));
375
+ const scopeFor = () => execution.execution === "durable" && runtime.scope ? runtime.scope.perStep() : UNSCOPED;
376
+ const contextFor = (idempotencyKey, attempt) => ({
377
+ ...runtime.context,
378
+ attempt,
379
+ idempotencyKey,
380
+ runId,
381
+ tenantId: runtime.tenantId
382
+ });
383
+ const runStep = async (step, input) => {
384
+ if (cancelledAfter !== null) throw new RunCancelledError(runId);
385
+ const used = (namesUsed.get(step.name) ?? 0) + 1;
386
+ namesUsed.set(step.name, used);
387
+ const recordedName = used === 1 ? step.name : `${step.name}#${used}`;
388
+ if (execution.unwindingBegan === true && execution.completedSteps?.has(recordedName) !== true) {
389
+ throw new WorkflowsError(
390
+ `the run had already begun unwinding when step '${recordedName}' was reached`
391
+ );
392
+ }
393
+ const current = seq;
394
+ seq += 1;
395
+ let cancellationRequested = false;
396
+ const output2 = await runner(recordedName, step.budget, async ({ attempt }) => {
397
+ const stepStartedAt = Date.now();
398
+ watch(runtime.observer?.onStepStart, () => ({
399
+ attempt,
400
+ name: recordedName,
401
+ runId,
402
+ seq: current
403
+ }));
404
+ try {
405
+ const produced = await scopeFor()(
406
+ async () => step.invoke(input, contextFor(stepIdempotencyKey(runId, current), attempt))
407
+ );
408
+ const recorded = await runtime.journal.recordStep({
409
+ attempt,
410
+ name: recordedName,
411
+ output: produced,
412
+ runId,
413
+ seq: current,
414
+ status: "completed",
415
+ tenantId: runtime.tenantId
416
+ });
417
+ cancellationRequested = recorded.cancellationRequested;
418
+ watch(runtime.observer?.onStepEnd, () => ({
419
+ attempt,
420
+ durationMs: Date.now() - stepStartedAt,
421
+ name: recordedName,
422
+ runId,
423
+ seq: current,
424
+ status: "completed"
425
+ }));
426
+ return produced;
427
+ } catch (error) {
428
+ failedStep = recordedName;
429
+ await runtime.journal.recordStep({
430
+ attempt,
431
+ error: messageOf(error),
432
+ name: recordedName,
433
+ runId,
434
+ seq: current,
435
+ status: "failed",
436
+ tenantId: runtime.tenantId
437
+ });
438
+ watch(runtime.observer?.onStepEnd, () => ({
439
+ attempt,
440
+ durationMs: Date.now() - stepStartedAt,
441
+ name: recordedName,
442
+ runId,
443
+ seq: current,
444
+ status: "failed"
445
+ }));
446
+ throw error;
447
+ }
448
+ });
449
+ const declaredCompensate = step.compensate;
450
+ if (declaredCompensate) {
451
+ undos.push({
452
+ budget: step.budget,
453
+ name: recordedName,
454
+ run: (undoContext, reason) => declaredCompensate(output2, undoContext, reason),
455
+ seq: current
456
+ });
457
+ }
458
+ if (cancellationRequested) {
459
+ cancelledAfter = recordedName;
460
+ throw new RunCancelledError(runId);
461
+ }
462
+ return output2;
463
+ };
464
+ const trackedStep = (step, input) => {
465
+ const running = runStep(step, input);
466
+ const tracked = {
467
+ name: step.name,
468
+ promise: running.then(
469
+ (value) => {
470
+ tracked.settled = true;
471
+ return value;
472
+ },
473
+ (error) => {
474
+ tracked.settled = true;
475
+ throw error;
476
+ }
477
+ ),
478
+ settled: false
479
+ };
480
+ tracked.promise.catch(() => void 0);
481
+ inflight.push(tracked);
482
+ return running;
483
+ };
484
+ const frame = {
485
+ call: trackedStep,
486
+ runId,
487
+ workflowName: name
488
+ };
489
+ const compensate = async (cause) => {
490
+ await Promise.allSettled(inflight.map((tracked) => tracked.promise));
491
+ let outcome = "compensated";
492
+ for (const undo of undos.toSorted((left, right) => right.seq - left.seq)) {
493
+ const current = seq;
494
+ seq += 1;
495
+ if (execution.refused?.has(compensationStepName(undo.name)) === true) {
496
+ outcome = "failed";
497
+ failedCompensations.push(undo.name);
498
+ continue;
499
+ }
500
+ try {
501
+ await runner(compensationStepName(undo.name), undo.budget, async ({ attempt }) => {
502
+ const undoStartedAt = Date.now();
503
+ watch(runtime.observer?.onCompensationStart, () => ({
504
+ attempt,
505
+ name: undo.name,
506
+ runId,
507
+ seq: undo.seq
508
+ }));
509
+ try {
510
+ await scopeFor()(async () => {
511
+ await undo.run(contextFor(compensationIdempotencyKey(runId, undo.seq), attempt), {
512
+ cause
513
+ });
514
+ });
515
+ await runtime.journal.recordStep({
516
+ attempt,
517
+ name: compensationStepName(undo.name),
518
+ runId,
519
+ seq: current,
520
+ status: "compensated",
521
+ tenantId: runtime.tenantId
522
+ });
523
+ compensated.push(undo.name);
524
+ watch(runtime.observer?.onCompensationEnd, () => ({
525
+ attempt,
526
+ durationMs: Date.now() - undoStartedAt,
527
+ name: undo.name,
528
+ runId,
529
+ seq: undo.seq,
530
+ status: "compensated"
531
+ }));
532
+ } catch (error) {
533
+ await runtime.journal.recordStep({
534
+ attempt,
535
+ error: messageOf(error),
536
+ name: compensationStepName(undo.name),
537
+ runId,
538
+ seq: current,
539
+ status: "failed",
540
+ tenantId: runtime.tenantId
541
+ });
542
+ watch(runtime.observer?.onCompensationEnd, () => ({
543
+ attempt,
544
+ durationMs: Date.now() - undoStartedAt,
545
+ name: undo.name,
546
+ runId,
547
+ seq: undo.seq,
548
+ status: "failed"
549
+ }));
550
+ throw error;
551
+ }
552
+ });
553
+ } catch {
554
+ outcome = "failed";
555
+ failedCompensations.push(undo.name);
556
+ }
557
+ }
558
+ return outcome;
559
+ };
560
+ let output;
561
+ try {
562
+ output = await runInFrame(frame, () => execution.invoke(frame));
563
+ if (cancelledAfter !== null) throw new RunCancelledError(runId);
564
+ if (execution.unwindingBegan === true) {
565
+ throw new WorkflowsError("the run had already begun unwinding before this invocation");
566
+ }
567
+ const abandoned = inflight.find((tracked) => !tracked.settled);
568
+ if (abandoned) throw new WorkflowsError(`step '${abandoned.name}' was not awaited`);
569
+ for (const [type, payload] of execution.announce?.(output) ?? []) {
570
+ if (!emitter) {
571
+ throw new WorkflowsError(
572
+ `the workflow "${name}" announces "${type}", and this runtime was built without an emit door. Give createRuntime an \`emit\` config with the \`source\` that identifies this producer \u2014 it is the one CloudEvents attribute nothing in a run can stand in for.`
573
+ );
574
+ }
575
+ held.push(emitter.emission(type, payload));
576
+ }
577
+ } catch (error) {
578
+ const undone = await compensate(error);
579
+ const outcome = error instanceof RunCancelledError && undone === "compensated" ? "cancelled" : undone;
580
+ const announcement = emitter?.closure("compensated", LIFECYCLE_EVENTS.compensated, {
581
+ error: messageOf(error),
582
+ name,
583
+ outcome,
584
+ runId
585
+ });
586
+ await runner(
587
+ RESERVED_STEP_NAMES.finishRun,
588
+ DEFAULT_STEP_BUDGET,
589
+ () => runtime.journal.finishRun({
590
+ error: messageOf(error),
591
+ ...announcement === void 0 ? {} : { events: [announcement] },
592
+ runId,
593
+ status: outcome,
594
+ tenantId: runtime.tenantId
595
+ })
596
+ );
597
+ watch(runtime.observer?.onRunEnd, () => ({
598
+ durationMs: Date.now() - startedAt,
599
+ events: announcement ? [announcement.type] : [],
600
+ name,
601
+ runId,
602
+ status: outcome
603
+ }));
604
+ throw new RunFailedError({
605
+ cause: error,
606
+ compensated,
607
+ failedCompensations,
608
+ failedStep: failedStep ?? cancelledAfter,
609
+ outcome,
610
+ runId,
611
+ workflowName: name
612
+ });
613
+ }
614
+ const closure = emitter?.closure("completed", LIFECYCLE_EVENTS.completed, { name, runId });
615
+ if (closure) held.push(closure);
616
+ await runner(
617
+ RESERVED_STEP_NAMES.finishRun,
618
+ DEFAULT_STEP_BUDGET,
619
+ () => runtime.journal.finishRun({
620
+ events: held,
621
+ output,
622
+ runId,
623
+ status: "completed",
624
+ tenantId: runtime.tenantId
625
+ })
626
+ );
627
+ watch(runtime.observer?.onRunEnd, () => ({
628
+ durationMs: Date.now() - startedAt,
629
+ events: held.map((envelope) => envelope.type),
630
+ name,
631
+ runId,
632
+ status: "completed"
633
+ }));
634
+ const sink = runtime.events;
635
+ if (sink && held.length > 0) {
636
+ await runner(
637
+ RESERVED_STEP_NAMES.emitEvents,
638
+ DEFAULT_STEP_BUDGET,
639
+ () => dispatchEvents({
640
+ envelopes: held,
641
+ markDispatched: (ids) => runtime.journal.markEventsDispatched({ ids, tenantId: runtime.tenantId }),
642
+ sink
643
+ })
644
+ ).catch(() => void 0);
645
+ }
646
+ return output;
647
+ };
648
+
649
+ // src/canonical.ts
650
+ var canonicalise = (value) => {
651
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
652
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalise(item)).join(",")}]`;
653
+ const entries = Object.entries(value).filter(([, item]) => item !== void 0).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalise(item)}`);
654
+ return `{${entries.join(",")}}`;
655
+ };
656
+ var OFFSET_BASIS = 0xcbf29ce484222325n;
657
+ var PRIME = 0x00000100000001b3n;
658
+ var MASK = 0xffffffffffffffffn;
659
+ var stableHash = (value) => {
660
+ const rendered = canonicalise(value);
661
+ let hash = OFFSET_BASIS;
662
+ for (let index = 0; index < rendered.length; index += 1) {
663
+ hash ^= BigInt(rendered.charCodeAt(index));
664
+ hash = hash * PRIME & MASK;
665
+ }
666
+ return hash.toString(16).padStart(16, "0");
667
+ };
668
+
669
+ // src/create-workflow.ts
670
+ var inlineRunner = createInlineRunner();
671
+ var isOne = (announced) => typeof announced[0] === "string";
672
+ var announcementsOf = (announce, output) => {
673
+ const announced = announce?.(output);
674
+ if (!announced) return [];
675
+ return isOne(announced) ? [announced] : announced;
676
+ };
677
+ var DEFINITION = /* @__PURE__ */ Symbol.for("geonosis.workflows.workflow");
678
+ var workflowDefinitionOf = (workflow) => {
679
+ const carried = workflow[DEFINITION];
680
+ if (!carried) {
681
+ throw new WorkflowsError(
682
+ "this is not a workflow: it did not come from createWorkflow, so there is no body to carry out and no name to journal it under."
683
+ );
684
+ }
685
+ return carried;
686
+ };
687
+ var idempotencyKeyFor = (name, idempotent, input) => {
688
+ if (idempotent === void 0) return null;
689
+ if (idempotent === true) return `${name}:${stableHash(input)}`;
690
+ return idempotent(input);
691
+ };
692
+ var createWorkflow = (name, body, options = {}) => {
693
+ const execution = options.execution ?? "inline";
694
+ const run = async (given) => {
695
+ const { input, runtime } = given;
696
+ if (execution === "durable") {
697
+ throw new WorkflowsError(
698
+ `the workflow "${name}" is declared \`execution: 'durable'\`, and \`.run()\` is the inline door: it holds one request open and never replays. Open the run with \`insertRun({ execution: 'durable' })\` and hand the workflow to the platform's entrypoint, which drives \`executeDurable\`.`
699
+ );
700
+ }
701
+ if (options.announce && !runtime.emit) {
702
+ throw new WorkflowsError(
703
+ `the workflow "${name}" announces, and this runtime was built without an emit door. Give createRuntime an \`emit\` config with the \`source\` that identifies this producer \u2014 it is the one CloudEvents attribute nothing in a run can stand in for.`
704
+ );
705
+ }
706
+ const idempotencyKey = given.idempotencyKey ?? idempotencyKeyFor(name, options.idempotent, input);
707
+ let runId;
708
+ try {
709
+ runId = await runtime.journal.insertRun({
710
+ execution: "inline",
711
+ idempotencyKey,
712
+ input,
713
+ name,
714
+ parentRunId: given.parentRunId ?? null,
715
+ tenantId: runtime.tenantId
716
+ });
717
+ } catch (error) {
718
+ if (idempotencyKey === null) throw error;
719
+ const held = await runtime.journal.findRunByIdempotencyKey({
720
+ idempotencyKey,
721
+ tenantId: runtime.tenantId
722
+ });
723
+ if (!held) throw error;
724
+ return { deduplicated: true, ok: true, result: held.output, runId: held.id };
725
+ }
726
+ try {
727
+ const result = await executeRun({
728
+ announce: (produced) => announcementsOf(options.announce, produced),
729
+ execution: "inline",
730
+ invoke: (frame) => body(input, {
731
+ ...runtime.context,
732
+ runId: frame.runId,
733
+ tenantId: runtime.tenantId
734
+ }),
735
+ name,
736
+ runId,
737
+ runner: inlineRunner,
738
+ runtime
739
+ });
740
+ return { deduplicated: false, ok: true, result, runId };
741
+ } catch (error) {
742
+ if (!(error instanceof RunFailedError)) throw error;
743
+ return { error, ok: false, result: void 0, runId };
744
+ }
745
+ };
746
+ const definition = {
747
+ announce: (produced) => announcementsOf(options.announce, produced),
748
+ body,
749
+ execution,
750
+ name
751
+ };
752
+ return {
753
+ [DEFINITION]: definition,
754
+ execution,
755
+ name,
756
+ run: async (given) => {
757
+ const answered = await run(given);
758
+ if (!answered.ok && given.throwOnError !== false) throw answered.error;
759
+ return answered;
760
+ }
761
+ };
762
+ };
763
+
764
+ // src/durable.ts
765
+ var entryStateOf = async (journal, tenantId, runId) => {
766
+ const run = await journal.getRun({ runId, tenantId });
767
+ if (!run || run.status === "running") return { closed: false };
768
+ return { closed: true, output: run.output, status: run.status };
769
+ };
770
+ var trailAtEntry = async (journal, tenantId, runId) => {
771
+ const trail = await journal.listRunSteps({ runId, tenantId });
772
+ const compensations = trail.filter((entry) => entry.name.startsWith(COMPENSATION_PREFIX));
773
+ return {
774
+ completedSteps: new Set(
775
+ trail.filter((entry) => entry.status === "completed").filter((entry) => !entry.name.startsWith(COMPENSATION_PREFIX)).map((entry) => entry.name)
776
+ ),
777
+ refused: new Set(
778
+ compensations.filter((entry) => entry.status === "failed").map((entry) => entry.name)
779
+ ),
780
+ unwindingBegan: compensations.length > 0
781
+ };
782
+ };
783
+ var executeDurable = async (execution) => {
784
+ const { input, runId, runtime, step, workflow } = execution;
785
+ const definition = workflowDefinitionOf(workflow);
786
+ if (definition.execution !== "durable") {
787
+ throw new WorkflowsError(
788
+ `the workflow "${definition.name}" is declared \`execution: '${definition.execution}'\` and this is the durable door. Declare it \`execution: 'durable'\` \u2014 a body written for one request is replayed here, and a read it never journalled answers differently the second time.`
789
+ );
790
+ }
791
+ const entry = await entryStateOf(runtime.journal, runtime.tenantId, runId);
792
+ if (entry.closed) {
793
+ if (entry.status === "completed") return entry.output;
794
+ throw new RunFailedError({
795
+ cause: new WorkflowsError(
796
+ `run ${runId} was already ${entry.status} when this invocation began`
797
+ ),
798
+ compensated: [],
799
+ failedCompensations: [],
800
+ failedStep: null,
801
+ outcome: entry.status,
802
+ runId,
803
+ workflowName: definition.name
804
+ });
805
+ }
806
+ const trail = await trailAtEntry(runtime.journal, runtime.tenantId, runId);
807
+ return executeRun({
808
+ ...trail,
809
+ announce: definition.announce,
810
+ execution: "durable",
811
+ invoke: (frame) => definition.body(input, {
812
+ ...runtime.context,
813
+ runId: frame.runId,
814
+ tenantId: runtime.tenantId
815
+ }),
816
+ name: definition.name,
817
+ runId,
818
+ // The budget travels with the name, so the PLATFORM does the retrying and the waiting. Inline,
819
+ // the same declaration is spent by the runner holding a request open.
820
+ runner: (name, budget, run) => step.do(name, budget, run),
821
+ runtime
822
+ });
823
+ };
824
+
825
+ // src/cloudflare/step-primitive.ts
826
+ var platformConfigOf = (budget) => ({
827
+ ...budget.retry === void 0 ? {} : {
828
+ retries: {
829
+ ...budget.retry.backoff === void 0 ? {} : { backoff: budget.retry.backoff },
830
+ delay: budget.retry.delay ?? 0,
831
+ limit: budget.retry.limit
832
+ }
833
+ },
834
+ ...budget.timeout === void 0 ? {} : { timeout: budget.timeout }
835
+ });
836
+ var createStepPrimitive = (step) => ({
837
+ do: (name, budget, run) => step.do(name, platformConfigOf(budget), run),
838
+ sleep: (name, duration) => step.sleep(name, duration),
839
+ waitForEvent: async (name, options) => (await step.waitForEvent(name, options)).payload
840
+ });
841
+
842
+ // src/cloudflare/entrypoint.ts
843
+ var envOf = (instance) => instance.env;
844
+ var resolve = (source, env) => typeof source === "function" ? source(env) : source;
845
+ var registryOf = (workflows) => {
846
+ const byName = /* @__PURE__ */ new Map();
847
+ for (const workflow of workflows) {
848
+ if (byName.has(workflow.name)) {
849
+ throw new WorkflowsError(
850
+ `two workflows are registered as "${workflow.name}". A durable run is dispatched by name, so one of them would never be reached.`
851
+ );
852
+ }
853
+ byName.set(workflow.name, workflow);
854
+ }
855
+ return byName;
856
+ };
857
+ var entrypointFor = (options) => {
858
+ const byEnv = /* @__PURE__ */ new WeakMap();
859
+ const declared = typeof options.workflows === "function" ? void 0 : registryOf(options.workflows);
860
+ const registryFor = (env) => {
861
+ if (declared) return declared;
862
+ const build = () => registryOf(resolve(options.workflows, env));
863
+ if (env === null || typeof env !== "object") return build();
864
+ const cached = byEnv.get(env);
865
+ if (cached) return cached;
866
+ const registry = build();
867
+ byEnv.set(env, registry);
868
+ return registry;
869
+ };
870
+ return class extends options.base {
871
+ async run(event, step) {
872
+ const { input, name, runId } = event.payload;
873
+ const registry = registryFor(envOf(this));
874
+ const workflow = registry.get(name);
875
+ if (!workflow) {
876
+ throw new WorkflowsError(
877
+ `no durable workflow is registered as "${name}" \u2014 known: ${[...registry.keys()].join(", ")}`
878
+ );
879
+ }
880
+ return executeDurable({
881
+ input,
882
+ runId,
883
+ runtime: options.runtime(envOf(this), event.payload).runtime,
884
+ step: createStepPrimitive(step),
885
+ workflow
886
+ });
887
+ }
888
+ };
889
+ };
890
+
891
+ // src/cloudflare/worker.ts
892
+ var handleQueue = (options = {}) => async (batch) => {
893
+ for (const message of batch.messages) {
894
+ try {
895
+ const envelope = message.body;
896
+ if (options.seen && await options.seen(envelope.id)) {
897
+ message.ack();
898
+ continue;
899
+ }
900
+ await options.onEvent?.(envelope);
901
+ message.ack();
902
+ } catch {
903
+ message.retry();
904
+ }
905
+ }
906
+ };
907
+ var DEFAULT_ABANDONED_AFTER_MS = 15 * 6e4;
908
+ var scheduledTimeOf = (controller) => {
909
+ const scheduledTime = controller?.scheduledTime;
910
+ return typeof scheduledTime === "number" ? scheduledTime : void 0;
911
+ };
912
+ var handleScheduled = (source, options = {}) => (
913
+ // Cloudflare hands a scheduled handler its env, and there is no request here either, so a factory
914
+ // has nowhere else to be called from.
915
+ async (controller, env) => {
916
+ const runtime = (typeof source === "function" ? source(env) : source).runtime;
917
+ const now = scheduledTimeOf(controller);
918
+ const sink = runtime.events;
919
+ const delivered = sink ? await sweepEventOutbox({
920
+ journal: runtime.journal,
921
+ sink,
922
+ ...now === void 0 ? {} : { now },
923
+ ...options.outboxOlderThanMs === void 0 ? {} : { olderThanMs: options.outboxOlderThanMs }
924
+ }) : 0;
925
+ const abandoned = await sweepAbandonedRuns({
926
+ journal: runtime.journal,
927
+ olderThanMs: options.abandonedAfterMs ?? DEFAULT_ABANDONED_AFTER_MS,
928
+ ...now === void 0 ? {} : { now },
929
+ ...runtime.emit === void 0 ? {} : { emit: runtime.emit }
930
+ });
931
+ return { abandoned, delivered };
932
+ }
933
+ );
934
+
935
+ // src/memory-platform.ts
936
+ var createMemoryPlatform = (options = {}) => {
937
+ const budgets = /* @__PURE__ */ new Map();
938
+ const calls = [];
939
+ const checkpoints = options.checkpoints ?? /* @__PURE__ */ new Map();
940
+ let evictedAt = null;
941
+ return {
942
+ budgets,
943
+ calls,
944
+ checkpoints,
945
+ evictAt: (stepName) => {
946
+ evictedAt = stepName;
947
+ },
948
+ primitive: {
949
+ do: async (name, budget, run) => {
950
+ calls.push(name);
951
+ budgets.set(name, budget);
952
+ if (name === evictedAt) throw new Error(`the instance was evicted at "${name}"`);
953
+ if (checkpoints.has(name)) return checkpoints.get(name);
954
+ const attempts = (budget.retry?.limit ?? 0) + 1;
955
+ let refusal;
956
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
957
+ try {
958
+ const output = await run({ attempt });
959
+ checkpoints.set(name, structuredClone(output));
960
+ return output;
961
+ } catch (error) {
962
+ refusal = error;
963
+ }
964
+ }
965
+ throw refusal;
966
+ },
967
+ sleep: async () => {
968
+ },
969
+ waitForEvent: async (name) => {
970
+ throw new WorkflowsError(
971
+ `nothing delivers "${name}" to an in-memory platform. waitForEvent needs a runtime that can hold an instance open; drive this case on a real one.`
972
+ );
973
+ }
974
+ }
975
+ };
976
+ };
977
+
978
+ // src/conformance.ts
979
+ var import_conformance = require("@geonosis/conformance");
980
+ var TENANT = "tenant_conformance";
981
+ var OTHER = "tenant_other";
982
+ var envelopeOf = (params) => ({
983
+ id: params.id,
984
+ occurredAt: params.occurredAt,
985
+ payload: { id: params.id },
986
+ source: "/conformance",
987
+ type: "conformance.event"
988
+ });
989
+ var required = (journal, method, because) => {
990
+ const read = journal[method];
991
+ if (typeof read !== "function") {
992
+ throw new import_conformance.ConformanceFailure(
993
+ `this journal has no \`${method}\`, and the engine requires it: ${because}. Implement it \u2014 it is one query \u2014 or the ordering claims this engine makes are not true of runs kept here.`
994
+ );
995
+ }
996
+ return read;
997
+ };
998
+ var journalConformance = (createSubject) => {
999
+ const withSubject = (name, body) => ({
1000
+ name,
1001
+ run: async () => {
1002
+ await body(await createSubject());
1003
+ }
1004
+ });
1005
+ const openRun = (journal, params = {}) => journal.insertRun({
1006
+ execution: params.execution ?? "inline",
1007
+ idempotencyKey: params.idempotencyKey ?? null,
1008
+ input: { asked: true },
1009
+ name: "conformance.workflow",
1010
+ tenantId: params.tenantId ?? TENANT
1011
+ });
1012
+ const closeRun = (journal, runId, status, output) => journal.finishRun({
1013
+ output,
1014
+ runId,
1015
+ status: status === "running" ? "failed" : status,
1016
+ tenantId: TENANT
1017
+ });
1018
+ return [
1019
+ withSubject("insertRun opens a run that is running", async ({ journal, runStatus }) => {
1020
+ const runId = await openRun(journal);
1021
+ (0, import_conformance.assertThat)(typeof runId === "string" && runId.length > 0, "insertRun must answer with an id");
1022
+ (0, import_conformance.assertIs)(await runStatus({ runId, tenantId: TENANT }), "running", "a new run is running");
1023
+ }),
1024
+ withSubject("insertRun records the run a run was started from", async ({ journal }) => {
1025
+ const parent = await openRun(journal);
1026
+ const child = await journal.insertRun({
1027
+ execution: "inline",
1028
+ idempotencyKey: null,
1029
+ input: {},
1030
+ name: "conformance.child",
1031
+ parentRunId: parent,
1032
+ tenantId: TENANT
1033
+ });
1034
+ (0, import_conformance.assertThat)(child !== parent, "a child run is its own run");
1035
+ }),
1036
+ withSubject("insertRun refuses a key a running run holds", async ({ journal }) => {
1037
+ await openRun(journal, { idempotencyKey: "key-a" });
1038
+ await (0, import_conformance.assertRefuses)(
1039
+ () => openRun(journal, { idempotencyKey: "key-a" }),
1040
+ "a key held by a running run must be refused"
1041
+ );
1042
+ }),
1043
+ // Named rather than merely thrown. Every store words a uniqueness violation differently, and an
1044
+ // engine that had to match on the wording would be wrong on the next store somebody brings.
1045
+ withSubject("insertRun names its refusal", async ({ journal }) => {
1046
+ await openRun(journal, { idempotencyKey: "key-named" });
1047
+ const thrown = await openRun(journal, { idempotencyKey: "key-named" }).catch(
1048
+ (error) => error
1049
+ );
1050
+ (0, import_conformance.assertThat)(
1051
+ thrown instanceof IdempotencyKeyHeldError,
1052
+ "a held key must be refused with IdempotencyKeyHeldError"
1053
+ );
1054
+ (0, import_conformance.assertIs)(
1055
+ thrown.idempotencyKey,
1056
+ "key-named",
1057
+ "the refusal names the key"
1058
+ );
1059
+ }),
1060
+ withSubject("insertRun refuses a key a completed run holds", async ({ journal }) => {
1061
+ const runId = await openRun(journal, { idempotencyKey: "key-b" });
1062
+ await closeRun(journal, runId, "completed", { done: true });
1063
+ await (0, import_conformance.assertRefuses)(
1064
+ () => openRun(journal, { idempotencyKey: "key-b" }),
1065
+ "a key held by a completed run must be refused"
1066
+ );
1067
+ }),
1068
+ ...["failed", "compensated", "cancelled"].map(
1069
+ (status) => withSubject(`insertRun accepts a key a ${status} run has released`, async ({ journal }) => {
1070
+ const runId = await openRun(journal, { idempotencyKey: `key-${status}` });
1071
+ await closeRun(journal, runId, status);
1072
+ const second = await openRun(journal, { idempotencyKey: `key-${status}` });
1073
+ (0, import_conformance.assertThat)(second !== runId, `a ${status} run must release its key`);
1074
+ })
1075
+ ),
1076
+ withSubject("insertRun allows one key per tenant", async ({ journal }) => {
1077
+ await openRun(journal, { idempotencyKey: "shared" });
1078
+ const elsewhere = await openRun(journal, { idempotencyKey: "shared", tenantId: OTHER });
1079
+ (0, import_conformance.assertThat)(elsewhere.length > 0, "another tenant may hold the same key");
1080
+ }),
1081
+ withSubject("insertRun allows any number of runs with no key", async ({ journal }) => {
1082
+ const first = await openRun(journal, { idempotencyKey: null });
1083
+ const second = await openRun(journal, { idempotencyKey: null });
1084
+ (0, import_conformance.assertThat)(first !== second, "runs without a key never collide");
1085
+ }),
1086
+ withSubject("findRunByIdempotencyKey answers with the held run", async ({ journal }) => {
1087
+ const runId = await openRun(journal, { idempotencyKey: "key-c" });
1088
+ await closeRun(journal, runId, "completed", { invoice: 7 });
1089
+ const found = await journal.findRunByIdempotencyKey({
1090
+ idempotencyKey: "key-c",
1091
+ tenantId: TENANT
1092
+ });
1093
+ (0, import_conformance.assertIs)(found?.id, runId, "the held run is the one that claimed the key");
1094
+ (0, import_conformance.assertIs)(found?.status, "completed", "the held run reports its status");
1095
+ (0, import_conformance.assertSame)(found?.output, { invoice: 7 }, "the held run reports its output");
1096
+ }),
1097
+ withSubject(
1098
+ "findRunByIdempotencyKey answers with nothing once released",
1099
+ async ({ journal }) => {
1100
+ const runId = await openRun(journal, { idempotencyKey: "key-d" });
1101
+ await closeRun(journal, runId, "compensated");
1102
+ (0, import_conformance.assertIs)(
1103
+ await journal.findRunByIdempotencyKey({ idempotencyKey: "key-d", tenantId: TENANT }),
1104
+ null,
1105
+ "a released key is held by nobody"
1106
+ );
1107
+ }
1108
+ ),
1109
+ withSubject(
1110
+ "findRunByIdempotencyKey answers with nothing for an unknown key",
1111
+ async ({ journal }) => {
1112
+ (0, import_conformance.assertIs)(
1113
+ await journal.findRunByIdempotencyKey({ idempotencyKey: "nothing", tenantId: TENANT }),
1114
+ null,
1115
+ "an unclaimed key is held by nobody"
1116
+ );
1117
+ }
1118
+ ),
1119
+ withSubject("recordStep writes one row per attempt", async ({ countSteps, journal }) => {
1120
+ const runId = await openRun(journal);
1121
+ for (const attempt of [1, 2]) {
1122
+ await journal.recordStep({
1123
+ attempt,
1124
+ error: "refused",
1125
+ name: "charge",
1126
+ runId,
1127
+ seq: 0,
1128
+ status: "failed",
1129
+ tenantId: TENANT
1130
+ });
1131
+ }
1132
+ (0, import_conformance.assertIs)(await countSteps({ runId, tenantId: TENANT }), 2, "each attempt is its own row");
1133
+ }),
1134
+ withSubject(
1135
+ "recordStep is idempotent on run, seq and attempt",
1136
+ async ({ countSteps, journal }) => {
1137
+ const runId = await openRun(journal);
1138
+ for (let write = 0; write < 3; write += 1) {
1139
+ await journal.recordStep({
1140
+ attempt: 1,
1141
+ name: "charge",
1142
+ output: { chargeId: "ch_1" },
1143
+ runId,
1144
+ seq: 0,
1145
+ status: "completed",
1146
+ tenantId: TENANT
1147
+ });
1148
+ }
1149
+ (0, import_conformance.assertIs)(await countSteps({ runId, tenantId: TENANT }), 1, "the same attempt is one row");
1150
+ }
1151
+ ),
1152
+ withSubject("recordStep reports no cancellation by default", async ({ journal }) => {
1153
+ const runId = await openRun(journal);
1154
+ const recorded = await journal.recordStep({
1155
+ attempt: 1,
1156
+ name: "charge",
1157
+ runId,
1158
+ seq: 0,
1159
+ status: "completed",
1160
+ tenantId: TENANT
1161
+ });
1162
+ (0, import_conformance.assertIs)(recorded.cancellationRequested, false, "nobody asked this run to stop");
1163
+ }),
1164
+ withSubject("recordStep reports a cancellation in the same round trip", async ({ journal }) => {
1165
+ const runId = await openRun(journal);
1166
+ await journal.requestCancellation({ runId, tenantId: TENANT });
1167
+ const recorded = await journal.recordStep({
1168
+ attempt: 1,
1169
+ name: "charge",
1170
+ runId,
1171
+ seq: 0,
1172
+ status: "completed",
1173
+ tenantId: TENANT
1174
+ });
1175
+ (0, import_conformance.assertIs)(recorded.cancellationRequested, true, "the flag comes back with the step");
1176
+ }),
1177
+ withSubject("requestCancellation is accepted by a running run", async ({ journal }) => {
1178
+ const runId = await openRun(journal);
1179
+ (0, import_conformance.assertIs)(
1180
+ await journal.requestCancellation({ runId, tenantId: TENANT }),
1181
+ true,
1182
+ "a running run can be asked to stop"
1183
+ );
1184
+ }),
1185
+ withSubject("requestCancellation is refused by a finished run", async ({ journal }) => {
1186
+ const runId = await openRun(journal);
1187
+ await closeRun(journal, runId, "completed");
1188
+ (0, import_conformance.assertIs)(
1189
+ await journal.requestCancellation({ runId, tenantId: TENANT }),
1190
+ false,
1191
+ "a finished run cannot be stopped"
1192
+ );
1193
+ }),
1194
+ withSubject("requestCancellation is refused for another tenant", async ({ journal }) => {
1195
+ const runId = await openRun(journal);
1196
+ (0, import_conformance.assertIs)(
1197
+ await journal.requestCancellation({ runId, tenantId: OTHER }),
1198
+ false,
1199
+ "a run belongs to one tenant"
1200
+ );
1201
+ }),
1202
+ withSubject("requestCancellation is refused for an unknown run", async ({ journal }) => {
1203
+ (0, import_conformance.assertIs)(
1204
+ await journal.requestCancellation({ runId: "run_nowhere", tenantId: TENANT }),
1205
+ false,
1206
+ "a run nobody has heard of cannot be stopped"
1207
+ );
1208
+ }),
1209
+ withSubject("finishRun closes the run", async ({ journal, runStatus }) => {
1210
+ const runId = await openRun(journal);
1211
+ await closeRun(journal, runId, "completed", { done: true });
1212
+ (0, import_conformance.assertIs)(await runStatus({ runId, tenantId: TENANT }), "completed", "the run is closed");
1213
+ }),
1214
+ withSubject("finishRun writes the events it carries", async ({ journal }) => {
1215
+ const runId = await openRun(journal);
1216
+ await journal.finishRun({
1217
+ events: [envelopeOf({ id: `${runId}:0`, occurredAt: 10 })],
1218
+ runId,
1219
+ status: "completed",
1220
+ tenantId: TENANT
1221
+ });
1222
+ const stranded = await journal.listUndispatchedEvents({ before: 1e3, limit: 10 });
1223
+ (0, import_conformance.assertSame)(
1224
+ stranded.map((row) => row.envelope.id),
1225
+ [`${runId}:0`],
1226
+ "the finish queues the events it was given"
1227
+ );
1228
+ }),
1229
+ withSubject(
1230
+ "finishRun is safe to call twice with the same arguments",
1231
+ async ({ journal, runStatus }) => {
1232
+ const runId = await openRun(journal);
1233
+ const events = [envelopeOf({ id: `${runId}:0`, occurredAt: 10 })];
1234
+ await journal.finishRun({ events, runId, status: "completed", tenantId: TENANT });
1235
+ await journal.finishRun({ events, runId, status: "completed", tenantId: TENANT });
1236
+ const stranded = await journal.listUndispatchedEvents({ before: 1e3, limit: 10 });
1237
+ (0, import_conformance.assertIs)(stranded.length, 1, "an outbox row is written once per envelope id");
1238
+ (0, import_conformance.assertIs)(
1239
+ await runStatus({ runId, tenantId: TENANT }),
1240
+ "completed",
1241
+ "the run is still closed"
1242
+ );
1243
+ }
1244
+ ),
1245
+ /*
1246
+ * A zombie must not come back and take a key somebody else now holds.
1247
+ *
1248
+ * The sequence is real: a sweeper closes an abandoned inline run, which releases its key; a
1249
+ * caller asks for the work again and the new run takes the key; then the first run turns out
1250
+ * not to have been dead after all and finishes. Whoever closed the run first decided how it
1251
+ * ended.
1252
+ */
1253
+ withSubject(
1254
+ "finishRun does not reopen a run somebody else already closed",
1255
+ async ({ journal, runStatus }) => {
1256
+ const abandoned = await openRun(journal, { idempotencyKey: "key-zombie" });
1257
+ await closeRun(journal, abandoned, "failed");
1258
+ const replacement = await openRun(journal, { idempotencyKey: "key-zombie" });
1259
+ await journal.finishRun({
1260
+ output: { late: true },
1261
+ runId: abandoned,
1262
+ status: "completed",
1263
+ tenantId: TENANT
1264
+ });
1265
+ (0, import_conformance.assertIs)(
1266
+ await runStatus({ runId: abandoned, tenantId: TENANT }),
1267
+ "failed",
1268
+ "a run that was already closed keeps the ending it was given"
1269
+ );
1270
+ (0, import_conformance.assertIs)(
1271
+ (await journal.findRunByIdempotencyKey({
1272
+ idempotencyKey: "key-zombie",
1273
+ tenantId: TENANT
1274
+ }))?.id,
1275
+ replacement,
1276
+ "the key stays with the run that holds it"
1277
+ );
1278
+ }
1279
+ ),
1280
+ withSubject("markEventsDispatched takes them out of the sweep", async ({ journal }) => {
1281
+ const runId = await openRun(journal);
1282
+ await journal.finishRun({
1283
+ events: [
1284
+ envelopeOf({ id: `${runId}:0`, occurredAt: 10 }),
1285
+ envelopeOf({ id: `${runId}:1`, occurredAt: 20 })
1286
+ ],
1287
+ runId,
1288
+ status: "completed",
1289
+ tenantId: TENANT
1290
+ });
1291
+ await journal.markEventsDispatched({ ids: [`${runId}:0`], tenantId: TENANT });
1292
+ const stranded = await journal.listUndispatchedEvents({ before: 1e3, limit: 10 });
1293
+ (0, import_conformance.assertSame)(
1294
+ stranded.map((row) => row.envelope.id),
1295
+ [`${runId}:1`],
1296
+ "a stamped row is not swept again"
1297
+ );
1298
+ }),
1299
+ withSubject("listUndispatchedEvents answers oldest first", async ({ journal }) => {
1300
+ const runId = await openRun(journal);
1301
+ await journal.finishRun({
1302
+ events: [
1303
+ envelopeOf({ id: `${runId}:1`, occurredAt: 300 }),
1304
+ envelopeOf({ id: `${runId}:0`, occurredAt: 100 })
1305
+ ],
1306
+ runId,
1307
+ status: "completed",
1308
+ tenantId: TENANT
1309
+ });
1310
+ const stranded = await journal.listUndispatchedEvents({ before: 1e3, limit: 10 });
1311
+ (0, import_conformance.assertSame)(
1312
+ stranded.map((row) => row.envelope.id),
1313
+ [`${runId}:0`, `${runId}:1`],
1314
+ "the sweep starts with the oldest"
1315
+ );
1316
+ }),
1317
+ withSubject("listUndispatchedEvents honours the cutoff", async ({ journal }) => {
1318
+ const runId = await openRun(journal);
1319
+ await journal.finishRun({
1320
+ events: [
1321
+ envelopeOf({ id: `${runId}:0`, occurredAt: 100 }),
1322
+ envelopeOf({ id: `${runId}:1`, occurredAt: 900 })
1323
+ ],
1324
+ runId,
1325
+ status: "completed",
1326
+ tenantId: TENANT
1327
+ });
1328
+ const stranded = await journal.listUndispatchedEvents({ before: 500, limit: 10 });
1329
+ (0, import_conformance.assertSame)(
1330
+ stranded.map((row) => row.envelope.id),
1331
+ [`${runId}:0`],
1332
+ "a row younger than the cutoff is left alone"
1333
+ );
1334
+ }),
1335
+ withSubject("listUndispatchedEvents honours the limit", async ({ journal }) => {
1336
+ const runId = await openRun(journal);
1337
+ await journal.finishRun({
1338
+ events: [0, 1, 2].map(
1339
+ (ordinal) => envelopeOf({ id: `${runId}:${ordinal}`, occurredAt: 100 + ordinal })
1340
+ ),
1341
+ runId,
1342
+ status: "completed",
1343
+ tenantId: TENANT
1344
+ });
1345
+ const stranded = await journal.listUndispatchedEvents({ before: 1e3, limit: 2 });
1346
+ (0, import_conformance.assertIs)(stranded.length, 2, "a sweep takes no more than it asked for");
1347
+ }),
1348
+ withSubject("listUndispatchedEvents crosses every tenant", async ({ journal }) => {
1349
+ const mine = await openRun(journal);
1350
+ const theirs = await openRun(journal, { tenantId: OTHER });
1351
+ await journal.finishRun({
1352
+ events: [envelopeOf({ id: `${mine}:0`, occurredAt: 100 })],
1353
+ runId: mine,
1354
+ status: "completed",
1355
+ tenantId: TENANT
1356
+ });
1357
+ await journal.finishRun({
1358
+ events: [envelopeOf({ id: `${theirs}:0`, occurredAt: 200 })],
1359
+ runId: theirs,
1360
+ status: "completed",
1361
+ tenantId: OTHER
1362
+ });
1363
+ const stranded = await journal.listUndispatchedEvents({ before: 1e3, limit: 10 });
1364
+ (0, import_conformance.assertSame)(
1365
+ stranded.map((row) => row.tenantId),
1366
+ [TENANT, OTHER],
1367
+ "the sweep reads for everybody, and says whose each row is"
1368
+ );
1369
+ }),
1370
+ withSubject(
1371
+ "listAbandonedRuns answers with inline runs older than the cutoff",
1372
+ async ({ journal }) => {
1373
+ const runId = await openRun(journal, { execution: "inline" });
1374
+ const abandoned = await journal.listAbandonedRuns({
1375
+ execution: "inline",
1376
+ limit: 10,
1377
+ startedBefore: Date.now() + 6e4
1378
+ });
1379
+ (0, import_conformance.assertSame)(
1380
+ abandoned,
1381
+ [{ name: "conformance.workflow", runId, tenantId: TENANT }],
1382
+ "an abandoned run is answered with its tenant and its name, because closing it announces it"
1383
+ );
1384
+ }
1385
+ ),
1386
+ withSubject("listAbandonedRuns leaves younger inline runs alone", async ({ journal }) => {
1387
+ await openRun(journal, { execution: "inline" });
1388
+ (0, import_conformance.assertIs)(
1389
+ (await journal.listAbandonedRuns({
1390
+ execution: "inline",
1391
+ limit: 10,
1392
+ startedBefore: Date.now() - 6e4
1393
+ })).length,
1394
+ 0,
1395
+ "nothing was old enough"
1396
+ );
1397
+ }),
1398
+ withSubject("listAbandonedRuns never answers with a durable run", async ({ journal }) => {
1399
+ await openRun(journal, { execution: "durable" });
1400
+ (0, import_conformance.assertIs)(
1401
+ (await journal.listAbandonedRuns({
1402
+ execution: "inline",
1403
+ limit: 10,
1404
+ startedBefore: Date.now() + 6e4
1405
+ })).length,
1406
+ 0,
1407
+ "a durable run may sleep for a week"
1408
+ );
1409
+ }),
1410
+ withSubject("listAbandonedRuns never answers with a run that ended", async ({ journal }) => {
1411
+ const runId = await openRun(journal, { execution: "inline" });
1412
+ await closeRun(journal, runId, "completed");
1413
+ (0, import_conformance.assertIs)(
1414
+ (await journal.listAbandonedRuns({
1415
+ execution: "inline",
1416
+ limit: 10,
1417
+ startedBefore: Date.now() + 6e4
1418
+ })).length,
1419
+ 0,
1420
+ "a closed run needs nobody to close it"
1421
+ );
1422
+ }),
1423
+ withSubject("listAbandonedRuns honours the limit", async ({ journal }) => {
1424
+ for (let opened = 0; opened < 3; opened += 1) await openRun(journal, { execution: "inline" });
1425
+ (0, import_conformance.assertIs)(
1426
+ (await journal.listAbandonedRuns({
1427
+ execution: "inline",
1428
+ limit: 2,
1429
+ startedBefore: Date.now() + 6e4
1430
+ })).length,
1431
+ 2,
1432
+ "a sweep takes no more than it asked for"
1433
+ );
1434
+ }),
1435
+ // Closing an abandoned run releases its key like any other ending, so the work can be asked for
1436
+ // again.
1437
+ withSubject("a swept run releases its key", async ({ journal }) => {
1438
+ const runId = await openRun(journal, { execution: "inline", idempotencyKey: "key-swept" });
1439
+ await closeRun(journal, runId, "failed");
1440
+ const second = await openRun(journal, { execution: "inline", idempotencyKey: "key-swept" });
1441
+ (0, import_conformance.assertThat)(second !== runId, "work whose run was abandoned can be asked for again");
1442
+ }),
1443
+ /*
1444
+ * The trail at entry, which the study left optional and this contract requires (CONTRACT.md,
1445
+ * D1). A durable invocation reads it to learn two things: which undos this run already refused,
1446
+ * and whether it had begun unwinding at all. Without them a refused undo is retried after undos
1447
+ * that started later, and a body is carried forward into steps that never ran — the ordering
1448
+ * claims of this engine are simply false about runs kept in a journal that cannot answer.
1449
+ */
1450
+ withSubject("listRunSteps answers with the run trail, oldest first", async ({ journal }) => {
1451
+ const read = required(
1452
+ journal,
1453
+ "listRunSteps",
1454
+ "a durable invocation reads the trail before it runs anything, to learn which undos already refused and whether the run had begun unwinding"
1455
+ );
1456
+ const runId = await openRun(journal);
1457
+ for (const [seq, name, status] of [
1458
+ [0, "reserve", "completed"],
1459
+ [1, "charge", "failed"],
1460
+ [2, "compensate:reserve", "compensated"]
1461
+ ]) {
1462
+ await journal.recordStep({ attempt: 1, name, runId, seq, status, tenantId: TENANT });
1463
+ }
1464
+ (0, import_conformance.assertSame)(
1465
+ (await read({ runId, tenantId: TENANT })).map((step) => `${step.name}:${step.status}`),
1466
+ ["reserve:completed", "charge:failed", "compensate:reserve:compensated"],
1467
+ "the trail reads back in the order it was written"
1468
+ );
1469
+ }),
1470
+ withSubject("listRunSteps keeps one run trail out of another tenant", async ({ journal }) => {
1471
+ const read = required(
1472
+ journal,
1473
+ "listRunSteps",
1474
+ "a trail is read under the tenant whose run it is, like every other read in this contract"
1475
+ );
1476
+ const runId = await openRun(journal);
1477
+ await journal.recordStep({
1478
+ attempt: 1,
1479
+ name: "reserve",
1480
+ runId,
1481
+ seq: 0,
1482
+ status: "completed",
1483
+ tenantId: TENANT
1484
+ });
1485
+ (0, import_conformance.assertIs)(
1486
+ (await read({ runId, tenantId: OTHER })).length,
1487
+ 0,
1488
+ "a run belongs to one tenant, and so does its trail"
1489
+ );
1490
+ }),
1491
+ /*
1492
+ * The entry guard. The model shows it clean at these bounds once the trail is read — it is
1493
+ * defence in depth — and it is required because it closes a window the trail cannot see: a run
1494
+ * closed by something that left no compensation behind has no trail to read, and only the run's
1495
+ * own status says it has ended.
1496
+ */
1497
+ withSubject("getRun answers with the run and what it decided", async ({ journal }) => {
1498
+ const read = required(
1499
+ journal,
1500
+ "getRun",
1501
+ "a durable invocation reads the run before it runs anything, so a run closed with no compensation behind it is not carried forward"
1502
+ );
1503
+ const runId = await openRun(journal);
1504
+ await closeRun(journal, runId, "completed", { invoice: 7 });
1505
+ const run = await read({ runId, tenantId: TENANT });
1506
+ (0, import_conformance.assertIs)(run?.id, runId, "the run answers under its own id");
1507
+ (0, import_conformance.assertIs)(run?.name, "conformance.workflow", "the run says what it is");
1508
+ (0, import_conformance.assertIs)(run?.status, "completed", "the run says how it ended");
1509
+ (0, import_conformance.assertSame)(run?.output, { invoice: 7 }, "the run says what it decided");
1510
+ }),
1511
+ withSubject("getRun answers with nothing for a run nobody opened", async ({ journal }) => {
1512
+ const read = required(
1513
+ journal,
1514
+ "getRun",
1515
+ "no record is not a closed record, so the absence has to be answerable"
1516
+ );
1517
+ (0, import_conformance.assertIs)(
1518
+ await read({ runId: "run_nowhere", tenantId: TENANT }),
1519
+ null,
1520
+ "a run that is not there is null, never a refusal"
1521
+ );
1522
+ }),
1523
+ // The strongest promise in the contract, and the only one that needs the store to be broken on
1524
+ // purpose: a run is completed IF AND ONLY IF its events are queued. A journal that writes the
1525
+ // two separately can be interrupted between them, and "completed, audit trail lost" is exactly
1526
+ // the state that must not exist.
1527
+ withSubject("finishRun closes nothing when its events cannot be written", async (subject) => {
1528
+ const { journal, runStatus } = subject;
1529
+ const runId = await openRun(journal);
1530
+ await subject.breakOutboxWrites();
1531
+ await (0, import_conformance.assertRefuses)(
1532
+ () => journal.finishRun({
1533
+ events: [envelopeOf({ id: `${runId}:0`, occurredAt: 10 })],
1534
+ runId,
1535
+ status: "completed",
1536
+ tenantId: TENANT
1537
+ }),
1538
+ "a finish that cannot write its events must fail"
1539
+ );
1540
+ (0, import_conformance.assertIs)(
1541
+ await runStatus({ runId, tenantId: TENANT }),
1542
+ "running",
1543
+ "the run must be left running, for somebody to finish"
1544
+ );
1545
+ })
1546
+ ];
1547
+ };
1548
+
1549
+ // src/create-step.ts
1550
+ var DEFINITION2 = /* @__PURE__ */ Symbol.for("geonosis.workflows.step");
1551
+ var stepDefinitionOf = (call) => {
1552
+ const carried = call[DEFINITION2];
1553
+ if (!carried) {
1554
+ throw new WorkflowsError(
1555
+ "this is not a step: it did not come from createStep, so it has no name to journal it under and no undo to register."
1556
+ );
1557
+ }
1558
+ return carried;
1559
+ };
1560
+ var assertNameIsAvailable = (name) => {
1561
+ if (name === "") {
1562
+ throw new WorkflowsError(
1563
+ "a step has no name. The name is what the run record and a durable platform memoise the step by, so it is the one thing that is never optional."
1564
+ );
1565
+ }
1566
+ if (Object.values(RESERVED_STEP_NAMES).includes(name)) {
1567
+ throw new WorkflowsError(
1568
+ `"${name}" is a step name the engine uses for itself. A durable platform memoises by name, so this step would be handed the engine's result on a replay, or hand the engine its own.`
1569
+ );
1570
+ }
1571
+ if (name.startsWith(COMPENSATION_PREFIX)) {
1572
+ throw new WorkflowsError(
1573
+ `a step name may not start with "${COMPENSATION_PREFIX}" \u2014 that is what the engine calls the step that undoes another one, and a trail with both in it could not be read back.`
1574
+ );
1575
+ }
1576
+ };
1577
+ var budgetOf = (config) => {
1578
+ if (config.retry === void 0 && config.timeout === void 0) return DEFAULT_STEP_BUDGET;
1579
+ if (config.retry !== void 0 && !(config.retry.limit >= 0)) {
1580
+ throw new WorkflowsError(
1581
+ `a retry limit of ${config.retry.limit} is not a budget. Say 0 for "run it once" and leave the key off to take the default.`
1582
+ );
1583
+ }
1584
+ return {
1585
+ ...config.retry === void 0 ? {} : { retry: config.retry },
1586
+ ...config.timeout === void 0 ? {} : { timeout: config.timeout }
1587
+ };
1588
+ };
1589
+ function createStep(config, invoke, compensate) {
1590
+ const declared = typeof config === "string" ? { name: config } : config;
1591
+ assertNameIsAvailable(declared.name);
1592
+ if (declared.noCompensation === true && compensate !== void 0) {
1593
+ throw new WorkflowsError(
1594
+ `the step "${declared.name}" declares noCompensation and was given a compensate function. One of the two is a mistake, and guessing which would either drop an undo or run one the caller said was not there.`
1595
+ );
1596
+ }
1597
+ const definition = {
1598
+ budget: budgetOf(declared),
1599
+ invoke,
1600
+ name: declared.name,
1601
+ ...compensate === void 0 ? {} : { compensate }
1602
+ };
1603
+ const call = async (input) => frameFor(definition.name).call(definition, input);
1604
+ return Object.assign(call, {
1605
+ [DEFINITION2]: definition,
1606
+ stepName: definition.name
1607
+ });
1608
+ }
1609
+
1610
+ // src/memory-journal.ts
1611
+ var createMemoryJournal = (options = {}) => {
1612
+ const now = options.now ?? (() => Date.now());
1613
+ let outboxWritesFail = false;
1614
+ const runs = [];
1615
+ const steps = [];
1616
+ const finishes = [];
1617
+ const outbox = [];
1618
+ const dispatched = [];
1619
+ const runOf = (tenantId, runId) => runs.find((run) => run.tenantId === tenantId && run.id === runId);
1620
+ const heldRun = (tenantId, idempotencyKey) => runs.find(
1621
+ (run) => run.tenantId === tenantId && run.idempotencyKey === idempotencyKey && (run.status === "running" || run.status === "completed")
1622
+ );
1623
+ const journal = {
1624
+ findRunByIdempotencyKey: async (params) => {
1625
+ const run = heldRun(params.tenantId, params.idempotencyKey);
1626
+ return run ? { id: run.id, output: run.output, status: run.status } : null;
1627
+ },
1628
+ finishRun: async (params) => {
1629
+ if (outboxWritesFail && (params.events?.length ?? 0) > 0) {
1630
+ throw new Error("the outbox is unwritable");
1631
+ }
1632
+ finishes.push({
1633
+ error: params.error ?? null,
1634
+ events: params.events ?? [],
1635
+ output: params.output,
1636
+ runId: params.runId,
1637
+ status: params.status
1638
+ });
1639
+ for (const envelope of params.events ?? []) {
1640
+ if (outbox.some((row) => row.envelope.id === envelope.id)) continue;
1641
+ outbox.push({ envelope, tenantId: params.tenantId });
1642
+ }
1643
+ const run = runs.find((candidate) => candidate.id === params.runId);
1644
+ if (!run) return;
1645
+ if (run.status !== "running") return;
1646
+ run.status = params.status;
1647
+ run.output = params.output;
1648
+ run.error = params.error ?? null;
1649
+ run.finishedAt = now();
1650
+ },
1651
+ getRun: async (params) => {
1652
+ const run = runOf(params.tenantId, params.runId);
1653
+ if (!run) return null;
1654
+ return {
1655
+ error: run.error ?? null,
1656
+ execution: run.execution,
1657
+ finishedAt: run.finishedAt ?? null,
1658
+ id: run.id,
1659
+ input: run.input,
1660
+ name: run.name,
1661
+ output: run.output ?? null,
1662
+ parentRunId: run.parentRunId,
1663
+ replayOf: run.replayOf ?? null,
1664
+ startedAt: run.startedAt,
1665
+ status: run.status
1666
+ };
1667
+ },
1668
+ insertRun: async (params) => {
1669
+ const claimed = params.idempotencyKey !== null && heldRun(params.tenantId, params.idempotencyKey);
1670
+ if (claimed) {
1671
+ throw new IdempotencyKeyHeldError({
1672
+ idempotencyKey: params.idempotencyKey,
1673
+ tenantId: params.tenantId
1674
+ });
1675
+ }
1676
+ const id = `run_${runs.length + 1}`;
1677
+ runs.push({
1678
+ cancelRequested: false,
1679
+ finishedAt: null,
1680
+ id,
1681
+ startedAt: now(),
1682
+ status: "running",
1683
+ ...params,
1684
+ // A column nobody set is null in a table, never undefined. An adapter that answered
1685
+ // otherwise would let a test pass here and fail against a real database.
1686
+ parentRunId: params.parentRunId ?? null,
1687
+ replayOf: params.replayOf ?? null
1688
+ });
1689
+ return id;
1690
+ },
1691
+ listAbandonedRuns: async (params) => runs.filter(
1692
+ (run) => run.execution === params.execution && run.status === "running" && run.startedAt < params.startedBefore
1693
+ ).slice(0, params.limit).map((run) => ({ name: run.name, runId: run.id, tenantId: run.tenantId })),
1694
+ listRunSteps: async (params) => steps.filter((row) => row.tenantId === params.tenantId && row.runId === params.runId).map((row) => ({
1695
+ attempt: row.attempt,
1696
+ error: row.error ?? null,
1697
+ name: row.name,
1698
+ seq: row.seq,
1699
+ status: row.status
1700
+ })),
1701
+ listUndispatchedEvents: async (params) => outbox.filter(
1702
+ (row) => !dispatched.includes(row.envelope.id) && row.envelope.occurredAt <= params.before
1703
+ ).toSorted((left, right) => left.envelope.occurredAt - right.envelope.occurredAt).slice(0, params.limit),
1704
+ markEventsDispatched: async (params) => {
1705
+ dispatched.push(...params.ids);
1706
+ },
1707
+ recordStep: async (params) => {
1708
+ const written = steps.some(
1709
+ (step) => step.runId === params.runId && step.seq === params.seq && step.attempt === params.attempt
1710
+ );
1711
+ if (!written) steps.push({ ...params });
1712
+ const run = runOf(params.tenantId, params.runId);
1713
+ return { cancellationRequested: run?.cancelRequested ?? false };
1714
+ },
1715
+ requestCancellation: async (params) => {
1716
+ const run = runOf(params.tenantId, params.runId);
1717
+ if (!run || run.status !== "running") return false;
1718
+ run.cancelRequested = true;
1719
+ return true;
1720
+ }
1721
+ };
1722
+ return {
1723
+ breakOutboxWrites: () => {
1724
+ outboxWritesFail = true;
1725
+ },
1726
+ dispatched,
1727
+ finishes,
1728
+ journal,
1729
+ outbox,
1730
+ runs,
1731
+ steps
1732
+ };
1733
+ };
1734
+ var createMemorySink = (options = {}) => {
1735
+ const sent = [];
1736
+ const batches = [];
1737
+ const sink = {
1738
+ sendBatch: async (messages) => {
1739
+ if (options.refuses) throw new Error("the sink is unreachable");
1740
+ const bodies = messages.map((message) => message.body);
1741
+ batches.push(bodies);
1742
+ sent.push(...bodies);
1743
+ }
1744
+ };
1745
+ return { batches, sent, sink };
1746
+ };
1747
+ var createInProcessSink = (deliver) => ({
1748
+ sendBatch: async (messages) => {
1749
+ for (const message of messages) await deliver(message.body);
1750
+ }
1751
+ });