@nestjs/workflows 0.0.3 → 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.
Files changed (35) hide show
  1. package/dist/core/interfaces/result-waiter-options.interface.d.ts +6 -2
  2. package/dist/core/results/result-waiter.d.ts +5 -3
  3. package/dist/core/results/result-waiter.js +34 -10
  4. package/dist/core/utils/timer.util.d.ts +12 -0
  5. package/dist/core/utils/timer.util.js +16 -0
  6. package/dist/core/workers/leased-worker.js +4 -3
  7. package/dist/cqrs/workflow-event.publisher.js +3 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/interfaces/workflow-client.interface.d.ts +4 -1
  10. package/dist/interfaces/workflow-context.interface.d.ts +2 -2
  11. package/dist/interfaces/workflow-store.interface.d.ts +18 -1
  12. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +1 -1
  13. package/dist/mysql/mysql-workflow.store.d.ts +5 -1
  14. package/dist/mysql/mysql-workflow.store.js +7 -0
  15. package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +1 -1
  16. package/dist/postgres/postgres-workflow.store.d.ts +6 -1
  17. package/dist/postgres/postgres-workflow.store.js +8 -0
  18. package/dist/services/workflow-client.service.d.ts +11 -2
  19. package/dist/services/workflow-client.service.js +23 -5
  20. package/dist/services/workflow-execution.service.d.ts +3 -0
  21. package/dist/services/workflow-execution.service.js +31 -5
  22. package/dist/services/workflow-scheduler.service.d.ts +8 -0
  23. package/dist/services/workflow-scheduler.service.js +20 -1
  24. package/dist/services/workflow-schedules.service.js +2 -0
  25. package/dist/services/workflow-worker.service.js +3 -0
  26. package/dist/storage/encoded-workflow.store.d.ts +11 -0
  27. package/dist/storage/encoded-workflow.store.js +38 -3
  28. package/dist/utils/index.d.ts +1 -0
  29. package/dist/utils/index.js +1 -0
  30. package/dist/utils/new-instance.util.d.ts +3 -0
  31. package/dist/utils/new-instance.util.js +9 -2
  32. package/dist/utils/serialize-error.util.js +25 -1
  33. package/dist/utils/storable-text.util.d.ts +36 -0
  34. package/dist/utils/storable-text.util.js +96 -0
  35. package/package.json +23 -22
@@ -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
- for (const branch of branches.values()) {
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
- this.customStatus = json === undefined ? null : JSON.parse(json);
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
- : setTimeout(() => expire(new StepTimeoutError(`Step "${name}" timed out after ${overallMs}ms.`)), overallMs).unref();
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 = setTimeout(() => expire(new StepTimeoutError(`Step "${name}" sent no heartbeat for ${idleMs}ms.`)), idleMs).unref();
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
- current = { ...current, progress: normalize(progress) };
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()].map(([id, schedule]) => [
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([this.codecs.encodeError(reopen.error, { field: 'error', instanceId: id }), this.encodeEntries(reopen.entries, id)]);
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
  }));
@@ -5,3 +5,4 @@ export * from './step-scope.util.js';
5
5
  export * from './journal-limits.util.js';
6
6
  export * from './new-instance.util.js';
7
7
  export * from './normalize.util.js';
8
+ export * from './storable-text.util.js';
@@ -5,3 +5,4 @@ export * from './step-scope.util.js';
5
5
  export * from './journal-limits.util.js';
6
6
  export * from './new-instance.util.js';
7
7
  export * from './normalize.util.js';
