@chidchanun/bcp 0.2.10 → 0.2.12

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.
@@ -0,0 +1,31 @@
1
+ export {
2
+ createEventBus,
3
+ createMemoryOutboxStore,
4
+ createOutboxDispatcher,
5
+ createOutboxMigrationSql,
6
+ createSqlOutboxStore,
7
+ createTransactionalOutbox,
8
+ } from "../../server/src/events.js";
9
+
10
+ export type {
11
+ ClaimOutboxEventsOptions,
12
+ CleanupOutboxOptions,
13
+ EventBus,
14
+ FailOutboxEventOptions,
15
+ MemoryOutboxStore,
16
+ OutboxDispatcher,
17
+ OutboxDispatcherOptions,
18
+ OutboxDispatcherRunner,
19
+ OutboxEventHandler,
20
+ OutboxEventHandlerContext,
21
+ OutboxEventRecord,
22
+ OutboxEventState,
23
+ OutboxRetryDelay,
24
+ OutboxStats,
25
+ OutboxStore,
26
+ PublishOutboxEventOptions,
27
+ SqlOutboxStore,
28
+ SqlOutboxStoreOptions,
29
+ TransactionalOutbox,
30
+ TransactionalOutboxOptions,
31
+ } from "../../server/src/events.js";
@@ -0,0 +1,601 @@
1
+ // packages/server/src/workflow.ts
2
+ import { randomUUID } from "node:crypto";
3
+ function createMemoryWorkflowStore() {
4
+ const runs = /* @__PURE__ */ new Map();
5
+ return {
6
+ async create(run) {
7
+ if (runs.has(run.id)) {
8
+ throw new Error(`BCP Workflow: run id "${run.id}" already exists.`);
9
+ }
10
+ runs.set(run.id, cloneRun(run));
11
+ },
12
+ async update(run) {
13
+ if (!runs.has(run.id)) {
14
+ throw new Error(`BCP Workflow: run id "${run.id}" does not exist.`);
15
+ }
16
+ runs.set(run.id, cloneRun(run));
17
+ },
18
+ async get(id) {
19
+ const run = runs.get(id);
20
+ return run ? cloneRun(run) : null;
21
+ },
22
+ async list(workflowName) {
23
+ return [...runs.values()].filter((run) => !workflowName || run.workflowName === workflowName).map(cloneRun).sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
24
+ },
25
+ async claim(id, options) {
26
+ const run = runs.get(id);
27
+ if (!run) {
28
+ return false;
29
+ }
30
+ if (run.leaseOwner && run.leaseOwner !== options.ownerId && run.leaseUntil !== void 0 && run.leaseUntil > options.now) {
31
+ return false;
32
+ }
33
+ run.leaseOwner = options.ownerId;
34
+ run.leaseUntil = options.now + options.leaseMs;
35
+ return true;
36
+ },
37
+ async release(id, ownerId) {
38
+ const run = runs.get(id);
39
+ if (!run || run.leaseOwner !== ownerId) {
40
+ return;
41
+ }
42
+ run.leaseOwner = void 0;
43
+ run.leaseUntil = void 0;
44
+ },
45
+ clear() {
46
+ runs.clear();
47
+ }
48
+ };
49
+ }
50
+ function createWorkflow(name, configure, options = {}) {
51
+ const workflowName = normalizeText(name, "workflow name");
52
+ const store = options.store ?? createMemoryWorkflowStore();
53
+ const queue = options.queue;
54
+ const now = options.now ?? Date.now;
55
+ const idFactory = options.idFactory ?? randomUUID;
56
+ const ownerId = normalizeText(options.ownerId ?? `workflow-${randomUUID()}`, "ownerId");
57
+ const leaseMs = positiveInteger(options.leaseMs ?? 3e4, "leaseMs");
58
+ const defaultMaxAttempts = positiveInteger(
59
+ options.defaultMaxAttempts ?? 1,
60
+ "defaultMaxAttempts"
61
+ );
62
+ const defaultRetryDelay = options.retryDelayMs ?? 0;
63
+ const definitions = [];
64
+ const ids = /* @__PURE__ */ new Set();
65
+ const builder = {
66
+ step(id, handler, stepOptions = {}) {
67
+ definitions.push(makeStep(id, handler, stepOptions, defaultMaxAttempts, ids));
68
+ return builder;
69
+ },
70
+ parallel(id, configureParallel) {
71
+ const parallelId = reserveId(id, ids);
72
+ const children = [];
73
+ const childIds = /* @__PURE__ */ new Set();
74
+ const parallelBuilder = {
75
+ step(childId, handler, stepOptions = {}) {
76
+ children.push(
77
+ makeStep(childId, handler, stepOptions, defaultMaxAttempts, childIds)
78
+ );
79
+ return parallelBuilder;
80
+ }
81
+ };
82
+ if (typeof configureParallel !== "function") {
83
+ throw new TypeError("BCP Workflow: parallel configure callback is required.");
84
+ }
85
+ configureParallel(parallelBuilder);
86
+ if (children.length === 0) {
87
+ throw new Error(`BCP Workflow: parallel step "${parallelId}" must contain child steps.`);
88
+ }
89
+ definitions.push({ kind: "parallel", id: parallelId, steps: children });
90
+ return builder;
91
+ },
92
+ delay(id, durationMs) {
93
+ definitions.push({
94
+ kind: "delay",
95
+ id: reserveId(id, ids),
96
+ durationMs: nonNegativeNumber(durationMs, "delay durationMs")
97
+ });
98
+ return builder;
99
+ }
100
+ };
101
+ if (typeof configure !== "function") {
102
+ throw new TypeError("BCP Workflow: configure callback is required.");
103
+ }
104
+ configure(builder);
105
+ if (definitions.length === 0) {
106
+ throw new Error("BCP Workflow: at least one step is required.");
107
+ }
108
+ const queueJobName = `bcp.workflow.${workflowName}`;
109
+ const unregisterQueueHandler = queue ? queue.register(queueJobName, async ({ payload, signal }) => {
110
+ await execute(payload.runId, signal);
111
+ }) : void 0;
112
+ const api = {
113
+ name: workflowName,
114
+ store,
115
+ queue,
116
+ ownerId,
117
+ async start(input, startOptions = {}) {
118
+ const createdAt = now();
119
+ const run = {
120
+ id: normalizeText(startOptions.id ?? idFactory(), "run id"),
121
+ workflowName,
122
+ input,
123
+ state: "pending",
124
+ createdAt,
125
+ updatedAt: createdAt,
126
+ steps: definitions.map(makeRecord),
127
+ completionOrder: []
128
+ };
129
+ await store.create(run);
130
+ if (queue) {
131
+ await enqueueRun(run.id);
132
+ return requireRun(run.id);
133
+ }
134
+ return execute(run.id, startOptions.signal);
135
+ },
136
+ run(id, signal) {
137
+ return execute(normalizeText(id, "run id"), signal);
138
+ },
139
+ get(id) {
140
+ return store.get(normalizeText(id, "run id"));
141
+ },
142
+ async list() {
143
+ return await store.list(workflowName);
144
+ },
145
+ async resume(id, resumeOptions = {}) {
146
+ const run = await requireRun(id);
147
+ if (run.state !== "waiting") {
148
+ return run;
149
+ }
150
+ if (!resumeOptions.force && run.waitUntil !== void 0 && run.waitUntil > now()) {
151
+ return run;
152
+ }
153
+ if (resumeOptions.force) {
154
+ forceWaitingStepDue(run, now());
155
+ }
156
+ run.state = "pending";
157
+ run.updatedAt = now();
158
+ await store.update(run);
159
+ if (queue) {
160
+ await enqueueRun(run.id);
161
+ return requireRun(run.id);
162
+ }
163
+ return execute(run.id, resumeOptions.signal);
164
+ },
165
+ async retry(id, retryOptions = {}) {
166
+ const run = await requireRun(id);
167
+ if (run.state !== "failed") {
168
+ return run;
169
+ }
170
+ if (!resetFailed(run.steps)) {
171
+ throw new Error(`BCP Workflow: failed run "${run.id}" has no failed step.`);
172
+ }
173
+ run.state = "pending";
174
+ run.error = void 0;
175
+ run.completedAt = void 0;
176
+ run.currentStep = void 0;
177
+ run.updatedAt = now();
178
+ await store.update(run);
179
+ if (queue) {
180
+ await enqueueRun(run.id);
181
+ return requireRun(run.id);
182
+ }
183
+ return execute(run.id, retryOptions.signal);
184
+ },
185
+ async cancel(id, cancelOptions = {}) {
186
+ const run = await requireRun(id);
187
+ if (isTerminal(run.state)) {
188
+ return run;
189
+ }
190
+ run.state = "cancelled";
191
+ run.completedAt = now();
192
+ run.updatedAt = run.completedAt;
193
+ run.waitUntil = void 0;
194
+ await store.update(run);
195
+ return cancelOptions.compensate ? compensateRun(run, cancelOptions.signal) : cloneRun(run);
196
+ },
197
+ async compensate(id, signal) {
198
+ const run = await requireRun(id);
199
+ if (run.state !== "failed" && run.state !== "cancelled") {
200
+ return run;
201
+ }
202
+ return compensateRun(run, signal);
203
+ },
204
+ async close() {
205
+ unregisterQueueHandler?.();
206
+ if (store.close) {
207
+ await store.close();
208
+ }
209
+ }
210
+ };
211
+ return api;
212
+ async function execute(rawId, signal = new AbortController().signal) {
213
+ const id = normalizeText(rawId, "run id");
214
+ const claimed = await store.claim(id, { ownerId, now: now(), leaseMs });
215
+ if (!claimed) {
216
+ return requireRun(id);
217
+ }
218
+ try {
219
+ const run = await requireRun(id);
220
+ if (isTerminal(run.state) || run.state === "compensating") {
221
+ return run;
222
+ }
223
+ if (run.state === "waiting" && run.waitUntil !== void 0 && run.waitUntil > now()) {
224
+ return run;
225
+ }
226
+ run.state = "running";
227
+ run.startedAt ??= now();
228
+ run.waitUntil = void 0;
229
+ run.updatedAt = now();
230
+ await store.update(run);
231
+ for (let index = 0; index < definitions.length; index += 1) {
232
+ if (signal.aborted) {
233
+ run.state = "cancelled";
234
+ run.completedAt = now();
235
+ run.updatedAt = run.completedAt;
236
+ await store.update(run);
237
+ return cloneRun(run);
238
+ }
239
+ const definition = definitions[index];
240
+ const record = run.steps[index];
241
+ if (!definition || !record) {
242
+ throw new Error("BCP Workflow: definition/run step mismatch.");
243
+ }
244
+ if (record.state === "succeeded" || record.state === "compensated") {
245
+ continue;
246
+ }
247
+ run.currentStep = definition.id;
248
+ run.updatedAt = now();
249
+ await store.update(run);
250
+ const outcome = await runDefinition(definition, record, run, signal);
251
+ if (outcome === "waiting") {
252
+ return cloneRun(run);
253
+ }
254
+ if (outcome === "failed") {
255
+ run.state = "failed";
256
+ run.error = record.error ?? `Workflow step "${definition.id}" failed.`;
257
+ run.completedAt = now();
258
+ run.updatedAt = run.completedAt;
259
+ await store.update(run);
260
+ return cloneRun(run);
261
+ }
262
+ }
263
+ run.state = "succeeded";
264
+ run.currentStep = void 0;
265
+ run.completedAt = now();
266
+ run.updatedAt = run.completedAt;
267
+ await store.update(run);
268
+ return cloneRun(run);
269
+ } finally {
270
+ await store.release(id, ownerId);
271
+ }
272
+ }
273
+ async function runDefinition(definition, record, run, signal) {
274
+ if (definition.kind === "delay") {
275
+ if (record.state === "waiting" && record.waitUntil !== void 0 && record.waitUntil <= now()) {
276
+ record.state = "succeeded";
277
+ record.completedAt = now();
278
+ record.waitUntil = void 0;
279
+ run.waitUntil = void 0;
280
+ await store.update(run);
281
+ return "ok";
282
+ }
283
+ if (definition.durationMs === 0) {
284
+ record.state = "succeeded";
285
+ record.startedAt ??= now();
286
+ record.completedAt = now();
287
+ await store.update(run);
288
+ return "ok";
289
+ }
290
+ const waitUntil = now() + definition.durationMs;
291
+ record.state = "waiting";
292
+ record.startedAt ??= now();
293
+ record.waitUntil = waitUntil;
294
+ run.state = "waiting";
295
+ run.waitUntil = waitUntil;
296
+ run.updatedAt = now();
297
+ await store.update(run);
298
+ if (queue) {
299
+ await queue.enqueue(
300
+ queueJobName,
301
+ { runId: run.id },
302
+ { delayMs: definition.durationMs, maxAttempts: 1 }
303
+ );
304
+ }
305
+ return "waiting";
306
+ }
307
+ if (definition.kind === "parallel") {
308
+ record.state = "running";
309
+ record.startedAt ??= now();
310
+ record.children ??= definition.steps.map(makeRecord);
311
+ await store.update(run);
312
+ const results = await Promise.all(
313
+ definition.steps.map(async (child, index) => {
314
+ const childRecord = record.children?.[index];
315
+ if (!childRecord) {
316
+ throw new Error("BCP Workflow: parallel child record is missing.");
317
+ }
318
+ if (childRecord.state === "succeeded" || childRecord.state === "compensated") {
319
+ return false;
320
+ }
321
+ return runLeaf(
322
+ child,
323
+ childRecord,
324
+ run,
325
+ signal,
326
+ `${definition.id}.${child.id}`
327
+ );
328
+ })
329
+ );
330
+ if (results.some(Boolean)) {
331
+ record.state = "failed";
332
+ record.completedAt = now();
333
+ record.error = record.children.find((child) => child.state === "failed")?.error ?? `Parallel step "${definition.id}" failed.`;
334
+ await store.update(run);
335
+ return "failed";
336
+ }
337
+ record.state = "succeeded";
338
+ record.completedAt = now();
339
+ await store.update(run);
340
+ return "ok";
341
+ }
342
+ return await runLeaf(definition, record, run, signal, definition.id) ? "failed" : "ok";
343
+ }
344
+ async function runLeaf(definition, record, run, signal, completionId) {
345
+ record.state = "running";
346
+ record.startedAt ??= now();
347
+ record.error = void 0;
348
+ await store.update(run);
349
+ while (record.attempts < definition.options.maxAttempts) {
350
+ if (signal.aborted) {
351
+ record.state = "cancelled";
352
+ record.completedAt = now();
353
+ await store.update(run);
354
+ return true;
355
+ }
356
+ record.attempts += 1;
357
+ await store.update(run);
358
+ try {
359
+ await definition.handler({ input: run.input, run: cloneRun(run), signal });
360
+ record.state = "succeeded";
361
+ record.completedAt = now();
362
+ record.error = void 0;
363
+ if (!run.completionOrder.includes(completionId)) {
364
+ run.completionOrder.push(completionId);
365
+ }
366
+ await store.update(run);
367
+ return false;
368
+ } catch (error) {
369
+ record.error = formatError(error);
370
+ if (record.attempts >= definition.options.maxAttempts) {
371
+ record.state = "failed";
372
+ record.completedAt = now();
373
+ await store.update(run);
374
+ return true;
375
+ }
376
+ const retryDelay = resolveRetryDelay(
377
+ definition.options.retryDelayMs ?? defaultRetryDelay,
378
+ record.attempts
379
+ );
380
+ if (retryDelay > 0) {
381
+ await sleep(retryDelay, signal);
382
+ }
383
+ }
384
+ }
385
+ record.state = "failed";
386
+ record.completedAt = now();
387
+ await store.update(run);
388
+ return true;
389
+ }
390
+ async function compensateRun(run, signal = new AbortController().signal) {
391
+ run.state = "compensating";
392
+ run.updatedAt = now();
393
+ await store.update(run);
394
+ try {
395
+ for (const completionId of [...run.completionOrder].reverse()) {
396
+ if (signal.aborted) {
397
+ throw new Error("Workflow compensation was aborted.");
398
+ }
399
+ const target = findCompensationTarget(completionId, definitions, run.steps);
400
+ if (!target?.definition.options.compensate) {
401
+ continue;
402
+ }
403
+ if (target.record.state !== "succeeded") {
404
+ continue;
405
+ }
406
+ target.record.state = "compensating";
407
+ await store.update(run);
408
+ await target.definition.options.compensate({
409
+ input: run.input,
410
+ run: cloneRun(run),
411
+ signal
412
+ });
413
+ target.record.state = "compensated";
414
+ target.record.completedAt = now();
415
+ await store.update(run);
416
+ }
417
+ run.state = "compensated";
418
+ run.completedAt = now();
419
+ run.updatedAt = run.completedAt;
420
+ await store.update(run);
421
+ return cloneRun(run);
422
+ } catch (error) {
423
+ run.state = "failed";
424
+ run.error = `Compensation failed: ${formatError(error)}`;
425
+ run.completedAt = now();
426
+ run.updatedAt = run.completedAt;
427
+ await store.update(run);
428
+ return cloneRun(run);
429
+ }
430
+ }
431
+ async function enqueueRun(runId) {
432
+ if (!queue) {
433
+ return;
434
+ }
435
+ await queue.enqueue(queueJobName, { runId }, { maxAttempts: 1 });
436
+ }
437
+ async function requireRun(rawId) {
438
+ const id = normalizeText(rawId, "run id");
439
+ const run = await store.get(id);
440
+ if (!run || run.workflowName !== workflowName) {
441
+ throw new Error(
442
+ `BCP Workflow: run "${id}" was not found for workflow "${workflowName}".`
443
+ );
444
+ }
445
+ return run;
446
+ }
447
+ }
448
+ function makeStep(id, handler, options, defaultMaxAttempts, ids) {
449
+ if (typeof handler !== "function") {
450
+ throw new TypeError("BCP Workflow: step handler must be a function.");
451
+ }
452
+ return {
453
+ kind: "step",
454
+ id: reserveId(id, ids),
455
+ handler,
456
+ options: {
457
+ ...options,
458
+ maxAttempts: positiveInteger(options.maxAttempts ?? defaultMaxAttempts, "step maxAttempts")
459
+ }
460
+ };
461
+ }
462
+ function makeRecord(definition) {
463
+ return {
464
+ id: definition.id,
465
+ kind: definition.kind,
466
+ state: "pending",
467
+ attempts: 0,
468
+ children: definition.kind === "parallel" ? definition.steps.map(makeRecord) : void 0
469
+ };
470
+ }
471
+ function resetFailed(steps) {
472
+ let found = false;
473
+ for (const step of steps) {
474
+ const childFound = step.children ? resetFailed(step.children) : false;
475
+ if (step.state === "failed" || childFound) {
476
+ step.state = "pending";
477
+ step.attempts = 0;
478
+ step.error = void 0;
479
+ step.completedAt = void 0;
480
+ step.waitUntil = void 0;
481
+ found = true;
482
+ }
483
+ }
484
+ return found;
485
+ }
486
+ function forceWaitingStepDue(run, timestamp) {
487
+ run.waitUntil = timestamp;
488
+ for (const step of run.steps) {
489
+ if (step.state === "waiting") {
490
+ step.waitUntil = timestamp;
491
+ return;
492
+ }
493
+ }
494
+ }
495
+ function findCompensationTarget(completionId, definitions, records) {
496
+ const parts = completionId.split(".");
497
+ for (let index = 0; index < definitions.length; index += 1) {
498
+ const definition = definitions[index];
499
+ const record = records[index];
500
+ if (!definition || !record) {
501
+ continue;
502
+ }
503
+ if (parts.length === 1 && definition.kind === "step" && definition.id === completionId) {
504
+ return { definition, record };
505
+ }
506
+ if (parts.length === 2 && definition.kind === "parallel" && definition.id === parts[0]) {
507
+ const childIndex = definition.steps.findIndex((child) => child.id === parts[1]);
508
+ const childDefinition = definition.steps[childIndex];
509
+ const childRecord = record.children?.[childIndex];
510
+ if (childDefinition && childRecord) {
511
+ return { definition: childDefinition, record: childRecord };
512
+ }
513
+ }
514
+ }
515
+ return null;
516
+ }
517
+ function reserveId(value, ids) {
518
+ const id = normalizeText(value, "step id");
519
+ if (id.includes(".")) {
520
+ throw new TypeError("BCP Workflow: step ids must not contain '.'.");
521
+ }
522
+ if (ids.has(id)) {
523
+ throw new Error(`BCP Workflow: duplicate step id "${id}".`);
524
+ }
525
+ ids.add(id);
526
+ return id;
527
+ }
528
+ function resolveRetryDelay(value, attempt) {
529
+ const delay = typeof value === "function" ? value(attempt) : value;
530
+ return nonNegativeNumber(delay, "retryDelayMs");
531
+ }
532
+ function sleep(durationMs, signal) {
533
+ if (durationMs === 0 || signal.aborted) {
534
+ return Promise.resolve();
535
+ }
536
+ return new Promise((resolve) => {
537
+ const timeout = setTimeout(finish, durationMs);
538
+ const onAbort = () => finish();
539
+ signal.addEventListener("abort", onAbort, { once: true });
540
+ function finish() {
541
+ clearTimeout(timeout);
542
+ signal.removeEventListener("abort", onAbort);
543
+ resolve();
544
+ }
545
+ });
546
+ }
547
+ function isTerminal(state) {
548
+ return state === "succeeded" || state === "cancelled" || state === "compensated";
549
+ }
550
+ function normalizeText(value, field) {
551
+ const normalized = String(value).trim();
552
+ if (!normalized) {
553
+ throw new TypeError(`BCP Workflow: ${field} must be a non-empty string.`);
554
+ }
555
+ if (normalized.length > 200) {
556
+ throw new TypeError(`BCP Workflow: ${field} must not exceed 200 characters.`);
557
+ }
558
+ return normalized;
559
+ }
560
+ function positiveInteger(value, field) {
561
+ if (!Number.isInteger(value) || value <= 0) {
562
+ throw new TypeError(`BCP Workflow: ${field} must be a positive integer.`);
563
+ }
564
+ return value;
565
+ }
566
+ function nonNegativeNumber(value, field) {
567
+ if (!Number.isFinite(value) || value < 0) {
568
+ throw new TypeError(`BCP Workflow: ${field} must be a non-negative finite number.`);
569
+ }
570
+ return Math.floor(value);
571
+ }
572
+ function formatError(error) {
573
+ if (error instanceof Error) {
574
+ return error.message || error.name;
575
+ }
576
+ if (typeof error === "string") {
577
+ return error;
578
+ }
579
+ try {
580
+ return JSON.stringify(error) ?? String(error);
581
+ } catch {
582
+ return String(error);
583
+ }
584
+ }
585
+ function cloneRun(run) {
586
+ return {
587
+ ...run,
588
+ steps: run.steps.map(cloneStep),
589
+ completionOrder: [...run.completionOrder]
590
+ };
591
+ }
592
+ function cloneStep(step) {
593
+ return {
594
+ ...step,
595
+ children: step.children?.map(cloneStep)
596
+ };
597
+ }
598
+ export {
599
+ createMemoryWorkflowStore,
600
+ createWorkflow
601
+ };
@@ -0,0 +1,23 @@
1
+ export {
2
+ createMemoryWorkflowStore,
3
+ createWorkflow,
4
+ type CancelWorkflowOptions,
5
+ type MemoryWorkflowStore,
6
+ type ResumeWorkflowOptions,
7
+ type StartWorkflowOptions,
8
+ type Workflow,
9
+ type WorkflowBuilder,
10
+ type WorkflowCompensationHandler,
11
+ type WorkflowOptions,
12
+ type WorkflowParallelBuilder,
13
+ type WorkflowRetryDelay,
14
+ type WorkflowRunRecord,
15
+ type WorkflowRunState,
16
+ type WorkflowStepContext,
17
+ type WorkflowStepHandler,
18
+ type WorkflowStepKind,
19
+ type WorkflowStepOptions,
20
+ type WorkflowStepRecord,
21
+ type WorkflowStepState,
22
+ type WorkflowStore,
23
+ } from "../../server/src/workflow.js";