@nestjs/workflows 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/core/interfaces/leased-worker-options.interface.d.ts +5 -1
  2. package/dist/core/interfaces/result-waiter-options.interface.d.ts +6 -2
  3. package/dist/core/results/result-waiter.d.ts +5 -3
  4. package/dist/core/results/result-waiter.js +34 -10
  5. package/dist/core/utils/timer.util.d.ts +12 -0
  6. package/dist/core/utils/timer.util.js +16 -0
  7. package/dist/core/workers/leased-worker.d.ts +5 -2
  8. package/dist/core/workers/leased-worker.js +104 -12
  9. package/dist/cqrs/workflow-event.publisher.js +3 -3
  10. package/dist/index.d.ts +1 -1
  11. package/dist/interfaces/workflow-client.interface.d.ts +4 -1
  12. package/dist/interfaces/workflow-context.interface.d.ts +2 -2
  13. package/dist/interfaces/workflow-store.interface.d.ts +18 -1
  14. package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +1 -1
  15. package/dist/mysql/mysql-workflow.store.d.ts +5 -1
  16. package/dist/mysql/mysql-workflow.store.js +7 -0
  17. package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +1 -1
  18. package/dist/postgres/postgres-workflow.store.d.ts +6 -1
  19. package/dist/postgres/postgres-workflow.store.js +8 -0
  20. package/dist/services/workflow-client.service.d.ts +11 -2
  21. package/dist/services/workflow-client.service.js +23 -5
  22. package/dist/services/workflow-execution.service.d.ts +3 -0
  23. package/dist/services/workflow-execution.service.js +31 -5
  24. package/dist/services/workflow-scheduler.service.d.ts +8 -0
  25. package/dist/services/workflow-scheduler.service.js +20 -1
  26. package/dist/services/workflow-schedules.service.js +2 -0
  27. package/dist/services/workflow-worker.service.js +3 -0
  28. package/dist/storage/encoded-workflow.store.d.ts +11 -0
  29. package/dist/storage/encoded-workflow.store.js +38 -3
  30. package/dist/testing/index.js +4 -4
  31. package/dist/utils/index.d.ts +1 -0
  32. package/dist/utils/index.js +1 -0
  33. package/dist/utils/new-instance.util.d.ts +3 -0
  34. package/dist/utils/new-instance.util.js +9 -2
  35. package/dist/utils/serialize-error.util.js +25 -1
  36. package/dist/utils/storable-text.util.d.ts +36 -0
  37. package/dist/utils/storable-text.util.js +96 -0
  38. package/package.json +37 -24
