@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/LICENSE +202 -0
- package/README.md +110 -0
- package/dist/index.cjs +1751 -0
- package/dist/index.d.cts +1153 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +1734 -0
- package/package.json +58 -0
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
|
+
});
|