8
+ export * from './storable-text.util.js';
@@ -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: keyOf(workflow, json, options.caller, 'concurrency', options.concurrencyKey),
25
- rateLimitKey: keyOf(workflow, json, options.caller, 'rateLimit', options.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
- return { name: error.name || 'Error', message: String(error.message), stack: error.stack };
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;
@@ -0,0 +1,96 @@
1
+ /** `'U+0000'` when `text` holds it (PostgreSQL keeps it in neither `text` nor `jsonb`), else `undefined`. */
2
+ export function nulIn(text) {
3
+ return text.includes('\u0000') ? 'U+0000' : undefined;
4
+ }
5
+ /** `'a lone UTF-16 surrogate'` when `text` holds one (PostgreSQL and MySQL refuse it in JSON), else `undefined`. */
6
+ export function loneSurrogateIn(text) {
7
+ return text.isWellFormed() ? undefined : 'a lone UTF-16 surrogate';
8
+ }
9
+ /**
10
+ * The first string of a JSON value (what `JSON.parse()` returns), a value or an object's key, that `check` refuses,
11
+ * with its path (`$.lines[0].note`), or `undefined`. Walks with a stack of its own, so a value as deep as
12
+ * `JSON.stringify()` takes can't overflow the call stack; the path is built only for what it finds.
13
+ */
14
+ export function findUnstorableText(value, check) {
15
+ const pending = [{ value, parent: undefined, at: undefined }];
16
+ for (let step = pending.pop(); step; step = pending.pop()) {
17
+ if (step.keyProblem) {
18
+ return { path: `${pathOf(step)} (its key)`, problem: step.keyProblem };
19
+ }
20
+ const current = step.value;
21
+ if (typeof current === 'string') {
22
+ const problem = check(current);
23
+ if (problem) {
24
+ return { path: pathOf(step), problem };
25
+ }
26
+ }
27
+ else if (Array.isArray(current)) {
28
+ // Last first, so the first is checked first. Numbers, booleans and null hold no text.
29
+ for (let i = current.length - 1; i >= 0; i--) {
30
+ if (holdsText(current[i])) {
31
+ pending.push({ value: current[i], parent: step, at: i });
32
+ }
33
+ }
34
+ }
35
+ else if (current !== null && typeof current === 'object') {
36
+ const keys = Object.keys(current);
37
+ for (let i = keys.length - 1; i >= 0; i--) {
38
+ const key = keys[i];
39
+ const child = current[key];
40
+ const keyProblem = check(key);
41
+ if (keyProblem || holdsText(child)) {
42
+ pending.push({ value: child, parent: step, at: key, keyProblem });
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+ /** Throws a `TypeError` that names `what` when `check` refuses `text`. */
50
+ export function assertStorableText(what, text, check) {
51
+ const problem = check && typeof text === 'string' ? check(text) : undefined;
52
+ if (problem) {
53
+ throw unstorable(what, problem);
54
+ }
55
+ }
56
+ /** The same for a JSON value (what `normalize()` returns), naming where in `what` the text sits. */
57
+ export function assertStorableJson(what, value, check) {
58
+ const refused = unstorableJson(what, value, check);
59
+ if (refused) {
60
+ throw refused;
61
+ }
62
+ }
63
+ /** `assertStorableJson()`'s error, for a caller that reports it in its own way, or `undefined`. */
64
+ export function unstorableJson(what, value, check) {
65
+ const found = check && findUnstorableText(value, check);
66
+ return found ? unstorable(`${what} at ${found.path}`, found.problem) : undefined;
67
+ }
68
+ /**
69
+ * `text`, with U+0000 and lone surrogates replaced with U+FFFD when `check` refuses it: for an error's text, which is
70
+ * recorded whatever it holds.
71
+ */
72
+ export function storableText(text, check) {
73
+ return check?.(text) === undefined ? text : text.replaceAll('\u0000', '�').toWellFormed();
74
+ }
75
+ function unstorable(where, problem) {
76
+ return new TypeError(`${where} holds ${problem}, which the workflow store can't keep.`);
77
+ }
78
+ function holdsText(value) {
79
+ return typeof value === 'string' || (value !== null && typeof value === 'object');
80
+ }
81
+ function pathOf(step) {
82
+ let path = '';
83
+ for (let at = step; at; at = at.parent) {
84
+ path = segment(at.at) + path;
85
+ }
86
+ return path;
87
+ }
88
+ function segment(at) {
89
+ if (at === undefined) {
90
+ return '$';
91
+ }
92
+ if (typeof at === 'number') {
93
+ return `[${at}]`;
94
+ }
95
+ return /^[A-Za-z_$][\w$]*$/.test(at) ? `.${at}` : `[${JSON.stringify(at)}]`;
96
+ }