@procedures/schedule 0.0.0-stage → 0.3.0

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 (75) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +98 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/cron.d.ts +47 -0
  6. package/dist/cron.d.ts.map +1 -0
  7. package/dist/cron.js +197 -0
  8. package/dist/cron.js.map +1 -0
  9. package/dist/errors.d.ts +22 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +13 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +11 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/module.d.ts +42 -0
  18. package/dist/module.d.ts.map +1 -0
  19. package/dist/module.js +174 -0
  20. package/dist/module.js.map +1 -0
  21. package/dist/procedures/schedule/add.d.ts +17 -0
  22. package/dist/procedures/schedule/add.d.ts.map +1 -0
  23. package/dist/procedures/schedule/add.js +66 -0
  24. package/dist/procedures/schedule/add.js.map +1 -0
  25. package/dist/procedures/schedule/index.d.ts +5 -0
  26. package/dist/procedures/schedule/index.d.ts.map +1 -0
  27. package/dist/procedures/schedule/index.js +5 -0
  28. package/dist/procedures/schedule/index.js.map +1 -0
  29. package/dist/procedures/schedule/list.d.ts +12 -0
  30. package/dist/procedures/schedule/list.d.ts.map +1 -0
  31. package/dist/procedures/schedule/list.js +18 -0
  32. package/dist/procedures/schedule/list.js.map +1 -0
  33. package/dist/procedures/schedule/next.d.ts +13 -0
  34. package/dist/procedures/schedule/next.d.ts.map +1 -0
  35. package/dist/procedures/schedule/next.js +24 -0
  36. package/dist/procedures/schedule/next.js.map +1 -0
  37. package/dist/procedures/schedule/remove.d.ts +12 -0
  38. package/dist/procedures/schedule/remove.d.ts.map +1 -0
  39. package/dist/procedures/schedule/remove.js +13 -0
  40. package/dist/procedures/schedule/remove.js.map +1 -0
  41. package/dist/programs.d.ts +26 -0
  42. package/dist/programs.d.ts.map +1 -0
  43. package/dist/programs.js +34 -0
  44. package/dist/programs.js.map +1 -0
  45. package/dist/register.d.ts +6 -0
  46. package/dist/register.d.ts.map +1 -0
  47. package/dist/register.js +8 -0
  48. package/dist/register.js.map +1 -0
  49. package/dist/scheduler.d.ts +106 -0
  50. package/dist/scheduler.d.ts.map +1 -0
  51. package/dist/scheduler.js +274 -0
  52. package/dist/scheduler.js.map +1 -0
  53. package/dist/store.d.ts +62 -0
  54. package/dist/store.d.ts.map +1 -0
  55. package/dist/store.js +188 -0
  56. package/dist/store.js.map +1 -0
  57. package/dist/types.d.ts +100 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +8 -0
  60. package/dist/types.js.map +1 -0
  61. package/package.json +78 -3
  62. package/src/cron.ts +230 -0
  63. package/src/errors.ts +27 -0
  64. package/src/index.ts +10 -0
  65. package/src/module.ts +227 -0
  66. package/src/procedures/schedule/add.ts +66 -0
  67. package/src/procedures/schedule/index.ts +4 -0
  68. package/src/procedures/schedule/list.ts +20 -0
  69. package/src/procedures/schedule/next.ts +25 -0
  70. package/src/procedures/schedule/remove.ts +15 -0
  71. package/src/programs.ts +43 -0
  72. package/src/register.ts +10 -0
  73. package/src/scheduler.ts +331 -0
  74. package/src/store.ts +213 -0
  75. package/src/types.ts +122 -0
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The stored programs that a schedule starts, and the settings of the procedures.
3
+ *
4
+ * A schedule names a stored program: a procedure that `procedure.define` made, or a program of
5
+ * the program store of the registry (`@procedures/store`). A procedure of code is not a program:
6
+ * a schedule cannot start `shell.run` directly.
7
+ */
8
+
9
+ import { isRuntimeDefined, keyToPath, type ProcedureRegistryLike } from "@procedures/client";
10
+ import { getProgramStore } from "@procedures/store";
11
+ import { ScheduleError } from "./errors.js";
12
+ import { defaultScheduleStore, type ScheduleStore } from "./store.js";
13
+
14
+ /** What the procedures of a module use: the store and the wall clock. */
15
+ export interface ScheduleSettings {
16
+ readonly store: ScheduleStore;
17
+ /** The wall time, in milliseconds since 1970. */
18
+ readonly now: () => number;
19
+ }
20
+
21
+ /** The settings of the default module: `defaultScheduleStore` and `Date.now`. */
22
+ export const DEFAULT_SCHEDULE_SETTINGS: ScheduleSettings = Object.freeze({ store: defaultScheduleStore, now: () => Date.now() });
23
+
24
+ /** The form of the key of a program: segments without spaces, joined with dots. */
25
+ export const PROGRAM_KEY: RegExp = /^[^.\s]+(\.[^.\s]+)*$/;
26
+
27
+ /**
28
+ * This function tells where the stored program of a key is: in the registry (a procedure that
29
+ * `procedure.define` made), or only in the program store of the registry. It throws
30
+ * `NOT_A_PROGRAM` for a procedure of code and `NOT_FOUND` for no program.
31
+ */
32
+ export async function findProgram(registry: ProcedureRegistryLike, key: string): Promise<"registry" | "store"> {
33
+ const procedure = registry.get(keyToPath(key));
34
+ if (procedure !== undefined) {
35
+ if (!isRuntimeDefined(procedure)) {
36
+ throw new ScheduleError("NOT_A_PROGRAM", `${key} is a procedure of code, not a stored program. A schedule starts only a program of procedure.define or of the program store.`);
37
+ }
38
+ return "registry";
39
+ }
40
+ const programs = getProgramStore(registry);
41
+ if (programs !== undefined && (await programs.get(key)) !== undefined) return "store";
42
+ throw new ScheduleError("NOT_FOUND", `No stored program has the key ${key}: it is not in the registry and not in its program store`);
43
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Importing this file registers the procedures of @procedures/schedule in the global registry:
3
+ * `import "@procedures/schedule/register"`. The package root registers nothing.
4
+ */
5
+
6
+ import { registerScheduleProcedures } from "./module.js";
7
+
8
+ export * from "./module.js";
9
+
10
+ registerScheduleProcedures();
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The scheduler: a host starts it, and it starts the stored programs of the schedules at their
3
+ * times (roadmap R46).
4
+ *
5
+ * - It wakes at each minute (the unit of cron), reads the schedules of its store, and starts each
6
+ * schedule whose time came. A schedule that a procedure adds or removes counts at the next minute.
7
+ * - A run goes through the registry, with the capability of the host narrowed by the capability
8
+ * of the caller that added the schedule. The expose rule, the call rule, the interceptors and
9
+ * the resources of the host policy apply too.
10
+ * - A schedule has at most one run at a time. A time that comes during a run does not start a
11
+ * second run: the store counts it as skipped.
12
+ * - The store records the last run of each schedule: its start, its time and its outcome.
13
+ * - A run that passes `runTimeoutMs` gets the abort of its signal and the outcome `TIMEOUT`. The
14
+ * schedule stays busy until the handler ends: a handler that does not stop blocks only its own
15
+ * schedule.
16
+ * - The scheduler does not run the times that it missed while it was stopped. Times that it
17
+ * missed during a run or a slow minute give one run, not one for each time.
18
+ * - The timers use an injectable `Clock` (`after` and `now`), and the calendar uses an injectable
19
+ * wall clock (`now`, default `Date.now`). A test gives `fakeClock` of `@mark1russell7/service/testing`.
20
+ */
21
+
22
+ import {
23
+ InvocationError,
24
+ PROCEDURE_REGISTRY,
25
+ invokePath,
26
+ invokeProcedure,
27
+ isRuntimeDefined,
28
+ keyToPath,
29
+ narrowCapability,
30
+ outputValue,
31
+ realClock,
32
+ type Cancel,
33
+ type Capability,
34
+ type Clock,
35
+ type HostPolicy,
36
+ type InvokeOptions,
37
+ type ProcedureRegistry,
38
+ } from "@procedures/client";
39
+ import { nextTime, parseCron } from "./cron.js";
40
+ import { ScheduleError } from "./errors.js";
41
+ import { findProgram } from "./programs.js";
42
+ import { defaultScheduleStore, type ScheduleStore } from "./store.js";
43
+ import type { Schedule, ScheduleRun } from "./types.js";
44
+
45
+ /** The default time limit of a run: 10 minutes. */
46
+ export const DEFAULT_RUN_TIMEOUT_MS = 600_000;
47
+
48
+ /** What a host gets after each run and each skipped time. */
49
+ export interface ScheduleRunEvent {
50
+ readonly id: string;
51
+ readonly program: string;
52
+ readonly outcome: "success" | "failure" | "skipped";
53
+ /** The wall time of the start, in milliseconds since 1970. */
54
+ readonly startedAt: number;
55
+ readonly durationMs: number;
56
+ readonly error?: { readonly code?: string | undefined; readonly message: string } | undefined;
57
+ }
58
+
59
+ /** The options of a `Scheduler`. */
60
+ export interface SchedulerOptions {
61
+ /** The registry of the programs. The default is the global registry. */
62
+ readonly registry?: ProcedureRegistry | undefined;
63
+ /** The store of the schedules: the store of the module of the procedures. The default is `defaultScheduleStore`. */
64
+ readonly store?: ScheduleStore | undefined;
65
+ /** The capability of the host for each run. The default is the capability of `policy`, else no limits. */
66
+ readonly capability?: Capability | undefined;
67
+ /** The policy of the host: its expose rule, call rule, interceptors, clearance order, resources and capability. */
68
+ readonly policy?: HostPolicy | undefined;
69
+ /** The clock of the timers and of the durations. The default is `realClock`. */
70
+ readonly clock?: Clock | undefined;
71
+ /** The wall time, in milliseconds since 1970. The default is `Date.now`. */
72
+ readonly now?: (() => number) | undefined;
73
+ /** The time limit of a run, in milliseconds. 0: no limit. The default is 10 minutes. */
74
+ readonly runTimeoutMs?: number | undefined;
75
+ /** This function gets each run and each skipped time. */
76
+ readonly onRun?: ((event: ScheduleRunEvent) => void) | undefined;
77
+ /** This function gets an error of the scheduler itself: a store that cannot be read, for example. */
78
+ readonly onError?: ((error: unknown) => void) | undefined;
79
+ }
80
+
81
+ /** The next time of one schedule, for the version of the schedule that it came from. */
82
+ interface Due {
83
+ readonly version: string;
84
+ at: number | undefined;
85
+ }
86
+
87
+ const versionOf = (schedule: Schedule): string => `${schedule.createdAt}|${schedule.cron}|${schedule.timeZone}`;
88
+
89
+ /** The code and the message of an error. */
90
+ function errorInfo(error: unknown): { code?: string; message: string } {
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ const code = (error as { code?: unknown } | null)?.code;
93
+ return typeof code === "string" ? { code, message } : { message };
94
+ }
95
+
96
+ /** The next time of a schedule after a time, or undefined for an expression that is not correct. */
97
+ function nextOf(schedule: Schedule, after: number): number | undefined {
98
+ try {
99
+ return nextTime(parseCron(schedule.cron), after, schedule.timeZone);
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /** A scheduler of the stored programs. See the comment of the file. */
106
+ export class Scheduler {
107
+ private readonly registry: ProcedureRegistry;
108
+ private readonly store: ScheduleStore;
109
+ private readonly clock: Clock;
110
+ private readonly now: () => number;
111
+ private readonly runTimeoutMs: number;
112
+ private readonly options: SchedulerOptions;
113
+ private readonly due = new Map<string, Due>();
114
+ private readonly runs = new Map<string, { promise: Promise<void>; controller: AbortController }>();
115
+ private startedAt: number | undefined;
116
+ private cancel: Cancel | undefined;
117
+ private ticking: Promise<void> | undefined;
118
+
119
+ constructor(options: SchedulerOptions = {}) {
120
+ this.options = options;
121
+ this.registry = options.registry ?? PROCEDURE_REGISTRY;
122
+ this.store = options.store ?? defaultScheduleStore;
123
+ this.clock = options.clock ?? realClock;
124
+ this.now = options.now ?? (() => Date.now());
125
+ this.runTimeoutMs = options.runTimeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
126
+ if (!(this.runTimeoutMs >= 0)) throw new TypeError("Scheduler: runTimeoutMs must be 0 or more");
127
+ }
128
+
129
+ /** True after `start()` and before `stop()`. */
130
+ get started(): boolean {
131
+ return this.startedAt !== undefined;
132
+ }
133
+
134
+ /** The names of the schedules with a run that has not ended. */
135
+ get active(): string[] {
136
+ return [...this.runs.keys()].sort();
137
+ }
138
+
139
+ /** This method starts the scheduler: the first times come after now. It gives the end of the first examination. */
140
+ async start(): Promise<void> {
141
+ if (this.startedAt !== undefined) return;
142
+ this.startedAt = this.now();
143
+ await this.tick();
144
+ }
145
+
146
+ /** This method stops the timer, aborts the runs, and gives the end of the runs. */
147
+ async stop(): Promise<void> {
148
+ this.startedAt = undefined;
149
+ this.cancel?.();
150
+ this.cancel = undefined;
151
+ this.due.clear();
152
+ for (const run of this.runs.values()) run.controller.abort(new Error("The scheduler stopped"));
153
+ await this.settled();
154
+ }
155
+
156
+ /** This method gives the end of the examination in progress and of the runs in progress. */
157
+ async settled(): Promise<void> {
158
+ await this.ticking;
159
+ await Promise.all([...this.runs.values()].map((run) => run.promise));
160
+ }
161
+
162
+ /**
163
+ * This method examines the schedules now and starts each one whose time came. It does not wait
164
+ * for the runs. A second call during an examination gives the same examination.
165
+ */
166
+ tick(): Promise<void> {
167
+ this.ticking ??= this.examine().finally(() => {
168
+ this.ticking = undefined;
169
+ this.arm();
170
+ });
171
+ return this.ticking;
172
+ }
173
+
174
+ /** This method starts one run of a schedule now, unless it has a run in progress. It gives the event of the run. */
175
+ async runNow(id: string): Promise<ScheduleRunEvent> {
176
+ const schedule = await this.store.get(id);
177
+ if (schedule === undefined) throw new ScheduleError("NOT_FOUND", `No schedule is named ${id}`);
178
+ return new Promise<ScheduleRunEvent>((resolve) => {
179
+ if (!this.launch(schedule, this.now(), resolve)) resolve(this.skip(schedule, this.now()));
180
+ });
181
+ }
182
+
183
+ private async examine(): Promise<void> {
184
+ if (this.startedAt === undefined) return;
185
+ const now = this.now();
186
+ let schedules: Schedule[];
187
+ try {
188
+ schedules = await this.store.list();
189
+ } catch (error) {
190
+ this.options.onError?.(error);
191
+ return;
192
+ }
193
+ const ids = new Set(schedules.map((schedule) => schedule.id));
194
+ for (const id of this.due.keys()) if (!ids.has(id)) this.due.delete(id);
195
+ for (const schedule of schedules) {
196
+ // A stop during the read of the store starts nothing
197
+ if (this.startedAt === undefined) return;
198
+ const version = versionOf(schedule);
199
+ let due = this.due.get(schedule.id);
200
+ if (due === undefined || due.version !== version) {
201
+ // A new or a changed schedule: its first time is after it was added, and after the start
202
+ due = { version, at: nextOf(schedule, Math.max(schedule.createdAt, this.startedAt) - 1) };
203
+ this.due.set(schedule.id, due);
204
+ }
205
+ if (due.at === undefined || due.at > now) continue;
206
+ // The times that passed give one run: the next time comes after now
207
+ due.at = nextOf(schedule, now);
208
+ if (!this.launch(schedule, now)) void this.skip(schedule, now);
209
+ }
210
+ }
211
+
212
+ /** This method arms the timer for the next minute, or for an earlier time that is due. */
213
+ private arm(): void {
214
+ if (this.startedAt === undefined) return;
215
+ this.cancel?.();
216
+ const now = this.now();
217
+ let wake = Math.floor(now / 60_000) * 60_000 + 60_000;
218
+ for (const due of this.due.values()) if (due.at !== undefined && due.at < wake) wake = due.at;
219
+ this.cancel = this.clock.after(Math.max(0, wake - now), () => {
220
+ this.cancel = undefined;
221
+ void this.tick();
222
+ });
223
+ }
224
+
225
+ /** This method gives an event to `onRun`. An error of `onRun` goes to `onError`, not to the scheduler. */
226
+ private report(event: ScheduleRunEvent): void {
227
+ try {
228
+ this.options.onRun?.(event);
229
+ } catch (error) {
230
+ this.options.onError?.(error);
231
+ }
232
+ }
233
+
234
+ /** This method records a time that came during a run of the same schedule. */
235
+ private skip(schedule: Schedule, now: number): ScheduleRunEvent {
236
+ const event: ScheduleRunEvent = { id: schedule.id, program: schedule.program, outcome: "skipped", startedAt: now, durationMs: 0 };
237
+ void this.store
238
+ .update(schedule.id, (current) => (current.createdAt === schedule.createdAt ? { ...current, skipped: current.skipped + 1 } : undefined))
239
+ .catch((error: unknown) => this.options.onError?.(error));
240
+ this.report(event);
241
+ return event;
242
+ }
243
+
244
+ /** This method starts a run, unless the schedule has one. It gives false for a schedule with a run. */
245
+ private launch(schedule: Schedule, startedAt: number, done?: (event: ScheduleRunEvent) => void): boolean {
246
+ if (this.runs.has(schedule.id)) return false;
247
+ const controller = new AbortController();
248
+ const promise = this.run(schedule, startedAt, controller).then((event) => {
249
+ this.runs.delete(schedule.id);
250
+ this.report(event);
251
+ done?.(event);
252
+ });
253
+ this.runs.set(schedule.id, { promise, controller });
254
+ return true;
255
+ }
256
+
257
+ /** One run: the program through the registry, then its record. */
258
+ private async run(schedule: Schedule, startedAt: number, controller: AbortController): Promise<ScheduleRunEvent> {
259
+ const start = this.clock.now();
260
+ let timedOut = false;
261
+ const stopTimer =
262
+ this.runTimeoutMs > 0
263
+ ? this.clock.after(this.runTimeoutMs, () => {
264
+ timedOut = true;
265
+ controller.abort(new Error(`The run of ${schedule.id} passed its time limit of ${this.runTimeoutMs} ms`));
266
+ })
267
+ : undefined;
268
+ let error: { code?: string; message: string } | undefined;
269
+ try {
270
+ await this.invoke(schedule, controller.signal);
271
+ } catch (caught) {
272
+ error = errorInfo(caught);
273
+ } finally {
274
+ stopTimer?.();
275
+ }
276
+ if (timedOut) error = { code: "TIMEOUT", message: `The run passed its time limit of ${this.runTimeoutMs} ms${error ? `: ${error.message}` : ""}` };
277
+ const run: ScheduleRun = {
278
+ startedAt,
279
+ durationMs: Math.max(0, this.clock.now() - start),
280
+ outcome: error === undefined ? "success" : "failure",
281
+ ...(error !== undefined ? { error } : {}),
282
+ };
283
+ try {
284
+ await this.store.update(schedule.id, (current) =>
285
+ current.createdAt === schedule.createdAt
286
+ ? { ...current, lastRun: run, runs: current.runs + 1, failures: current.failures + (error === undefined ? 0 : 1) }
287
+ : undefined,
288
+ );
289
+ } catch (caught) {
290
+ this.options.onError?.(caught);
291
+ }
292
+ return { id: schedule.id, program: schedule.program, outcome: run.outcome, startedAt, durationMs: run.durationMs, ...(error !== undefined ? { error } : {}) };
293
+ }
294
+
295
+ /** The options of an invocation: the policy of the host, a capability and the signal of the run. */
296
+ private invokeOptions(capability: Capability | undefined, signal: AbortSignal): InvokeOptions {
297
+ const policy = this.options.policy;
298
+ return {
299
+ registry: this.registry,
300
+ signal,
301
+ ...(capability !== undefined ? { capability } : {}),
302
+ ...(policy?.expose ? { expose: policy.expose } : {}),
303
+ ...(policy?.strictCalls ? { strictCalls: true } : {}),
304
+ ...(policy && policy.interceptors.length > 0 ? { interceptors: policy.interceptors } : {}),
305
+ ...(policy?.clearanceOrder ? { clearanceOrder: policy.clearanceOrder } : {}),
306
+ ...(policy?.resources ? { resources: policy.resources } : {}),
307
+ };
308
+ }
309
+
310
+ /** The program of a schedule through the registry, after a load from the program store when the registry does not have it. */
311
+ private async invoke(schedule: Schedule, signal: AbortSignal): Promise<void> {
312
+ const path = keyToPath(schedule.program);
313
+ const host = this.options.capability ?? this.options.policy?.capabilityOf?.();
314
+ if ((await findProgram(this.registry, schedule.program)) === "store") {
315
+ // The load defines the program: a step of the host, with the capability of the host
316
+ await outputValue(await invokePath(["procedure", "load"], { path }, this.invokeOptions(host, signal)), ["procedure", "load"]);
317
+ }
318
+ const procedure = this.registry.get(path);
319
+ if (procedure === undefined || !isRuntimeDefined(procedure)) {
320
+ throw new ScheduleError("NOT_FOUND", `The program ${schedule.program} is not in the registry after its load`);
321
+ }
322
+ const capability = schedule.capability === undefined ? host : narrowCapability(host, schedule.capability, this.options.policy?.clearanceOrder);
323
+ if (signal.aborted) throw new InvocationError("ABORTED", `The run of ${schedule.id} was aborted before its start`, path);
324
+ const output = await invokeProcedure(procedure, schedule.input ?? {}, this.invokeOptions(capability, signal));
325
+ if (output.kind === "stream") {
326
+ for await (const _item of output.items) {
327
+ // A program that gives a stream runs to its end
328
+ }
329
+ }
330
+ }
331
+ }
package/src/store.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The stores of the schedules: in memory, or in one JSON file.
3
+ *
4
+ * A host gives one store to the module of the procedures (`createScheduleModule({ store })`) and
5
+ * the same store to its `Scheduler`. `update()` changes one schedule in one step, so the record of
6
+ * a run does not lose a change of `schedule.add` that came at the same time.
7
+ */
8
+
9
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
10
+ import { dirname } from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+ import { ScheduleError } from "./errors.js";
13
+ import type { Schedule } from "./types.js";
14
+
15
+ /** A store of schedules. Each method gives a Promise: a caller cannot tell a local store from a remote one. */
16
+ export interface ScheduleStore {
17
+ /** The kind of the backend: "memory", "file" or another name. */
18
+ readonly kind: string;
19
+ get(id: string): Promise<Schedule | undefined>;
20
+ /** The schedules, sorted by name. */
21
+ list(): Promise<Schedule[]>;
22
+ /** Write a schedule. A schedule of the same name is replaced. */
23
+ put(schedule: Schedule): Promise<void>;
24
+ /** Delete a schedule. True when it existed. */
25
+ delete(id: string): Promise<boolean>;
26
+ /**
27
+ * Change one schedule in one step. `change` gets a copy and gives the new schedule, or
28
+ * undefined for no change. The result is the stored schedule, or undefined when none exists.
29
+ */
30
+ update(id: string, change: (schedule: Schedule) => Schedule | undefined): Promise<Schedule | undefined>;
31
+ }
32
+
33
+ const byId = (a: Schedule, b: Schedule): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
34
+
35
+ const isCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
36
+
37
+ /** The schedule of a record that came from a file: its shape is not trusted. */
38
+ export function checkSchedule(value: unknown): Schedule {
39
+ const record = value as Partial<Schedule> | null;
40
+ if (record === null || typeof record !== "object") throw new ScheduleError("BAD_STORE", "A stored schedule must be an object");
41
+ if (typeof record.id !== "string" || record.id === "") throw new ScheduleError("BAD_STORE", "A stored schedule needs an id");
42
+ if (typeof record.cron !== "string" || typeof record.program !== "string" || record.program === "") {
43
+ throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs a cron expression and a program`);
44
+ }
45
+ if (record.timeZone !== "utc" && record.timeZone !== "local") throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the time zone utc or local`);
46
+ if (typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt)) throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs createdAt`);
47
+ if (!isCount(record.runs) || !isCount(record.failures) || !isCount(record.skipped)) throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the counts of its runs`);
48
+ return record as Schedule;
49
+ }
50
+
51
+ /** A store in memory: for tests, and for a host that keeps its schedules only while it runs. */
52
+ export class MemoryScheduleStore implements ScheduleStore {
53
+ readonly kind = "memory";
54
+ private readonly schedules = new Map<string, Schedule>();
55
+
56
+ async get(id: string): Promise<Schedule | undefined> {
57
+ const schedule = this.schedules.get(id);
58
+ return schedule ? structuredClone(schedule) : undefined;
59
+ }
60
+
61
+ async list(): Promise<Schedule[]> {
62
+ return [...this.schedules.values()].map((schedule) => structuredClone(schedule)).sort(byId);
63
+ }
64
+
65
+ async put(schedule: Schedule): Promise<void> {
66
+ const checked = checkSchedule(structuredClone(schedule));
67
+ this.schedules.set(checked.id, checked);
68
+ }
69
+
70
+ async delete(id: string): Promise<boolean> {
71
+ return this.schedules.delete(id);
72
+ }
73
+
74
+ async update(id: string, change: (schedule: Schedule) => Schedule | undefined): Promise<Schedule | undefined> {
75
+ const current = this.schedules.get(id);
76
+ if (current === undefined) return undefined;
77
+ const next = change(structuredClone(current));
78
+ if (next === undefined) return structuredClone(current);
79
+ const checked = checkSchedule(structuredClone(next));
80
+ if (checked.id !== id) throw new ScheduleError("BAD_STORE", "An update cannot change the id of a schedule");
81
+ this.schedules.set(id, checked);
82
+ return structuredClone(checked);
83
+ }
84
+ }
85
+
86
+ interface ScheduleFile {
87
+ schemaVersion: 1;
88
+ schedules: Record<string, Schedule>;
89
+ }
90
+
91
+ /** True when Windows holds a file for a moment. */
92
+ function isContention(error: unknown): boolean {
93
+ const code = (error as NodeJS.ErrnoException).code;
94
+ return process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY");
95
+ }
96
+
97
+ const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
98
+
99
+ /**
100
+ * A store in one JSON file: `{ "schemaVersion": 1, "schedules": { "<id>": <Schedule> } }`. Each
101
+ * operation reads the file again. A write goes through a temporary file and a rename, so a reader
102
+ * never sees a part of a write. The writes of this process go one at a time. The store is for
103
+ * one process: two processes on one file can lose a write.
104
+ */
105
+ export class FileScheduleStore implements ScheduleStore {
106
+ readonly kind = "file";
107
+ private queue: Promise<unknown> = Promise.resolve();
108
+
109
+ /** @param path - The JSON file. The store makes it, and its folder, at the first write. */
110
+ constructor(readonly path: string) {}
111
+
112
+ async get(id: string): Promise<Schedule | undefined> {
113
+ return (await this.read()).schedules[id];
114
+ }
115
+
116
+ async list(): Promise<Schedule[]> {
117
+ return Object.values((await this.read()).schedules).sort(byId);
118
+ }
119
+
120
+ async put(schedule: Schedule): Promise<void> {
121
+ const checked = checkSchedule(structuredClone(schedule));
122
+ await this.change((file) => {
123
+ file.schedules[checked.id] = checked;
124
+ return true;
125
+ });
126
+ }
127
+
128
+ async delete(id: string): Promise<boolean> {
129
+ let deleted = false;
130
+ await this.change((file) => {
131
+ deleted = Object.hasOwn(file.schedules, id);
132
+ if (deleted) delete file.schedules[id];
133
+ return deleted;
134
+ });
135
+ return deleted;
136
+ }
137
+
138
+ async update(id: string, change: (schedule: Schedule) => Schedule | undefined): Promise<Schedule | undefined> {
139
+ let result: Schedule | undefined;
140
+ await this.change((file) => {
141
+ const current = file.schedules[id];
142
+ if (current === undefined) return false;
143
+ const next = change(structuredClone(current));
144
+ if (next === undefined) {
145
+ result = current;
146
+ return false;
147
+ }
148
+ const checked = checkSchedule(structuredClone(next));
149
+ if (checked.id !== id) throw new ScheduleError("BAD_STORE", "An update cannot change the id of a schedule");
150
+ file.schedules[id] = checked;
151
+ result = checked;
152
+ return true;
153
+ });
154
+ return result;
155
+ }
156
+
157
+ /** The content of the file. A missing file is an empty store. */
158
+ private async read(): Promise<ScheduleFile> {
159
+ let text: string;
160
+ try {
161
+ text = await readFile(this.path, "utf8");
162
+ } catch (error) {
163
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { schemaVersion: 1, schedules: {} };
164
+ throw error;
165
+ }
166
+ let parsed: unknown;
167
+ try {
168
+ parsed = JSON.parse(text);
169
+ } catch {
170
+ throw new ScheduleError("BAD_STORE", `The schedule file ${this.path} is not JSON. The store does not write over it.`);
171
+ }
172
+ const file = parsed as Partial<ScheduleFile> | null;
173
+ if (file === null || typeof file !== "object" || file.schemaVersion !== 1 || file.schedules === null || typeof file.schedules !== "object") {
174
+ throw new ScheduleError("BAD_STORE", `The schedule file ${this.path} does not have schemaVersion 1 and schedules`);
175
+ }
176
+ const schedules: Record<string, Schedule> = {};
177
+ for (const [id, value] of Object.entries(file.schedules)) {
178
+ const schedule = checkSchedule(value);
179
+ if (schedule.id !== id) throw new ScheduleError("BAD_STORE", `The schedule file ${this.path} has the schedule ${schedule.id} under the key ${id}`);
180
+ schedules[id] = schedule;
181
+ }
182
+ return { schemaVersion: 1, schedules };
183
+ }
184
+
185
+ /** One change of the file, after the changes before it. `edit` gives false for no change. */
186
+ private change(edit: (file: ScheduleFile) => boolean): Promise<void> {
187
+ const run = this.queue.then(async () => {
188
+ const file = await this.read();
189
+ if (!edit(file)) return;
190
+ await mkdir(dirname(this.path), { recursive: true });
191
+ const temporary = `${this.path}.${randomBytes(6).toString("hex")}.tmp`;
192
+ await writeFile(temporary, JSON.stringify(file, null, 2) + "\n", "utf8");
193
+ for (let attempt = 1; ; attempt++) {
194
+ try {
195
+ await rename(temporary, this.path);
196
+ return;
197
+ } catch (error) {
198
+ if (attempt >= 20 || !isContention(error)) {
199
+ await rm(temporary, { force: true });
200
+ throw error;
201
+ }
202
+ await pause(10 * attempt);
203
+ }
204
+ }
205
+ });
206
+ // A failed change does not stop the next one
207
+ this.queue = run.catch(() => undefined);
208
+ return run;
209
+ }
210
+ }
211
+
212
+ /** The store of the default module and of a `Scheduler` without a store: in memory, one for the process. */
213
+ export const defaultScheduleStore: ScheduleStore = new MemoryScheduleStore();