@@ -40,7 +40,11 @@ export interface LeasedRun {
40
40
  readonly detached: boolean;
41
41
  /**
42
42
  * Renews the lease now, as the heartbeat does (a long step that proves it's alive): `false` once it's lost. A renewal
43
- * that fails (the store is unreachable) keeps the lease, which the next heartbeat renews before it runs out.
43
+ * that fails (the store is unreachable) keeps the lease, which the next heartbeat renews before it runs out. One
44
+ * renewal is in flight at a time: called while one is (a heartbeat's held up by the store), it waits for that one, then
45
+ * for one more that goes out as soon as it answered, or beside it once it went a heartbeat interval unanswered, and
46
+ * resolves with that one's answer. None goes once the lease was lost, the run ended or was detached, before the call
47
+ * or meanwhile: it then resolves `false` if the lease was lost, else `true`.
44
48
  */
45
49
  renew(): Promise<boolean>;
46
50
  /** Marks the lease lost, when a fenced write found it gone: aborts `signal` and stops renewing. */
@@ -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
+ }
@@ -2,8 +2,11 @@ import type { LeasedWorkerOptions } from '../interfaces/leased-worker-options.in
2
2
  /**
3
3
  * A claim-execute loop under leases, any number of processes sharing a store: it claims due items (as many as it has
4
4
  * free slots), executes them, renews each one's lease every `heartbeatInterval` (a third of the lease by default), and
5
- * aborts an item's `signal` when a renewal finds its lease gone. It looks for work every `pollInterval`, and at once
6
- * after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
5
+ * aborts an item's `signal` when a renewal finds its lease gone. An item has one renewal in flight at a time, so a store
6
+ * that holds a renewal up (a lock its row waits on) gets no more of them meanwhile: one asked for in the meantime goes
7
+ * out once that one answered, or beside it once it went a heartbeat interval unanswered (a connection that died without
8
+ * an error never answers), and none more while those two are in flight. It looks for work every `pollInterval`, and at
9
+ * once after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
7
10
  * tests and scripts.
8
11
  *
9
12
  * Shutdown is graceful: call `shutdown()` from `onModuleDestroy`. It stops claiming, waits for the claims in flight
@@ -3,11 +3,15 @@ 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
9
- * aborts an item's `signal` when a renewal finds its lease gone. It looks for work every `pollInterval`, and at once
10
- * after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
10
+ * aborts an item's `signal` when a renewal finds its lease gone. An item has one renewal in flight at a time, so a store
11
+ * that holds a renewal up (a lock its row waits on) gets no more of them meanwhile: one asked for in the meantime goes
12
+ * out once that one answered, or beside it once it went a heartbeat interval unanswered (a connection that died without
13
+ * an error never answers), and none more while those two are in flight. It looks for work every `pollInterval`, and at
14
+ * once after `kick()` (a local write made something due) or when an item ends. `drain()` runs due items on demand, for
11
15
  * tests and scripts.
12
16
  *
13
17
  * Shutdown is graceful: call `shutdown()` from `onModuleDestroy`. It stops claiming, waits for the claims in flight
@@ -122,7 +126,7 @@ export class LeasedWorker {
122
126
  let timer;
123
127
  await Promise.race([
124
128
  Promise.allSettled([...this.runs].map((run) => run.done)),
125
- new Promise((resolve) => (timer = setTimeout(resolve, this.shutdownMs))),
129
+ new Promise((resolve) => (timer = setTimer(resolve, this.shutdownMs))),
126
130
  ]);
127
131
  clearTimeout(timer);
128
132
  for (const run of this.runs) {
@@ -185,7 +189,7 @@ export class LeasedWorker {
185
189
  this.wake = undefined;
186
190
  resolve();
187
191
  };
188
- const timer = setTimeout(done, this.pollMs);
192
+ const timer = setTimer(done, this.pollMs);
189
193
  this.wake = () => {
190
194
  this.kicked = false;
191
195
  done();
@@ -210,12 +214,12 @@ export class LeasedWorker {
210
214
  }
211
215
  }
212
216
  launch(item) {
213
- const run = new Run(item, () => this.renew(run));
217
+ const run = new Run(item, this.heartbeatMs, () => this.renew(run));
214
218
  this.runs.add(run);
215
219
  if (this.stopped) {
216
220
  run.halt();
217
221
  }
218
- run.heartbeat = setInterval(() => void run.renew(), this.heartbeatMs);
222
+ run.heartbeat = setRepeatingTimer(() => void run.renew(), this.heartbeatMs);
219
223
  run.heartbeat.unref();
220
224
  let executing;
221
225
  try {
@@ -227,7 +231,7 @@ export class LeasedWorker {
227
231
  run.done = executing
228
232
  .catch((error) => this.report(error, 'execute'))
229
233
  .finally(() => {
230
- clearInterval(run.heartbeat);
234
+ run.end();
231
235
  this.runs.delete(run);
232
236
  });
233
237
  return run.done;
@@ -246,12 +250,21 @@ export class LeasedWorker {
246
250
  }
247
251
  /** A claimed item's run: its signal, its heartbeat, and what became of its lease. */
