@graphorin/triggers 0.6.1 → 0.7.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.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +8 -6
- package/src/cron.ts +221 -0
- package/src/index.ts +651 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @graphorin/triggers
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-072: every export map's `import` condition becomes `default`, and the Node floor rises to `>=22.12.0`.
|
|
8
|
+
|
|
9
|
+
CJS consumers previously hit a bewildering `ERR_PACKAGE_PATH_NOT_EXPORTED` instead of a clear ESM-only signal. With the `default` condition, plain `require('@graphorin/core')` works via Node's stable `require(esm)` - which shipped in 22.12, hence the engines bump across every workspace manifest (packages, examples, benchmarks, docs; enforced by the widened mvp-readiness sweep). No dual-instance hazard: there is no CJS build, `require()` returns the same ESM module instance. ESM consumers are unaffected (`default` serves both paths; `types` stays first). The pack gate now runs attw under the full `node16` profile (was `esm-only`) and adds a runtime `require(esm)` smoke against the packed tarballs. Installs on Node 22.0-22.11 with `engine-strict` will refuse - upgrade Node (see the migration guide).
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Tarballs now ship `src/` so the published `dist/**/*.d.ts.map` files actually work (W-136): the maps referenced `../src/*.ts` that the `files` whitelist excluded, so go-to-definition fell back into `.d.ts` and the shipped maps were dead weight. The pack gate gains a `map-integrity` leg: every source referenced by a shipped map must resolve inside the tarball (or be embedded via `sourcesContent`), with an anti-vacuous guard - a package whose tsdown config emits declaration maps must contain a non-zero number of `.d.ts.map` files, so a cache-restored dist that silently dropped maps fails the gate instead of passing vacuously. `mvp-readiness` now requires `src` in every publishable `files` array.
|
|
14
|
+
|
|
15
|
+
- [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Every published package now declares its tree-shaking contract via `sideEffects` (W-137): 18 packages audited to a pure module scope get `false`, the CLI declares its bin entry (`["./dist/bin/*"]`), and `@graphorin/security` gets an explicit `true` - its secrets subsystem registers built-in resolvers and the SecretValue caller-context provider at import time, so marking it pure would let bundlers drop those registrations. `mvp-readiness` now fails any publishable manifest without a declared `sideEffects`, closing the drift for future packages.
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [[`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04)]:
|
|
18
|
+
- @graphorin/core@0.7.0
|
|
19
|
+
|
|
3
20
|
## 0.6.1
|
|
4
21
|
|
|
5
22
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -74,4 +74,4 @@ MIT © 2026 Oleksiy Stepurenko.
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
-
**Project Graphorin** · v0.
|
|
77
|
+
**Project Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
|
package/dist/package.js
CHANGED
package/dist/package.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/triggers\",\n \"version\": \"0.
|
|
1
|
+
{"version":3,"file":"package.js","names":[],"sources":["../package.json"],"sourcesContent":["{\n \"name\": \"@graphorin/triggers\",\n \"version\": \"0.7.0\",\n \"description\": \"Background-task scheduler for the Graphorin framework. Ships durable cron / interval / idle / event triggers with the same code path in library and server modes, an in-tree 5-field cron parser, per-trigger catch-up policies (`'none'` default; `'last'` or `'all'` opt-in), and an AsyncIterable lifecycle event stream so consumers can plug into observability without monkey-patching.\",\n \"license\": \"MIT\",\n \"author\": \"Oleksiy Stepurenko\",\n \"homepage\": \"https://github.com/o-stepper/graphorin/tree/main/packages/triggers\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/o-stepper/graphorin.git\",\n \"directory\": \"packages/triggers\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/o-stepper/graphorin/issues\"\n },\n \"keywords\": [\n \"graphorin\",\n \"ai\",\n \"agents\",\n \"framework\",\n \"triggers\",\n \"scheduler\",\n \"cron\",\n \"background-tasks\",\n \"durable\"\n ],\n \"type\": \"module\",\n \"sideEffects\": false,\n \"engines\": {\n \"node\": \">=22.12.0\"\n },\n \"main\": \"./dist/index.js\",\n \"module\": \"./dist/index.js\",\n \"types\": \"./dist/index.d.ts\",\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"default\": \"./dist/index.js\"\n },\n \"./cron\": {\n \"types\": \"./dist/cron.d.ts\",\n \"default\": \"./dist/cron.js\"\n },\n \"./package.json\": \"./package.json\"\n },\n \"files\": [\n \"dist\",\n \"src\",\n \"README.md\",\n \"CHANGELOG.md\",\n \"LICENSE\"\n ],\n \"scripts\": {\n \"build\": \"tsdown\",\n \"typecheck\": \"tsc --noEmit && tsc -p tsconfig.tests.json\",\n \"test\": \"vitest run\",\n \"lint\": \"biome check .\",\n \"clean\": \"rimraf dist .turbo *.tsbuildinfo\"\n },\n \"dependencies\": {\n \"@graphorin/core\": \"workspace:*\"\n },\n \"publishConfig\": {\n \"access\": \"public\",\n \"provenance\": true\n },\n \"devDependencies\": {\n \"@graphorin/store-sqlite\": \"workspace:*\"\n }\n}\n"],"mappings":";cAEa"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphorin/triggers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Background-task scheduler for the Graphorin framework. Ships durable cron / interval / idle / event triggers with the same code path in library and server modes, an in-tree 5-field cron parser, per-trigger catch-up policies (`'none'` default; `'last'` or `'all'` opt-in), and an AsyncIterable lifecycle event stream so consumers can plug into observability without monkey-patching.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Oleksiy Stepurenko",
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
"durable"
|
|
26
26
|
],
|
|
27
27
|
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
28
29
|
"engines": {
|
|
29
|
-
"node": ">=22.
|
|
30
|
+
"node": ">=22.12.0"
|
|
30
31
|
},
|
|
31
32
|
"main": "./dist/index.js",
|
|
32
33
|
"module": "./dist/index.js",
|
|
@@ -34,29 +35,30 @@
|
|
|
34
35
|
"exports": {
|
|
35
36
|
".": {
|
|
36
37
|
"types": "./dist/index.d.ts",
|
|
37
|
-
"
|
|
38
|
+
"default": "./dist/index.js"
|
|
38
39
|
},
|
|
39
40
|
"./cron": {
|
|
40
41
|
"types": "./dist/cron.d.ts",
|
|
41
|
-
"
|
|
42
|
+
"default": "./dist/cron.js"
|
|
42
43
|
},
|
|
43
44
|
"./package.json": "./package.json"
|
|
44
45
|
},
|
|
45
46
|
"files": [
|
|
46
47
|
"dist",
|
|
48
|
+
"src",
|
|
47
49
|
"README.md",
|
|
48
50
|
"CHANGELOG.md",
|
|
49
51
|
"LICENSE"
|
|
50
52
|
],
|
|
51
53
|
"dependencies": {
|
|
52
|
-
"@graphorin/core": "0.
|
|
54
|
+
"@graphorin/core": "0.7.0"
|
|
53
55
|
},
|
|
54
56
|
"publishConfig": {
|
|
55
57
|
"access": "public",
|
|
56
58
|
"provenance": true
|
|
57
59
|
},
|
|
58
60
|
"devDependencies": {
|
|
59
|
-
"@graphorin/store-sqlite": "0.
|
|
61
|
+
"@graphorin/store-sqlite": "0.7.0"
|
|
60
62
|
},
|
|
61
63
|
"scripts": {
|
|
62
64
|
"build": "tsdown",
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny in-tree 5-field cron parser used by `@graphorin/triggers`.
|
|
3
|
+
*
|
|
4
|
+
* Supported syntax:
|
|
5
|
+
*
|
|
6
|
+
* minute (0-59)
|
|
7
|
+
* hour (0-23)
|
|
8
|
+
* day (1-31)
|
|
9
|
+
* month (1-12)
|
|
10
|
+
* dayOfWeek (0-6; Sunday = 0)
|
|
11
|
+
*
|
|
12
|
+
* Each field accepts:
|
|
13
|
+
* - star (asterisk) for every value
|
|
14
|
+
* - a single number `5`
|
|
15
|
+
* - a comma list `1,2,5`
|
|
16
|
+
* - a range `1-4`
|
|
17
|
+
* - a step expression with the slash operator (e.g. every-5 minutes,
|
|
18
|
+
* `0-30/10` for "0,10,20,30 minutes")
|
|
19
|
+
*
|
|
20
|
+
* The parser is intentionally **strict**: any unrecognised character
|
|
21
|
+
* raises {@link CronParseError} so a typo never silently never-fires.
|
|
22
|
+
*
|
|
23
|
+
* **Day vs. day-of-week semantics - AND, not OR.** When *both* `day`
|
|
24
|
+
* and `dayOfWeek` are restricted (neither is the every-value
|
|
25
|
+
* wildcard), Graphorin requires **both** to match for a fire to
|
|
26
|
+
* happen - i.e. `0 12 1-7 * 1` means "noon on the first Monday of
|
|
27
|
+
* every month". This differs from Vixie / POSIX cron, which
|
|
28
|
+
* OR-combines the two restricted fields. AND semantics are easier to
|
|
29
|
+
* reason about in personal-assistant scenarios; the framework stays
|
|
30
|
+
* consistent with this rule rather than mixing the two conventions.
|
|
31
|
+
*
|
|
32
|
+
* The scheduler treats every trigger as **UTC**. Operators that need
|
|
33
|
+
* a local-time fire encode the offset directly into their cron
|
|
34
|
+
* expression (e.g. `0 14 * * *` for "9am Eastern in winter").
|
|
35
|
+
*
|
|
36
|
+
* @packageDocumentation
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** @stable */
|
|
40
|
+
export class CronParseError extends Error {
|
|
41
|
+
override readonly name = 'CronParseError';
|
|
42
|
+
constructor(
|
|
43
|
+
public readonly expression: string,
|
|
44
|
+
message: string,
|
|
45
|
+
) {
|
|
46
|
+
super(`[graphorin/triggers] cron expression '${expression}': ${message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @stable */
|
|
51
|
+
export interface ParsedCron {
|
|
52
|
+
readonly expression: string;
|
|
53
|
+
readonly minute: ReadonlySet<number>;
|
|
54
|
+
readonly hour: ReadonlySet<number>;
|
|
55
|
+
readonly day: ReadonlySet<number>;
|
|
56
|
+
readonly month: ReadonlySet<number>;
|
|
57
|
+
readonly dayOfWeek: ReadonlySet<number>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface FieldRange {
|
|
61
|
+
readonly min: number;
|
|
62
|
+
readonly max: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const RANGES: Readonly<Record<keyof Omit<ParsedCron, 'expression'>, FieldRange>> = {
|
|
66
|
+
minute: { min: 0, max: 59 },
|
|
67
|
+
hour: { min: 0, max: 23 },
|
|
68
|
+
day: { min: 1, max: 31 },
|
|
69
|
+
month: { min: 1, max: 12 },
|
|
70
|
+
dayOfWeek: { min: 0, max: 6 },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a 5-field cron expression. Throws {@link CronParseError} on
|
|
75
|
+
* any malformed input.
|
|
76
|
+
*
|
|
77
|
+
* @stable
|
|
78
|
+
*/
|
|
79
|
+
export function parseCron(expression: string): ParsedCron {
|
|
80
|
+
const trimmed = expression.trim();
|
|
81
|
+
if (trimmed.length === 0) {
|
|
82
|
+
throw new CronParseError(expression, 'expression is empty');
|
|
83
|
+
}
|
|
84
|
+
const fields = trimmed.split(/\s+/);
|
|
85
|
+
if (fields.length !== 5) {
|
|
86
|
+
throw new CronParseError(
|
|
87
|
+
expression,
|
|
88
|
+
`expected 5 whitespace-separated fields (minute hour day month dayOfWeek), got ${fields.length}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const [minuteStr, hourStr, dayStr, monthStr, dowStr] = fields as [
|
|
92
|
+
string,
|
|
93
|
+
string,
|
|
94
|
+
string,
|
|
95
|
+
string,
|
|
96
|
+
string,
|
|
97
|
+
];
|
|
98
|
+
return {
|
|
99
|
+
expression: trimmed,
|
|
100
|
+
minute: parseField('minute', minuteStr, RANGES.minute, expression),
|
|
101
|
+
hour: parseField('hour', hourStr, RANGES.hour, expression),
|
|
102
|
+
day: parseField('day', dayStr, RANGES.day, expression),
|
|
103
|
+
month: parseField('month', monthStr, RANGES.month, expression),
|
|
104
|
+
dayOfWeek: parseField('dayOfWeek', dowStr, RANGES.dayOfWeek, expression),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseField(
|
|
109
|
+
name: string,
|
|
110
|
+
raw: string,
|
|
111
|
+
range: FieldRange,
|
|
112
|
+
expression: string,
|
|
113
|
+
): ReadonlySet<number> {
|
|
114
|
+
const out = new Set<number>();
|
|
115
|
+
for (const part of raw.split(',')) {
|
|
116
|
+
if (part.length === 0) {
|
|
117
|
+
throw new CronParseError(expression, `field '${name}' has an empty list element`);
|
|
118
|
+
}
|
|
119
|
+
let stepStr: string | undefined;
|
|
120
|
+
let bodyStr = part;
|
|
121
|
+
if (part.includes('/')) {
|
|
122
|
+
const [body, step] = part.split('/', 2);
|
|
123
|
+
bodyStr = body ?? '';
|
|
124
|
+
stepStr = step;
|
|
125
|
+
}
|
|
126
|
+
const step = stepStr === undefined ? 1 : Number.parseInt(stepStr, 10);
|
|
127
|
+
if (!Number.isFinite(step) || step <= 0) {
|
|
128
|
+
throw new CronParseError(expression, `field '${name}' has invalid step '${stepStr}'`);
|
|
129
|
+
}
|
|
130
|
+
let from: number;
|
|
131
|
+
let to: number;
|
|
132
|
+
if (bodyStr === '*') {
|
|
133
|
+
from = range.min;
|
|
134
|
+
to = range.max;
|
|
135
|
+
} else if (bodyStr.includes('-')) {
|
|
136
|
+
const [a, b] = bodyStr.split('-', 2);
|
|
137
|
+
from = parseNumeric(name, a, expression);
|
|
138
|
+
to = parseNumeric(name, b, expression);
|
|
139
|
+
} else {
|
|
140
|
+
const single = parseNumeric(name, bodyStr, expression);
|
|
141
|
+
from = single;
|
|
142
|
+
to = stepStr === undefined ? single : range.max;
|
|
143
|
+
}
|
|
144
|
+
if (from < range.min || to > range.max || from > to) {
|
|
145
|
+
throw new CronParseError(
|
|
146
|
+
expression,
|
|
147
|
+
`field '${name}' value out of range [${range.min}, ${range.max}]: ${bodyStr}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
for (let v = from; v <= to; v += step) {
|
|
151
|
+
out.add(v);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseNumeric(name: string, raw: string | undefined, expression: string): number {
|
|
158
|
+
if (raw === undefined || raw.length === 0) {
|
|
159
|
+
throw new CronParseError(expression, `field '${name}' has empty numeric component`);
|
|
160
|
+
}
|
|
161
|
+
if (!/^\d+$/.test(raw)) {
|
|
162
|
+
throw new CronParseError(expression, `field '${name}' value '${raw}' is not numeric`);
|
|
163
|
+
}
|
|
164
|
+
return Number.parseInt(raw, 10);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compute the next fire time strictly after `from` for the supplied
|
|
169
|
+
* cron schedule. Returns a UTC `Date` (the scheduler treats every
|
|
170
|
+
* trigger as UTC; operators that need local time express that in
|
|
171
|
+
* their cron expression).
|
|
172
|
+
*
|
|
173
|
+
* Returns `null` if no fire happens in the next 4 years (defensive -
|
|
174
|
+
* impossible for a well-formed cron expression except a vacuous
|
|
175
|
+
* combination that never aligns).
|
|
176
|
+
*
|
|
177
|
+
* @stable
|
|
178
|
+
*/
|
|
179
|
+
export function nextFireAfter(parsed: ParsedCron, from: Date): Date | null {
|
|
180
|
+
const start = new Date(from.getTime() + 60_000);
|
|
181
|
+
start.setUTCSeconds(0, 0);
|
|
182
|
+
const horizon = new Date(start.getTime() + 4 * 365 * 24 * 60 * 60 * 1000);
|
|
183
|
+
|
|
184
|
+
let cursor = new Date(start.getTime());
|
|
185
|
+
while (cursor.getTime() < horizon.getTime()) {
|
|
186
|
+
const month = cursor.getUTCMonth() + 1;
|
|
187
|
+
if (!parsed.month.has(month)) {
|
|
188
|
+
cursor = new Date(
|
|
189
|
+
Date.UTC(
|
|
190
|
+
cursor.getUTCMonth() === 11 ? cursor.getUTCFullYear() + 1 : cursor.getUTCFullYear(),
|
|
191
|
+
(cursor.getUTCMonth() + 1) % 12,
|
|
192
|
+
1,
|
|
193
|
+
0,
|
|
194
|
+
0,
|
|
195
|
+
0,
|
|
196
|
+
0,
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const day = cursor.getUTCDate();
|
|
202
|
+
const dow = cursor.getUTCDay();
|
|
203
|
+
if (!parsed.day.has(day) || !parsed.dayOfWeek.has(dow)) {
|
|
204
|
+
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
|
|
205
|
+
cursor.setUTCHours(0, 0, 0, 0);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!parsed.hour.has(cursor.getUTCHours())) {
|
|
209
|
+
cursor = new Date(cursor.getTime() + 60 * 60 * 1000);
|
|
210
|
+
cursor.setUTCMinutes(0, 0, 0);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!parsed.minute.has(cursor.getUTCMinutes())) {
|
|
214
|
+
cursor = new Date(cursor.getTime() + 60_000);
|
|
215
|
+
cursor.setUTCSeconds(0, 0);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
return cursor;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @graphorin/triggers - durable cron / interval / idle / event
|
|
3
|
+
* trigger scheduler for the Graphorin framework.
|
|
4
|
+
*
|
|
5
|
+
* @packageDocumentation
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TriggerState, TriggerStore } from '@graphorin/core/contracts';
|
|
9
|
+
import { nextFireAfter, type ParsedCron, parseCron } from './cron.js';
|
|
10
|
+
|
|
11
|
+
export { CronParseError, nextFireAfter, type ParsedCron, parseCron } from './cron.js';
|
|
12
|
+
|
|
13
|
+
/** Canonical version constant, derived from `package.json` at build time. */
|
|
14
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
15
|
+
|
|
16
|
+
export const VERSION: string = pkg.version;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Catch-up policy applied when a trigger missed one or more fires
|
|
20
|
+
* while the scheduler was offline.
|
|
21
|
+
*
|
|
22
|
+
* - `'none'` - drop missed fires (default; safest for personal-assistant scenarios).
|
|
23
|
+
* - `'last'` - fire once on resume (best for cron-style daily jobs).
|
|
24
|
+
* - `'all'` - fire each missed run up to `maxCatchupRuns` within `catchupWindowMs`.
|
|
25
|
+
*
|
|
26
|
+
* @stable
|
|
27
|
+
*/
|
|
28
|
+
export type CatchupPolicy = 'none' | 'last' | 'all';
|
|
29
|
+
|
|
30
|
+
/** @stable */
|
|
31
|
+
export type TriggerKind = 'cron' | 'interval' | 'idle' | 'event';
|
|
32
|
+
|
|
33
|
+
/** @stable */
|
|
34
|
+
export interface TriggerOptions {
|
|
35
|
+
readonly catchupPolicy?: CatchupPolicy;
|
|
36
|
+
readonly maxCatchupRuns?: number;
|
|
37
|
+
/**
|
|
38
|
+
* How far back (from the last successful fire) misses are honored.
|
|
39
|
+
* Catch-up counts REAL missed fires (RP-12), so the window must
|
|
40
|
+
* exceed the trigger period - a 24h window can never catch up a
|
|
41
|
+
* daily cron whose boundary is itself 24h after the last fire.
|
|
42
|
+
* Default 24h.
|
|
43
|
+
*/
|
|
44
|
+
readonly catchupWindowMs?: number;
|
|
45
|
+
readonly tags?: ReadonlyArray<string>;
|
|
46
|
+
/**
|
|
47
|
+
* Suppress the one-time per-process library-mode WARN. Library
|
|
48
|
+
* callers acknowledging that triggers fire only as long as the
|
|
49
|
+
* process lives pass `true` here.
|
|
50
|
+
*/
|
|
51
|
+
readonly acknowledgeLibMode?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Trigger callback. Receives an optional `payload` for `event`
|
|
56
|
+
* triggers; for cron / interval / idle triggers `payload` is
|
|
57
|
+
* `undefined`.
|
|
58
|
+
*
|
|
59
|
+
* @stable
|
|
60
|
+
*/
|
|
61
|
+
export type TriggerCallback = (payload?: unknown) => void | Promise<void>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Public trigger declaration emitted by the helper functions
|
|
65
|
+
* (`cron(...)`, `interval(...)`, `idle(...)`, `event(...)`).
|
|
66
|
+
*
|
|
67
|
+
* @stable
|
|
68
|
+
*/
|
|
69
|
+
export interface TriggerDeclaration {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly kind: TriggerKind;
|
|
72
|
+
readonly spec: string;
|
|
73
|
+
readonly callback: TriggerCallback;
|
|
74
|
+
readonly options: TriggerOptions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a cron trigger declaration. The expression is validated
|
|
79
|
+
* eagerly - a malformed cron expression throws at registration time,
|
|
80
|
+
* not at first fire.
|
|
81
|
+
*
|
|
82
|
+
* @stable
|
|
83
|
+
*/
|
|
84
|
+
export function cron(
|
|
85
|
+
id: string,
|
|
86
|
+
expression: string,
|
|
87
|
+
callback: TriggerCallback,
|
|
88
|
+
options: TriggerOptions = {},
|
|
89
|
+
): TriggerDeclaration {
|
|
90
|
+
parseCron(expression);
|
|
91
|
+
return { id, kind: 'cron', spec: expression, callback, options };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @stable */
|
|
95
|
+
export function interval(
|
|
96
|
+
id: string,
|
|
97
|
+
intervalMs: number,
|
|
98
|
+
callback: TriggerCallback,
|
|
99
|
+
options: TriggerOptions = {},
|
|
100
|
+
): TriggerDeclaration {
|
|
101
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
102
|
+
throw new Error(`[graphorin/triggers] interval(${id}): intervalMs must be > 0`);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
kind: 'interval',
|
|
107
|
+
spec: String(intervalMs),
|
|
108
|
+
callback,
|
|
109
|
+
options,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @stable */
|
|
114
|
+
export function idle(
|
|
115
|
+
id: string,
|
|
116
|
+
idleMs: number,
|
|
117
|
+
callback: TriggerCallback,
|
|
118
|
+
options: TriggerOptions = {},
|
|
119
|
+
): TriggerDeclaration {
|
|
120
|
+
if (!Number.isFinite(idleMs) || idleMs <= 0) {
|
|
121
|
+
throw new Error(`[graphorin/triggers] idle(${id}): idleMs must be > 0`);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
id,
|
|
125
|
+
kind: 'idle',
|
|
126
|
+
spec: String(idleMs),
|
|
127
|
+
callback,
|
|
128
|
+
options,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @stable */
|
|
133
|
+
export function event(
|
|
134
|
+
id: string,
|
|
135
|
+
eventName: string,
|
|
136
|
+
callback: TriggerCallback,
|
|
137
|
+
options: TriggerOptions = {},
|
|
138
|
+
): TriggerDeclaration {
|
|
139
|
+
if (eventName.length === 0) {
|
|
140
|
+
throw new Error(`[graphorin/triggers] event(${id}): eventName must not be empty`);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
id,
|
|
144
|
+
kind: 'event',
|
|
145
|
+
spec: eventName,
|
|
146
|
+
callback,
|
|
147
|
+
options,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Lifecycle event emitted by {@link Scheduler.events}. Useful for
|
|
153
|
+
* tests and for piping into observability without monkey-patching.
|
|
154
|
+
*
|
|
155
|
+
* @stable
|
|
156
|
+
*/
|
|
157
|
+
export type SchedulerEvent =
|
|
158
|
+
| { readonly type: 'started' }
|
|
159
|
+
| { readonly type: 'stopped' }
|
|
160
|
+
| { readonly type: 'registered'; readonly id: string; readonly kind: TriggerKind }
|
|
161
|
+
| { readonly type: 'fire-start'; readonly id: string; readonly firedAt: number }
|
|
162
|
+
| { readonly type: 'fire-end'; readonly id: string; readonly durationMs: number }
|
|
163
|
+
| {
|
|
164
|
+
readonly type: 'fire-error';
|
|
165
|
+
readonly id: string;
|
|
166
|
+
readonly error: unknown;
|
|
167
|
+
readonly durationMs: number;
|
|
168
|
+
}
|
|
169
|
+
| { readonly type: 'catchup-applied'; readonly id: string; readonly missed: number }
|
|
170
|
+
| { readonly type: 'lib-mode-warning'; readonly id: string };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Options for {@link createScheduler}.
|
|
174
|
+
*
|
|
175
|
+
* @stable
|
|
176
|
+
*/
|
|
177
|
+
export interface CreateSchedulerOptions {
|
|
178
|
+
readonly store: TriggerStore;
|
|
179
|
+
/** Default `'lib'`. Server mode skips the lib-mode warning. */
|
|
180
|
+
readonly mode?: 'lib' | 'server';
|
|
181
|
+
/** Override the wall clock - used by tests. */
|
|
182
|
+
readonly now?: () => number;
|
|
183
|
+
/**
|
|
184
|
+
* Override `setTimeout`. The callback receives the chosen delay in
|
|
185
|
+
* milliseconds; the return value is the handle the scheduler
|
|
186
|
+
* later passes to `clearTimeout`. Tests inject a controllable timer.
|
|
187
|
+
*/
|
|
188
|
+
readonly setTimeout?: (cb: () => void, ms: number) => unknown;
|
|
189
|
+
readonly clearTimeout?: (handle: unknown) => void;
|
|
190
|
+
/**
|
|
191
|
+
* Resets the per-process WARN-once flag. Used by the test suite to
|
|
192
|
+
* verify the warning fires exactly once per run.
|
|
193
|
+
*
|
|
194
|
+
* @internal
|
|
195
|
+
*/
|
|
196
|
+
readonly _resetLibModeFlag?: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Public Scheduler surface.
|
|
201
|
+
*
|
|
202
|
+
* @stable
|
|
203
|
+
*/
|
|
204
|
+
export interface Scheduler {
|
|
205
|
+
register(declaration: TriggerDeclaration): Promise<TriggerState>;
|
|
206
|
+
unregister(id: string): Promise<void>;
|
|
207
|
+
list(): Promise<readonly TriggerState[]>;
|
|
208
|
+
start(): Promise<void>;
|
|
209
|
+
stop(): Promise<void>;
|
|
210
|
+
/** Emit `eventName` to every registered `event` trigger. */
|
|
211
|
+
emit(eventName: string, payload?: unknown): Promise<void>;
|
|
212
|
+
/** Manually fire `id` (used by `graphorin triggers fire`, Phase 15). */
|
|
213
|
+
fire(id: string, payload?: unknown): Promise<void>;
|
|
214
|
+
/**
|
|
215
|
+
* Flip the persistent `disabled` flag (IP-17). Disabling cancels the
|
|
216
|
+
* armed timer but keeps the trigger registered + persisted; enabling
|
|
217
|
+
* recomputes the next fire from now and re-arms. The destructive
|
|
218
|
+
* removal is `unregister(...)`.
|
|
219
|
+
*/
|
|
220
|
+
setDisabled(id: string, disabled: boolean): Promise<TriggerState>;
|
|
221
|
+
/** AsyncIterable lifecycle event stream. */
|
|
222
|
+
events(): AsyncIterable<SchedulerEvent>;
|
|
223
|
+
/** Notify the scheduler that the user / runtime is no longer idle. */
|
|
224
|
+
recordActivity(): void;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @stable */
|
|
228
|
+
export function createScheduler(options: CreateSchedulerOptions): Scheduler {
|
|
229
|
+
return new SchedulerImpl(options);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Module-scoped flag - one WARN per process. */
|
|
233
|
+
let LIB_MODE_WARNED = false;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Test-only helper. Drops the per-process WARN-once flag so the next
|
|
237
|
+
* `register(...)` call in lib mode emits the warning again.
|
|
238
|
+
*
|
|
239
|
+
* @internal
|
|
240
|
+
*/
|
|
241
|
+
export function _resetLibModeWarningForTesting(): void {
|
|
242
|
+
LIB_MODE_WARNED = false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const DEFAULT_CATCHUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Node clamps `setTimeout` delays beyond `2^31 - 1` ms to 1 ms - a
|
|
249
|
+
* quarterly cron would fire immediately in a hot loop (RP-15). Delays
|
|
250
|
+
* above this arm an intermediate wake-up that re-schedules instead.
|
|
251
|
+
*/
|
|
252
|
+
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
253
|
+
|
|
254
|
+
class SchedulerImpl implements Scheduler {
|
|
255
|
+
readonly #store: TriggerStore;
|
|
256
|
+
readonly #mode: 'lib' | 'server';
|
|
257
|
+
readonly #now: () => number;
|
|
258
|
+
readonly #setTimeout: (cb: () => void, ms: number) => unknown;
|
|
259
|
+
readonly #clearTimeout: (handle: unknown) => void;
|
|
260
|
+
#started = false;
|
|
261
|
+
#disposed = false;
|
|
262
|
+
#lastActivity: number;
|
|
263
|
+
#handles: Map<string, unknown> = new Map();
|
|
264
|
+
#parsedCron: Map<string, ParsedCron> = new Map();
|
|
265
|
+
#declarations: Map<string, TriggerDeclaration> = new Map();
|
|
266
|
+
#eventQueue: SchedulerEvent[] = [];
|
|
267
|
+
#eventResolvers: Array<(value: IteratorResult<SchedulerEvent>) => void> = [];
|
|
268
|
+
#closedEvents = false;
|
|
269
|
+
|
|
270
|
+
constructor(options: CreateSchedulerOptions) {
|
|
271
|
+
this.#store = options.store;
|
|
272
|
+
this.#mode = options.mode ?? 'lib';
|
|
273
|
+
this.#now = options.now ?? Date.now;
|
|
274
|
+
this.#setTimeout = options.setTimeout ?? ((cb, ms) => globalThis.setTimeout(cb, ms));
|
|
275
|
+
this.#clearTimeout =
|
|
276
|
+
options.clearTimeout ?? ((h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>));
|
|
277
|
+
this.#lastActivity = this.#now();
|
|
278
|
+
if (options._resetLibModeFlag) LIB_MODE_WARNED = false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async register(declaration: TriggerDeclaration): Promise<TriggerState> {
|
|
282
|
+
if (this.#mode === 'lib' && declaration.options.acknowledgeLibMode !== true) {
|
|
283
|
+
if (!LIB_MODE_WARNED) {
|
|
284
|
+
LIB_MODE_WARNED = true;
|
|
285
|
+
console.warn(
|
|
286
|
+
`[graphorin/triggers] running in library mode - triggers fire only while the parent process is alive. ` +
|
|
287
|
+
`Pass { acknowledgeLibMode: true } to suppress this warning.`,
|
|
288
|
+
);
|
|
289
|
+
this.#publish({ type: 'lib-mode-warning', id: declaration.id });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (declaration.kind === 'cron') {
|
|
294
|
+
this.#parsedCron.set(declaration.id, parseCron(declaration.spec));
|
|
295
|
+
}
|
|
296
|
+
this.#declarations.set(declaration.id, declaration);
|
|
297
|
+
|
|
298
|
+
const now = this.#now();
|
|
299
|
+
const existing = await this.#store.get(declaration.id);
|
|
300
|
+
const nextFireMs = this.#computeNextFire(declaration, existing, now);
|
|
301
|
+
const state: TriggerState = {
|
|
302
|
+
id: declaration.id,
|
|
303
|
+
kind: declaration.kind,
|
|
304
|
+
spec: declaration.spec,
|
|
305
|
+
callbackRef: declaration.id,
|
|
306
|
+
...(existing?.lastFiredAt !== undefined ? { lastFiredAt: existing.lastFiredAt } : {}),
|
|
307
|
+
...(nextFireMs !== null ? { nextFireAt: new Date(nextFireMs).toISOString() } : {}),
|
|
308
|
+
missedFires: 0,
|
|
309
|
+
disabled: existing?.disabled === true,
|
|
310
|
+
catchupPolicy: declaration.options.catchupPolicy ?? 'none',
|
|
311
|
+
maxCatchupRuns: declaration.options.maxCatchupRuns ?? 1,
|
|
312
|
+
catchupWindowMs: declaration.options.catchupWindowMs ?? DEFAULT_CATCHUP_WINDOW_MS,
|
|
313
|
+
...(declaration.options.tags !== undefined ? { tags: declaration.options.tags } : {}),
|
|
314
|
+
createdAt: existing !== null ? existing.createdAt : new Date(now).toISOString(),
|
|
315
|
+
...(existing !== null ? { updatedAt: new Date(now).toISOString() } : {}),
|
|
316
|
+
};
|
|
317
|
+
await this.#store.upsert(state);
|
|
318
|
+
|
|
319
|
+
this.#publish({ type: 'registered', id: declaration.id, kind: declaration.kind });
|
|
320
|
+
|
|
321
|
+
if (existing !== null && (declaration.kind === 'cron' || declaration.kind === 'interval')) {
|
|
322
|
+
await this.#applyCatchup(state, existing, now);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (this.#started) this.#schedule(declaration.id, state);
|
|
326
|
+
return state;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async unregister(id: string): Promise<void> {
|
|
330
|
+
this.#cancelHandle(id);
|
|
331
|
+
this.#declarations.delete(id);
|
|
332
|
+
this.#parsedCron.delete(id);
|
|
333
|
+
await this.#store.remove(id);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async list(): Promise<readonly TriggerState[]> {
|
|
337
|
+
return this.#store.list();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async start(): Promise<void> {
|
|
341
|
+
if (this.#started || this.#disposed) return;
|
|
342
|
+
this.#started = true;
|
|
343
|
+
this.#publish({ type: 'started' });
|
|
344
|
+
const states = await this.#store.list();
|
|
345
|
+
for (const state of states) {
|
|
346
|
+
if (state.disabled) continue;
|
|
347
|
+
this.#schedule(state.id, state);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async stop(): Promise<void> {
|
|
352
|
+
if (!this.#started) return;
|
|
353
|
+
this.#started = false;
|
|
354
|
+
for (const id of [...this.#handles.keys()]) this.#cancelHandle(id);
|
|
355
|
+
this.#publish({ type: 'stopped' });
|
|
356
|
+
this.#closedEvents = true;
|
|
357
|
+
while (this.#eventResolvers.length > 0) {
|
|
358
|
+
const resolver = this.#eventResolvers.shift();
|
|
359
|
+
resolver?.({ done: true, value: undefined });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async emit(eventName: string, payload?: unknown): Promise<void> {
|
|
364
|
+
for (const decl of this.#declarations.values()) {
|
|
365
|
+
if (decl.kind === 'event' && decl.spec === eventName) {
|
|
366
|
+
await this.fire(decl.id, payload);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async fire(id: string, payload?: unknown): Promise<void> {
|
|
372
|
+
const decl = this.#declarations.get(id);
|
|
373
|
+
if (decl === undefined) {
|
|
374
|
+
throw new Error(`[graphorin/triggers] no trigger registered with id '${id}'`);
|
|
375
|
+
}
|
|
376
|
+
const firedAt = this.#now();
|
|
377
|
+
this.#publish({ type: 'fire-start', id, firedAt });
|
|
378
|
+
const start = firedAt;
|
|
379
|
+
try {
|
|
380
|
+
await decl.callback(payload);
|
|
381
|
+
const durationMs = this.#now() - start;
|
|
382
|
+
this.#publish({ type: 'fire-end', id, durationMs });
|
|
383
|
+
const state = await this.#store.get(id);
|
|
384
|
+
// RP-13: compute the next fire from THIS fire's timestamp - the
|
|
385
|
+
// persisted state still carries the PREVIOUS lastFiredAt, which
|
|
386
|
+
// for interval triggers yields `prev + interval ≈ now` and an
|
|
387
|
+
// immediate duplicate via the clamped-to-0 delay. Idle triggers
|
|
388
|
+
// never self-reschedule: their next window starts only when
|
|
389
|
+
// `recordActivity()` observes new activity (a fire-driven re-arm
|
|
390
|
+
// would compute a stale `lastActivity + idleMs`, clamp to 0 and
|
|
391
|
+
// refire in a loop).
|
|
392
|
+
const nextFireMs =
|
|
393
|
+
state !== null && decl.kind !== 'idle'
|
|
394
|
+
? this.#computeNextFire(
|
|
395
|
+
decl,
|
|
396
|
+
{ ...state, lastFiredAt: new Date(firedAt).toISOString() },
|
|
397
|
+
this.#now(),
|
|
398
|
+
)
|
|
399
|
+
: null;
|
|
400
|
+
await this.#store.recordFire(
|
|
401
|
+
id,
|
|
402
|
+
new Date(firedAt).toISOString(),
|
|
403
|
+
nextFireMs !== null ? new Date(nextFireMs).toISOString() : undefined,
|
|
404
|
+
);
|
|
405
|
+
if (this.#started && decl.kind !== 'idle') {
|
|
406
|
+
const refreshed = await this.#store.get(id);
|
|
407
|
+
if (refreshed !== null) this.#schedule(id, refreshed);
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
const durationMs = this.#now() - start;
|
|
411
|
+
this.#publish({ type: 'fire-error', id, error: err, durationMs });
|
|
412
|
+
// RP-14: the one-shot timer is consumed by this fire - recompute
|
|
413
|
+
// and persist the next fire WITHOUT recording a successful fire
|
|
414
|
+
// (lastFiredAt stays put), and re-arm, so a single callback
|
|
415
|
+
// failure cannot permanently silence a daily cron.
|
|
416
|
+
try {
|
|
417
|
+
const state = await this.#store.get(id);
|
|
418
|
+
if (state !== null && decl.kind !== 'idle') {
|
|
419
|
+
const nextFireMs = this.#computeNextFire(
|
|
420
|
+
decl,
|
|
421
|
+
{ ...state, lastFiredAt: new Date(firedAt).toISOString() },
|
|
422
|
+
this.#now(),
|
|
423
|
+
);
|
|
424
|
+
const updated: TriggerState = {
|
|
425
|
+
...state,
|
|
426
|
+
...(nextFireMs !== null ? { nextFireAt: new Date(nextFireMs).toISOString() } : {}),
|
|
427
|
+
updatedAt: new Date(this.#now()).toISOString(),
|
|
428
|
+
};
|
|
429
|
+
await this.#store.upsert(updated);
|
|
430
|
+
if (this.#started) this.#schedule(id, updated);
|
|
431
|
+
}
|
|
432
|
+
} catch {
|
|
433
|
+
// Best-effort re-arm - a store failure here must not become an
|
|
434
|
+
// unhandled rejection out of the timer callback.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async setDisabled(id: string, disabled: boolean): Promise<TriggerState> {
|
|
440
|
+
const state = await this.#store.get(id);
|
|
441
|
+
if (state === null) {
|
|
442
|
+
throw new Error(`[graphorin/triggers] no trigger registered with id '${id}'`);
|
|
443
|
+
}
|
|
444
|
+
const nowIso = new Date(this.#now()).toISOString();
|
|
445
|
+
if (disabled) {
|
|
446
|
+
this.#cancelHandle(id);
|
|
447
|
+
const updated: TriggerState = { ...state, disabled: true, updatedAt: nowIso };
|
|
448
|
+
await this.#store.upsert(updated);
|
|
449
|
+
return updated;
|
|
450
|
+
}
|
|
451
|
+
// Re-enable: recompute the next fire from NOW (a stale persisted
|
|
452
|
+
// nextFireAt from before the disable would otherwise clamp to 0 and
|
|
453
|
+
// fire immediately), then re-arm.
|
|
454
|
+
const decl = this.#declarations.get(id);
|
|
455
|
+
const nextMs =
|
|
456
|
+
decl !== undefined
|
|
457
|
+
? this.#computeNextFire(decl, { ...state, lastFiredAt: nowIso }, this.#now())
|
|
458
|
+
: null;
|
|
459
|
+
const updated: TriggerState = {
|
|
460
|
+
...state,
|
|
461
|
+
disabled: false,
|
|
462
|
+
...(nextMs !== null ? { nextFireAt: new Date(nextMs).toISOString() } : {}),
|
|
463
|
+
updatedAt: nowIso,
|
|
464
|
+
};
|
|
465
|
+
await this.#store.upsert(updated);
|
|
466
|
+
if (this.#started) this.#schedule(id, updated);
|
|
467
|
+
return updated;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
recordActivity(): void {
|
|
471
|
+
this.#lastActivity = this.#now();
|
|
472
|
+
// periphery (P-14): never (re)arm idle timers on a scheduler that
|
|
473
|
+
// is not started - a stopped scheduler firing callbacks is a
|
|
474
|
+
// lifecycle violation. The activity timestamp is still recorded so
|
|
475
|
+
// a later start() computes the idle window correctly.
|
|
476
|
+
if (!this.#started) return;
|
|
477
|
+
// Reschedule idle triggers so they treat "now" as the start of
|
|
478
|
+
// their idle window.
|
|
479
|
+
for (const decl of this.#declarations.values()) {
|
|
480
|
+
if (decl.kind !== 'idle') continue;
|
|
481
|
+
this.#cancelHandle(decl.id);
|
|
482
|
+
const idleMs = Number.parseInt(decl.spec, 10);
|
|
483
|
+
const handle = this.#setTimeout(() => {
|
|
484
|
+
void this.fire(decl.id);
|
|
485
|
+
}, idleMs);
|
|
486
|
+
this.#handles.set(decl.id, handle);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
events(): AsyncIterable<SchedulerEvent> {
|
|
491
|
+
const queue = this.#eventQueue;
|
|
492
|
+
const resolvers = this.#eventResolvers;
|
|
493
|
+
const me = this;
|
|
494
|
+
return {
|
|
495
|
+
[Symbol.asyncIterator](): AsyncIterator<SchedulerEvent> {
|
|
496
|
+
return {
|
|
497
|
+
async next(): Promise<IteratorResult<SchedulerEvent>> {
|
|
498
|
+
if (queue.length > 0) {
|
|
499
|
+
const value = queue.shift() as SchedulerEvent;
|
|
500
|
+
return { done: false, value };
|
|
501
|
+
}
|
|
502
|
+
if (me.#closedEvents) return { done: true, value: undefined };
|
|
503
|
+
return new Promise((resolve) => resolvers.push(resolve));
|
|
504
|
+
},
|
|
505
|
+
async return(): Promise<IteratorResult<SchedulerEvent>> {
|
|
506
|
+
return { done: true, value: undefined };
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
#publish(event: SchedulerEvent): void {
|
|
516
|
+
if (this.#closedEvents) return;
|
|
517
|
+
if (this.#eventResolvers.length > 0) {
|
|
518
|
+
const resolver = this.#eventResolvers.shift();
|
|
519
|
+
resolver?.({ done: false, value: event });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
this.#eventQueue.push(event);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#computeNextFire(
|
|
526
|
+
decl: TriggerDeclaration,
|
|
527
|
+
state: TriggerState | null,
|
|
528
|
+
now: number,
|
|
529
|
+
): number | null {
|
|
530
|
+
switch (decl.kind) {
|
|
531
|
+
case 'cron': {
|
|
532
|
+
const parsed = this.#parsedCron.get(decl.id) ?? parseCron(decl.spec);
|
|
533
|
+
const next = nextFireAfter(parsed, new Date(now));
|
|
534
|
+
return next === null ? null : next.getTime();
|
|
535
|
+
}
|
|
536
|
+
case 'interval': {
|
|
537
|
+
const intervalMs = Number.parseInt(decl.spec, 10);
|
|
538
|
+
const last = state?.lastFiredAt !== undefined ? Date.parse(state.lastFiredAt) : now;
|
|
539
|
+
const next = last + intervalMs;
|
|
540
|
+
// periphery-11: after downtime `last + interval` is in the past;
|
|
541
|
+
// the schedule clamp turned that into an IMMEDIATE fire even
|
|
542
|
+
// under `catchupPolicy: 'none'` ("drop missed fires"). Advance
|
|
543
|
+
// to the next FUTURE boundary on the original cadence - missed
|
|
544
|
+
// boundaries are the catch-up machinery's business, not the
|
|
545
|
+
// base schedule's. (Cron already behaves this way:
|
|
546
|
+
// `nextFireAfter(now)` is always future.)
|
|
547
|
+
if (next <= now && Number.isFinite(intervalMs) && intervalMs > 0) {
|
|
548
|
+
const missedBoundaries = Math.floor((now - last) / intervalMs) + 1;
|
|
549
|
+
return last + missedBoundaries * intervalMs;
|
|
550
|
+
}
|
|
551
|
+
return next;
|
|
552
|
+
}
|
|
553
|
+
case 'idle': {
|
|
554
|
+
const idleMs = Number.parseInt(decl.spec, 10);
|
|
555
|
+
return this.#lastActivity + idleMs;
|
|
556
|
+
}
|
|
557
|
+
case 'event':
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async #applyCatchup(state: TriggerState, existing: TriggerState, now: number): Promise<void> {
|
|
563
|
+
if (state.catchupPolicy === 'none') return;
|
|
564
|
+
const decl = this.#declarations.get(state.id);
|
|
565
|
+
if (decl === undefined || (decl.kind !== 'cron' && decl.kind !== 'interval')) return;
|
|
566
|
+
const lastFired = existing.lastFiredAt !== undefined ? Date.parse(existing.lastFiredAt) : null;
|
|
567
|
+
if (lastFired === null) return;
|
|
568
|
+
if (lastFired < now - state.catchupWindowMs) return;
|
|
569
|
+
|
|
570
|
+
// RP-12: count the REAL missed fires by walking the schedule from
|
|
571
|
+
// the last successful fire to now - a restart with zero crossed
|
|
572
|
+
// boundaries must apply zero catch-up (the old code fired `1` /
|
|
573
|
+
// `maxCatchupRuns` times unconditionally).
|
|
574
|
+
const SCAN_CAP = 1000;
|
|
575
|
+
let missed = 0;
|
|
576
|
+
if (decl.kind === 'cron') {
|
|
577
|
+
const parsed = this.#parsedCron.get(decl.id) ?? parseCron(decl.spec);
|
|
578
|
+
let cursor = lastFired;
|
|
579
|
+
while (missed < SCAN_CAP) {
|
|
580
|
+
const next = nextFireAfter(parsed, new Date(cursor));
|
|
581
|
+
if (next === null || next.getTime() > now) break;
|
|
582
|
+
cursor = next.getTime();
|
|
583
|
+
missed += 1;
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
const intervalMs = Number.parseInt(decl.spec, 10);
|
|
587
|
+
missed = Math.floor((now - lastFired) / intervalMs);
|
|
588
|
+
}
|
|
589
|
+
if (missed <= 0) return;
|
|
590
|
+
|
|
591
|
+
const toFire = state.catchupPolicy === 'last' ? 1 : Math.min(missed, state.maxCatchupRuns);
|
|
592
|
+
for (let i = 0; i < toFire; i++) {
|
|
593
|
+
await this.fire(state.id);
|
|
594
|
+
}
|
|
595
|
+
// The overflow beyond what we re-ran is recorded for health/CLI.
|
|
596
|
+
const excess = missed - toFire;
|
|
597
|
+
if (excess > 0) {
|
|
598
|
+
const refreshed = await this.#store.get(state.id);
|
|
599
|
+
if (refreshed !== null) {
|
|
600
|
+
await this.#store.upsert({ ...refreshed, missedFires: excess });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
this.#publish({ type: 'catchup-applied', id: state.id, missed });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
#schedule(id: string, state: TriggerState): void {
|
|
607
|
+
this.#cancelHandle(id);
|
|
608
|
+
if (state.disabled) return;
|
|
609
|
+
const decl = this.#declarations.get(id);
|
|
610
|
+
if (decl === undefined) return;
|
|
611
|
+
if (decl.kind === 'event') return;
|
|
612
|
+
|
|
613
|
+
let delay: number | null = null;
|
|
614
|
+
if (state.nextFireAt !== undefined) {
|
|
615
|
+
delay = Date.parse(state.nextFireAt) - this.#now();
|
|
616
|
+
} else {
|
|
617
|
+
const next = this.#computeNextFire(decl, state, this.#now());
|
|
618
|
+
delay = next === null ? null : next - this.#now();
|
|
619
|
+
}
|
|
620
|
+
if (delay === null) return;
|
|
621
|
+
if (delay < 0) delay = 0;
|
|
622
|
+
|
|
623
|
+
if (delay > MAX_TIMEOUT_MS) {
|
|
624
|
+
// RP-15: chunk the wait - the intermediate wake-up re-reads the
|
|
625
|
+
// freshest state and re-schedules (it never fires the callback).
|
|
626
|
+
const handle = this.#setTimeout(() => {
|
|
627
|
+
void this.#store
|
|
628
|
+
.get(id)
|
|
629
|
+
.then((s) => {
|
|
630
|
+
if (s !== null) this.#schedule(id, s);
|
|
631
|
+
})
|
|
632
|
+
.catch(() => {});
|
|
633
|
+
}, MAX_TIMEOUT_MS);
|
|
634
|
+
this.#handles.set(id, handle);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const handle = this.#setTimeout(() => {
|
|
639
|
+
void this.fire(id);
|
|
640
|
+
}, delay);
|
|
641
|
+
this.#handles.set(id, handle);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
#cancelHandle(id: string): void {
|
|
645
|
+
const handle = this.#handles.get(id);
|
|
646
|
+
if (handle !== undefined) {
|
|
647
|
+
this.#clearTimeout(handle);
|
|
648
|
+
this.#handles.delete(id);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|