@nestjs/workflows 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +50 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +8 -0
- package/dist/core/codecs/aes-gcm-payload.codec.d.ts +42 -0
- package/dist/core/codecs/aes-gcm-payload.codec.js +119 -0
- package/dist/core/codecs/index.d.ts +2 -0
- package/dist/core/codecs/index.js +2 -0
- package/dist/core/codecs/payload-codecs.d.ts +59 -0
- package/dist/core/codecs/payload-codecs.js +130 -0
- package/dist/core/index.d.ts +16 -0
- package/dist/core/index.js +21 -0
- package/dist/core/interfaces/clock.interface.d.ts +18 -0
- package/dist/core/interfaces/clock.interface.js +1 -0
- package/dist/core/interfaces/index.d.ts +9 -0
- package/dist/core/interfaces/index.js +9 -0
- package/dist/core/interfaces/leased-worker-options.interface.d.ts +94 -0
- package/dist/core/interfaces/leased-worker-options.interface.js +1 -0
- package/dist/core/interfaces/limits.interface.d.ts +92 -0
- package/dist/core/interfaces/limits.interface.js +1 -0
- package/dist/core/interfaces/payload-codec.interface.d.ts +64 -0
- package/dist/core/interfaces/payload-codec.interface.js +1 -0
- package/dist/core/interfaces/result-waiter-options.interface.d.ts +52 -0
- package/dist/core/interfaces/result-waiter-options.interface.js +1 -0
- package/dist/core/interfaces/retry-settings.interface.d.ts +71 -0
- package/dist/core/interfaces/retry-settings.interface.js +1 -0
- package/dist/core/interfaces/schedule-store.interface.d.ts +143 -0
- package/dist/core/interfaces/schedule-store.interface.js +1 -0
- package/dist/core/interfaces/schedule.interface.d.ts +106 -0
- package/dist/core/interfaces/schedule.interface.js +1 -0
- package/dist/core/interfaces/scheduler-options.interface.d.ts +152 -0
- package/dist/core/interfaces/scheduler-options.interface.js +1 -0
- package/dist/core/limits/index.d.ts +1 -0
- package/dist/core/limits/index.js +1 -0
- package/dist/core/limits/limits.d.ts +61 -0
- package/dist/core/limits/limits.js +130 -0
- package/dist/core/results/index.d.ts +1 -0
- package/dist/core/results/index.js +1 -0
- package/dist/core/results/result-waiter.d.ts +54 -0
- package/dist/core/results/result-waiter.js +140 -0
- package/dist/core/retries/index.d.ts +1 -0
- package/dist/core/retries/index.js +1 -0
- package/dist/core/retries/retry.d.ts +60 -0
- package/dist/core/retries/retry.js +107 -0
- package/dist/core/scheduling/cron.util.d.ts +52 -0
- package/dist/core/scheduling/cron.util.js +262 -0
- package/dist/core/scheduling/encoded-schedule.store.d.ts +26 -0
- package/dist/core/scheduling/encoded-schedule.store.js +54 -0
- package/dist/core/scheduling/in-memory-schedule.store.d.ts +20 -0
- package/dist/core/scheduling/in-memory-schedule.store.js +90 -0
- package/dist/core/scheduling/index.d.ts +3 -0
- package/dist/core/scheduling/index.js +3 -0
- package/dist/core/scheduling/rrule.util.d.ts +55 -0
- package/dist/core/scheduling/rrule.util.js +371 -0
- package/dist/core/scheduling/schedule-spec.d.ts +57 -0
- package/dist/core/scheduling/schedule-spec.js +233 -0
- package/dist/core/scheduling/scheduler.d.ts +134 -0
- package/dist/core/scheduling/scheduler.js +450 -0
- package/dist/core/scheduling/time-zone.util.d.ts +18 -0
- package/dist/core/scheduling/time-zone.util.js +88 -0
- package/dist/core/time/clock.d.ts +28 -0
- package/dist/core/time/clock.js +35 -0
- package/dist/core/time/duration.d.ts +21 -0
- package/dist/core/time/duration.js +30 -0
- package/dist/core/time/index.d.ts +2 -0
- package/dist/core/time/index.js +2 -0
- package/dist/core/utils/canonical.util.d.ts +2 -0
- package/dist/core/utils/canonical.util.js +4 -0
- package/dist/core/utils/maybe.util.d.ts +8 -0
- package/dist/core/utils/maybe.util.js +14 -0
- package/dist/core/utils/payload-place.util.d.ts +10 -0
- package/dist/core/utils/payload-place.util.js +32 -0
- package/dist/core/workers/index.d.ts +1 -0
- package/dist/core/workers/index.js +1 -0
- package/dist/core/workers/leased-worker.d.ts +86 -0
- package/dist/core/workers/leased-worker.js +280 -0
- package/dist/cqrs/decorators/index.d.ts +2 -0
- package/dist/cqrs/decorators/index.js +2 -0
- package/dist/cqrs/decorators/signal-on.decorator.d.ts +18 -0
- package/dist/cqrs/decorators/signal-on.decorator.js +34 -0
- package/dist/cqrs/decorators/start-on.decorator.d.ts +18 -0
- package/dist/cqrs/decorators/start-on.decorator.js +49 -0
- package/dist/cqrs/index.d.ts +3 -0
- package/dist/cqrs/index.js +5 -0
- package/dist/cqrs/interfaces/index.d.ts +4 -0
- package/dist/cqrs/interfaces/index.js +4 -0
- package/dist/cqrs/interfaces/signal-on-options.interface.d.ts +22 -0
- package/dist/cqrs/interfaces/signal-on-options.interface.js +1 -0
- package/dist/cqrs/interfaces/start-on-options.interface.d.ts +23 -0
- package/dist/cqrs/interfaces/start-on-options.interface.js +1 -0
- package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.d.ts +10 -0
- package/dist/cqrs/interfaces/workflow-dispatcher-context.interface.js +1 -0
- package/dist/cqrs/interfaces/workflow-event-route.interface.d.ts +20 -0
- package/dist/cqrs/interfaces/workflow-event-route.interface.js +1 -0
- package/dist/cqrs/utils/event-routes.util.d.ts +7 -0
- package/dist/cqrs/utils/event-routes.util.js +22 -0
- package/dist/cqrs/workflow-event.publisher.d.ts +52 -0
- package/dist/cqrs/workflow-event.publisher.js +257 -0
- package/dist/cqrs/workflow-events.explorer.d.ts +27 -0
- package/dist/cqrs/workflow-events.explorer.js +92 -0
- package/dist/cqrs/workflows-cqrs.module.d.ts +10 -0
- package/dist/cqrs/workflows-cqrs.module.js +27 -0
- package/dist/decorators/index.d.ts +1 -0
- package/dist/decorators/index.js +1 -0
- package/dist/decorators/workflow.decorator.d.ts +7 -0
- package/dist/decorators/workflow.decorator.js +52 -0
- package/dist/errors/child-workflow-failed.error.d.ts +13 -0
- package/dist/errors/child-workflow-failed.error.js +14 -0
- package/dist/errors/index.d.ts +13 -0
- package/dist/errors/index.js +13 -0
- package/dist/errors/non-retryable-step.error.d.ts +4 -0
- package/dist/errors/non-retryable-step.error.js +7 -0
- package/dist/errors/step-failed.error.d.ts +15 -0
- package/dist/errors/step-failed.error.js +16 -0
- package/dist/errors/step-timeout.error.d.ts +5 -0
- package/dist/errors/step-timeout.error.js +8 -0
- package/dist/errors/workflow-definition.error.d.ts +10 -0
- package/dist/errors/workflow-definition.error.js +13 -0
- package/dist/errors/workflow-failed.error.d.ts +20 -0
- package/dist/errors/workflow-failed.error.js +17 -0
- package/dist/errors/workflow-id-conflict.error.d.ts +6 -0
- package/dist/errors/workflow-id-conflict.error.js +9 -0
- package/dist/errors/workflow-interrupt.error.d.ts +32 -0
- package/dist/errors/workflow-interrupt.error.js +19 -0
- package/dist/errors/workflow-non-determinism.error.d.ts +4 -0
- package/dist/errors/workflow-non-determinism.error.js +7 -0
- package/dist/errors/workflow-not-found.error.d.ts +10 -0
- package/dist/errors/workflow-not-found.error.js +13 -0
- package/dist/errors/workflow-result-timeout.error.d.ts +11 -0
- package/dist/errors/workflow-result-timeout.error.js +13 -0
- package/dist/errors/workflow-state.error.d.ts +11 -0
- package/dist/errors/workflow-state.error.js +14 -0
- package/dist/errors/workflow.error.d.ts +7 -0
- package/dist/errors/workflow.error.js +7 -0
- package/dist/events/index.d.ts +3 -0
- package/dist/events/index.js +3 -0
- package/dist/events/workflow-events.interface.d.ts +145 -0
- package/dist/events/workflow-events.interface.js +1 -0
- package/dist/events/workflow-events.service.d.ts +17 -0
- package/dist/events/workflow-events.service.js +38 -0
- package/dist/events/workflows.channels.d.ts +3 -0
- package/dist/events/workflows.channels.js +9 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +23 -0
- package/dist/interfaces/index.d.ts +13 -0
- package/dist/interfaces/index.js +13 -0
- package/dist/interfaces/serialized-workflow-error.interface.d.ts +6 -0
- package/dist/interfaces/serialized-workflow-error.interface.js +1 -0
- package/dist/interfaces/workflow-client.interface.d.ts +156 -0
- package/dist/interfaces/workflow-client.interface.js +1 -0
- package/dist/interfaces/workflow-clock.interface.d.ts +7 -0
- package/dist/interfaces/workflow-clock.interface.js +1 -0
- package/dist/interfaces/workflow-context.interface.d.ts +241 -0
- package/dist/interfaces/workflow-context.interface.js +1 -0
- package/dist/interfaces/workflow-decorator-options.interface.d.ts +101 -0
- package/dist/interfaces/workflow-decorator-options.interface.js +1 -0
- package/dist/interfaces/workflow-instance.interface.d.ts +83 -0
- package/dist/interfaces/workflow-instance.interface.js +1 -0
- package/dist/interfaces/workflow-payload-codec.interface.d.ts +32 -0
- package/dist/interfaces/workflow-payload-codec.interface.js +1 -0
- package/dist/interfaces/workflow-retry-options.interface.d.ts +9 -0
- package/dist/interfaces/workflow-retry-options.interface.js +1 -0
- package/dist/interfaces/workflow-runner.interface.d.ts +11 -0
- package/dist/interfaces/workflow-runner.interface.js +1 -0
- package/dist/interfaces/workflow-schedule.interface.d.ts +104 -0
- package/dist/interfaces/workflow-schedule.interface.js +1 -0
- package/dist/interfaces/workflow-storage.interface.d.ts +4 -0
- package/dist/interfaces/workflow-storage.interface.js +1 -0
- package/dist/interfaces/workflow-store.interface.d.ts +488 -0
- package/dist/interfaces/workflow-store.interface.js +1 -0
- package/dist/interfaces/workflows-module-options.interface.d.ts +80 -0
- package/dist/interfaces/workflows-module-options.interface.js +1 -0
- package/dist/mysql/index.d.ts +5 -0
- package/dist/mysql/index.js +8 -0
- package/dist/mysql/interfaces/index.d.ts +1 -0
- package/dist/mysql/interfaces/index.js +1 -0
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.d.ts +25 -0
- package/dist/mysql/interfaces/mysql-workflow-store-options.interface.js +1 -0
- package/dist/mysql/key-limits.d.ts +30 -0
- package/dist/mysql/key-limits.js +41 -0
- package/dist/mysql/migrations/index.d.ts +7 -0
- package/dist/mysql/migrations/index.js +16 -0
- package/dist/mysql/migrations/initial.migration.d.ts +9 -0
- package/dist/mysql/migrations/initial.migration.js +104 -0
- package/dist/mysql/mysql-workflow.store.d.ts +197 -0
- package/dist/mysql/mysql-workflow.store.js +828 -0
- package/dist/postgres/errors/index.d.ts +1 -0
- package/dist/postgres/errors/index.js +2 -0
- package/dist/postgres/index.d.ts +5 -0
- package/dist/postgres/index.js +8 -0
- package/dist/postgres/interfaces/index.d.ts +1 -0
- package/dist/postgres/interfaces/index.js +1 -0
- package/dist/postgres/interfaces/postgres-workflow-store-options.interface.d.ts +22 -0
- package/dist/postgres/interfaces/postgres-workflow-store-options.interface.js +1 -0
- package/dist/postgres/migrations/index.d.ts +6 -0
- package/dist/postgres/migrations/index.js +15 -0
- package/dist/postgres/migrations/initial.migration.d.ts +6 -0
- package/dist/postgres/migrations/initial.migration.js +106 -0
- package/dist/postgres/postgres-workflow.store.d.ts +132 -0
- package/dist/postgres/postgres-workflow.store.js +586 -0
- package/dist/services/index.d.ts +6 -0
- package/dist/services/index.js +6 -0
- package/dist/services/workflow-client.service.d.ts +146 -0
- package/dist/services/workflow-client.service.js +436 -0
- package/dist/services/workflow-execution.service.d.ts +267 -0
- package/dist/services/workflow-execution.service.js +1076 -0
- package/dist/services/workflow-registry.service.d.ts +56 -0
- package/dist/services/workflow-registry.service.js +172 -0
- package/dist/services/workflow-scheduler.service.d.ts +54 -0
- package/dist/services/workflow-scheduler.service.js +173 -0
- package/dist/services/workflow-schedules.service.d.ts +60 -0
- package/dist/services/workflow-schedules.service.js +193 -0
- package/dist/services/workflow-worker.service.d.ts +92 -0
- package/dist/services/workflow-worker.service.js +468 -0
- package/dist/signals/index.d.ts +1 -0
- package/dist/signals/index.js +1 -0
- package/dist/signals/workflow.signal.d.ts +17 -0
- package/dist/signals/workflow.signal.js +28 -0
- package/dist/sql/rate-windows.d.ts +41 -0
- package/dist/sql/rate-windows.js +53 -0
- package/dist/sql/workflow-rows.d.ts +16 -0
- package/dist/sql/workflow-rows.js +102 -0
- package/dist/sql/workflow-schema.error.d.ts +21 -0
- package/dist/sql/workflow-schema.error.js +15 -0
- package/dist/storage/encoded-workflow.store.d.ts +71 -0
- package/dist/storage/encoded-workflow.store.js +201 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/workflow.storage.d.ts +55 -0
- package/dist/storage/workflow.storage.js +159 -0
- package/dist/stores/in-memory-workflow.store.d.ts +75 -0
- package/dist/stores/in-memory-workflow.store.js +380 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.js +1 -0
- package/dist/testing/assertions.d.ts +10 -0
- package/dist/testing/assertions.js +72 -0
- package/dist/testing/index.d.ts +38 -0
- package/dist/testing/index.js +1089 -0
- package/dist/testing/schedule-store.contract.d.ts +33 -0
- package/dist/testing/schedule-store.contract.js +161 -0
- package/dist/utils/index.d.ts +7 -0
- package/dist/utils/index.js +7 -0
- package/dist/utils/journal-limits.util.d.ts +6 -0
- package/dist/utils/journal-limits.util.js +25 -0
- package/dist/utils/new-instance.util.d.ts +26 -0
- package/dist/utils/new-instance.util.js +87 -0
- package/dist/utils/normalize.util.d.ts +5 -0
- package/dist/utils/normalize.util.js +11 -0
- package/dist/utils/run-timeout.util.d.ts +3 -0
- package/dist/utils/run-timeout.util.js +15 -0
- package/dist/utils/schedule-spec.util.d.ts +16 -0
- package/dist/utils/schedule-spec.util.js +9 -0
- package/dist/utils/schedule-store.util.d.ts +15 -0
- package/dist/utils/schedule-store.util.js +44 -0
- package/dist/utils/serialize-error.util.d.ts +7 -0
- package/dist/utils/serialize-error.util.js +22 -0
- package/dist/utils/step-scope.util.d.ts +16 -0
- package/dist/utils/step-scope.util.js +38 -0
- package/dist/workflows.constants.d.ts +10 -0
- package/dist/workflows.constants.js +10 -0
- package/dist/workflows.module-definition.d.ts +4 -0
- package/dist/workflows.module-definition.js +6 -0
- package/dist/workflows.module.d.ts +24 -0
- package/dist/workflows.module.js +68 -0
- package/package.json +123 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { parseDuration } from '../time/duration.js';
|
|
2
|
+
/** The family's default: 3 attempts, 1s doubling up to 5m, no jitter. */
|
|
3
|
+
const DEFAULT_RETRY = {
|
|
4
|
+
attempts: 3,
|
|
5
|
+
backoff: { delay: 1_000, factor: 2, maxDelay: 300_000, jitter: 'none' },
|
|
6
|
+
};
|
|
7
|
+
const DEFAULT_BACKOFF = DEFAULT_RETRY.backoff;
|
|
8
|
+
/**
|
|
9
|
+
* Checks `retry` (a count, `false` or `RetrySettings`) and resolves it over `base`, field by field: `undefined` is
|
|
10
|
+
* `base`, `false` one attempt, a number the attempts. `base` defaults to the family's default: 3 attempts, 1s doubling
|
|
11
|
+
* up to 5m, no jitter. Throws a `TypeError` for invalid settings, so they fail where they are given rather than at the
|
|
12
|
+
* first retry.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const defaults = resolveRetry(moduleOptions.retry);
|
|
16
|
+
* const policy = resolveRetry(jobOptions.retry, defaults);
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function resolveRetry(retry, base = DEFAULT_RETRY) {
|
|
20
|
+
if (retry === undefined) {
|
|
21
|
+
return base;
|
|
22
|
+
}
|
|
23
|
+
if (retry === false) {
|
|
24
|
+
return { ...base, attempts: 1 };
|
|
25
|
+
}
|
|
26
|
+
if (typeof retry === 'number') {
|
|
27
|
+
return { ...base, attempts: attempts(retry) };
|
|
28
|
+
}
|
|
29
|
+
const { backoff } = retry;
|
|
30
|
+
return {
|
|
31
|
+
attempts: retry.attempts === undefined ? base.attempts : attempts(retry.attempts),
|
|
32
|
+
backoff: typeof backoff === 'function'
|
|
33
|
+
? (attempt, error) => parseDuration(backoff(attempt, error))
|
|
34
|
+
: backoff
|
|
35
|
+
? resolveBackoff(backoff, typeof base.backoff === 'function' ? DEFAULT_BACKOFF : base.backoff)
|
|
36
|
+
: base.backoff,
|
|
37
|
+
retryIf: retry.retryIf ?? base.retryIf,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The wait (ms) after the failed `attempt` (1-based): `delay * factor^(attempt - 1)`, capped at `maxDelay`, then
|
|
42
|
+
* randomized by `jitter` (`'full'`: from 0 to the wait, `'equal'`: from half of it to all of it). `random` returns a
|
|
43
|
+
* number in `[0, 1)`, as `Math.random()` (the default) does.
|
|
44
|
+
*
|
|
45
|
+
* ```ts
|
|
46
|
+
* backoffDelay({ delay: 1_000, factor: 2, maxDelay: 60_000, jitter: 'none' }, 4); // 8_000
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function backoffDelay(backoff, attempt, random = Math.random) {
|
|
50
|
+
const wait = Math.min(backoff.maxDelay, backoff.delay * backoff.factor ** (attempt - 1));
|
|
51
|
+
switch (backoff.jitter) {
|
|
52
|
+
case 'full':
|
|
53
|
+
return Math.floor(random() * wait);
|
|
54
|
+
case 'equal':
|
|
55
|
+
return Math.floor(wait / 2 + random() * (wait / 2));
|
|
56
|
+
default:
|
|
57
|
+
return wait;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether, and when, to retry after `attempt` (1-based) failed with `error`: not once the policy's attempts are
|
|
62
|
+
* spent, nor when `retryIf` returns false; otherwise after the backoff's wait. A `retryIf` or backoff function that
|
|
63
|
+
* throws gives up, with what it threw. Errors that must never be retried (a `NonRetryableStepError`) are the caller's
|
|
64
|
+
* to check first.
|
|
65
|
+
*
|
|
66
|
+
* ```ts
|
|
67
|
+
* const next = nextRetry(resolveRetry({ attempts: 3 }), 1, new Error('ECONNRESET'));
|
|
68
|
+
* // { retry: true, delay: 1_000 }
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function nextRetry(policy, attempt, error) {
|
|
72
|
+
if (attempt >= policy.attempts) {
|
|
73
|
+
return { retry: false, reason: 'exhausted' };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
if (!(policy.retryIf?.(error, attempt) ?? true)) {
|
|
77
|
+
return { retry: false, reason: 'refused' };
|
|
78
|
+
}
|
|
79
|
+
const { backoff } = policy;
|
|
80
|
+
return { retry: true, delay: typeof backoff === 'function' ? backoff(attempt, error) : backoffDelay(backoff, attempt) };
|
|
81
|
+
}
|
|
82
|
+
catch (thrown) {
|
|
83
|
+
return { retry: false, reason: 'threw', error: thrown };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function resolveBackoff(backoff, base) {
|
|
87
|
+
const factor = backoff.factor ?? base.factor;
|
|
88
|
+
if (!(factor > 0)) {
|
|
89
|
+
throw new TypeError(`Invalid backoff factor ${factor}. Use a positive number (1 = constant).`);
|
|
90
|
+
}
|
|
91
|
+
const jitter = backoff.jitter ?? base.jitter;
|
|
92
|
+
if (!['full', 'equal', 'none'].includes(jitter)) {
|
|
93
|
+
throw new TypeError(`Invalid backoff jitter "${jitter}". Use "full", "equal" or "none".`);
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
delay: backoff.delay === undefined ? base.delay : parseDuration(backoff.delay),
|
|
97
|
+
factor,
|
|
98
|
+
maxDelay: backoff.maxDelay === undefined ? base.maxDelay : parseDuration(backoff.maxDelay),
|
|
99
|
+
jitter,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function attempts(value) {
|
|
103
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
104
|
+
throw new TypeError(`Invalid retry attempts ${value}. Use a whole number of attempts, including the first.`);
|
|
105
|
+
}
|
|
106
|
+
return Math.max(1, value);
|
|
107
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** `n#k` in the day-of-week field: the `nth` (1-5) `weekday` (0-6, Sunday is 0) of the month. */
|
|
2
|
+
export interface CronNthWeekday {
|
|
3
|
+
readonly weekday: number;
|
|
4
|
+
readonly nth: number;
|
|
5
|
+
}
|
|
6
|
+
/** A parsed cron expression: the values each field allows, ascending. */
|
|
7
|
+
export interface CronExpression {
|
|
8
|
+
/** The expression as given. */
|
|
9
|
+
readonly source: string;
|
|
10
|
+
readonly seconds: readonly number[];
|
|
11
|
+
readonly minutes: readonly number[];
|
|
12
|
+
readonly hours: readonly number[];
|
|
13
|
+
/** 1-31; `L` is `lastDayOfMonth`. */
|
|
14
|
+
readonly daysOfMonth: readonly number[];
|
|
15
|
+
readonly lastDayOfMonth: boolean;
|
|
16
|
+
/** 1-12. */
|
|
17
|
+
readonly months: readonly number[];
|
|
18
|
+
/** 0-6, Sunday is 0 (7 is read as 0). */
|
|
19
|
+
readonly daysOfWeek: readonly number[];
|
|
20
|
+
/** `nL`: the last such weekday of the month. */
|
|
21
|
+
readonly lastWeekdays: readonly number[];
|
|
22
|
+
readonly nthWeekdays: readonly CronNthWeekday[];
|
|
23
|
+
/** Whether the day-of-month text starts with something other than `*` or `?` (Vixie cron's DOM_STAR, negated). */
|
|
24
|
+
readonly dayOfMonthRestricted: boolean;
|
|
25
|
+
/** Whether the day-of-week text starts with something other than `*` or `?` (Vixie cron's DOW_STAR, negated). */
|
|
26
|
+
readonly dayOfWeekRestricted: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parses 5 fields (minute hour day-of-month month day-of-week) or 6 (second first), whitespace-separated, or a
|
|
30
|
+
* nickname (`@yearly`, `@annually`, `@monthly`, `@weekly`, `@daily`, `@midnight`, `@hourly`). A field is a list
|
|
31
|
+
* (`a,b-c,*\/n`) of `*` or `?` (any), values, ranges `a-b` and steps `*\/n`, `a-b/n` and `a/n` (a to the field's
|
|
32
|
+
* maximum). Months take JAN-DEC and days of the week SUN-SAT (any case, in ranges too); days of the week are 0-7,
|
|
33
|
+
* where 0 and 7 are Sunday. The day of the month also takes `L` (its last day), the day of the week `nL` (the last
|
|
34
|
+
* weekday n of the month, `5L` the last Friday) and `n#k` (the k-th weekday n, k 1-5, `1#1` the first Monday).
|
|
35
|
+
*
|
|
36
|
+
* Days match as in Vixie cron and cronie: when both day fields are restricted, a day that matches either one runs;
|
|
37
|
+
* otherwise a day must match both. A field whose text starts with `*` or `?` is not restricted, even with a step
|
|
38
|
+
* (Vixie's DOM_STAR and DOW_STAR), so `0 0 *\/2 * 1` runs on odd days of the month that are Mondays, while
|
|
39
|
+
* `0 0 1,15 * 1` runs on the 1st, the 15th and every Monday.
|
|
40
|
+
*
|
|
41
|
+
* Throws a TypeError for a malformed expression and for one that never matches (February 30).
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseCron(expression: string): CronExpression;
|
|
44
|
+
/**
|
|
45
|
+
* The first occurrence strictly after instant `after`, in `tz`; null if none within 50 years. An occurrence is a
|
|
46
|
+
* wall time in `tz` that matches every field, to the second, mapped to an instant as `fromWallTime` does: on the day
|
|
47
|
+
* clocks go forward a skipped 02:30 runs at 03:30 (once, even when 03:30 matches too); on the day they go back a
|
|
48
|
+
* repeated 02:30 runs once, the first time (so an every-minute cron skips the repeated hour's second pass).
|
|
49
|
+
*/
|
|
50
|
+
export declare function nextCron(cron: CronExpression, after: number, tz: string): number | null;
|
|
51
|
+
/** Occurrences strictly after `after`, ascending, strictly increasing (no duplicate instants), in `tz`. */
|
|
52
|
+
export declare function cronOccurrences(cron: CronExpression, after: number, tz: string): Generator<number>;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { fromWallTime, recentClockChange, toWallTime } from './time-zone.util.js';
|
|
2
|
+
const SECOND = 1_000;
|
|
3
|
+
const DAY = 86_400_000;
|
|
4
|
+
const HORIZON_YEARS = 50;
|
|
5
|
+
// Any fixed wall time does: a cron that matches at all does so within 50 years of it (February 29 within 8 years,
|
|
6
|
+
// the fifth Monday of February within 40, as 2100 isn't a leap year).
|
|
7
|
+
const REFERENCE = Date.UTC(2000, 0, 1);
|
|
8
|
+
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
9
|
+
const LONGEST_MONTHS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
10
|
+
const NICKNAMES = {
|
|
11
|
+
'@yearly': '0 0 1 1 *',
|
|
12
|
+
'@annually': '0 0 1 1 *',
|
|
13
|
+
'@monthly': '0 0 1 * *',
|
|
14
|
+
'@weekly': '0 0 * * 0',
|
|
15
|
+
'@daily': '0 0 * * *',
|
|
16
|
+
'@midnight': '0 0 * * *',
|
|
17
|
+
'@hourly': '0 * * * *',
|
|
18
|
+
};
|
|
19
|
+
const SECONDS = { min: 0, max: 59, accepts: 'seconds are 0-59' };
|
|
20
|
+
const MINUTES = { min: 0, max: 59, accepts: 'minutes are 0-59' };
|
|
21
|
+
const HOURS = { min: 0, max: 23, accepts: 'hours are 0-23' };
|
|
22
|
+
const DAYS_OF_MONTH = { min: 1, max: 31, accepts: 'days of the month are 1-31 or L (the last day)' };
|
|
23
|
+
const MONTHS = {
|
|
24
|
+
min: 1,
|
|
25
|
+
max: 12,
|
|
26
|
+
names: ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'],
|
|
27
|
+
accepts: 'months are 1-12 or JAN-DEC',
|
|
28
|
+
};
|
|
29
|
+
const DAYS_OF_WEEK = {
|
|
30
|
+
min: 0,
|
|
31
|
+
max: 7,
|
|
32
|
+
names: ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'],
|
|
33
|
+
accepts: 'days of the week are 0-7 (0 and 7 are Sunday) or SUN-SAT, nL (5L is the last Friday) or n#k (1#1 is the first Monday)',
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Parses 5 fields (minute hour day-of-month month day-of-week) or 6 (second first), whitespace-separated, or a
|
|
37
|
+
* nickname (`@yearly`, `@annually`, `@monthly`, `@weekly`, `@daily`, `@midnight`, `@hourly`). A field is a list
|
|
38
|
+
* (`a,b-c,*\/n`) of `*` or `?` (any), values, ranges `a-b` and steps `*\/n`, `a-b/n` and `a/n` (a to the field's
|
|
39
|
+
* maximum). Months take JAN-DEC and days of the week SUN-SAT (any case, in ranges too); days of the week are 0-7,
|
|
40
|
+
* where 0 and 7 are Sunday. The day of the month also takes `L` (its last day), the day of the week `nL` (the last
|
|
41
|
+
* weekday n of the month, `5L` the last Friday) and `n#k` (the k-th weekday n, k 1-5, `1#1` the first Monday).
|
|
42
|
+
*
|
|
43
|
+
* Days match as in Vixie cron and cronie: when both day fields are restricted, a day that matches either one runs;
|
|
44
|
+
* otherwise a day must match both. A field whose text starts with `*` or `?` is not restricted, even with a step
|
|
45
|
+
* (Vixie's DOM_STAR and DOW_STAR), so `0 0 *\/2 * 1` runs on odd days of the month that are Mondays, while
|
|
46
|
+
* `0 0 1,15 * 1` runs on the 1st, the 15th and every Monday.
|
|
47
|
+
*
|
|
48
|
+
* Throws a TypeError for a malformed expression and for one that never matches (February 30).
|
|
49
|
+
*/
|
|
50
|
+
export function parseCron(expression) {
|
|
51
|
+
if (typeof expression !== 'string') {
|
|
52
|
+
throw new TypeError(`Invalid cron expression ${JSON.stringify(expression)}. Use a string, such as "0 9 * * MON-FRI".`);
|
|
53
|
+
}
|
|
54
|
+
const trimmed = expression.trim();
|
|
55
|
+
const text = trimmed.startsWith('@') ? NICKNAMES[trimmed.toLowerCase()] : trimmed;
|
|
56
|
+
if (text === undefined) {
|
|
57
|
+
throw new TypeError(`Invalid cron expression "${expression}": use @yearly, @annually, @monthly, @weekly, @daily, @midnight or @hourly, or 5 or 6 fields.`);
|
|
58
|
+
}
|
|
59
|
+
const fields = text === '' ? [] : text.split(/\s+/);
|
|
60
|
+
if (fields.length !== 5 && fields.length !== 6) {
|
|
61
|
+
throw new TypeError(`Invalid cron expression "${expression}": use 5 fields (minute hour day-of-month month day-of-week) or 6 (second first), not ${fields.length}.`);
|
|
62
|
+
}
|
|
63
|
+
const [second, minute, hour, dayOfMonth, month, dayOfWeek] = fields.length === 6 ? fields : ['0', ...fields];
|
|
64
|
+
const failIn = (field) => (reason) => {
|
|
65
|
+
throw new TypeError(`Invalid cron field "${field}" in "${expression}": ${reason}.`);
|
|
66
|
+
};
|
|
67
|
+
const cron = {
|
|
68
|
+
source: expression,
|
|
69
|
+
seconds: parseList(second, SECONDS, failIn(second)),
|
|
70
|
+
minutes: parseList(minute, MINUTES, failIn(minute)),
|
|
71
|
+
hours: parseList(hour, HOURS, failIn(hour)),
|
|
72
|
+
...parseDaysOfMonth(dayOfMonth, failIn(dayOfMonth)),
|
|
73
|
+
months: parseList(month, MONTHS, failIn(month)),
|
|
74
|
+
...parseDaysOfWeek(dayOfWeek, failIn(dayOfWeek)),
|
|
75
|
+
dayOfMonthRestricted: !/^[*?]/.test(dayOfMonth),
|
|
76
|
+
dayOfWeekRestricted: !/^[*?]/.test(dayOfWeek),
|
|
77
|
+
};
|
|
78
|
+
if (nextMatch(cron, REFERENCE, addYears(REFERENCE, HORIZON_YEARS)) === null) {
|
|
79
|
+
throw new TypeError(`Invalid cron expression "${expression}": it never matches${neverReason(cron)}.`);
|
|
80
|
+
}
|
|
81
|
+
return cron;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The first occurrence strictly after instant `after`, in `tz`; null if none within 50 years. An occurrence is a
|
|
85
|
+
* wall time in `tz` that matches every field, to the second, mapped to an instant as `fromWallTime` does: on the day
|
|
86
|
+
* clocks go forward a skipped 02:30 runs at 03:30 (once, even when 03:30 matches too); on the day they go back a
|
|
87
|
+
* repeated 02:30 runs once, the first time (so an every-minute cron skips the repeated hour's second pass).
|
|
88
|
+
*/
|
|
89
|
+
export function nextCron(cron, after, tz) {
|
|
90
|
+
// Right after clocks went forward, a skipped wall time that lands later is still ahead, though its wall time is
|
|
91
|
+
// before `after`'s: search from as far back as they jumped.
|
|
92
|
+
const from = Math.floor((toWallTime(after, tz) - Math.max(0, recentClockChange(after, tz))) / SECOND) * SECOND + SECOND;
|
|
93
|
+
let until = addYears(from, HORIZON_YEARS);
|
|
94
|
+
let next = null;
|
|
95
|
+
for (let wall = nextMatch(cron, from, until); wall !== null; wall = nextMatch(cron, wall + SECOND, until)) {
|
|
96
|
+
const instant = fromWallTime(wall, tz);
|
|
97
|
+
if (instant > after && (next === null || instant < next)) {
|
|
98
|
+
next = instant;
|
|
99
|
+
// A wall time in a gap lands the gap's length later, so a match between it and where it landed comes first
|
|
100
|
+
// (with a 30-minute gap, 02:10 lands at 02:40, after 02:35).
|
|
101
|
+
until = toWallTime(instant, tz) - SECOND;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
/** Occurrences strictly after `after`, ascending, strictly increasing (no duplicate instants), in `tz`. */
|
|
107
|
+
export function* cronOccurrences(cron, after, tz) {
|
|
108
|
+
for (let next = nextCron(cron, after, tz); next !== null; next = nextCron(cron, next, tz)) {
|
|
109
|
+
yield next;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** The first wall time at or after `from` (a whole second) that every field allows, or null past `until`. */
|
|
113
|
+
function nextMatch(cron, from, until) {
|
|
114
|
+
let wall = from;
|
|
115
|
+
// A field that doesn't allow its value moves to its next allowed one, resetting the fields below; when none is
|
|
116
|
+
// left, it moves the field above instead.
|
|
117
|
+
while (wall <= until) {
|
|
118
|
+
const date = new Date(wall);
|
|
119
|
+
const year = date.getUTCFullYear();
|
|
120
|
+
const month = date.getUTCMonth() + 1;
|
|
121
|
+
const day = date.getUTCDate();
|
|
122
|
+
const hour = date.getUTCHours();
|
|
123
|
+
const minute = date.getUTCMinutes();
|
|
124
|
+
const second = date.getUTCSeconds();
|
|
125
|
+
const nextMonth = following(cron.months, month);
|
|
126
|
+
if (nextMonth !== month) {
|
|
127
|
+
wall = nextMonth === undefined ? Date.UTC(year + 1, cron.months[0] - 1, 1) : Date.UTC(year, nextMonth - 1, 1);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (!dayMatches(cron, year, month, day, date.getUTCDay())) {
|
|
131
|
+
wall = Date.UTC(year, month - 1, day + 1);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const nextHour = following(cron.hours, hour);
|
|
135
|
+
if (nextHour !== hour) {
|
|
136
|
+
wall = nextHour === undefined ? Date.UTC(year, month - 1, day + 1) : Date.UTC(year, month - 1, day, nextHour);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const nextMinute = following(cron.minutes, minute);
|
|
140
|
+
if (nextMinute !== minute) {
|
|
141
|
+
wall = nextMinute === undefined ? Date.UTC(year, month - 1, day, hour + 1) : Date.UTC(year, month - 1, day, hour, nextMinute);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const nextSecond = following(cron.seconds, second);
|
|
145
|
+
if (nextSecond !== second) {
|
|
146
|
+
wall =
|
|
147
|
+
nextSecond === undefined ? Date.UTC(year, month - 1, day, hour, minute + 1) : Date.UTC(year, month - 1, day, hour, minute, nextSecond);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
return wall;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
function dayMatches(cron, year, month, day, weekday) {
|
|
155
|
+
const length = daysIn(year, month);
|
|
156
|
+
const byMonthDay = cron.daysOfMonth.includes(day) || (cron.lastDayOfMonth && day === length);
|
|
157
|
+
const byWeekday = cron.daysOfWeek.includes(weekday) ||
|
|
158
|
+
(cron.lastWeekdays.includes(weekday) && day + 7 > length) ||
|
|
159
|
+
cron.nthWeekdays.some((nth) => nth.weekday === weekday && Math.ceil(day / 7) === nth.nth);
|
|
160
|
+
return cron.dayOfMonthRestricted && cron.dayOfWeekRestricted ? byMonthDay || byWeekday : byMonthDay && byWeekday;
|
|
161
|
+
}
|
|
162
|
+
function parseDaysOfMonth(text, fail) {
|
|
163
|
+
const items = text.split(',');
|
|
164
|
+
const lastDayOfMonth = items.some((item) => item.toUpperCase() === 'L');
|
|
165
|
+
const rest = items.filter((item) => item.toUpperCase() !== 'L');
|
|
166
|
+
return { daysOfMonth: rest.length > 0 ? parseList(rest.join(','), DAYS_OF_MONTH, fail) : [], lastDayOfMonth };
|
|
167
|
+
}
|
|
168
|
+
function parseDaysOfWeek(text, fail) {
|
|
169
|
+
const lastWeekdays = new Set();
|
|
170
|
+
const nthWeekdays = [];
|
|
171
|
+
const rest = [];
|
|
172
|
+
for (const item of text.split(',')) {
|
|
173
|
+
const last = /^(\w+)L$/i.exec(item);
|
|
174
|
+
const nth = /^(\w+)#(.*)$/.exec(item);
|
|
175
|
+
if (last) {
|
|
176
|
+
lastWeekdays.add(parseValue(last[1], DAYS_OF_WEEK, fail) % 7);
|
|
177
|
+
}
|
|
178
|
+
else if (nth) {
|
|
179
|
+
if (!/^[1-5]$/.test(nth[2])) {
|
|
180
|
+
fail(`k in n#k is 1-5 (1#1 is the first Monday of the month)`);
|
|
181
|
+
}
|
|
182
|
+
nthWeekdays.push({ weekday: parseValue(nth[1], DAYS_OF_WEEK, fail) % 7, nth: Number(nth[2]) });
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
rest.push(item);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const daysOfWeek = rest.length > 0 ? parseList(rest.join(','), DAYS_OF_WEEK, fail).map((day) => day % 7) : [];
|
|
189
|
+
return { daysOfWeek: [...new Set(daysOfWeek)].sort((a, b) => a - b), lastWeekdays: [...lastWeekdays].sort((a, b) => a - b), nthWeekdays };
|
|
190
|
+
}
|
|
191
|
+
/** The values a list of `*`, `?`, values, ranges and steps allows, ascending. */
|
|
192
|
+
function parseList(text, field, fail) {
|
|
193
|
+
const values = new Set();
|
|
194
|
+
for (const item of text.split(',')) {
|
|
195
|
+
if (item === '') {
|
|
196
|
+
fail('a list item is empty');
|
|
197
|
+
}
|
|
198
|
+
const match = /^(?:([*?])|([^-/]+)(?:-([^-/]+))?)(?:\/(.*))?$/.exec(item);
|
|
199
|
+
if (!match) {
|
|
200
|
+
fail(field.accepts);
|
|
201
|
+
}
|
|
202
|
+
const [, any, low, high, step] = match;
|
|
203
|
+
if (step !== undefined && !/^\d+$/.test(step)) {
|
|
204
|
+
fail('a step is a whole number, such as */15');
|
|
205
|
+
}
|
|
206
|
+
const by = step === undefined ? 1 : Number(step);
|
|
207
|
+
if (by === 0) {
|
|
208
|
+
fail('a step of 0 never moves; use a positive one, such as */15');
|
|
209
|
+
}
|
|
210
|
+
const from = any ? field.min : parseValue(low, field, fail);
|
|
211
|
+
const to = any ? field.max : high !== undefined ? parseValue(high, field, fail) : step !== undefined ? field.max : from;
|
|
212
|
+
if (from > to) {
|
|
213
|
+
fail(`the range ${low}-${high} is reversed; put the lower bound first`);
|
|
214
|
+
}
|
|
215
|
+
for (let value = from; value <= to; value += by) {
|
|
216
|
+
values.add(value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return [...values].sort((a, b) => a - b);
|
|
220
|
+
}
|
|
221
|
+
function parseValue(text, field, fail) {
|
|
222
|
+
const named = field.names?.indexOf(text.toUpperCase()) ?? -1;
|
|
223
|
+
if (named >= 0) {
|
|
224
|
+
return field.min + named;
|
|
225
|
+
}
|
|
226
|
+
if (!/^\d+$/.test(text) || Number(text) < field.min || Number(text) > field.max) {
|
|
227
|
+
fail(field.accepts);
|
|
228
|
+
}
|
|
229
|
+
return Number(text);
|
|
230
|
+
}
|
|
231
|
+
/** Why a cron never matches, when it's because every day of the month it allows is past the end of its months. */
|
|
232
|
+
function neverReason(cron) {
|
|
233
|
+
const [first] = cron.daysOfMonth;
|
|
234
|
+
if (cron.lastDayOfMonth || first === undefined || cron.months.some((month) => LONGEST_MONTHS[month - 1] >= first)) {
|
|
235
|
+
return '';
|
|
236
|
+
}
|
|
237
|
+
const months = cron.months.map((month) => MONTH_NAMES[month - 1]);
|
|
238
|
+
return ` (${join(months, 'and')} ${months.length === 1 ? 'has' : 'have'} no ${join(cron.daysOfMonth.map(ordinal), 'or')})`;
|
|
239
|
+
}
|
|
240
|
+
function following(values, from) {
|
|
241
|
+
for (const value of values) {
|
|
242
|
+
if (value >= from) {
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
function daysIn(year, month) {
|
|
249
|
+
return (Date.UTC(year, month, 1) - Date.UTC(year, month - 1, 1)) / DAY;
|
|
250
|
+
}
|
|
251
|
+
function addYears(wall, years) {
|
|
252
|
+
const date = new Date(wall);
|
|
253
|
+
date.setUTCFullYear(date.getUTCFullYear() + years);
|
|
254
|
+
return date.getTime();
|
|
255
|
+
}
|
|
256
|
+
function ordinal(n) {
|
|
257
|
+
const suffix = n % 100 >= 11 && n % 100 <= 13 ? 'th' : (['th', 'st', 'nd', 'rd'][n % 10] ?? 'th');
|
|
258
|
+
return `${n}${suffix}`;
|
|
259
|
+
}
|
|
260
|
+
function join(items, conjunction) {
|
|
261
|
+
return items.length === 1 ? items[0] : `${items.slice(0, -1).join(', ')} ${conjunction} ${items[items.length - 1]}`;
|
|
262
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LoggerService } from '@nestjs/common';
|
|
2
|
+
import { type PayloadCodecs } from '../codecs/payload-codecs.js';
|
|
3
|
+
import type { PayloadContext } from '../interfaces/payload-codec.interface.js';
|
|
4
|
+
import type { ScheduleClaimRequest, ScheduleQuery, ScheduleRecord, ScheduleSave, ScheduleStore, ScheduleWrite } from '../interfaces/schedule-store.interface.js';
|
|
5
|
+
/**
|
|
6
|
+
* @internal A `ScheduleStore` as the `Scheduler` sees it: payloads encoded on the way in and decoded on the way out,
|
|
7
|
+
* so the store sees only what the codec returns. A claimed schedule no codec can read any more is left out of the
|
|
8
|
+
* claim (logged) and handed back, due when its lease would have ended, and read once the codec is back.
|
|
9
|
+
*/
|
|
10
|
+
export declare class EncodedScheduleStore implements ScheduleStore {
|
|
11
|
+
readonly inner: ScheduleStore;
|
|
12
|
+
private readonly codecs;
|
|
13
|
+
private readonly context;
|
|
14
|
+
private readonly logger;
|
|
15
|
+
constructor(inner: ScheduleStore, codecs: PayloadCodecs, context: (schedule: string) => PayloadContext, logger: Pick<LoggerService, 'error'>);
|
|
16
|
+
/** Whether a codec encodes what is written. */
|
|
17
|
+
get encodes(): boolean;
|
|
18
|
+
saveSchedule(save: ScheduleSave): Promise<ScheduleRecord | null>;
|
|
19
|
+
getSchedule(id: string): Promise<ScheduleRecord | null>;
|
|
20
|
+
listSchedules(query: ScheduleQuery): Promise<ScheduleRecord[]>;
|
|
21
|
+
deleteSchedule(id: string, revision?: number): Promise<boolean>;
|
|
22
|
+
claimSchedules(request: ScheduleClaimRequest): Promise<ScheduleRecord[]>;
|
|
23
|
+
writeSchedule(id: string, token: string, write: ScheduleWrite): Promise<boolean>;
|
|
24
|
+
/** A schedule as stored, with its payload decoded. Throws if no listed codec can decode it. */
|
|
25
|
+
decode(record: ScheduleRecord): Promise<ScheduleRecord>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { isEncodedPayload } from '../codecs/payload-codecs.js';
|
|
2
|
+
/**
|
|
3
|
+
* @internal A `ScheduleStore` as the `Scheduler` sees it: payloads encoded on the way in and decoded on the way out,
|
|
4
|
+
* so the store sees only what the codec returns. A claimed schedule no codec can read any more is left out of the
|
|
5
|
+
* claim (logged) and handed back, due when its lease would have ended, and read once the codec is back.
|
|
6
|
+
*/
|
|
7
|
+
export class EncodedScheduleStore {
|
|
8
|
+
constructor(inner, codecs, context, logger) {
|
|
9
|
+
this.inner = inner;
|
|
10
|
+
this.codecs = codecs;
|
|
11
|
+
this.context = context;
|
|
12
|
+
this.logger = logger;
|
|
13
|
+
}
|
|
14
|
+
/** Whether a codec encodes what is written. */
|
|
15
|
+
get encodes() {
|
|
16
|
+
return this.codecs.encodes;
|
|
17
|
+
}
|
|
18
|
+
async saveSchedule(save) {
|
|
19
|
+
const payload = await this.codecs.encode(save.payload, this.context(save.id));
|
|
20
|
+
const saved = await this.inner.saveSchedule({ ...save, payload });
|
|
21
|
+
return saved && this.decode(saved);
|
|
22
|
+
}
|
|
23
|
+
async getSchedule(id) {
|
|
24
|
+
const record = await this.inner.getSchedule(id);
|
|
25
|
+
return record && this.decode(record);
|
|
26
|
+
}
|
|
27
|
+
async listSchedules(query) {
|
|
28
|
+
return Promise.all((await this.inner.listSchedules(query)).map((record) => this.decode(record)));
|
|
29
|
+
}
|
|
30
|
+
deleteSchedule(id, revision) {
|
|
31
|
+
return this.inner.deleteSchedule(id, revision);
|
|
32
|
+
}
|
|
33
|
+
async claimSchedules(request) {
|
|
34
|
+
const decoded = [];
|
|
35
|
+
for (const record of await this.inner.claimSchedules(request)) {
|
|
36
|
+
try {
|
|
37
|
+
decoded.push(await this.decode(record));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
this.logger.error(`Schedule "${record.id}" can't be read, so it isn't run; it is claimed again later. ${error.message}`);
|
|
41
|
+
// Handed back untouched, due when its lease would have ended; at worst its lease expires, as if its worker died.
|
|
42
|
+
await this.inner.writeSchedule(record.id, request.token, { now: request.now, state: record.state, wakeAt: request.leaseUntil, release: true }).catch(() => undefined);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return decoded;
|
|
46
|
+
}
|
|
47
|
+
writeSchedule(id, token, write) {
|
|
48
|
+
return this.inner.writeSchedule(id, token, write);
|
|
49
|
+
}
|
|
50
|
+
/** A schedule as stored, with its payload decoded. Throws if no listed codec can decode it. */
|
|
51
|
+
async decode(record) {
|
|
52
|
+
return isEncodedPayload(record.payload) ? { ...record, payload: await this.codecs.decode(record.payload, this.context(record.id)) } : record;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ScheduleClaimRequest, ScheduleQuery, ScheduleRecord, ScheduleSave, ScheduleStore, ScheduleWrite } from '../interfaces/schedule-store.interface.js';
|
|
2
|
+
/**
|
|
3
|
+
* A `ScheduleStore` in this process's memory: for tests, and for a package's in-memory store (the in-memory workflow
|
|
4
|
+
* store keeps its schedules in one). Every method does its work before its first `await`, so calls never interleave,
|
|
5
|
+
* which makes claims, fenced writes and conditional saves atomic without locks. Values are copied on the way in and
|
|
6
|
+
* out, as a database would.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const scheduler = new Scheduler({ store: new InMemoryScheduleStore(), ... });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export declare class InMemoryScheduleStore implements ScheduleStore {
|
|
13
|
+
private readonly schedules;
|
|
14
|
+
saveSchedule(save: ScheduleSave): Promise<ScheduleRecord | null>;
|
|
15
|
+
getSchedule(id: string): Promise<ScheduleRecord | null>;
|
|
16
|
+
listSchedules(query: ScheduleQuery): Promise<ScheduleRecord[]>;
|
|
17
|
+
deleteSchedule(id: string, revision?: number): Promise<boolean>;
|
|
18
|
+
claimSchedules(request: ScheduleClaimRequest): Promise<ScheduleRecord[]>;
|
|
19
|
+
writeSchedule(id: string, token: string, write: ScheduleWrite): Promise<boolean>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `ScheduleStore` in this process's memory: for tests, and for a package's in-memory store (the in-memory workflow
|
|
3
|
+
* store keeps its schedules in one). Every method does its work before its first `await`, so calls never interleave,
|
|
4
|
+
* which makes claims, fenced writes and conditional saves atomic without locks. Values are copied on the way in and
|
|
5
|
+
* out, as a database would.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const scheduler = new Scheduler({ store: new InMemoryScheduleStore(), ... });
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export class InMemoryScheduleStore {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.schedules = new Map();
|
|
14
|
+
}
|
|
15
|
+
async saveSchedule(save) {
|
|
16
|
+
const existing = this.schedules.get(save.id);
|
|
17
|
+
if (save.expectRevision === null ? existing !== undefined : existing?.record.revision !== save.expectRevision) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const { expectRevision: _expect, releaseLease, now, ...fields } = save;
|
|
21
|
+
const record = {
|
|
22
|
+
...copy(fields),
|
|
23
|
+
revision: (existing?.record.revision ?? 0) + 1,
|
|
24
|
+
leaseOwner: existing?.record.leaseOwner ?? null,
|
|
25
|
+
leaseUntil: releaseLease ? null : (existing?.record.leaseUntil ?? null),
|
|
26
|
+
createdAt: existing?.record.createdAt ?? now,
|
|
27
|
+
updatedAt: now,
|
|
28
|
+
};
|
|
29
|
+
this.schedules.set(save.id, { record, leaseToken: releaseLease ? null : (existing?.leaseToken ?? null) });
|
|
30
|
+
return copy(record);
|
|
31
|
+
}
|
|
32
|
+
async getSchedule(id) {
|
|
33
|
+
const schedule = this.schedules.get(id);
|
|
34
|
+
return schedule ? copy(schedule.record) : null;
|
|
35
|
+
}
|
|
36
|
+
async listSchedules(query) {
|
|
37
|
+
return [...this.schedules.values()]
|
|
38
|
+
.map((schedule) => schedule.record)
|
|
39
|
+
.filter((record) => (query.target === undefined || record.target === query.target) && (query.declared === undefined || record.declared === query.declared))
|
|
40
|
+
.sort((a, b) => compare(a.id, b.id))
|
|
41
|
+
.slice(query.offset, query.offset + query.limit)
|
|
42
|
+
.map(copy);
|
|
43
|
+
}
|
|
44
|
+
async deleteSchedule(id, revision) {
|
|
45
|
+
const schedule = this.schedules.get(id);
|
|
46
|
+
if (!schedule || (revision !== undefined && schedule.record.revision !== revision)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return this.schedules.delete(id);
|
|
50
|
+
}
|
|
51
|
+
async claimSchedules(request) {
|
|
52
|
+
const targets = new Set(request.targets);
|
|
53
|
+
const due = [...this.schedules.values()]
|
|
54
|
+
.filter(({ record }) => !record.paused &&
|
|
55
|
+
record.wakeAt !== null &&
|
|
56
|
+
record.wakeAt <= request.now &&
|
|
57
|
+
(record.leaseUntil === null || record.leaseUntil < request.now) &&
|
|
58
|
+
targets.has(record.target))
|
|
59
|
+
.sort((a, b) => a.record.wakeAt - b.record.wakeAt || compare(a.record.id, b.record.id))
|
|
60
|
+
.slice(0, request.limit);
|
|
61
|
+
for (const schedule of due) {
|
|
62
|
+
schedule.leaseToken = request.token;
|
|
63
|
+
schedule.record.leaseOwner = request.owner;
|
|
64
|
+
schedule.record.leaseUntil = request.leaseUntil;
|
|
65
|
+
}
|
|
66
|
+
return due.map((schedule) => copy(schedule.record));
|
|
67
|
+
}
|
|
68
|
+
async writeSchedule(id, token, write) {
|
|
69
|
+
const schedule = this.schedules.get(id);
|
|
70
|
+
if (!schedule || schedule.leaseToken === null || schedule.leaseToken !== token) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const { record } = schedule;
|
|
74
|
+
record.state = copy(write.state);
|
|
75
|
+
record.wakeAt = write.wakeAt;
|
|
76
|
+
record.revision++;
|
|
77
|
+
record.updatedAt = write.now;
|
|
78
|
+
if (write.release) {
|
|
79
|
+
schedule.leaseToken = null;
|
|
80
|
+
record.leaseUntil = null;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function copy(value) {
|
|
86
|
+
return value === undefined ? value : structuredClone(value);
|
|
87
|
+
}
|
|
88
|
+
function compare(a, b) {
|
|
89
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
90
|
+
}
|