248
252
  class Run {
249
- constructor(item, renewer) {
253
+ constructor(item, heartbeatMs, renewer) {
250
254
  this.item = item;
255
+ this.heartbeatMs = heartbeatMs;
251
256
  this.renewer = renewer;
252
257
  this.controller = new AbortController();
253
258
  this.lost = false;
254
259
  this.isDetached = false;
260
+ /** Its `execute` settled: no more renewals. */
261
+ this.ended = false;
262
+ /** The renewals in flight: one, or two once the older went a heartbeat interval unanswered. */
263
+ this.inFlight = new Set();
264
+ /** How many renewals went out: the next one's `seq` follows. */
265
+ this.sent = 0;
266
+ /** The `seq` of the latest renewal that landed (the store wrote its `until`). */
267
+ this.landed = 0;
255
268
  }
256
269
  get signal() {
257
270
  return this.controller.signal;
@@ -262,10 +275,78 @@ class Run {
262
275
  get detached() {
263
276
  return this.isDetached;
264
277
  }
265
- async renew() {
266
- if (this.lost || this.isDetached) {
267
- return !this.lost;
278
+ /**
279
+ * Renews the lease (a heartbeat's tick, or the item's own call), one renewal at a time: a store that holds one up
280
+ * would otherwise get another at each tick, each holding a connection, and an older one could land after a newer
281
+ * one, shortening the lease. One asked for while another is in flight sends nothing then: it goes out once that one
282
+ * answered, its `until` read then (so after a renewal that was held up, the lease's deadline counts from when it
283
+ * answered), those asked for meanwhile sharing it, and resolves with its answer. A renewal a heartbeat interval
284
+ * unanswered no longer holds the next one back, as it may never answer (a connection that died without an error):
285
+ * the next goes out beside it, and no third while both are in flight. One that lands after a later one did leaves
286
+ * its older `until`, so one more follows it. None goes once the lease is lost (an answer, or `loseLease()`), the run
287
+ * ended, or it was detached: those asked for meanwhile resolve as `renew()` would then.
288
+ */
289
+ renew() {
290
+ if (this.lost || this.isDetached || this.ended) {
291
+ return Promise.resolve(!this.lost);
292
+ }
293
+ return this.canSend() ? this.send() : this.ask();
294
+ }
295
+ /** Whether a renewal can go out now: none is in flight, or one went a heartbeat interval unanswered; never a third. */
296
+ canSend() {
297
+ const [oldest, next] = this.inFlight;
298
+ return !oldest || (!next && oldest.late);
299
+ }
300
+ /** Asks for the renewal that goes out as soon as one can (`flush()`): its callers' answer. */
301
+ ask() {
302
+ if (!this.again) {
303
+ let resolve;
304
+ const promise = new Promise((resolved) => (resolve = resolved));
305
+ this.again = { promise, resolve };
306
+ }
307
+ return this.again.promise;
308
+ }
309
+ /**
310
+ * Sends the renewal asked for meanwhile if one can go out now (a renewal answered, or went a heartbeat interval
311
+ * unanswered). None goes once the lease is lost, the run ended or was detached: its callers hear what `renew()` would
312
+ * say then.
313
+ */
314
+ flush() {
315
+ const again = this.again;
316
+ if (!again) {
317
+ return;
318
+ }
319
+ if (this.lost || this.isDetached || this.ended) {
320
+ this.again = undefined;
321
+ again.resolve(!this.lost);
322
+ }
323
+ else if (this.canSend()) {
324
+ this.again = undefined;
325
+ void this.send().then(again.resolve);
268
326
  }
327
+ }
328
+ /** Sends a renewal, and once it answered, before its callers hear it, the one asked for meanwhile if it can go. */
329
+ send() {
330
+ const renewal = {
331
+ seq: ++this.sent,
332
+ late: false,
333
+ // Unanswered for a heartbeat interval, it no longer holds the next one back.
334
+ timer: setTimer(() => {
335
+ renewal.late = true;
336
+ this.flush();
337
+ }, this.heartbeatMs).unref(),
338
+ };
339
+ this.inFlight.add(renewal);
340
+ const answer = this.renewOnce(renewal.seq);
341
+ void answer.then(() => {
342
+ clearTimeout(renewal.timer);
343
+ this.inFlight.delete(renewal);
344
+ this.flush();
345
+ });
346
+ return answer;
347
+ }
348
+ /** One renewal: `false` once it found the lease gone; one that fails keeps the lease. */
349
+ async renewOnce(seq) {
269
350
  let held;
270
351
  try {
271
352
  held = await this.renewer();
@@ -276,8 +357,14 @@ class Run {
276
357
  }
277
358
  if (!held) {
278
359
  this.loseLease();
360
+ return false;
361
+ }
362
+ // It landed after a later renewal did (on a slower connection): the store keeps its older `until` now.
363
+ if (seq < this.landed) {
364
+ void this.ask();
279
365
  }
280
- return held;
366
+ this.landed = Math.max(this.landed, seq);
367
+ return true;
281
368
  }
282
369
  loseLease() {
283
370
  if (this.lost) {
@@ -298,4 +385,9 @@ class Run {
298
385
  this.isDetached = true;
299
386
  clearInterval(this.heartbeat);
300
387
  }
388
+ /** Its `execute` settled: no more heartbeats, and no more renewals. */
389
+ end() {
390
+ this.ended = true;
391
+ clearInterval(this.heartbeat);
392
+ }
301
393
  }
@@ -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.