@nestjs/workflows 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/interfaces/leased-worker-options.interface.d.ts +5 -1
- package/dist/core/interfaces/result-waiter-options.interface.d.ts +6 -2
- package/dist/core/results/result-waiter.d.ts +5 -3
- package/dist/core/results/result-waiter.js +34 -10
- package/dist/core/utils/timer.util.d.ts +12 -0
- package/dist/core/utils/timer.util.js +16 -0
- package/dist/core/workers/leased-worker.d.ts +5 -2
- package/dist/core/workers/leased-worker.js +104 -12
- package/dist/cqrs/workflow-event.publisher.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/interfaces/workflow-client.interface.d.ts +4 -1
- package/dist/interfaces/workflow-context.interface.d.ts +2 -2
- package/dist/interfaces/workflow-store.interface.d.ts +18 -1
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +1 -1
- package/dist/mysql/mysql-workflow.store.d.ts +5 -1
- package/dist/mysql/mysql-workflow.store.js +7 -0
- package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +1 -1
- package/dist/postgres/postgres-workflow.store.d.ts +6 -1
- package/dist/postgres/postgres-workflow.store.js +8 -0
- package/dist/services/workflow-client.service.d.ts +11 -2
- package/dist/services/workflow-client.service.js +23 -5
- package/dist/services/workflow-execution.service.d.ts +3 -0
- package/dist/services/workflow-execution.service.js +31 -5
- package/dist/services/workflow-scheduler.service.d.ts +8 -0
- package/dist/services/workflow-scheduler.service.js +20 -1
- package/dist/services/workflow-schedules.service.js +2 -0
- package/dist/services/workflow-worker.service.js +3 -0
- package/dist/storage/encoded-workflow.store.d.ts +11 -0
- package/dist/storage/encoded-workflow.store.js +38 -3
- package/dist/testing/index.js +4 -4
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/new-instance.util.d.ts +3 -0
- package/dist/utils/new-instance.util.js +9 -2
- package/dist/utils/serialize-error.util.js +25 -1
- package/dist/utils/storable-text.util.d.ts +36 -0
- package/dist/utils/storable-text.util.js +96 -0
- package/package.json +37 -24
|
@@ -39,7 +39,12 @@ export declare class WorkflowClient implements BeforeApplicationShutdown {
|
|
|
39
39
|
* with a different workflow or input. With `transaction`, the instance is
|
|
40
40
|
* created in your transaction and commits or rolls back with it. Inside a
|
|
41
41
|
* workflow step, a start without an `id` gets one derived from the step, so a
|
|
42
|
-
* retried step gets its instance back instead of starting another.
|
|
42
|
+
* retried step gets its instance back instead of starting another. An id, a
|
|
43
|
+
* key or an input string the store can't keep (`WorkflowStore.unstorableText()`:
|
|
44
|
+
* U+0000 on PostgreSQL, a lone UTF-16 surrogate in JSON on PostgreSQL and MySQL)
|
|
45
|
+
* is refused with a `TypeError`. Await it in your transaction's callback: a
|
|
46
|
+
* driver whose callback can't be async (better-sqlite3) commits before a failure
|
|
47
|
+
* is reported.
|
|
43
48
|
*/
|
|
44
49
|
start<W>(workflow: Type<W> | string, input: WorkflowInput<W>, options?: StartWorkflowOptions): Promise<WorkflowStartResult>;
|
|
45
50
|
/**
|
|
@@ -72,7 +77,8 @@ export declare class WorkflowClient implements BeforeApplicationShutdown {
|
|
|
72
77
|
private outcomeOf;
|
|
73
78
|
/**
|
|
74
79
|
* `start()`, then `result()`: resolves with the output of the instance (the existing one, for
|
|
75
|
-
* an id that already has one), or rejects as `result()` does. `wait` is `result()`'s options
|
|
80
|
+
* an id that already has one), or rejects as `result()` does. `wait` is `result()`'s options,
|
|
81
|
+
* counted once the instance is stored (`start()` isn't bounded by them);
|
|
76
82
|
* `options.timeout` stays the instance's run timeout. Not with `{ transaction }`: the instance
|
|
77
83
|
* wouldn't exist before your transaction commits.
|
|
78
84
|
*/
|
|
@@ -107,6 +113,9 @@ export declare class WorkflowClient implements BeforeApplicationShutdown {
|
|
|
107
113
|
* instances parked on a matching wait right now. With `transaction`, the
|
|
108
114
|
* signal and the wake-ups commit or roll back with your transaction. With
|
|
109
115
|
* `id`, or from inside a workflow step, a repeated signal is stored once.
|
|
116
|
+
* A name, key, id or payload string the store can't keep is refused with a
|
|
117
|
+
* `TypeError`, as in `start()`. Await it in your transaction's callback, as
|
|
118
|
+
* `start()`.
|
|
110
119
|
*/
|
|
111
120
|
signal<T>(signal: WorkflowSignal<T> | string, payload: NoInfer<T>, options?: SignalWorkflowOptions): Promise<WorkflowSignalSendResult>;
|
|
112
121
|
/**
|
|
@@ -25,6 +25,7 @@ import { assertSameInstance, newInstance } from '../utils/new-instance.util.js';
|
|
|
25
25
|
import { signalName } from '../signals/workflow.signal.js';
|
|
26
26
|
import { ENGINE_STORE, WorkflowStorage } from '../storage/workflow.storage.js';
|
|
27
27
|
import { stepSignalId, stepStartId } from '../utils/step-scope.util.js';
|
|
28
|
+
import { assertStorableJson, assertStorableText } from '../utils/storable-text.util.js';
|
|
28
29
|
import { WorkflowRegistry } from './workflow-registry.service.js';
|
|
29
30
|
import { WorkflowSchedules } from './workflow-schedules.service.js';
|
|
30
31
|
import { WorkflowWorker } from './workflow-worker.service.js';
|
|
@@ -75,7 +76,12 @@ let WorkflowClient = class WorkflowClient {
|
|
|
75
76
|
* with a different workflow or input. With `transaction`, the instance is
|
|
76
77
|
* created in your transaction and commits or rolls back with it. Inside a
|
|
77
78
|
* workflow step, a start without an `id` gets one derived from the step, so a
|
|
78
|
-
* retried step gets its instance back instead of starting another.
|
|
79
|
+
* retried step gets its instance back instead of starting another. An id, a
|
|
80
|
+
* key or an input string the store can't keep (`WorkflowStore.unstorableText()`:
|
|
81
|
+
* U+0000 on PostgreSQL, a lone UTF-16 surrogate in JSON on PostgreSQL and MySQL)
|
|
82
|
+
* is refused with a `TypeError`. Await it in your transaction's callback: a
|
|
83
|
+
* driver whose callback can't be async (better-sqlite3) commits before a failure
|
|
84
|
+
* is reported.
|
|
79
85
|
*/
|
|
80
86
|
async start(workflow, input, options = {}) {
|
|
81
87
|
const resolved = this.registry.resolve(workflow, options.version);
|
|
@@ -84,13 +90,14 @@ let WorkflowClient = class WorkflowClient {
|
|
|
84
90
|
const data = newInstance(resolved, id, input, {
|
|
85
91
|
caller: 'start()',
|
|
86
92
|
now: this.clock.now(),
|
|
93
|
+
text: this.store.textCheck,
|
|
87
94
|
timeout: options.timeout,
|
|
88
95
|
concurrencyKey: options.concurrencyKey,
|
|
89
96
|
rateLimitKey: options.rateLimitKey,
|
|
90
97
|
priority: options.priority,
|
|
91
98
|
});
|
|
92
|
-
// Nothing is awaited before the store's call:
|
|
93
|
-
//
|
|
99
|
+
// Nothing is awaited before the store's call: calls issued back to back (the CQRS publisher's)
|
|
100
|
+
// reach the transaction's connection in the order they were made.
|
|
94
101
|
const { instance, created } = await (options.transaction === undefined
|
|
95
102
|
? this.store.create(data)
|
|
96
103
|
: this.storeMethod('createInTransaction', 'start')(options.transaction, data));
|
|
@@ -150,7 +157,8 @@ let WorkflowClient = class WorkflowClient {
|
|
|
150
157
|
}
|
|
151
158
|
/**
|
|
152
159
|
* `start()`, then `result()`: resolves with the output of the instance (the existing one, for
|
|
153
|
-
* an id that already has one), or rejects as `result()` does. `wait` is `result()`'s options
|
|
160
|
+
* an id that already has one), or rejects as `result()` does. `wait` is `result()`'s options,
|
|
161
|
+
* counted once the instance is stored (`start()` isn't bounded by them);
|
|
154
162
|
* `options.timeout` stays the instance's run timeout. Not with `{ transaction }`: the instance
|
|
155
163
|
* wouldn't exist before your transaction commits.
|
|
156
164
|
*/
|
|
@@ -208,6 +216,7 @@ let WorkflowClient = class WorkflowClient {
|
|
|
208
216
|
return this.stop(id, reason, true);
|
|
209
217
|
}
|
|
210
218
|
async stop(id, reason, terminate) {
|
|
219
|
+
assertStorableText('Reason', reason, this.store.textCheck('cancelReason'));
|
|
211
220
|
const accepted = await this.store.requestCancel(id, { reason: reason ?? null, now: this.clock.now(), terminate });
|
|
212
221
|
// Accepted is accepted: an instance whose payloads no codec can read any more is returned as stored.
|
|
213
222
|
const details = await this.store.get(id).catch(() => this.store.inner.get(id));
|
|
@@ -228,6 +237,9 @@ let WorkflowClient = class WorkflowClient {
|
|
|
228
237
|
* instances parked on a matching wait right now. With `transaction`, the
|
|
229
238
|
* signal and the wake-ups commit or roll back with your transaction. With
|
|
230
239
|
* `id`, or from inside a workflow step, a repeated signal is stored once.
|
|
240
|
+
* A name, key, id or payload string the store can't keep is refused with a
|
|
241
|
+
* `TypeError`, as in `start()`. Await it in your transaction's callback, as
|
|
242
|
+
* `start()`.
|
|
231
243
|
*/
|
|
232
244
|
async signal(signal, payload, options = {}) {
|
|
233
245
|
const name = signalName(signal);
|
|
@@ -236,7 +248,13 @@ let WorkflowClient = class WorkflowClient {
|
|
|
236
248
|
throw new TypeError(`Invalid signal id ${JSON.stringify(options.id)}. Use a non-empty string, such as the id of the event that causes it.`);
|
|
237
249
|
}
|
|
238
250
|
const dedupeId = options.id ?? stepSignalId(name, key) ?? null;
|
|
239
|
-
const
|
|
251
|
+
const keyCheck = this.store.textCheck('key');
|
|
252
|
+
assertStorableText('A signal name', name, keyCheck);
|
|
253
|
+
assertStorableText('A signal key', key, keyCheck);
|
|
254
|
+
assertStorableText('A signal id', dedupeId, keyCheck);
|
|
255
|
+
const json = normalize(payload);
|
|
256
|
+
assertStorableJson('Signal payload', json, this.store.textCheck('payload'));
|
|
257
|
+
const data = { name, key, dedupeId, payload: json, now: this.clock.now() };
|
|
240
258
|
// Nothing is awaited before the store's call, as in start().
|
|
241
259
|
const result = await (options.transaction === undefined
|
|
242
260
|
? this.store.signal(data)
|
|
@@ -2,6 +2,7 @@ import { type Type } from '@nestjs/common';
|
|
|
2
2
|
import type { LeasedRun } from '../core/interfaces/leased-worker-options.interface.js';
|
|
3
3
|
import type { WorkflowClock } from '../interfaces/workflow-clock.interface.js';
|
|
4
4
|
import { type Duration } from '../core/time/duration.js';
|
|
5
|
+
import { type TextChecks } from '../utils/storable-text.util.js';
|
|
5
6
|
import type { SerializedWorkflowError } from '../interfaces/serialized-workflow-error.interface.js';
|
|
6
7
|
import type { WorkflowContext } from '../interfaces/workflow-context.interface.js';
|
|
7
8
|
import type { WorkflowInstance, WorkflowJournalEntry, WorkflowStatus, WorkflowWait } from '../interfaces/workflow-instance.interface.js';
|
|
@@ -21,6 +22,8 @@ export declare function uniqueEntries<T extends {
|
|
|
21
22
|
}>(entries: T[]): T[];
|
|
22
23
|
export interface ExecutionDeps {
|
|
23
24
|
store: WorkflowInstanceStore;
|
|
25
|
+
/** What the store can't keep of a text at a place (`EncodedWorkflowStore.textCheck`), refused where the run makes it. */
|
|
26
|
+
textCheck: TextChecks;
|
|
24
27
|
/** The workflow `ctx.startChild()` starts: its name, version and run timeout (`WorkflowRegistry.resolve()`). */
|
|
25
28
|
resolve(workflow: Type<unknown> | string, version?: number): WorkflowMetadata;
|
|
26
29
|
/**
|
|
@@ -11,7 +11,9 @@ import { StepFailedError } from '../errors/step-failed.error.js';
|
|
|
11
11
|
import { NonRetryableStepError } from '../errors/non-retryable-step.error.js';
|
|
12
12
|
import { isWorkflowInterrupt, WorkflowInterrupt, } from '../errors/workflow-interrupt.error.js';
|
|
13
13
|
import { serializeError } from '../utils/serialize-error.util.js';
|
|
14
|
+
import { assertStorableJson, assertStorableText, unstorableJson } from '../utils/storable-text.util.js';
|
|
14
15
|
import { runInStepScope } from '../utils/step-scope.util.js';
|
|
16
|
+
import { setTimer } from '../core/utils/timer.util.js';
|
|
15
17
|
import { ChildWorkflowFailedError } from '../errors/child-workflow-failed.error.js';
|
|
16
18
|
import { WorkflowIdConflictError } from '../errors/workflow-id-conflict.error.js';
|
|
17
19
|
import { CHILD_ENDED_SIGNAL } from '../workflows.constants.js';
|
|
@@ -400,6 +402,10 @@ export class WorkflowExecution {
|
|
|
400
402
|
// The journal first: a replay returns the child it started, whatever the keys of its workflow's limits
|
|
401
403
|
// compute today (a deploy may change them); they are computed only to start it.
|
|
402
404
|
const name = `$child:${id}`;
|
|
405
|
+
// Only for a child this run starts, as visit() checks names: a replay returns the one a store already kept.
|
|
406
|
+
if (this.mode === 'run' && !this.journal.has(name)) {
|
|
407
|
+
assertStorableText("A child's id", id, this.deps.textCheck('name'));
|
|
408
|
+
}
|
|
403
409
|
this.visit(name, 'child', true);
|
|
404
410
|
const request = { id, workflow: resolved.name, input: createHash('sha256').update(canonical(normalize(input) ?? null)).digest('base64url') };
|
|
405
411
|
const entry = this.journal.get(name);
|
|
@@ -429,6 +435,7 @@ export class WorkflowExecution {
|
|
|
429
435
|
priority: options.priority ?? this.instance.priority,
|
|
430
436
|
parentId: this.instance.id,
|
|
431
437
|
parentClose,
|
|
438
|
+
text: this.deps.textCheck,
|
|
432
439
|
});
|
|
433
440
|
this.checkKey('instanceId', child.id);
|
|
434
441
|
this.checkKey('workflow', child.workflow);
|
|
@@ -485,7 +492,10 @@ export class WorkflowExecution {
|
|
|
485
492
|
throw this.interrupt('halt');
|
|
486
493
|
}
|
|
487
494
|
this.assertAlive();
|
|
488
|
-
|
|
495
|
+
const branchNames = this.deps.textCheck('payload');
|
|
496
|
+
for (const [key, branch] of branches) {
|
|
497
|
+
// The entry's data holds the branches by name.
|
|
498
|
+
assertStorableText(`A waitForAny("${name}") branch's name`, key, branchNames);
|
|
489
499
|
if (branch.wait) {
|
|
490
500
|
this.checkWait(branch.wait);
|
|
491
501
|
}
|
|
@@ -587,7 +597,10 @@ export class WorkflowExecution {
|
|
|
587
597
|
throw new TypeError(`ctx.setStatus() got ${bytes} bytes of JSON, over the ${MAX_CUSTOM_STATUS_BYTES}-byte limit. Keep the status a summary; ` +
|
|
588
598
|
'return large data from the workflow, or keep it in your own tables.');
|
|
589
599
|
}
|
|
590
|
-
|
|
600
|
+
// Before it is kept: the write of a failing run carries the last status that was set.
|
|
601
|
+
const parsed = json === undefined ? null : JSON.parse(json);
|
|
602
|
+
assertStorableJson('ctx.setStatus() value', parsed, this.deps.textCheck('payload'));
|
|
603
|
+
this.customStatus = parsed;
|
|
591
604
|
}
|
|
592
605
|
/** `{ customStatus }` when `ctx.setStatus()` changed it since it was last written, else `{}`: spread into a write. */
|
|
593
606
|
statusChange() {
|
|
@@ -686,6 +699,11 @@ export class WorkflowExecution {
|
|
|
686
699
|
catch (error) {
|
|
687
700
|
throw new NonRetryableStepError(`Result of "${name}" is not JSON-serializable: ${error.message}`);
|
|
688
701
|
}
|
|
702
|
+
// Like the above, the same result every time: the store would refuse it at every attempt.
|
|
703
|
+
const unstorable = unstorableJson(`Result of "${name}"`, value, this.deps.textCheck('payload'));
|
|
704
|
+
if (unstorable) {
|
|
705
|
+
throw new NonRetryableStepError(unstorable.message);
|
|
706
|
+
}
|
|
689
707
|
}
|
|
690
708
|
catch (error) {
|
|
691
709
|
if (this.abort.signal.aborted) {
|
|
@@ -752,14 +770,14 @@ export class WorkflowExecution {
|
|
|
752
770
|
});
|
|
753
771
|
const overall = overallMs === undefined
|
|
754
772
|
? undefined
|
|
755
|
-
:
|
|
773
|
+
: setTimer(() => expire(new StepTimeoutError(`Step "${name}" timed out after ${overallMs}ms.`)), overallMs).unref();
|
|
756
774
|
let idle;
|
|
757
775
|
const armIdle = () => {
|
|
758
776
|
if (idleMs === undefined) {
|
|
759
777
|
return;
|
|
760
778
|
}
|
|
761
779
|
clearTimeout(idle);
|
|
762
|
-
idle =
|
|
780
|
+
idle = setTimer(() => expire(new StepTimeoutError(`Step "${name}" sent no heartbeat for ${idleMs}ms.`)), idleMs).unref();
|
|
763
781
|
};
|
|
764
782
|
armIdle();
|
|
765
783
|
let current = entry;
|
|
@@ -784,7 +802,9 @@ export class WorkflowExecution {
|
|
|
784
802
|
}
|
|
785
803
|
return;
|
|
786
804
|
}
|
|
787
|
-
|
|
805
|
+
const json = normalize(progress);
|
|
806
|
+
assertStorableJson('Step progress', json, this.deps.textCheck('payload'));
|
|
807
|
+
current = { ...current, progress: json };
|
|
788
808
|
update(current);
|
|
789
809
|
await this.write([current]);
|
|
790
810
|
},
|
|
@@ -847,6 +867,7 @@ export class WorkflowExecution {
|
|
|
847
867
|
if (!entry && this.mode === 'run') {
|
|
848
868
|
this.assertJournalRoom(name);
|
|
849
869
|
this.checkKey('journalName', name);
|
|
870
|
+
assertStorableText("A journal entry's name (a step's, a sleep's or a wait's)", name, this.deps.textCheck('name'));
|
|
850
871
|
}
|
|
851
872
|
if (entry && entry.kind !== kind) {
|
|
852
873
|
throw this.setFatal(new WorkflowNonDeterminismError(`${this.describe()} does not match its journal: "${name}" was recorded as a ${entry.kind}, but the code now calls it as a ${kind}. ${ADVICE}`));
|
|
@@ -988,6 +1009,11 @@ export class WorkflowExecution {
|
|
|
988
1009
|
checkWait(wait) {
|
|
989
1010
|
this.checkKey('signal', wait.signal);
|
|
990
1011
|
this.checkKey('signalKey', wait.key);
|
|
1012
|
+
// In the waits' own columns, and in the journal entry's data.
|
|
1013
|
+
for (const check of [this.deps.textCheck('key'), this.deps.textCheck('payload')]) {
|
|
1014
|
+
assertStorableText("A wait's signal name", wait.signal, check);
|
|
1015
|
+
assertStorableText("A wait's signal key", wait.key, check);
|
|
1016
|
+
}
|
|
991
1017
|
}
|
|
992
1018
|
/** Fenced write of staged entries plus `entries`. */
|
|
993
1019
|
async write(entries) {
|
|
@@ -8,6 +8,7 @@ import type { WorkflowInstance } from '../interfaces/workflow-instance.interface
|
|
|
8
8
|
import type { WorkflowSchedule } from '../interfaces/workflow-schedule.interface.js';
|
|
9
9
|
import type { WorkflowsModuleOptions } from '../interfaces/workflows-module-options.interface.js';
|
|
10
10
|
import { WorkflowStorage } from '../storage/workflow.storage.js';
|
|
11
|
+
import { type TextCheck, type TextPlace } from '../utils/storable-text.util.js';
|
|
11
12
|
import type { WorkflowScheduleSpec } from '../utils/schedule-spec.util.js';
|
|
12
13
|
import { WorkflowRegistry } from './workflow-registry.service.js';
|
|
13
14
|
/**
|
|
@@ -27,6 +28,8 @@ export declare class WorkflowScheduler implements OnApplicationBootstrap {
|
|
|
27
28
|
constructor(storage: WorkflowStorage, options: WorkflowsModuleOptions, registry: WorkflowRegistry, events: WorkflowEvents);
|
|
28
29
|
/** Read at each call, never in the constructor: sources register while providers are created. */
|
|
29
30
|
private get store();
|
|
31
|
+
/** What the store can't keep of a text at `place` (`EncodedWorkflowStore.textCheck`), for `WorkflowSchedules`. */
|
|
32
|
+
textCheck(place: TextPlace): TextCheck | undefined;
|
|
30
33
|
onApplicationBootstrap(): Promise<void>;
|
|
31
34
|
/** @internal Called by the worker: starts the due occurrences of the schedules of the workflows it runs. */
|
|
32
35
|
produce(owner: string, leaseMs: number): Promise<ScheduleProduction>;
|
|
@@ -47,6 +50,11 @@ export declare class WorkflowScheduler implements OnApplicationBootstrap {
|
|
|
47
50
|
readableView(record: ScheduleRecord): Promise<WorkflowSchedule>;
|
|
48
51
|
/** The schedules the workflows this process runs declare (each name's highest registered version's), for the `Scheduler`. */
|
|
49
52
|
private declared;
|
|
53
|
+
/**
|
|
54
|
+
* Whether the store can keep a declared schedule's input (`WorkflowStore.unstorableText()`). One it can't is left out,
|
|
55
|
+
* logged, instead of failing every reconciliation of the others; an input the schedule computes is checked as it starts.
|
|
56
|
+
*/
|
|
57
|
+
private storable;
|
|
50
58
|
private fire;
|
|
51
59
|
/** The unfinished instances the schedule started. */
|
|
52
60
|
private running;
|
|
@@ -18,6 +18,7 @@ import { WorkflowStateError } from '../errors/workflow-state.error.js';
|
|
|
18
18
|
import { WorkflowEvents } from '../events/workflow-events.service.js';
|
|
19
19
|
import { ENGINE_STORE, PAYLOAD_CODECS, WorkflowStorage } from '../storage/workflow.storage.js';
|
|
20
20
|
import { newInstance } from '../utils/new-instance.util.js';
|
|
21
|
+
import { unstorableJson } from '../utils/storable-text.util.js';
|
|
21
22
|
import { workflowScheduleStore } from '../utils/schedule-store.util.js';
|
|
22
23
|
import { WORKFLOWS_MODULE_OPTIONS } from '../workflows.module-definition.js';
|
|
23
24
|
import { WorkflowRegistry } from './workflow-registry.service.js';
|
|
@@ -57,6 +58,10 @@ let WorkflowScheduler = class WorkflowScheduler {
|
|
|
57
58
|
get store() {
|
|
58
59
|
return this.storage[ENGINE_STORE];
|
|
59
60
|
}
|
|
61
|
+
/** What the store can't keep of a text at `place` (`EncodedWorkflowStore.textCheck`), for `WorkflowSchedules`. */
|
|
62
|
+
textCheck(place) {
|
|
63
|
+
return this.store.textCheck(place);
|
|
64
|
+
}
|
|
60
65
|
async onApplicationBootstrap() {
|
|
61
66
|
// A definition error (an id two workflows declare) fails the startup; the store's errors don't.
|
|
62
67
|
this.registry.schedules();
|
|
@@ -93,6 +98,7 @@ let WorkflowScheduler = class WorkflowScheduler {
|
|
|
93
98
|
priority: spec.priority === 0 ? undefined : spec.priority,
|
|
94
99
|
scheduleId: record.id,
|
|
95
100
|
scheduledAt: at,
|
|
101
|
+
text: this.store.textCheck,
|
|
96
102
|
});
|
|
97
103
|
}
|
|
98
104
|
catch (error) {
|
|
@@ -142,12 +148,25 @@ let WorkflowScheduler = class WorkflowScheduler {
|
|
|
142
148
|
}
|
|
143
149
|
/** The schedules the workflows this process runs declare (each name's highest registered version's), for the `Scheduler`. */
|
|
144
150
|
declared() {
|
|
145
|
-
this.declarations ??= new Map([...this.registry.schedules()]
|
|
151
|
+
this.declarations ??= new Map([...this.registry.schedules()]
|
|
152
|
+
.filter(([id, schedule]) => this.storable(id, schedule.input))
|
|
153
|
+
.map(([id, schedule]) => [
|
|
146
154
|
id,
|
|
147
155
|
{ id, target: schedule.workflow, version: this.registry.latest(schedule.workflow).version, spec: schedule.spec, payload: schedule.input },
|
|
148
156
|
]));
|
|
149
157
|
return this.declarations;
|
|
150
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Whether the store can keep a declared schedule's input (`WorkflowStore.unstorableText()`). One it can't is left out,
|
|
161
|
+
* logged, instead of failing every reconciliation of the others; an input the schedule computes is checked as it starts.
|
|
162
|
+
*/
|
|
163
|
+
storable(id, input) {
|
|
164
|
+
const refused = typeof input === 'function' ? undefined : unstorableJson(`Schedule "${id}": its input`, input, this.store.textCheck('payload'));
|
|
165
|
+
if (refused) {
|
|
166
|
+
this.logger.error(`${refused.message} The schedule isn't saved, and doesn't start its workflow.`);
|
|
167
|
+
}
|
|
168
|
+
return !refused;
|
|
169
|
+
}
|
|
151
170
|
async fire({ schedule, spec, declared, at, now }) {
|
|
152
171
|
const workflow = this.registry.resolve(schedule.target, spec.version ?? undefined);
|
|
153
172
|
const started = await this.startOccurrence(schedule, spec, declared, workflow, at, now);
|
|
@@ -16,6 +16,7 @@ import { systemClock } from '../core/time/clock.js';
|
|
|
16
16
|
import { WorkflowNotFoundError } from '../errors/workflow-not-found.error.js';
|
|
17
17
|
import { WorkflowStateError } from '../errors/workflow-state.error.js';
|
|
18
18
|
import { normalize } from '../utils/normalize.util.js';
|
|
19
|
+
import { assertStorableJson } from '../utils/storable-text.util.js';
|
|
19
20
|
import { scheduleSpec } from '../utils/schedule-spec.util.js';
|
|
20
21
|
import { WORKFLOWS_MODULE_OPTIONS } from '../workflows.module-definition.js';
|
|
21
22
|
import { WorkflowRegistry } from './workflow-registry.service.js';
|
|
@@ -62,6 +63,7 @@ let WorkflowSchedules = class WorkflowSchedules {
|
|
|
62
63
|
catch (error) {
|
|
63
64
|
throw new TypeError(`${owner}: its input is not JSON-serializable: ${error.message}`);
|
|
64
65
|
}
|
|
66
|
+
assertStorableJson(`${owner}: its input`, input, this.scheduler.textCheck('payload'));
|
|
65
67
|
const saved = await this.engine.save(id, (current) => {
|
|
66
68
|
if (current?.declared) {
|
|
67
69
|
throw declaredError(current);
|
|
@@ -16,6 +16,7 @@ import { LeasedWorker } from '../core/workers/leased-worker.js';
|
|
|
16
16
|
import { WorkflowNonDeterminismError } from '../errors/workflow-non-determinism.error.js';
|
|
17
17
|
import { isWorkflowInterrupt } from '../errors/workflow-interrupt.error.js';
|
|
18
18
|
import { serializeError } from '../utils/serialize-error.util.js';
|
|
19
|
+
import { assertStorableJson } from '../utils/storable-text.util.js';
|
|
19
20
|
import { resolveRetry } from '../core/retries/retry.js';
|
|
20
21
|
import { resolveJournalLimits } from '../utils/journal-limits.util.js';
|
|
21
22
|
import { normalize } from '../utils/normalize.util.js';
|
|
@@ -64,6 +65,7 @@ let WorkflowWorker = class WorkflowWorker {
|
|
|
64
65
|
get store() {
|
|
65
66
|
return storage[ENGINE_STORE];
|
|
66
67
|
},
|
|
68
|
+
textCheck: (place) => storage[ENGINE_STORE].textCheck(place),
|
|
67
69
|
resolve: (workflow, version) => registry.resolve(workflow, version),
|
|
68
70
|
createChild: async (child) => {
|
|
69
71
|
const { instance, created } = await storage[ENGINE_STORE].create(child);
|
|
@@ -272,6 +274,7 @@ let WorkflowWorker = class WorkflowWorker {
|
|
|
272
274
|
let output;
|
|
273
275
|
try {
|
|
274
276
|
output = normalize(outcome.value);
|
|
277
|
+
assertStorableJson('Workflow output', output, this.store.textCheck('payload'));
|
|
275
278
|
}
|
|
276
279
|
catch (error) {
|
|
277
280
|
return this.compensate(exec, instance, serializeError(error), false);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WorkflowPayloadContext } from '../interfaces/workflow-payload-codec.interface.js';
|
|
2
2
|
import { type PayloadCodecs } from '../core/codecs/payload-codecs.js';
|
|
3
3
|
import type { WorkflowScheduleMethods } from '../utils/schedule-store.util.js';
|
|
4
|
+
import { type TextCheck, type TextPlace } from '../utils/storable-text.util.js';
|
|
4
5
|
import type { WorkflowInstance, WorkflowStatus } from '../interfaces/workflow-instance.interface.js';
|
|
5
6
|
import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
|
|
6
7
|
/** @internal A `WorkflowStore` without its schedules, which the engine reaches through the core's `Scheduler`. */
|
|
@@ -24,6 +25,11 @@ export declare class EncodedWorkflowStore implements WorkflowInstanceStore {
|
|
|
24
25
|
inner: WorkflowStore, codecs: PayloadCodecs<WorkflowPayloadContext>);
|
|
25
26
|
/** Whether a codec encodes what is written. */
|
|
26
27
|
get encodes(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* What the store can't keep of a text at `place` (`WorkflowStore.unstorableText()`), or `undefined` where it keeps
|
|
30
|
+
* any: a store without the method, and a payload or a cancel reason a codec encodes, which the store sees encoded.
|
|
31
|
+
*/
|
|
32
|
+
readonly textCheck: (place: TextPlace) => TextCheck | undefined;
|
|
27
33
|
create(instance: NewWorkflowInstance): Promise<{
|
|
28
34
|
instance: WorkflowInstance;
|
|
29
35
|
created: boolean;
|
|
@@ -53,6 +59,11 @@ export declare class EncodedWorkflowStore implements WorkflowInstanceStore {
|
|
|
53
59
|
terminateRequested: boolean;
|
|
54
60
|
} | null>;
|
|
55
61
|
write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* `error` with what the store can't keep in its text replaced with U+FFFD (`storableText()`), as an error is recorded
|
|
64
|
+
* whatever it holds: its name, and its message and stack unless a codec encodes them.
|
|
65
|
+
*/
|
|
66
|
+
private storableError;
|
|
56
67
|
private encodeNew;
|
|
57
68
|
private encodeSignal;
|
|
58
69
|
private encodeEntries;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Logger } from '@nestjs/common';
|
|
2
2
|
import { isEncodedPayload as encoded } from '../core/codecs/payload-codecs.js';
|
|
3
3
|
import { andThen, toPromise } from '../core/utils/maybe.util.js';
|
|
4
|
+
import { storableText } from '../utils/storable-text.util.js';
|
|
4
5
|
/**
|
|
5
6
|
* @internal The store as the engine sees it with a codec: payloads encoded on the way in, decoded on the way out, so
|
|
6
7
|
* the store sees only what the codec returns, and the engine only plain values. Ids, names, keys, statuses and
|
|
@@ -13,6 +14,18 @@ export class EncodedWorkflowStore {
|
|
|
13
14
|
this.inner = inner;
|
|
14
15
|
this.codecs = codecs;
|
|
15
16
|
this.unreadable = new Set();
|
|
17
|
+
/**
|
|
18
|
+
* What the store can't keep of a text at `place` (`WorkflowStore.unstorableText()`), or `undefined` where it keeps
|
|
19
|
+
* any: a store without the method, and a payload or a cancel reason a codec encodes, which the store sees encoded.
|
|
20
|
+
*/
|
|
21
|
+
this.textCheck = (place) => {
|
|
22
|
+
const inner = this.inner;
|
|
23
|
+
if (typeof inner.unstorableText !== 'function' || (this.encodes && (place === 'payload' || place === 'cancelReason'))) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const column = place === 'payload' || place === 'name' ? 'json' : 'text';
|
|
27
|
+
return (text) => inner.unstorableText(text, column);
|
|
28
|
+
};
|
|
16
29
|
// Only when the store has them: the client tells a store that can't join transactions by their absence.
|
|
17
30
|
// Nothing is awaited before the store's call when the codec is synchronous (see WorkflowClient.start()).
|
|
18
31
|
if (typeof inner.createInTransaction === 'function') {
|
|
@@ -52,7 +65,10 @@ export class EncodedWorkflowStore {
|
|
|
52
65
|
return this.inner.requestCancel(id, { ...request, reason });
|
|
53
66
|
}
|
|
54
67
|
async reopen(id, reopen) {
|
|
55
|
-
const [error, entries] = await Promise.all([
|
|
68
|
+
const [error, entries] = await Promise.all([
|
|
69
|
+
this.codecs.encodeError(this.storableError(reopen.error), { field: 'error', instanceId: id }),
|
|
70
|
+
this.encodeEntries(reopen.entries, id),
|
|
71
|
+
]);
|
|
56
72
|
return this.inner.reopen(id, { ...reopen, error, entries });
|
|
57
73
|
}
|
|
58
74
|
delete(id, statuses) {
|
|
@@ -93,7 +109,7 @@ export class EncodedWorkflowStore {
|
|
|
93
109
|
const [entries, output, error, customStatus, signal] = await Promise.all([
|
|
94
110
|
this.encodeEntries(write.entries, id),
|
|
95
111
|
this.codecs.encode(write.output, context('output')),
|
|
96
|
-
this.codecs.encodeError(write.error, context('error')),
|
|
112
|
+
this.codecs.encodeError(this.storableError(write.error), context('error')),
|
|
97
113
|
this.codecs.encode(write.customStatus, context('customStatus')),
|
|
98
114
|
write.signal ? this.encodeSignal(write.signal) : undefined,
|
|
99
115
|
]);
|
|
@@ -107,6 +123,25 @@ export class EncodedWorkflowStore {
|
|
|
107
123
|
});
|
|
108
124
|
}
|
|
109
125
|
// ---------------------------------------------------------------- payloads
|
|
126
|
+
/**
|
|
127
|
+
* `error` with what the store can't keep in its text replaced with U+FFFD (`storableText()`), as an error is recorded
|
|
128
|
+
* whatever it holds: its name, and its message and stack unless a codec encodes them.
|
|
129
|
+
*/
|
|
130
|
+
storableError(error) {
|
|
131
|
+
const check = this.textCheck('name');
|
|
132
|
+
if (!check || error === null || error === undefined) {
|
|
133
|
+
return error;
|
|
134
|
+
}
|
|
135
|
+
const text = (value) => (typeof value !== 'string' || this.encodes ? value : storableText(value, check));
|
|
136
|
+
const { compensation } = error;
|
|
137
|
+
return {
|
|
138
|
+
...error,
|
|
139
|
+
name: typeof error.name === 'string' ? storableText(error.name, check) : error.name,
|
|
140
|
+
message: text(error.message),
|
|
141
|
+
...(error.stack === undefined ? {} : { stack: text(error.stack) }),
|
|
142
|
+
...(compensation === undefined ? {} : { compensation: this.storableError(compensation) }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
110
145
|
encodeNew(instance) {
|
|
111
146
|
return andThen(this.codecs.encode(instance.input, { field: 'input', instanceId: instance.id }), (input) => ({ ...instance, input }));
|
|
112
147
|
}
|
|
@@ -120,7 +155,7 @@ export class EncodedWorkflowStore {
|
|
|
120
155
|
this.codecs.encode(entry.result, context('result')),
|
|
121
156
|
this.codecs.encode(entry.progress, context('progress')),
|
|
122
157
|
this.codecs.encode(entry.data, context('data')),
|
|
123
|
-
this.codecs.encodeError(entry.error, context('error')),
|
|
158
|
+
this.codecs.encodeError(this.storableError(entry.error), context('error')),
|
|
124
159
|
]);
|
|
125
160
|
return { ...entry, result, progress, data, error };
|
|
126
161
|
}));
|
package/dist/testing/index.js
CHANGED
|
@@ -426,11 +426,11 @@ export function workflowStoreContract(createStore, options = {}) {
|
|
|
426
426
|
add('write() with a signal records it as signal() does, only when the write lands', async (t) => {
|
|
427
427
|
await t.create('parent', 0);
|
|
428
428
|
await t.create('child', 1);
|
|
429
|
-
await t.claim(1, { token: 'p', limit: 1 });
|
|
429
|
+
equal((await t.claim(1, { token: 'p', limit: 1 })).instances.map((i) => i.id), ['parent'], 'the parent, claimed');
|
|
430
430
|
const parked = { wakeAt: FAR, waits: [{ signal: 'child.ended', key: 'child' }], signalCursor: 0 };
|
|
431
|
-
await t.store.write('parent', 'p', { now: 2, entries: [], status: 'suspended', release: parked });
|
|
432
|
-
await t.claim(3, { token: 'stale', leaseUntil: 4 });
|
|
433
|
-
await t.claim(5, { token: 'c' });
|
|
431
|
+
equal(await t.store.write('parent', 'p', { now: 2, entries: [], status: 'suspended', release: parked }), true, 'the parent, parked');
|
|
432
|
+
equal((await t.claim(3, { token: 'stale', leaseUntil: 4 })).instances.map((i) => i.id), ['child'], 'the child, claimed under a lease that ends at 4');
|
|
433
|
+
equal((await t.claim(5, { token: 'c' })).instances.map((i) => i.id), ['child'], 'the child, claimed again once that lease ended');
|
|
434
434
|
const ended = { name: 'child.ended', key: 'child', dedupeId: 'child', payload: { status: 'completed', output: { label: 'LBL-1' } }, now: 6 };
|
|
435
435
|
const finish = (token, now) => t.store.write('child', token, { now, entries: [], status: 'completed', output: 1, error: null, release: { wakeAt: null, waits: [], signalCursor: 0 }, signal: { ...ended, now } });
|
|
436
436
|
equal(await finish('stale', 6), false, 'a stale write');
|
package/dist/utils/index.d.ts
CHANGED
package/dist/utils/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import type { WorkflowMetadata } from '../interfaces/workflow-decorator-options.
|
|
|
2
2
|
import type { WorkflowInstance, WorkflowParentClose } from '../interfaces/workflow-instance.interface.js';
|
|
3
3
|
import type { NewWorkflowInstance } from '../interfaces/workflow-store.interface.js';
|
|
4
4
|
import type { Duration } from '../core/time/duration.js';
|
|
5
|
+
import { type TextChecks } from './storable-text.util.js';
|
|
5
6
|
export interface NewInstanceOptions {
|
|
6
7
|
caller: string;
|
|
7
8
|
now: number;
|
|
@@ -13,6 +14,8 @@ export interface NewInstanceOptions {
|
|
|
13
14
|
parentClose?: WorkflowParentClose;
|
|
14
15
|
scheduleId?: string;
|
|
15
16
|
scheduledAt?: number;
|
|
17
|
+
/** The store's checks of text (`EncodedWorkflowStore.textCheck`): what it can't keep is refused before it is asked to. */
|
|
18
|
+
text: TextChecks;
|
|
16
19
|
}
|
|
17
20
|
/** What `WorkflowClient.start()` and `ctx.startChild()` ask the store to create. */
|
|
18
21
|
export declare function newInstance(workflow: WorkflowMetadata, id: unknown, input: unknown, options: NewInstanceOptions): NewWorkflowInstance;
|
|
@@ -3,16 +3,23 @@ import { assertPriority } from '../core/limits/limits.js';
|
|
|
3
3
|
import { canonical } from '../core/utils/canonical.util.js';
|
|
4
4
|
import { runTimeoutMs } from './run-timeout.util.js';
|
|
5
5
|
import { normalize } from './normalize.util.js';
|
|
6
|
+
import { assertStorableJson, assertStorableText } from './storable-text.util.js';
|
|
6
7
|
/** What `WorkflowClient.start()` and `ctx.startChild()` ask the store to create. */
|
|
7
8
|
export function newInstance(workflow, id, input, options) {
|
|
8
9
|
if (typeof id !== 'string' || id.length === 0) {
|
|
9
10
|
throw new TypeError(`Invalid workflow instance id ${JSON.stringify(id)}. Use a non-empty string, such as \`order-\${orderId}\`.`);
|
|
10
11
|
}
|
|
12
|
+
assertStorableText('An instance id', id, options.text('key'));
|
|
11
13
|
if (options.priority !== undefined) {
|
|
12
14
|
assertPriority(options.priority, options.caller);
|
|
13
15
|
}
|
|
14
16
|
const json = normalize(input);
|
|
17
|
+
assertStorableJson('Input', json, options.text('payload'));
|
|
15
18
|
const timeoutMs = options.timeout === undefined ? workflow.timeout : runTimeoutMs(options.timeout, options.caller);
|
|
19
|
+
const concurrencyKey = keyOf(workflow, json, options.caller, 'concurrency', options.concurrencyKey);
|
|
20
|
+
assertStorableText('A concurrency key', concurrencyKey, options.text('key'));
|
|
21
|
+
const rateLimitKey = keyOf(workflow, json, options.caller, 'rateLimit', options.rateLimitKey);
|
|
22
|
+
assertStorableText('A rate-limit key', rateLimitKey, options.text('key'));
|
|
16
23
|
return {
|
|
17
24
|
id,
|
|
18
25
|
workflow: workflow.name,
|
|
@@ -21,8 +28,8 @@ export function newInstance(workflow, id, input, options) {
|
|
|
21
28
|
deadline: timeoutMs === undefined ? null : options.now + timeoutMs,
|
|
22
29
|
parentId: options.parentId ?? null,
|
|
23
30
|
parentClose: options.parentClose ?? null,
|
|
24
|
-
concurrencyKey
|
|
25
|
-
rateLimitKey
|
|
31
|
+
concurrencyKey,
|
|
32
|
+
rateLimitKey,
|
|
26
33
|
priority: options.priority ?? 0,
|
|
27
34
|
scheduleId: options.scheduleId ?? null,
|
|
28
35
|
scheduledAt: options.scheduledAt ?? null,
|
|
@@ -6,7 +6,13 @@ import { inspect } from 'node:util';
|
|
|
6
6
|
*/
|
|
7
7
|
export function serializeError(error) {
|
|
8
8
|
if (error instanceof Error) {
|
|
9
|
-
|
|
9
|
+
// Strings, as the record promises: a name or stack that isn't one is described, not thrown on.
|
|
10
|
+
const stack = stackOf(error);
|
|
11
|
+
return {
|
|
12
|
+
name: text(error.name || 'Error'),
|
|
13
|
+
message: text(error.message),
|
|
14
|
+
...(stack === undefined ? {} : { stack }),
|
|
15
|
+
};
|
|
10
16
|
}
|
|
11
17
|
if (typeof error === 'string') {
|
|
12
18
|
return { name: 'Error', message: error };
|
|
@@ -20,3 +26,21 @@ export function serializeError(error) {
|
|
|
20
26
|
}
|
|
21
27
|
return { name: 'Error', message: message ?? inspect(error, { depth: 3, breakLength: Infinity }) };
|
|
22
28
|
}
|
|
29
|
+
/** `String(value)`, or `util.inspect()` for a value `String()` throws on (no prototype, a throwing `toString()`). */
|
|
30
|
+
function text(value) {
|
|
31
|
+
try {
|
|
32
|
+
return String(value);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return inspect(value, { depth: 3, breakLength: Infinity });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The error's stack, or `undefined`: V8 formats it on first read from the error's name, and throws if it can't. */
|
|
39
|
+
function stackOf(error) {
|
|
40
|
+
try {
|
|
41
|
+
return error.stack === undefined ? undefined : text(error.stack);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|