@procedures/schedule 0.0.0-stage → 0.2.3

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -2
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/cron.d.ts +47 -0
  5. package/dist/cron.d.ts.map +1 -0
  6. package/dist/cron.js +197 -0
  7. package/dist/cron.js.map +1 -0
  8. package/dist/errors.d.ts +22 -0
  9. package/dist/errors.d.ts.map +1 -0
  10. package/dist/errors.js +13 -0
  11. package/dist/errors.js.map +1 -0
  12. package/dist/index.d.ts +9 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +11 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/module.d.ts +42 -0
  17. package/dist/module.d.ts.map +1 -0
  18. package/dist/module.js +174 -0
  19. package/dist/module.js.map +1 -0
  20. package/dist/procedures/schedule/add.d.ts +17 -0
  21. package/dist/procedures/schedule/add.d.ts.map +1 -0
  22. package/dist/procedures/schedule/add.js +66 -0
  23. package/dist/procedures/schedule/add.js.map +1 -0
  24. package/dist/procedures/schedule/index.d.ts +5 -0
  25. package/dist/procedures/schedule/index.d.ts.map +1 -0
  26. package/dist/procedures/schedule/index.js +5 -0
  27. package/dist/procedures/schedule/index.js.map +1 -0
  28. package/dist/procedures/schedule/list.d.ts +12 -0
  29. package/dist/procedures/schedule/list.d.ts.map +1 -0
  30. package/dist/procedures/schedule/list.js +18 -0
  31. package/dist/procedures/schedule/list.js.map +1 -0
  32. package/dist/procedures/schedule/next.d.ts +13 -0
  33. package/dist/procedures/schedule/next.d.ts.map +1 -0
  34. package/dist/procedures/schedule/next.js +24 -0
  35. package/dist/procedures/schedule/next.js.map +1 -0
  36. package/dist/procedures/schedule/remove.d.ts +12 -0
  37. package/dist/procedures/schedule/remove.d.ts.map +1 -0
  38. package/dist/procedures/schedule/remove.js +13 -0
  39. package/dist/procedures/schedule/remove.js.map +1 -0
  40. package/dist/programs.d.ts +26 -0
  41. package/dist/programs.d.ts.map +1 -0
  42. package/dist/programs.js +34 -0
  43. package/dist/programs.js.map +1 -0
  44. package/dist/register.d.ts +6 -0
  45. package/dist/register.d.ts.map +1 -0
  46. package/dist/register.js +8 -0
  47. package/dist/register.js.map +1 -0
  48. package/dist/scheduler.d.ts +106 -0
  49. package/dist/scheduler.d.ts.map +1 -0
  50. package/dist/scheduler.js +274 -0
  51. package/dist/scheduler.js.map +1 -0
  52. package/dist/store.d.ts +62 -0
  53. package/dist/store.d.ts.map +1 -0
  54. package/dist/store.js +188 -0
  55. package/dist/store.js.map +1 -0
  56. package/dist/types.d.ts +100 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +8 -0
  59. package/dist/types.js.map +1 -0
  60. package/package.json +78 -3
  61. package/src/cron.ts +230 -0
  62. package/src/errors.ts +27 -0
  63. package/src/index.ts +10 -0
  64. package/src/module.ts +227 -0
  65. package/src/procedures/schedule/add.ts +66 -0
  66. package/src/procedures/schedule/index.ts +4 -0
  67. package/src/procedures/schedule/list.ts +20 -0
  68. package/src/procedures/schedule/next.ts +25 -0
  69. package/src/procedures/schedule/remove.ts +15 -0
  70. package/src/programs.ts +43 -0
  71. package/src/register.ts +10 -0
  72. package/src/scheduler.ts +331 -0
  73. package/src/store.ts +213 -0
  74. package/src/types.ts +122 -0
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Type definitions of the procedures of this package
3
+ *
4
+ * mark procedure new adds the types of each new procedure here. The zod schemas are in
5
+ * `module.ts`: an exported schema needs a written type (isolatedDeclarations).
6
+ */
7
+ import type { PermittedEffects } from "@procedures/client";
8
+ /** The time zone of a cron expression: the UTC time, or the local time of the host. */
9
+ export type ScheduleTimeZone = "utc" | "local";
10
+ /** The last run of a schedule. */
11
+ export interface ScheduleRun {
12
+ /** The wall time of the start, in milliseconds since 1970. */
13
+ startedAt: number;
14
+ /** The time of the run, in milliseconds. */
15
+ durationMs: number;
16
+ outcome: "success" | "failure";
17
+ /** The error of a failure: its code, when it has one, and its message. */
18
+ error?: {
19
+ code?: string | undefined;
20
+ message: string;
21
+ } | undefined;
22
+ }
23
+ /** One stored schedule: a cron expression that starts a stored program with an input. */
24
+ export interface Schedule {
25
+ /** The name of the schedule. */
26
+ id: string;
27
+ /** The cron expression: 5 fields, or a name such as `@daily`. */
28
+ cron: string;
29
+ /** The dotted key of the stored program: `reports.weekly`. */
30
+ program: string;
31
+ /** The input of the program. */
32
+ input?: unknown;
33
+ timeZone: ScheduleTimeZone;
34
+ /** When the schedule was added, in milliseconds since 1970. A schedule starts no run for a time before it. */
35
+ createdAt: number;
36
+ /**
37
+ * The capability of the caller that added the schedule, without its deadline. A run gets the
38
+ * capability of the host, narrowed by this one: a caller cannot start more than it can do.
39
+ */
40
+ capability?: {
41
+ clearance?: unknown;
42
+ effects?: PermittedEffects;
43
+ } | undefined;
44
+ lastRun?: ScheduleRun | undefined;
45
+ /** The number of runs. */
46
+ runs: number;
47
+ /** The number of runs that failed. */
48
+ failures: number;
49
+ /** The number of times that came while a run of the schedule was not finished: no second run started. */
50
+ skipped: number;
51
+ }
52
+ /** A schedule as the procedures give it: with its next time. */
53
+ export interface ScheduleView extends Schedule {
54
+ /** The next time, as an ISO text in the time zone of the schedule, or null when the expression has no next time. */
55
+ nextRunAt: string | null;
56
+ }
57
+ export interface ScheduleAddInput {
58
+ /** The name of the schedule. The default is the key of the program. */
59
+ id?: string | undefined;
60
+ cron: string;
61
+ /** The dotted key of the stored program. */
62
+ program: string;
63
+ input?: unknown;
64
+ /** The time zone of the expression. The default is `local`. */
65
+ timeZone?: ScheduleTimeZone | undefined;
66
+ /** True: replace a schedule of the same name. The default is false: a second schedule of a name is an error. */
67
+ replace?: boolean | undefined;
68
+ }
69
+ export interface ScheduleAddOutput {
70
+ schedule: ScheduleView;
71
+ }
72
+ export interface ScheduleListInput {
73
+ /** Only the schedules of this program. */
74
+ program?: string | undefined;
75
+ }
76
+ export interface ScheduleListOutput {
77
+ /** The schedules, sorted by name. */
78
+ schedules: ScheduleView[];
79
+ }
80
+ export interface ScheduleRemoveInput {
81
+ id: string;
82
+ }
83
+ export interface ScheduleRemoveOutput {
84
+ /** True when the schedule existed. */
85
+ removed: boolean;
86
+ }
87
+ export interface ScheduleNextInput {
88
+ cron: string;
89
+ /** The number of times: 1 to 100. The default is 5. */
90
+ count?: number | undefined;
91
+ /** The times come after this time: an ISO text. The default is now. */
92
+ after?: string | undefined;
93
+ /** The time zone of the expression. The default is `local`. */
94
+ timeZone?: ScheduleTimeZone | undefined;
95
+ }
96
+ export interface ScheduleNextOutput {
97
+ /** The next times, as ISO texts in the time zone of the expression. Fewer than `count` when the expression has fewer times in the next 9 years. */
98
+ times: string[];
99
+ }
100
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,uFAAuF;AACvF,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,OAAO,CAAC;AAE/C,kCAAkC;AAClC,MAAM,WAAW,WAAW;IAC1B,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,4CAA4C;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,0EAA0E;IAC1E,KAAK,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACpE;AAED,yFAAyF;AACzF,MAAM,WAAW,QAAQ;IACvB,gCAAgC;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,iEAAiE;IACjE,IAAI,EAAE,MAAM,CAAC;IACb,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC;IAChB,gCAAgC;IAChC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,8GAA8G;IAC9G,SAAS,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,UAAU,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,gBAAgB,CAAA;KAAE,GAAG,SAAS,CAAC;IAC7E,OAAO,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAClC,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,yGAAyG;IACzG,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,gEAAgE;AAChE,MAAM,WAAW,YAAa,SAAQ,QAAQ;IAC5C,oHAAoH;IACpH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAMD,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,4CAA4C;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACxC,gHAAgH;IAChH,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,YAAY,CAAC;CACxB;AAMD,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9B;AAED,MAAM,WAAW,kBAAkB;IACjC,qCAAqC;IACrC,SAAS,EAAE,YAAY,EAAE,CAAC;CAC3B;AAMD,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,oBAAoB;IACnC,sCAAsC;IACtC,OAAO,EAAE,OAAO,CAAC;CAClB;AAMD,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC;CACzC;AAED,MAAM,WAAW,kBAAkB;IACjC,mJAAmJ;IACnJ,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB"}
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Type definitions of the procedures of this package
3
+ *
4
+ * mark procedure new adds the types of each new procedure here. The zod schemas are in
5
+ * `module.ts`: an exported schema needs a written type (isolatedDeclarations).
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
package/package.json CHANGED
@@ -1,6 +1,81 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package",
2
3
  "name": "@procedures/schedule",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
