@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
@@ -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, WorkflowKeyKind, 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;
@@ -213,9 +216,23 @@ export interface WorkflowStore {
213
216
  * store's own check in `write()` or `create()` is all there is, and the instance is retried as after any store error.
214
217
  */
215
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;
216
231
  }
217
232
  /** The ids, names and keys `WorkflowStore.checkKey()` checks. */
218
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';
219
236
  /** `WorkflowStore.get()`'s result, and `WorkflowClient.getStatus()`'s. */
220
237
  export interface WorkflowInstanceDetails extends WorkflowInstance {
221
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, WorkflowKeyKind, 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
  /**
@@ -34,6 +34,8 @@ import type { MySqlWorkflowStoreOptions } from './interfaces/mysql-workflow-stor
34
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
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
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.
37
39
  * - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
38
40
  * instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
39
41
  * signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
@@ -168,6 +170,8 @@ export declare class MySqlWorkflowStore implements WorkflowStore, OnModuleInit {
168
170
  write(id: string, token: string, write: WorkflowWrite): Promise<boolean>;
169
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. */
170
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;
171
175
  /** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
172
176
  private ready;
173
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';
@@ -50,6 +51,8 @@ const ROWS_PER_INSERT = 1_000;
50
51
  * journal entry's name 512. A longer one fails with a `RangeError` that names it, before any SQL. One a run makes up
51
52
  * (a step's or a wait's name, `$compensate:<step>`, a child's id or keys) is thrown where the run makes it
52
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.
53
56
  * - The application's transactions may be REPEATABLE READ, MySQL's default. `signal(..., { transaction })` finds the
54
57
  * instances to wake with a locking read of their waits, which sees the latest committed ones whatever the level; the
55
58
  * signal lock is held until the application's transaction ends, as on PostgreSQL. `start(..., { transaction })`
@@ -656,6 +659,10 @@ WHERE ${p.in('id', ids)}`, p.values);
656
659
  checkKey(kind, value) {
657
660
  checkKey(KEYS[kind], value, L[kind]);
658
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
+ }
659
666
  // ---------------------------------------------------------------- internals
660
667
  /** The store's readiness (the server and the schema), then the signal lock's row: before its own first statement. */
661
668
  async ready() {
@@ -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)
@@ -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
  /**