@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.
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 +29 -1
  12. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +1 -1
  13. package/dist/mysql/mysql-workflow.store.d.ts +10 -2
  14. package/dist/mysql/mysql-workflow.store.js +27 -4
  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 +11 -0
  21. package/dist/services/workflow-execution.service.js +61 -4
  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 +12 -0
  27. package/dist/storage/encoded-workflow.store.js +42 -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 +27 -26
@@ -29,7 +29,8 @@ export type ResultOutcome<T> = {
29
29
  export interface ResultWaiterOptions<T> {
30
30
  /**
31
31
  * Reads the outcome from the store: `null` while it hasn't ended. Called when a wait starts, then after a backoff
32
- * (25 ms at first, doubling up to a second), until the wait ends; a rejection rejects the wait.
32
+ * (25 ms at first, doubling up to a second), until the wait ends; a rejection rejects the wait. A wait that times
33
+ * out, is aborted or closes doesn't wait for a read in flight: its answer or rejection is then ignored.
33
34
  */
34
35
  read(id: string): Promise<ResultOutcome<T> | null>;
35
36
  /** The error a wait rejects with past its `timeout`. Default: an `Error` that names `id`. */
@@ -45,7 +46,10 @@ export interface ResultWaiterOptions<T> {
45
46
  * ```
46
47
  */
47
48
  export interface ResultWaitOptions {
48
- /** Stop waiting after this long, in wall-clock time (not a `Clock`'s). Default: no limit. */
49
+ /**
50
+ * Stop waiting after this long, in wall-clock time (not a `Clock`'s). Default: no limit. A hard deadline: a read
51
+ * still in flight when it passes isn't awaited, so a timeout shorter than one read (0 included) rejects.
52
+ */
49
53
  timeout?: Duration;
50
54
  /** Stop waiting when it aborts: the wait rejects with its reason. */
51
55
  signal?: AbortSignal;
@@ -3,7 +3,8 @@ import type { ResultOutcome, ResultWaiterOptions, ResultWaitOptions } from '../i
3
3
  * Waits for the outcome of something another part of the application runs (a workflow instance, a job): at once
4
4
  * when this process ends it and says so (`settle()`), else by reading the store with a backoff (every 25 ms at
5
5
  * first, doubling up to every second), which sees what other processes ended. A wait rejects past its `timeout`
6
- * (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again:
6
+ * (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again
7
+ * nor waiting for a read in flight (it ends in the background, and its answer or failure is ignored):
7
8
  * close it in `beforeApplicationShutdown`, which Nest runs after `onModuleDestroy` (where workers drain, so work that
8
9
  * ends during the drain still answers) and before the HTTP server closes.
9
10
  *
@@ -50,8 +51,9 @@ export declare class ResultWaiter<T = unknown> {
50
51
  */
51
52
  close(): Promise<void>;
52
53
  /**
53
- * Reads `id`, unless the wait settles first. It races a promise of its own, not one the whole wait shares: each
54
- * race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
54
+ * Reads `id`, unless the wait settles, times out or is aborted first: the read is then given up, and left to end in
55
+ * the background (`Promise.race` handles its failure). It races a promise of its own, not one the whole wait
56
+ * shares: each race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
55
57
  */
56
58
  private read;
57
59
  private add;
@@ -1,12 +1,16 @@
1
1
  import { setImmediate as nextMacrotask } from 'node:timers/promises';
2
2
  import { parseDuration } from '../time/duration.js';
3
+ import { MAX_TIMER_DELAY, setTimer } from '../utils/timer.util.js';
3
4
  /** What a read in flight gives way to: an outcome settled locally, or the waiter closing. */
4
5
  const SETTLED = Symbol('settled');
6
+ /** What a read in flight gives way to when the wait's timeout passes. */
7
+ const EXPIRED = Symbol('expired');
5
8
  /**
6
9
  * Waits for the outcome of something another part of the application runs (a workflow instance, a job): at once
7
10
  * when this process ends it and says so (`settle()`), else by reading the store with a backoff (every 25 ms at
8
11
  * first, doubling up to every second), which sees what other processes ended. A wait rejects past its `timeout`
9
- * (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again:
12
+ * (the work goes on), when its `signal` aborts, and as soon as the waiter closes, without reading the store again
13
+ * nor waiting for a read in flight (it ends in the background, and its answer or failure is ignored):
10
14
  * close it in `beforeApplicationShutdown`, which Nest runs after `onModuleDestroy` (where workers drain, so work that
11
15
  * ends during the drain still answers) and before the HTTP server closes.
12
16
  *
@@ -48,7 +52,10 @@ export class ResultWaiter {
48
52
  const deadline = performance.now() + timeoutMs;
49
53
  options.signal?.throwIfAborted();
50
54
  const wait = { closed: this.closed };
51
- const nudge = () => wait.wake?.();
55
+ const nudge = () => {
56
+ wait.giveUp?.();
57
+ wait.wake?.();
58
+ };
52
59
  const close = () => {
53
60
  wait.closed = true;
54
61
  wait.settle?.();
@@ -59,19 +66,19 @@ export class ResultWaiter {
59
66
  options.signal?.addEventListener('abort', nudge);
60
67
  try {
61
68
  for (let delay = 25;; delay = Math.min(delay * 2, 1_000)) {
62
- const read = wait.outcome || wait.closed ? SETTLED : await this.read(id, wait);
69
+ const read = wait.outcome || wait.closed ? SETTLED : await this.read(id, wait, deadline, options.signal);
63
70
  if (wait.outcome) {
64
71
  return unwrap(wait.outcome);
65
72
  }
66
73
  if (read === SETTLED) {
67
74
  throw this.options.closedError?.(id) ?? new Error(`The waiter closed while waiting for the result of "${id}".`);
68
75
  }
69
- if (read) {
76
+ if (read && read !== EXPIRED) {
70
77
  return unwrap(read);
71
78
  }
72
79
  options.signal?.throwIfAborted();
73
80
  const remaining = deadline - performance.now();
74
- if (remaining <= 0) {
81
+ if (read === EXPIRED || remaining <= 0) {
75
82
  throw this.options.timeoutError?.(id, timeoutMs) ?? new Error(`"${id}" didn't end within ${timeoutMs}ms.`);
76
83
  }
77
84
  await new Promise((resolve) => {
@@ -116,16 +123,33 @@ export class ResultWaiter {
116
123
  await nextMacrotask();
117
124
  }
118
125
  /**
119
- * Reads `id`, unless the wait settles first. It races a promise of its own, not one the whole wait shares: each
120
- * race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
126
+ * Reads `id`, unless the wait settles, times out or is aborted first: the read is then given up, and left to end in
127
+ * the background (`Promise.race` handles its failure). It races a promise of its own, not one the whole wait
128
+ * shares: each race leaves a reaction on its inputs until they settle, so a shared one would keep one per poll.
121
129
  */
122
- async read(id, wait) {
123
- const settled = new Promise((resolve) => (wait.settle = () => resolve(SETTLED)));
130
+ async read(id, wait, deadline, signal) {
131
+ // Aborted during the backoff's sleep: no read for a caller that went away.
132
+ if (signal?.aborted) {
133
+ return null;
134
+ }
135
+ let timer;
136
+ const givenWay = new Promise((resolve) => {
137
+ wait.settle = () => resolve(SETTLED);
138
+ wait.giveUp = () => resolve(null);
139
+ const remaining = deadline - performance.now();
140
+ if (remaining !== Infinity) {
141
+ // A deadline beyond what a timer supports: the read is given up at the longest delay, and the next one waits on.
142
+ const expired = remaining <= MAX_TIMER_DELAY ? EXPIRED : null;
143
+ timer = setTimer(() => resolve(expired), Math.max(remaining, 0));
144
+ }
145
+ });
124
146
  try {
125
- return await Promise.race([this.options.read(id), settled]);
147
+ return await Promise.race([this.options.read(id), givenWay]);
126
148
  }
127
149
  finally {
128
150
  wait.settle = undefined;
151
+ wait.giveUp = undefined;
152
+ clearTimeout(timer);
129
153
  }
130
154
  }
131
155
  add(id, wait) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The longest delay `setTimeout` and `setInterval` support: 2^31 - 1 ms, about 24.8 days. Node runs a longer one
3
+ * after 1 ms instead (with a `TimeoutOverflowWarning`), so a step `timeout: '30d'` would time out every attempt at
4
+ * once, and a `heartbeatInterval` of '30d' would renew the lease every millisecond.
5
+ *
6
+ * @internal
7
+ */
8
+ export declare const MAX_TIMER_DELAY: number;
9
+ /** @internal `setTimeout`, with delays beyond what timers support treated as the longest one. */
10
+ export declare function setTimer(callback: () => void, ms: number): NodeJS.Timeout;
11
+ /** @internal `setInterval`, capped like `setTimer()`. */
12
+ export declare function setRepeatingTimer(callback: () => void, ms: number): NodeJS.Timeout;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The longest delay `setTimeout` and `setInterval` support: 2^31 - 1 ms, about 24.8 days. Node runs a longer one
3
+ * after 1 ms instead (with a `TimeoutOverflowWarning`), so a step `timeout: '30d'` would time out every attempt at
4
+ * once, and a `heartbeatInterval` of '30d' would renew the lease every millisecond.
5
+ *
6
+ * @internal
7
+ */
8
+ export const MAX_TIMER_DELAY = 2 ** 31 - 1;
9
+ /** @internal `setTimeout`, with delays beyond what timers support treated as the longest one. */
10
+ export function setTimer(callback, ms) {
11
+ return setTimeout(callback, Math.min(ms, MAX_TIMER_DELAY));
12
+ }
13
+ /** @internal `setInterval`, capped like `setTimer()`. */
14
+ export function setRepeatingTimer(callback, ms) {
15
+ return setInterval(callback, Math.min(ms, MAX_TIMER_DELAY));
16
+ }
@@ -3,6 +3,7 @@ import { hostname } from 'node:os';
3
3
  import { Logger } from '@nestjs/common';
4
4
  import { systemClock } from '../time/clock.js';
5
5
  import { parseDuration } from '../time/duration.js';
6
+ import { setRepeatingTimer, setTimer } from '../utils/timer.util.js';
6
7
  /**
7
8
  * A claim-execute loop under leases, any number of processes sharing a store: it claims due items (as many as it has
8
9
  * free slots), executes them, renews each one's lease every `heartbeatInterval` (a third of the lease by default), and
@@ -122,7 +123,7 @@ export class LeasedWorker {
122
123
  let timer;
123
124
  await Promise.race([
124
125
  Promise.allSettled([...this.runs].map((run) => run.done)),
125
- new Promise((resolve) => (timer = setTimeout(resolve, this.shutdownMs))),
126
+ new Promise((resolve) => (timer = setTimer(resolve, this.shutdownMs))),
126
127
  ]);
127
128
  clearTimeout(timer);
128
129
  for (const run of this.runs) {
@@ -185,7 +186,7 @@ export class LeasedWorker {
185
186
  this.wake = undefined;
186
187
  resolve();
187
188
  };
188
- const timer = setTimeout(done, this.pollMs);
189
+ const timer = setTimer(done, this.pollMs);
189
190
  this.wake = () => {
190
191
  this.kicked = false;
191
192
  done();
@@ -215,7 +216,7 @@ export class LeasedWorker {
215
216
  if (this.stopped) {
216
217
  run.halt();
217
218
  }
218
- run.heartbeat = setInterval(() => void run.renew(), this.heartbeatMs);
219
+ run.heartbeat = setRepeatingTimer(() => void run.renew(), this.heartbeatMs);
219
220
  run.heartbeat.unref();
220
221
  let executing;
221
222
  try {
@@ -111,9 +111,9 @@ let WorkflowEventPublisher = class WorkflowEventPublisher {
111
111
  failure.event = undefined;
112
112
  return;
113
113
  }
114
- // Issued back to back, not awaited in between: a driver whose transactions are synchronous
115
- // must see every statement before the application's callback returns, and on the
116
- // transaction's one connection they still run in this order.
114
+ // Issued back to back, not awaited in between: on the transaction's one connection they still
115
+ // run in this order. The application awaits publish() in its transaction callback, so its
116
+ // driver must accept an async one (not better-sqlite3's), as start() and signal() require.
117
117
  const settled = await Promise.allSettled(writes.map((write) => write.run(transaction)));
118
118
  const index = settled.findIndex((result) => result.status === 'rejected');
119
119
  if (index !== -1) {
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { ChildWorkflowFailedError, isWorkflowInterrupt, NonRetryableStepError, S
11
11
  export type { SerializedWorkflowError } from './interfaces/index.js';
12
12
  export { WorkflowEvents, type WorkflowCancelledEvent, type WorkflowChildStartedEvent, type WorkflowCompensatingEvent, type WorkflowCompensationFailedEvent, type WorkflowCompletedEvent, type WorkflowCustomStatusEvent, type WorkflowDeletedEvent, type WorkflowEvent, type WorkflowFailedEvent, type WorkflowJournalLargeEvent, type WorkflowResumedEvent, type WorkflowRetriedEvent, type WorkflowScheduleSkippedEvent, type WorkflowSignalReceivedEvent, type WorkflowSignalTimedOutEvent, type WorkflowStartedEvent, type WorkflowStepCompensatedEvent, type WorkflowStepCompletedEvent, type WorkflowStepFailedEvent, type WorkflowSuspendedEvent, } from './events/index.js';
13
13
  export { WorkflowStorage } from './storage/index.js';
14
- export type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowConcurrencyLimit, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowRateLimitRule, WorkflowRateWindow, WorkflowRelease, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStorageRegisterOptions, WorkflowStore, WorkflowWrite, } from './interfaces/index.js';
14
+ export type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowConcurrencyLimit, WorkflowInstanceDetails, WorkflowKeyKind, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowRateLimitRule, WorkflowRateWindow, WorkflowRelease, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStorageRegisterOptions, WorkflowStore, WorkflowTextColumn, WorkflowWrite, } from './interfaces/index.js';
15
15
  export * from './stores/index.js';
16
16
  export { AesGcmPayloadCodec, type AesGcmPayloadCodecOptions } from './core/codecs/index.js';
17
17
  export type { WorkflowPayloadCodec, WorkflowPayloadContext } from './interfaces/index.js';
@@ -72,7 +72,10 @@ export interface SignalWorkflowOptions {
72
72
  export interface WorkflowResultOptions {
73
73
  /**
74
74
  * How long to wait. Past it, the call rejects with `WorkflowResultTimeoutError`, and the
75
- * instance keeps running. Wall-clock time, not the module's clock. Default: no limit.
75
+ * instance keeps running. Wall-clock time, not the module's clock. Default: no limit. A hard
76
+ * deadline: a store read still in flight when it passes isn't awaited, so a timeout shorter than
77
+ * one round trip to the store (0 included) rejects, also for an instance that has ended. To
78
+ * check an instance without waiting, use `getStatus()`.
76
79
  */
77
80
  timeout?: Duration;
78
81
  /** Stops waiting once aborted (a client that went away), rejecting with the signal's reason. */
@@ -53,9 +53,9 @@ export interface WorkflowCompensationContext extends WorkflowStepContext {
53
53
  export interface WorkflowStepOptions<T> {
54
54
  /** Default: the module's `retry` (3 attempts, 1s doubling up to 5m). */
55
55
  retry?: number | false | WorkflowRetryOptions;
56
- /** Fail the attempt (retryably) after this long. */
56
+ /** Fail the attempt (retryably) after this long. Capped at about 24.8 days, the longest timer Node supports. */
57
57
  timeout?: Duration;
58
- /** Fail the attempt (retryably) if `heartbeat()` is not called this often. */
58
+ /** Fail the attempt (retryably) if `heartbeat()` is not called this often. Capped like `timeout`. */
59
59
  heartbeatTimeout?: Duration;
60
60
  /**
61
61
  * Undo this step, once it completed, if the workflow later fails or is
@@ -35,7 +35,10 @@ export interface WorkflowStore {
35
35
  * hands its transaction callback (a Drizzle `tx`, a TypeORM `EntityManager`...). Write only
36
36
  * through it, so the instance commits or rolls back with the application's rows, and never
37
37
  * catch a database error inside it (on PostgreSQL that aborts the transaction: use
38
- * insert-or-ignore). Without this method, `start()` with a transaction throws.
38
+ * insert-or-ignore). Without this method, `start()` with a transaction throws. The
39
+ * application awaits `start()` in its transaction callback, so its driver must accept an
40
+ * async one: one whose transactions are synchronous (better-sqlite3) commits before a
41
+ * failure is reported.
39
42
  */
40
43
  createInTransaction?(transaction: unknown, instance: NewWorkflowInstance): Promise<{
41
44
  instance: WorkflowInstance;
@@ -204,7 +207,32 @@ export interface WorkflowStore {
204
207
  * See `WorkflowWrite` for what each field does.
205
208
  */
206
209
  write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
210
+ /**
211
+ * Optional, for a store whose ids, names or keys are bounded (columns of a fixed length): throws, synchronously, an
212
+ * error that names the key (a `RangeError`) when the store can't hold `value` as a `kind`. The engine calls it for
213
+ * what a run makes up as it goes (a step's or a wait's name, `$compensate:<step>`, a child's id and keys, a wait's
214
+ * signal and key) before it journals or starts anything with it, so the error fails the run at the call that made
215
+ * the key, and the instance compensates, instead of failing every write that carries it. Without this method, the
216
+ * store's own check in `write()` or `create()` is all there is, and the instance is retried as after any store error.
217
+ */
218
+ checkKey?(kind: WorkflowKeyKind, value: string): void;
219
+ /**
220
+ * Optional, for a store that can't keep some text (PostgreSQL can't keep U+0000, and neither PostgreSQL nor MySQL a
221
+ * lone UTF-16 surrogate in JSON): what `text` holds that the store can't keep in a `column`, such as `'U+0000'`, or
222
+ * `undefined`. `'json'` is text inside a JSON value (an input, a result, a payload, a journal entry, which holds its
223
+ * name); `'text'` is an id, a name, a key or a cancel reason in a column of its own. The engine asks before it stores
224
+ * what the application gives or a run makes, and refuses such text with a `TypeError` that names where it sits, so
225
+ * the call fails (a run fails and compensates) instead of every write that carries it. It doesn't ask about what a
226
+ * codec encodes, which the store sees only encoded. In an error's text, which can't be refused, U+0000 and lone
227
+ * surrogates are replaced with U+FFFD where the store refuses it. Without this method, nothing is refused or
228
+ * replaced, and the store's own failure in `write()` or `create()` is all there is.
229
+ */
230
+ unstorableText?(text: string, column: WorkflowTextColumn): string | undefined;
207
231
  }
232
+ /** The ids, names and keys `WorkflowStore.checkKey()` checks. */
233
+ export type WorkflowKeyKind = 'instanceId' | 'workflow' | 'concurrencyKey' | 'rateLimitKey' | 'journalName' | 'signal' | 'signalKey';
234
+ /** Where `WorkflowStore.unstorableText()`'s text is stored: inside a JSON value, or in a text column of its own. */
235
+ export type WorkflowTextColumn = 'json' | 'text';
208
236
  /** `WorkflowStore.get()`'s result, and `WorkflowClient.getStatus()`'s. */
209
237
  export interface WorkflowInstanceDetails extends WorkflowInstance {
210
238
  /** Signals the instance waits for, if suspended in `waitForSignal()`. */
@@ -18,7 +18,7 @@ export interface MySqlWorkflowStoreOptions {
18
18
  * Apply the store's pending migrations at startup, one statement at a time under a lock (`GET_LOCK()`), so processes
19
19
  * that start together migrate once, and a run that stopped halfway resumes where it stopped. With `false`, startup
20
20
  * fails with a `WorkflowSchemaError` while the schema is behind this version of the package: apply them with
21
- * `npx nest-workflows migrate`, or with your own migration tool (`MySqlWorkflowStore.migrationSql()` or
21
+ * `npx @nestjs/workflows migrate`, or with your own migration tool (`MySqlWorkflowStore.migrationSql()` or
22
22
  * `migrationStatements()`). Default: `true`, except when `NODE_ENV` is `production`.
23
23
  */
24
24
  migrate?: boolean;
@@ -1,7 +1,7 @@
1
1
  import { type OnModuleInit } from '@nestjs/common';
2
2
  import { type MigrationSqlOptions, type MigrationStatementsOptions } from '@nestjs/store-kit/mysql';
3
3
  import type { WorkflowInstance, WorkflowStatus } from '../interfaces/workflow-instance.interface.js';
4
- import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
4
+ import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowKeyKind, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowTextColumn, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
5
5
  import type { WorkflowStorage } from '../storage/workflow.storage.js';
6
6
  import type { MySqlWorkflowStoreOptions } from './interfaces/mysql-workflow-store-options.interface.js';
7
7
  /**
@@ -31,7 +31,11 @@ import type { MySqlWorkflowStoreOptions } from './interfaces/mysql-workflow-stor
31
31
  * On MySQL:
32
32
  * - Ids, names and keys are case- and accent-sensitive, and bounded: an instance id, a workflow's or a signal's name,
33
33
  * a signal's key or id, a concurrency or rate-limit key hold at most 255 characters, a schedule's id 230, a
34
- * journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL.
34
+ * journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL. One a run makes up
35
+ * (a step's or a wait's name, `$compensate:<step>`, a child's id or keys) is thrown where the run makes it
36
+ * (`checkKey()`), so the run fails and compensates, as any error it throws.
37
+ * - JSON can't hold a lone UTF-16 surrogate (`unstorableText()`): the engine refuses one in an input, a payload, a
38
+ * result or a step's name with a `TypeError` where the application or the run gives it, unless a codec encodes it.
35
39
  * - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
36
40
  * instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
37
41
  * signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
@@ -164,6 +168,10 @@ export declare class MySqlWorkflowStore implements WorkflowStore, OnModuleInit {
164
168
  terminateRequested: boolean;
165
169
  } | null>;
166
170
  write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
171
+ /** What a run makes up (a step's name, a child's id...), checked by the engine before it writes or starts anything with it. */
172
+ checkKey(kind: WorkflowKeyKind, value: string): void;
173
+ /** A lone surrogate in JSON: what the engine refuses before it writes (see the class). */
174
+ unstorableText(text: string, column: WorkflowTextColumn): string | undefined;
167
175
  /** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
168
176
  private ready;
169
177
  /**
@@ -1,5 +1,6 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { columns, ensureLockRows, keyColumn, lockKeys, mysqlErrorCode, quoteTable, retryOnDeadlock, SqlParams, toBool, toInt, toJson, toText, } from '@nestjs/store-kit/mysql';
3
+ import { loneSurrogateIn } from '../utils/storable-text.util.js';
3
4
  import { RateWindowClaim } from '../sql/rate-windows.js';
4
5
  import { CANCELLABLE_STATUSES, INSTANCE_COLUMNS, RUNNABLE_STATUSES, SCHEDULE_COLUMNS, SIGNAL_COLUMNS, toInstance, toSchedule } from '../sql/workflow-rows.js';
5
6
  import { checkKey, MYSQL_KEY_LIMITS as L } from './key-limits.js';
@@ -47,7 +48,11 @@ const ROWS_PER_INSERT = 1_000;
47
48
  * On MySQL:
48
49
  * - Ids, names and keys are case- and accent-sensitive, and bounded: an instance id, a workflow's or a signal's name,
49
50
  * a signal's key or id, a concurrency or rate-limit key hold at most 255 characters, a schedule's id 230, a
50
- * journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL.
51
+ * journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL. One a run makes up
52
+ * (a step's or a wait's name, `$compensate:<step>`, a child's id or keys) is thrown where the run makes it
53
+ * (`checkKey()`), so the run fails and compensates, as any error it throws.
54
+ * - JSON can't hold a lone UTF-16 surrogate (`unstorableText()`): the engine refuses one in an input, a payload, a
55
+ * result or a step's name with a `TypeError` where the application or the run gives it, unless a codec encodes it.
51
56
  * - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
52
57
  * instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
53
58
  * signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
@@ -604,8 +609,8 @@ WHERE ${p.in('id', ids)}`, p.values);
604
609
  checkSignal(write.signal);
605
610
  }
606
611
  for (const wait of write.release?.waits ?? []) {
607
- checkKey("a wait's signal name", wait.signal, L.signal);
608
- checkKey("a wait's signal key", wait.key, L.signalKey);
612
+ checkKey(KEYS.signal, wait.signal, L.signal);
613
+ checkKey(KEYS.signalKey, wait.key, L.signalKey);
609
614
  }
610
615
  return retryOnDeadlock(this.executor, async (tx) => {
611
616
  const release = write.release;
@@ -650,6 +655,14 @@ WHERE ${p.in('id', ids)}`, p.values);
650
655
  return true;
651
656
  });
652
657
  }
658
+ /** What a run makes up (a step's name, a child's id...), checked by the engine before it writes or starts anything with it. */
659
+ checkKey(kind, value) {
660
+ checkKey(KEYS[kind], value, L[kind]);
661
+ }
662
+ /** A lone surrogate in JSON: what the engine refuses before it writes (see the class). */
663
+ unstorableText(text, column) {
664
+ return column === 'json' ? loneSurrogateIn(text) : undefined;
665
+ }
653
666
  // ---------------------------------------------------------------- internals
654
667
  /** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
655
668
  async ready() {
@@ -823,6 +836,16 @@ function checkSignal(s) {
823
836
  }
824
837
  function checkEntries(entries) {
825
838
  for (const entry of entries) {
826
- checkKey("a journal entry's name (a step's, a wait's, or the engine's, such as $child: and a child's id)", entry.name, L.journalName);
839
+ checkKey(KEYS.journalName, entry.name, L.journalName);
827
840
  }
828
841
  }
842
+ /** How the errors of `checkKey()`, and of `write()`'s own checks, name each key. */
843
+ const KEYS = {
844
+ instanceId: 'an instance id',
845
+ workflow: "a workflow's name",
846
+ concurrencyKey: 'a concurrency key',
847
+ rateLimitKey: 'a rate-limit key',
848
+ journalName: "a journal entry's name (a step's, a wait's, or the engine's, such as $child: and a child's id)",
849
+ signal: "a wait's signal name",
850
+ signalKey: "a wait's signal key",
851
+ };
@@ -15,7 +15,7 @@ export interface PostgresWorkflowStoreOptions {
15
15
  /**
16
16
  * Apply the store's pending migrations at startup, in one transaction under an advisory lock, so processes that
17
17
  * start together migrate once. With `false`, startup fails with a `WorkflowSchemaError` while the schema is behind
18
- * this version of the package: apply them with `npx nest-workflows migrate`, or with your own migration tool
18
+ * this version of the package: apply them with `npx @nestjs/workflows migrate`, or with your own migration tool
19
19
  * (`PostgresWorkflowStore.migrationSql()`). Default: `true`, except when `NODE_ENV` is `production`.
20
20
  */
21
21
  migrate?: boolean;
@@ -1,7 +1,7 @@
1
1
  import { type OnModuleInit } from '@nestjs/common';
2
2
  import { type MigrationSqlOptions } from '@nestjs/store-kit/postgres';
3
3
  import type { WorkflowInstance, WorkflowStatus } from '../interfaces/workflow-instance.interface.js';
4
- import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
4
+ import type { NewWorkflowInstance, NewWorkflowSignal, WorkflowCancelRequest, WorkflowClaim, WorkflowClaimRequest, WorkflowInstanceDetails, WorkflowListQuery, WorkflowPurgeQuery, WorkflowPurgeResult, WorkflowReopen, WorkflowScheduleClaimRequest, WorkflowScheduleQuery, WorkflowScheduleRecord, WorkflowScheduleSave, WorkflowScheduleWrite, WorkflowSignalQuery, WorkflowSignalRecord, WorkflowSignalResult, WorkflowStore, WorkflowTextColumn, WorkflowWrite } from '../interfaces/workflow-store.interface.js';
5
5
  import type { WorkflowStorage } from '../storage/workflow.storage.js';
6
6
  import type { PostgresWorkflowStoreOptions } from './interfaces/postgres-workflow-store-options.interface.js';
7
7
  /**
@@ -26,6 +26,9 @@ import type { PostgresWorkflowStoreOptions } from './interfaces/postgres-workflo
26
26
  *
27
27
  * Given `storage`, it registers itself (`storage.registerSource(this)`), as every store does. It checks its schema,
28
28
  * or migrates it (`migrate`), in `onModuleInit` (so before the worker starts), or at its first call outside Nest.
29
+ *
30
+ * PostgreSQL keeps U+0000 in neither `text` nor `jsonb`, nor a lone UTF-16 surrogate in `jsonb` (`unstorableText()`):
31
+ * the engine refuses them with a `TypeError` where the application or a run gives them, unless a codec encodes them.
29
32
  */
30
33
  export declare class PostgresWorkflowStore implements WorkflowStore, OnModuleInit {
31
34
  /**
@@ -117,6 +120,8 @@ export declare class PostgresWorkflowStore implements WorkflowStore, OnModuleIni
117
120
  terminateRequested: boolean;
118
121
  } | null>;
119
122
  write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
123
+ /** U+0000 anywhere, and a lone surrogate in JSON: what the engine refuses before it writes (see the class). */
124
+ unstorableText(text: string, column: WorkflowTextColumn): string | undefined;
120
125
  private insertInstance;
121
126
  private insertSignal;
122
127
  /**
@@ -1,5 +1,6 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { advisoryLock, assertReadCommittedTransaction, columns, quoteSchema, SqlParams, toBool, toInt, toJson, toText, } from '@nestjs/store-kit/postgres';
3
+ import { loneSurrogateIn, nulIn } from '../utils/storable-text.util.js';
3
4
  import { RateWindowClaim } from '../sql/rate-windows.js';
4
5
  import { CANCELLABLE_STATUSES, INSTANCE_COLUMNS, RUNNABLE_STATUSES, SCHEDULE_COLUMNS, toInstance, toSchedule } from '../sql/workflow-rows.js';
5
6
  import { workflowStoreSchema } from './migrations/index.js';
@@ -29,6 +30,9 @@ const CANCELLABLE = `(${CANCELLABLE_STATUSES.map((status) => `'${status}'`).join
29
30
  *
30
31
  * Given `storage`, it registers itself (`storage.registerSource(this)`), as every store does. It checks its schema,
31
32
  * or migrates it (`migrate`), in `onModuleInit` (so before the worker starts), or at its first call outside Nest.
33
+ *
34
+ * PostgreSQL keeps U+0000 in neither `text` nor `jsonb`, nor a lone UTF-16 surrogate in `jsonb` (`unstorableText()`):
35
+ * the engine refuses them with a `TypeError` where the application or a run gives them, unless a codec encodes them.
32
36
  */
33
37
  export class PostgresWorkflowStore {
34
38
  /**
@@ -491,6 +495,10 @@ RETURNING cancel_requested::text AS cancel_requested, terminate_requested::text
491
495
  return true;
492
496
  }, READ_COMMITTED);
493
497
  }
498
+ /** U+0000 anywhere, and a lone surrogate in JSON: what the engine refuses before it writes (see the class). */
499
+ unstorableText(text, column) {
500
+ return nulIn(text) ?? (column === 'json' ? loneSurrogateIn(text) : undefined);
501
+ }
494
502
  // ---------------------------------------------------------------- internals
495
503
  async insertInstance(db, i) {
496
504
  // An instance deleted between the insert that met it and the read of it is created again.
@@ -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: on a driver whose transactions are
93
- // synchronous, its statements must run before the application's transaction callback returns.
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 data = { name, key, dedupeId, payload: normalize(payload), now: this.clock.now() };
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)