4
+ "version": "0.2.3",
5
+ "description": "A cron for stored programs: schedules as procedures, and a scheduler that a host starts, with an injectable clock and no overlapping runs.",
6
+ "keywords": [
7
+ "procedures",
8
+ "cron",
9
+ "schedule",
10
+ "scheduler",
11
+ "programs",
12
+ "jobs"
13
+ ],
14
+ "homepage": "https://mark1russell7.github.io/client/",
15
+ "bugs": {
16
+ "url": "https://github.com/mark1russell7/client/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/mark1russell7/client.git",
21
+ "directory": "packages/schedule"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Mark Russell (https://github.com/mark1russell7)",
25
+ "type": "module",
26
+ "main": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./module": {
34
+ "types": "./dist/module.d.ts",
35
+ "import": "./dist/module.js"
36
+ },
37
+ "./register": {
38
+ "types": "./dist/register.d.ts",
39
+ "import": "./dist/register.js"
40
+ }
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "src",
45
+ "!src/**/*.test.ts",
46
+ "!src/**/*.test.tsx",
47
+ "!src/**/__tests__",
48
+ "CHANGELOG.md"
49
+ ],
50
+ "sideEffects": [
51
+ "./dist/register.js"
52
+ ],
53
+ "devDependencies": {
54
+ "@mark1russell7/cue": "^1.0.0",
55
+ "@mark1russell7/service": "^0.4.0",
56
+ "typescript": "^7.0.2",
57
+ "vitest": "^5.0.3"
58
+ },
59
+ "engines": {
60
+ "node": ">=22"
61
+ },
62
+ "client": {
63
+ "procedures": "./dist/register.js"
64
+ },
65
+ "dependencies": {
66
+ "@procedures/client": "0.2.3",
67
+ "@procedures/store": "0.2.3",
68
+ "zod": "^4.6.5"
69
+ },
70
+ "publishConfig": {
71
+ "access": "public",
72
+ "provenance": true
73
+ },
74
+ "scripts": {
75
+ "build": "tsc -b",
76
+ "typecheck": "tsc --noEmit",
77
+ "clean": "rm -rf dist .tsbuildinfo",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest"
80
+ }
6
81
  }
