@nestjs/workflows 0.0.2 → 0.0.4
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/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.js +4 -3
- 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 +29 -1
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +1 -1
- package/dist/mysql/mysql-workflow.store.d.ts +10 -2
- package/dist/mysql/mysql-workflow.store.js +27 -4
- 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 +11 -0
- package/dist/services/workflow-execution.service.js +61 -4
- 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 +12 -0
- package/dist/storage/encoded-workflow.store.js +42 -3
- 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 +27 -26
|
@@ -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
|
/**
|
|
@@ -253,6 +256,14 @@ export declare class WorkflowExecution {
|
|
|
253
256
|
* compensates (compensations may still record their entries) and fails.
|
|
254
257
|
*/
|
|
255
258
|
private assertJournalRoom;
|
|
259
|
+
/**
|
|
260
|
+
* Throws the store's error for a key it can't hold (`WorkflowStore.checkKey()`), where the run makes the key: the
|
|
261
|
+
* run fails with it and compensates. Reaching the store, it would fail every write that carries it, and the instance
|
|
262
|
+
* would be retried at every lease, forever.
|
|
263
|
+
*/
|
|
264
|
+
private checkKey;
|
|
265
|
+
/** A wait's signal and key, registered when the instance suspends. */
|
|
266
|
+
private checkWait;
|
|
256
267
|
/** Fenced write of staged entries plus `entries`. */
|
|
257
268
|
private write;
|
|
258
269
|
private loseLease;
|
|
@@ -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';
|
|
@@ -254,6 +256,7 @@ export class WorkflowExecution {
|
|
|
254
256
|
return { state: 'failed', error: entry.error };
|
|
255
257
|
}
|
|
256
258
|
try {
|
|
259
|
+
this.checkKey('journalName', name);
|
|
257
260
|
const attempt = await this.attempt(name, 'compensation',
|
|
258
261
|
// Whatever the undo returns (a provider's response, say) is discarded: nothing
|
|
259
262
|
// reads it, so it can't fail the undo by being unserializable.
|
|
@@ -297,6 +300,10 @@ export class WorkflowExecution {
|
|
|
297
300
|
throw this.interrupt('halt');
|
|
298
301
|
}
|
|
299
302
|
this.assertCanStart();
|
|
303
|
+
if (options.compensate) {
|
|
304
|
+
// Checked before the step runs: a side effect whose undo the store couldn't journal never happens.
|
|
305
|
+
this.checkKey('journalName', `${COMPENSATE}${name}`);
|
|
306
|
+
}
|
|
300
307
|
const plan = this.plan(name, options);
|
|
301
308
|
// Reserved in call order, so parallel steps compensate in the same order
|
|
302
309
|
// on the first run and on a replay, whichever finished first.
|
|
@@ -341,6 +348,7 @@ export class WorkflowExecution {
|
|
|
341
348
|
throw this.interrupt('halt');
|
|
342
349
|
}
|
|
343
350
|
this.assertAlive();
|
|
351
|
+
this.checkWait(wait);
|
|
344
352
|
const deadline = entry
|
|
345
353
|
? (entry.wakeAt ?? null)
|
|
346
354
|
: options.timeout !== undefined
|
|
@@ -394,6 +402,10 @@ export class WorkflowExecution {
|
|
|
394
402
|
// The journal first: a replay returns the child it started, whatever the keys of its workflow's limits
|
|
395
403
|
// compute today (a deploy may change them); they are computed only to start it.
|
|
396
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
|
+
}
|
|
397
409
|
this.visit(name, 'child', true);
|
|
398
410
|
const request = { id, workflow: resolved.name, input: createHash('sha256').update(canonical(normalize(input) ?? null)).digest('base64url') };
|
|
399
411
|
const entry = this.journal.get(name);
|
|
@@ -423,7 +435,12 @@ export class WorkflowExecution {
|
|
|
423
435
|
priority: options.priority ?? this.instance.priority,
|
|
424
436
|
parentId: this.instance.id,
|
|
425
437
|
parentClose,
|
|
438
|
+
text: this.deps.textCheck,
|
|
426
439
|
});
|
|
440
|
+
this.checkKey('instanceId', child.id);
|
|
441
|
+
this.checkKey('workflow', child.workflow);
|
|
442
|
+
this.checkKey('concurrencyKey', child.concurrencyKey);
|
|
443
|
+
this.checkKey('rateLimitKey', child.rateLimitKey);
|
|
427
444
|
await this.reachFrontier(name);
|
|
428
445
|
this.assertCanStart();
|
|
429
446
|
// Recorded before the child exists, so a parent that crashes right after creating it still
|
|
@@ -475,6 +492,14 @@ export class WorkflowExecution {
|
|
|
475
492
|
throw this.interrupt('halt');
|
|
476
493
|
}
|
|
477
494
|
this.assertAlive();
|
|
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);
|
|
499
|
+
if (branch.wait) {
|
|
500
|
+
this.checkWait(branch.wait);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
478
503
|
// Timers count from when the wait was first reached: a replay keeps their deadlines.
|
|
479
504
|
const now = this.deps.clock.now();
|
|
480
505
|
const reached = entry?.data;
|
|
@@ -572,7 +597,10 @@ export class WorkflowExecution {
|
|
|
572
597
|
throw new TypeError(`ctx.setStatus() got ${bytes} bytes of JSON, over the ${MAX_CUSTOM_STATUS_BYTES}-byte limit. Keep the status a summary; ` +
|
|
573
598
|
'return large data from the workflow, or keep it in your own tables.');
|
|
574
599
|
}
|
|
575
|
-
|
|
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;
|
|
576
604
|
}
|
|
577
605
|
/** `{ customStatus }` when `ctx.setStatus()` changed it since it was last written, else `{}`: spread into a write. */
|
|
578
606
|
statusChange() {
|
|
@@ -671,6 +699,11 @@ export class WorkflowExecution {
|
|
|
671
699
|
catch (error) {
|
|
672
700
|
throw new NonRetryableStepError(`Result of "${name}" is not JSON-serializable: ${error.message}`);
|
|
673
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
|
+
}
|
|
674
707
|
}
|
|
675
708
|
catch (error) {
|
|
676
709
|
if (this.abort.signal.aborted) {
|
|
@@ -737,14 +770,14 @@ export class WorkflowExecution {
|
|
|
737
770
|
});
|
|
738
771
|
const overall = overallMs === undefined
|
|
739
772
|
? undefined
|
|
740
|
-
:
|
|
773
|
+
: setTimer(() => expire(new StepTimeoutError(`Step "${name}" timed out after ${overallMs}ms.`)), overallMs).unref();
|
|
741
774
|
let idle;
|
|
742
775
|
const armIdle = () => {
|
|
743
776
|
if (idleMs === undefined) {
|
|
744
777
|
return;
|
|
745
778
|
}
|
|
746
779
|
clearTimeout(idle);
|
|
747
|
-
idle =
|
|
780
|
+
idle = setTimer(() => expire(new StepTimeoutError(`Step "${name}" sent no heartbeat for ${idleMs}ms.`)), idleMs).unref();
|
|
748
781
|
};
|
|
749
782
|
armIdle();
|
|
750
783
|
let current = entry;
|
|
@@ -769,7 +802,9 @@ export class WorkflowExecution {
|
|
|
769
802
|
}
|
|
770
803
|
return;
|
|
771
804
|
}
|
|
772
|
-
|
|
805
|
+
const json = normalize(progress);
|
|
806
|
+
assertStorableJson('Step progress', json, this.deps.textCheck('payload'));
|
|
807
|
+
current = { ...current, progress: json };
|
|
773
808
|
update(current);
|
|
774
809
|
await this.write([current]);
|
|
775
810
|
},
|
|
@@ -831,6 +866,8 @@ export class WorkflowExecution {
|
|
|
831
866
|
const entry = this.journal.get(name);
|
|
832
867
|
if (!entry && this.mode === 'run') {
|
|
833
868
|
this.assertJournalRoom(name);
|
|
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'));
|
|
834
871
|
}
|
|
835
872
|
if (entry && entry.kind !== kind) {
|
|
836
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}`));
|
|
@@ -958,6 +995,26 @@ export class WorkflowExecution {
|
|
|
958
995
|
};
|
|
959
996
|
throw this.interrupt('journal-limit');
|
|
960
997
|
}
|
|
998
|
+
/**
|
|
999
|
+
* Throws the store's error for a key it can't hold (`WorkflowStore.checkKey()`), where the run makes the key: the
|
|
1000
|
+
* run fails with it and compensates. Reaching the store, it would fail every write that carries it, and the instance
|
|
1001
|
+
* would be retried at every lease, forever.
|
|
1002
|
+
*/
|
|
1003
|
+
checkKey(kind, value) {
|
|
1004
|
+
if (typeof value === 'string') {
|
|
1005
|
+
this.deps.store.checkKey?.(kind, value);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/** A wait's signal and key, registered when the instance suspends. */
|
|
1009
|
+
checkWait(wait) {
|
|
1010
|
+
this.checkKey('signal', wait.signal);
|
|
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
|
+
}
|
|
1017
|
+
}
|
|
961
1018
|
/** Fenced write of staged entries plus `entries`. */
|
|
962
1019
|
async write(entries) {
|
|
963
1020
|
if (this.detached) {
|
|
@@ -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`. */
|
|
@@ -18,11 +19,17 @@ export declare class EncodedWorkflowStore implements WorkflowInstanceStore {
|
|
|
18
19
|
private readonly unreadable;
|
|
19
20
|
readonly createInTransaction?: WorkflowStore['createInTransaction'];
|
|
20
21
|
readonly signalInTransaction?: WorkflowStore['signalInTransaction'];
|
|
22
|
+
readonly checkKey?: WorkflowStore['checkKey'];
|
|
21
23
|
constructor(
|
|
22
24
|
/** The store itself: what it holds, undecoded. */
|
|
23
25
|
inner: WorkflowStore, codecs: PayloadCodecs<WorkflowPayloadContext>);
|
|
24
26
|
/** Whether a codec encodes what is written. */
|
|
25
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;
|
|
26
33
|
create(instance: NewWorkflowInstance): Promise<{
|
|
27
34
|
instance: WorkflowInstance;
|
|
28
35
|
created: boolean;
|
|
@@ -52,6 +59,11 @@ export declare class EncodedWorkflowStore implements WorkflowInstanceStore {
|
|
|
52
59
|
terminateRequested: boolean;
|
|
53
60
|
} | null>;
|
|
54
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;
|
|
55
67
|
private encodeNew;
|
|
56
68
|
private encodeSignal;
|
|
57
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') {
|
|
@@ -21,6 +34,10 @@ export class EncodedWorkflowStore {
|
|
|
21
34
|
if (typeof inner.signalInTransaction === 'function') {
|
|
22
35
|
this.signalInTransaction = (transaction, signal) => toPromise(andThen(this.encodeSignal(signal), (encoded) => inner.signalInTransaction(transaction, encoded)));
|
|
23
36
|
}
|
|
37
|
+
// Keys aren't encoded: the store checks them as they are.
|
|
38
|
+
if (typeof inner.checkKey === 'function') {
|
|
39
|
+
this.checkKey = (kind, value) => inner.checkKey(kind, value);
|
|
40
|
+
}
|
|
24
41
|
}
|
|
25
42
|
/** Whether a codec encodes what is written. */
|
|
26
43
|
get encodes() {
|
|
@@ -48,7 +65,10 @@ export class EncodedWorkflowStore {
|
|
|
48
65
|
return this.inner.requestCancel(id, { ...request, reason });
|
|
49
66
|
}
|
|
50
67
|
async reopen(id, reopen) {
|
|
51
|
-
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
|
+
]);
|
|
52
72
|
return this.inner.reopen(id, { ...reopen, error, entries });
|
|
53
73
|
}
|
|
54
74
|
delete(id, statuses) {
|
|
@@ -89,7 +109,7 @@ export class EncodedWorkflowStore {
|
|
|
89
109
|
const [entries, output, error, customStatus, signal] = await Promise.all([
|
|
90
110
|
this.encodeEntries(write.entries, id),
|
|
91
111
|
this.codecs.encode(write.output, context('output')),
|
|
92
|
-
this.codecs.encodeError(write.error, context('error')),
|
|
112
|
+
this.codecs.encodeError(this.storableError(write.error), context('error')),
|
|
93
113
|
this.codecs.encode(write.customStatus, context('customStatus')),
|
|
94
114
|
write.signal ? this.encodeSignal(write.signal) : undefined,
|
|
95
115
|
]);
|
|
@@ -103,6 +123,25 @@ export class EncodedWorkflowStore {
|
|
|
103
123
|
});
|
|
104
124
|
}
|
|
105
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
|
+
}
|
|
106
145
|
encodeNew(instance) {
|
|
107
146
|
return andThen(this.codecs.encode(instance.input, { field: 'input', instanceId: instance.id }), (input) => ({ ...instance, input }));
|
|
108
147
|
}
|
|
@@ -116,7 +155,7 @@ export class EncodedWorkflowStore {
|
|
|
116
155
|
this.codecs.encode(entry.result, context('result')),
|
|
117
156
|
this.codecs.encode(entry.progress, context('progress')),
|
|
118
157
|
this.codecs.encode(entry.data, context('data')),
|
|
119
|
-
this.codecs.encodeError(entry.error, context('error')),
|
|
158
|
+
this.codecs.encodeError(this.storableError(entry.error), context('error')),
|
|
120
159
|
]);
|
|
121
160
|
return { ...entry, result, progress, data, error };
|
|
122
161
|
}));
|
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the engine stores a piece of text, which decides what it asks the store (`WorkflowStore.unstorableText()`):
|
|
3
|
+
* - `payload`: an input, a result, a payload, a status, an output, in a JSON column, which a codec encodes;
|
|
4
|
+
* - `cancelReason`: a cancel's reason, in a text column, which a codec encodes;
|
|
5
|
+
* - `name`: a journal entry's name (a step's, a wait's, `$child:<id>`), which the entry's JSON holds too, never encoded;
|
|
6
|
+
* - `key`: an id, a name or a key in a text column of its own, never encoded.
|
|
7
|
+
*/
|
|
8
|
+
export type TextPlace = 'payload' | 'cancelReason' | 'name' | 'key';
|
|
9
|
+
/** What the store can't keep of `text` at a place (such as `'U+0000'`), or `undefined`. */
|
|
10
|
+
export type TextCheck = (text: string) => string | undefined;
|
|
11
|
+
/** A place's check, or `undefined` where the store keeps any text (`EncodedWorkflowStore.textCheck()`). */
|
|
12
|
+
export type TextChecks = (place: TextPlace) => TextCheck | undefined;
|
|
13
|
+
/** `'U+0000'` when `text` holds it (PostgreSQL keeps it in neither `text` nor `jsonb`), else `undefined`. */
|
|
14
|
+
export declare function nulIn(text: string): string | undefined;
|
|
15
|
+
/** `'a lone UTF-16 surrogate'` when `text` holds one (PostgreSQL and MySQL refuse it in JSON), else `undefined`. */
|
|
16
|
+
export declare function loneSurrogateIn(text: string): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* The first string of a JSON value (what `JSON.parse()` returns), a value or an object's key, that `check` refuses,
|
|
19
|
+
* with its path (`$.lines[0].note`), or `undefined`. Walks with a stack of its own, so a value as deep as
|
|
20
|
+
* `JSON.stringify()` takes can't overflow the call stack; the path is built only for what it finds.
|
|
21
|
+
*/
|
|
22
|
+
export declare function findUnstorableText(value: unknown, check: TextCheck): {
|
|
23
|
+
path: string;
|
|
24
|
+
problem: string;
|
|
25
|
+
} | undefined;
|
|
26
|
+
/** Throws a `TypeError` that names `what` when `check` refuses `text`. */
|
|
27
|
+
export declare function assertStorableText(what: string, text: string | null | undefined, check: TextCheck | undefined): void;
|
|
28
|
+
/** The same for a JSON value (what `normalize()` returns), naming where in `what` the text sits. */
|
|
29
|
+
export declare function assertStorableJson(what: string, value: unknown, check: TextCheck | undefined): void;
|
|
30
|
+
/** `assertStorableJson()`'s error, for a caller that reports it in its own way, or `undefined`. */
|
|
31
|
+
export declare function unstorableJson(what: string, value: unknown, check: TextCheck | undefined): TypeError | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* `text`, with U+0000 and lone surrogates replaced with U+FFFD when `check` refuses it: for an error's text, which is
|
|
34
|
+
* recorded whatever it holds.
|
|
35
|
+
*/
|
|
36
|
+
export declare function storableText(text: string, check: TextCheck | undefined): string;
|