package/src/cron.ts ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Cron expressions (roadmap R46): the parse and the next times.
3
+ *
4
+ * This package parses cron itself. The format is small, and a dependency for it gives a time
5
+ * zone database or a second format that this package does not need.
6
+ *
7
+ * - 5 fields: minute (0-59), hour (0-23), day of the month (1-31), month (1-12), day of the week
8
+ * (0-7, with 0 and 7 for Sunday).
9
+ * - Each field: `*`, a number, a range `a-b`, a step `*` + `/n`, `a-b/n` or `a/n` (from `a` to the
10
+ * end), and lists of these with commas.
11
+ * - The names of the months (`jan` to `dec`) and of the days (`sun` to `sat`), in each case.
12
+ * - The names `@yearly` (`@annually`), `@monthly`, `@weekly`, `@daily` (`@midnight`) and `@hourly`.
13
+ * - The days, as in Vixie cron: when the two day fields have a limit (they do not start with
14
+ * `*`), a day matches when one of the two matches. Else the field with a limit decides.
15
+ *
16
+ * The times are in UTC or in the local time of the host. In local time, a time that the change
17
+ * to summer time skips does not run, and a time that the change back repeats can run two times.
18
+ */
19
+
20
+ import { ScheduleError } from "./errors.js";
21
+ import type { ScheduleTimeZone } from "./types.js";
22
+
23
+ /** A parsed cron expression. */
24
+ export interface CronExpression {
25
+ readonly source: string;
26
+ readonly minutes: ReadonlySet<number>;
27
+ readonly hours: ReadonlySet<number>;
28
+ readonly days: ReadonlySet<number>;
29
+ readonly months: ReadonlySet<number>;
30
+ /** The days of the week, 0 (Sunday) to 6. */
31
+ readonly weekdays: ReadonlySet<number>;
32
+ /** True when the field of the day of the month does not start with `*`. */
33
+ readonly dayLimited: boolean;
34
+ /** True when the field of the day of the week does not start with `*`. */
35
+ readonly weekdayLimited: boolean;
36
+ }
37
+
38
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
39
+ const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
40
+
41
+ interface FieldSpec {
42
+ readonly name: string;
43
+ readonly min: number;
44
+ readonly max: number;
45
+ /** The names of the values, from `first`. */
46
+ readonly names?: readonly string[];
47
+ readonly first?: number;
48
+ }
49
+
50
+ const FIELDS: readonly FieldSpec[] = [
51
+ { name: "minute", min: 0, max: 59 },
52
+ { name: "hour", min: 0, max: 23 },
53
+ { name: "day of the month", min: 1, max: 31 },
54
+ { name: "month", min: 1, max: 12, names: MONTHS, first: 1 },
55
+ { name: "day of the week", min: 0, max: 7, names: DAYS, first: 0 },
56
+ ];
57
+
58
+ /** The names of the expressions that are one name. */
59
+ export const CRON_NAMES: Readonly<Record<string, string>> = Object.freeze({
60
+ "@yearly": "0 0 1 1 *",
61
+ "@annually": "0 0 1 1 *",
62
+ "@monthly": "0 0 1 * *",
63
+ "@weekly": "0 0 * * 0",
64
+ "@daily": "0 0 * * *",
65
+ "@midnight": "0 0 * * *",
66
+ "@hourly": "0 * * * *",
67
+ });
68
+
69
+ function fail(source: string, message: string): never {
70
+ throw new ScheduleError("BAD_CRON", `The cron expression ${JSON.stringify(source)} is not correct: ${message}`);
71
+ }
72
+
73
+ /** The value of one token of a field: a number or a name. */
74
+ function valueOf(token: string, spec: FieldSpec, source: string): number {
75
+ if (/^\d+$/.test(token)) {
76
+ const value = Number(token);
77
+ if (value < spec.min || value > spec.max) fail(source, `${token} is out of the ${spec.name} range ${spec.min}-${spec.max}`);
78
+ return value;
79
+ }
80
+ const index = spec.names?.indexOf(token.toLowerCase()) ?? -1;
81
+ if (index < 0) fail(source, `${JSON.stringify(token)} is not a ${spec.name}`);
82
+ return index + (spec.first ?? 0);
83
+ }
84
+
85
+ /** The values of one field. */
86
+ function parseField(text: string, spec: FieldSpec, source: string): Set<number> {
87
+ const values = new Set<number>();
88
+ for (const part of text.split(",")) {
89
+ if (part === "") fail(source, `the ${spec.name} field has an empty part`);
90
+ const pieces = part.split("/");
91
+ if (pieces.length > 2) fail(source, `${JSON.stringify(part)} has more than one step`);
92
+ const [range, stepText] = pieces as [string, string | undefined];
93
+ let step = 1;
94
+ if (stepText !== undefined) {
95
+ if (!/^\d+$/.test(stepText) || Number(stepText) < 1) fail(source, `the step ${JSON.stringify(stepText)} must be a whole number of 1 or more`);
96
+ step = Number(stepText);
97
+ }
98
+ let low: number;
99
+ let high: number;
100
+ if (range === "*") {
101
+ low = spec.min;
102
+ // The day of the week: * is 0-6, not 0-7
103
+ high = spec.names === DAYS ? 6 : spec.max;
104
+ } else if (range.includes("-")) {
105
+ const bounds = range.split("-");
106
+ if (bounds.length !== 2) fail(source, `${JSON.stringify(range)} is not a range`);
107
+ low = valueOf(bounds[0]!, spec, source);
108
+ high = valueOf(bounds[1]!, spec, source);
109
+ if (low > high) fail(source, `the range ${range} goes down`);
110
+ } else {
111
+ low = valueOf(range, spec, source);
112
+ high = stepText === undefined ? low : spec.max;
113
+ }
114
+ for (let value = low; value <= high; value += step) values.add(spec.names === DAYS && value === 7 ? 0 : value);
115
+ }
116
+ return values;
117
+ }
118
+
119
+ /** This function parses a cron expression. It throws a `BAD_CRON` error that tells the problem. */
120
+ export function parseCron(text: string): CronExpression {
121
+ const source = text.trim();
122
+ const expanded = source.startsWith("@") ? CRON_NAMES[source.toLowerCase()] : source;
123
+ if (expanded === undefined) fail(source, `${source} is not a known name (${Object.keys(CRON_NAMES).join(", ")})`);
124
+ const fields = expanded.split(/\s+/).filter((field) => field !== "");
125
+ if (fields.length !== 5) fail(source, `it has ${fields.length} fields, and a cron expression has 5: minute, hour, day of the month, month, day of the week`);
126
+ const [minutes, hours, days, months, weekdays] = fields.map((field, index) => parseField(field, FIELDS[index]!, source)) as [Set<number>, Set<number>, Set<number>, Set<number>, Set<number>];
127
+ return {
128
+ source,
129
+ minutes,
130
+ hours,
131
+ days,
132
+ months,
133
+ weekdays,
134
+ dayLimited: !fields[2]!.startsWith("*"),
135
+ weekdayLimited: !fields[4]!.startsWith("*"),
136
+ };
137
+ }
138
+
139
+ /** The fields of a time in a time zone. */
140
+ interface Fields {
141
+ year: number;
142
+ month: number;
143
+ day: number;
144
+ hour: number;
145
+ minute: number;
146
+ weekday: number;
147
+ }
148
+
149
+ /** The calendar of a time zone: the fields of a time, and the time of fields (which can overflow). */
150
+ interface Calendar {
151
+ fields(time: number): Fields;
152
+ make(year: number, month: number, day: number, hour: number, minute: number): number;
153
+ }
154
+
155
+ const UTC: Calendar = {
156
+ fields(time) {
157
+ const date = new Date(time);
158
+ return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate(), hour: date.getUTCHours(), minute: date.getUTCMinutes(), weekday: date.getUTCDay() };
159
+ },
160
+ make: (year, month, day, hour, minute) => Date.UTC(year, month - 1, day, hour, minute),
161
+ };
162
+
163
+ const LOCAL: Calendar = {
164
+ fields(time) {
165
+ const date = new Date(time);
166
+ return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), hour: date.getHours(), minute: date.getMinutes(), weekday: date.getDay() };
167
+ },
168
+ make: (year, month, day, hour, minute) => new Date(year, month - 1, day, hour, minute).getTime(),
169
+ };
170
+
171
+ /** The years that a search covers: Feb 29 can be 8 years away (2096 to 2104). */
172
+ const SEARCH_YEARS = 9;
173
+
174
+ /** True when the day of the fields matches the two day fields. */
175
+ function dayMatches(cron: CronExpression, fields: Fields): boolean {
176
+ const day = cron.days.has(fields.day);
177
+ const weekday = cron.weekdays.has(fields.weekday);
178
+ if (cron.dayLimited && cron.weekdayLimited) return day || weekday;
179
+ if (cron.dayLimited) return day;
180
+ if (cron.weekdayLimited) return weekday;
181
+ return true;
182
+ }
183
+
184
+ /**
185
+ * This function gives the first time of the expression after `after` (milliseconds since 1970),
186
+ * or undefined when the expression has no time in the next 9 years.
187
+ */
188
+ export function nextTime(cron: CronExpression, after: number, timeZone: ScheduleTimeZone = "local"): number | undefined {
189
+ const calendar = timeZone === "utc" ? UTC : LOCAL;
190
+ let time = Math.floor(after / 60_000) * 60_000 + 60_000;
191
+ const lastYear = calendar.fields(after).year + SEARCH_YEARS;
192
+ for (let step = 0; step < 1_000_000; step++) {
193
+ const fields = calendar.fields(time);
194
+ if (fields.year > lastYear) return undefined;
195
+ let next: number;
196
+ if (!cron.months.has(fields.month)) next = calendar.make(fields.year, fields.month + 1, 1, 0, 0);
197
+ else if (!dayMatches(cron, fields)) next = calendar.make(fields.year, fields.month, fields.day + 1, 0, 0);
198
+ else if (!cron.hours.has(fields.hour)) next = calendar.make(fields.year, fields.month, fields.day, fields.hour + 1, 0);
199
+ else if (!cron.minutes.has(fields.minute)) next = time + 60_000;
200
+ else return time;
201
+ // A change of the clock can give a time that is not later: go one minute on
202
+ time = next > time ? next : time + 60_000;
203
+ }
204
+ return undefined;
205
+ }
206
+
207
+ /** This function gives the next `count` times of the expression after `after`. */
208
+ export function nextTimes(cron: CronExpression, after: number, count: number, timeZone: ScheduleTimeZone = "local"): number[] {
209
+ const times: number[] = [];
210
+ let from = after;
211
+ while (times.length < count) {
212
+ const time = nextTime(cron, from, timeZone);
213
+ if (time === undefined) break;
214
+ times.push(time);
215
+ from = time;
216
+ }
217
+ return times;
218
+ }
219
+
220
+ const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
221
+
222
+ /** The ISO text of a time in a time zone: `2026-10-17T09:00:00Z`, or with the offset of the host: `2026-10-17T09:00:00+02:00`. */
223
+ export function formatTime(time: number, timeZone: ScheduleTimeZone = "local"): string {
224
+ const date = new Date(time);
225
+ if (timeZone === "utc") return `${date.toISOString().slice(0, 19)}Z`;
226
+ const offset = -date.getTimezoneOffset();
227
+ const sign = offset >= 0 ? "+" : "-";
228
+ const zone = offset === 0 ? "Z" : `${sign}${pad(Math.floor(Math.abs(offset) / 60))}:${pad(Math.abs(offset) % 60)}`;
229
+ return `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${zone}`;
230
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The errors of the schedule procedures and of the scheduler.
3
+ */
4
+
5
+ /** The reason of a `ScheduleError`. */
6
+ export type ScheduleErrorCode =
7
+ /** The cron expression is not correct, or it has no time in the next 9 years. */
8
+ | "BAD_CRON"
9
+ /** A schedule of the name exists, and the input does not replace it. */
10
+ | "EXISTS"
11
+ /** No stored program has the key: not in the registry, not in the program store. */
12
+ | "NOT_FOUND"
13
+ /** The key names a procedure of code, not a stored program. */
14
+ | "NOT_A_PROGRAM"
15
+ /** The file of a `FileScheduleStore` is not correct. */
16
+ | "BAD_STORE";
17
+
18
+ /** An error of the schedules. */
19
+ export class ScheduleError extends Error {
20
+ override readonly name = "ScheduleError";
21
+ readonly code: ScheduleErrorCode;
22
+
23
+ constructor(code: ScheduleErrorCode, message: string) {
24
+ super(message);
25
+ this.code = code;
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
+ export * from "./module.js";
3
+ export * from "./types.js";
4
+ export { scheduleAdd, scheduleList, scheduleRemove, scheduleNext, viewOf } from "./procedures/schedule/index.js";
5
+ // The cron expressions, the stores and the scheduler (roadmap R46)
6
+ export { CRON_NAMES, formatTime, nextTime, nextTimes, parseCron, type CronExpression } from "./cron.js";
7
+ export { ScheduleError, type ScheduleErrorCode } from "./errors.js";
8
+ export { checkSchedule, defaultScheduleStore, FileScheduleStore, MemoryScheduleStore, type ScheduleStore } from "./store.js";
9
+ export { DEFAULT_SCHEDULE_SETTINGS, findProgram, PROGRAM_KEY, type ScheduleSettings } from "./programs.js";
10
+ export { DEFAULT_RUN_TIMEOUT_MS, Scheduler, type ScheduleRunEvent, type SchedulerOptions } from "./scheduler.js";