@graphorin/triggers 0.6.0 → 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 CHANGED
@@ -1,5 +1,31 @@
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
+
20
+ ## 0.6.1
21
+
22
+ ### Patch Changes
23
+
24
+ - [#142](https://github.com/o-stepper/graphorin/pull/142) [`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430) Thanks [@o-stepper](https://github.com/o-stepper)! - Version constants and version-bearing strings now derive from each package's manifest at build time (`VERSION = pkg.version`; writer ids, client/server info, OTLP framework attributes, build-info metrics interpolate it). No behavioral change at the current version: the rendered strings are byte-identical. A release bump no longer edits source; the new `check-version-consistency` gate fails any reintroduced hardcoded framework version.
25
+
26
+ - Updated dependencies [[`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430)]:
27
+ - @graphorin/core@0.6.1
28
+
3
29
  ## 0.6.0
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -54,6 +54,11 @@ scheduler.register(
54
54
  }),
55
55
  );
56
56
 
57
+ // Idle triggers measure idleness relative to the activity feed:
58
+ // call this from your request/message handlers so "idle" means
59
+ // "no user activity", not "process started 5 minutes ago".
60
+ scheduler.recordActivity();
61
+
57
62
  scheduler.register(
58
63
  event('on-handoff', 'session.handoff', async (payload) => {
59
64
  // …fires whenever a handoff event is published
@@ -69,4 +74,4 @@ MIT © 2026 Oleksiy Stepurenko.
69
74
 
70
75
  ---
71
76
 
72
- **Project Graphorin** · v0.6.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
77
+ **Project Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
package/dist/index.d.ts CHANGED
@@ -3,8 +3,7 @@ import { TriggerState, TriggerStore } from "@graphorin/core/contracts";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
- /** Canonical version constant. Mirrors the `package.json` version. */
7
- declare const VERSION = "0.6.0";
6
+ declare const VERSION: string;
8
7
  /**
9
8
  * Catch-up policy applied when a trigger missed one or more fires
10
9
  * while the scheduler was offline.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;AA0DA;AAQiB,cArDJ,OAAA,GAqDsB,OAAA;;;;;AAenC;;;;;AAWA;AAGY,KAtEA,aAAA,GAsEA,MAAA,GAAA,MAAA,GAAA,KAAA;;AAET,KArES,WAAA,GAqET,MAAA,GAAA,UAAA,GAAA,MAAA,GAAA,OAAA;;AAca,UAhFC,cAAA,CAgFG;EAGR,SAAA,aAAA,CAAA,EAlFe,aAkFf;EACD,SAAA,cAAA,CAAA,EAAA,MAAA;EACR;;AAcH;;;;;EAwBY,SAAA,eAAc,CAAA,EAAA,MAG6C;EAiBtD,SAAA,IAAA,CAAA,EApIC,aAoIqB,CAAA,MAAA,CACrB;EA0BD;;;;;EAGU,SAAA,kBAAA,CAAA,EAAA,OAAA;;;;;;;;;AAef,KAjKA,eAAA,GAiKA,CAAA,OAAA,CAAA,EAAA,OAAA,EAAA,GAAA,IAAA,GAjKgD,OAiKhD,CAAA,IAAA,CAAA;;AAMZ;AAaA;;;;UA5KiB,kBAAA;;iBAEA;;qBAEI;oBACD;;;;;;;;;iBAUJ,IAAA,2CAGJ,2BACD,iBACR;;iBAMa,QAAA,2CAGJ,2BACD,iBACR;;iBAca,IAAA,uCAGJ,2BACD,iBACR;;iBAca,KAAA,0CAGJ,2BACD,iBACR;;;;;;;KAmBS,cAAA;;;;;;;iBAG2D;;;;;;;;;;;;;;;;;;;;;;;;;;;UAiBtD,sBAAA;kBACC;;;;;;;;;;;;;;;;;;;;;;;;;UA0BD,SAAA;wBACO,qBAAqB,QAAQ;0BAC3B;UAChB,iBAAiB;WAChB;UACD;;8CAEoC;;uCAEP;;;;;;;8CAOO,QAAQ;;YAE1C,cAAc;;;;;iBAMV,eAAA,UAAyB,yBAAyB;;;;;;;iBAalD,8BAAA,CAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;;;AA4DY,cA7CC,OA6Cc,EAAA,MAAiC;AAQ5D;;;;;AAeA;;;;;AAWgB,KAnEJ,aAAA,GAmEY,MAAA,GAAA,MAAA,GAAA,KAAA;;AAIb,KApEC,WAAA,GAoED,MAAA,GAAA,UAAA,GAAA,MAAA,GAAA,OAAA;;AACU,UAlEJ,cAAA,CAkEI;EAcL,SAAI,aAAA,CAAA,EA/EO,aA+EP;EAGR,SAAA,cAAA,CAAA,EAAA,MAAA;EACD;;;AAeX;;;;EAKqB,SAAA,eAAA,CAAA,EAAA,MAAA;EAmBT,SAAA,IAAA,CAAA,EAhHM,aAmHqD,CAAA,MAAA,CAAA;EAiBtD;AA2BjB;;;;EAE0B,SAAA,kBAAA,CAAA,EAAA,OAAA;;;;;;;;;AAgBA,KAjKd,eAAA,GAiKc,CAAA,OAAA,CAAA,EAAA,OAAA,EAAA,GAAA,IAAA,GAjKkC,OAiKlC,CAAA,IAAA,CAAA;;;AAM1B;AAaA;;;UA5KiB,kBAAA;;iBAEA;;qBAEI;oBACD;;;;;;;;;iBAUJ,IAAA,2CAGJ,2BACD,iBACR;;iBAMa,QAAA,2CAGJ,2BACD,iBACR;;iBAca,IAAA,uCAGJ,2BACD,iBACR;;iBAca,KAAA,0CAGJ,2BACD,iBACR;;;;;;;KAmBS,cAAA;;;;;;;iBAG2D;;;;;;;;;;;;;;;;;;;;;;;;;;;UAiBtD,sBAAA;kBACC;;;;;;;;;;;;;;;;;;;;;;;;;UA0BD,SAAA;wBACO,qBAAqB,QAAQ;0BAC3B;UAChB,iBAAiB;WAChB;UACD;;8CAEoC;;uCAEP;;;;;;;8CAOO,QAAQ;;YAE1C,cAAc;;;;;iBAMV,eAAA,UAAyB,yBAAyB;;;;;;;iBAalD,8BAAA,CAAA"}
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { CronParseError, nextFireAfter, parseCron } from "./cron.js";
2
+ import { version } from "./package.js";
2
3
 
3
4
  //#region src/index.ts
4
- /** Canonical version constant. Mirrors the `package.json` version. */
5
- const VERSION = "0.6.0";
5
+ /** Canonical version constant, derived from `package.json` at build time. */
6
+ const VERSION = version;
6
7
  /**
7
8
  * Build a cron trigger declaration. The expression is validated
8
9
  * eagerly - a malformed cron expression throws at registration time,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["#store","#mode","#now","#setTimeout","#clearTimeout","#lastActivity","#publish","#parsedCron","#declarations","#computeNextFire","state: TriggerState","#applyCatchup","#started","#schedule","#cancelHandle","#disposed","#handles","#closedEvents","#eventResolvers","updated: TriggerState","updated","#eventQueue","event","delay: number | null","handle"],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @graphorin/triggers - durable cron / interval / idle / event\n * trigger scheduler for the Graphorin framework.\n *\n * @packageDocumentation\n */\n\nimport type { TriggerState, TriggerStore } from '@graphorin/core/contracts';\nimport { nextFireAfter, type ParsedCron, parseCron } from './cron.js';\n\nexport { CronParseError, nextFireAfter, type ParsedCron, parseCron } from './cron.js';\n\n/** Canonical version constant. Mirrors the `package.json` version. */\nexport const VERSION = '0.6.0';\n\n/**\n * Catch-up policy applied when a trigger missed one or more fires\n * while the scheduler was offline.\n *\n * - `'none'` - drop missed fires (default; safest for personal-assistant scenarios).\n * - `'last'` - fire once on resume (best for cron-style daily jobs).\n * - `'all'` - fire each missed run up to `maxCatchupRuns` within `catchupWindowMs`.\n *\n * @stable\n */\nexport type CatchupPolicy = 'none' | 'last' | 'all';\n\n/** @stable */\nexport type TriggerKind = 'cron' | 'interval' | 'idle' | 'event';\n\n/** @stable */\nexport interface TriggerOptions {\n readonly catchupPolicy?: CatchupPolicy;\n readonly maxCatchupRuns?: number;\n /**\n * How far back (from the last successful fire) misses are honored.\n * Catch-up counts REAL missed fires (RP-12), so the window must\n * exceed the trigger period - a 24h window can never catch up a\n * daily cron whose boundary is itself 24h after the last fire.\n * Default 24h.\n */\n readonly catchupWindowMs?: number;\n readonly tags?: ReadonlyArray<string>;\n /**\n * Suppress the one-time per-process library-mode WARN. Library\n * callers acknowledging that triggers fire only as long as the\n * process lives pass `true` here.\n */\n readonly acknowledgeLibMode?: boolean;\n}\n\n/**\n * Trigger callback. Receives an optional `payload` for `event`\n * triggers; for cron / interval / idle triggers `payload` is\n * `undefined`.\n *\n * @stable\n */\nexport type TriggerCallback = (payload?: unknown) => void | Promise<void>;\n\n/**\n * Public trigger declaration emitted by the helper functions\n * (`cron(...)`, `interval(...)`, `idle(...)`, `event(...)`).\n *\n * @stable\n */\nexport interface TriggerDeclaration {\n readonly id: string;\n readonly kind: TriggerKind;\n readonly spec: string;\n readonly callback: TriggerCallback;\n readonly options: TriggerOptions;\n}\n\n/**\n * Build a cron trigger declaration. The expression is validated\n * eagerly - a malformed cron expression throws at registration time,\n * not at first fire.\n *\n * @stable\n */\nexport function cron(\n id: string,\n expression: string,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n parseCron(expression);\n return { id, kind: 'cron', spec: expression, callback, options };\n}\n\n/** @stable */\nexport function interval(\n id: string,\n intervalMs: number,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n if (!Number.isFinite(intervalMs) || intervalMs <= 0) {\n throw new Error(`[graphorin/triggers] interval(${id}): intervalMs must be > 0`);\n }\n return {\n id,\n kind: 'interval',\n spec: String(intervalMs),\n callback,\n options,\n };\n}\n\n/** @stable */\nexport function idle(\n id: string,\n idleMs: number,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n if (!Number.isFinite(idleMs) || idleMs <= 0) {\n throw new Error(`[graphorin/triggers] idle(${id}): idleMs must be > 0`);\n }\n return {\n id,\n kind: 'idle',\n spec: String(idleMs),\n callback,\n options,\n };\n}\n\n/** @stable */\nexport function event(\n id: string,\n eventName: string,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n if (eventName.length === 0) {\n throw new Error(`[graphorin/triggers] event(${id}): eventName must not be empty`);\n }\n return {\n id,\n kind: 'event',\n spec: eventName,\n callback,\n options,\n };\n}\n\n/**\n * Lifecycle event emitted by {@link Scheduler.events}. Useful for\n * tests and for piping into observability without monkey-patching.\n *\n * @stable\n */\nexport type SchedulerEvent =\n | { readonly type: 'started' }\n | { readonly type: 'stopped' }\n | { readonly type: 'registered'; readonly id: string; readonly kind: TriggerKind }\n | { readonly type: 'fire-start'; readonly id: string; readonly firedAt: number }\n | { readonly type: 'fire-end'; readonly id: string; readonly durationMs: number }\n | {\n readonly type: 'fire-error';\n readonly id: string;\n readonly error: unknown;\n readonly durationMs: number;\n }\n | { readonly type: 'catchup-applied'; readonly id: string; readonly missed: number }\n | { readonly type: 'lib-mode-warning'; readonly id: string };\n\n/**\n * Options for {@link createScheduler}.\n *\n * @stable\n */\nexport interface CreateSchedulerOptions {\n readonly store: TriggerStore;\n /** Default `'lib'`. Server mode skips the lib-mode warning. */\n readonly mode?: 'lib' | 'server';\n /** Override the wall clock - used by tests. */\n readonly now?: () => number;\n /**\n * Override `setTimeout`. The callback receives the chosen delay in\n * milliseconds; the return value is the handle the scheduler\n * later passes to `clearTimeout`. Tests inject a controllable timer.\n */\n readonly setTimeout?: (cb: () => void, ms: number) => unknown;\n readonly clearTimeout?: (handle: unknown) => void;\n /**\n * Resets the per-process WARN-once flag. Used by the test suite to\n * verify the warning fires exactly once per run.\n *\n * @internal\n */\n readonly _resetLibModeFlag?: boolean;\n}\n\n/**\n * Public Scheduler surface.\n *\n * @stable\n */\nexport interface Scheduler {\n register(declaration: TriggerDeclaration): Promise<TriggerState>;\n unregister(id: string): Promise<void>;\n list(): Promise<readonly TriggerState[]>;\n start(): Promise<void>;\n stop(): Promise<void>;\n /** Emit `eventName` to every registered `event` trigger. */\n emit(eventName: string, payload?: unknown): Promise<void>;\n /** Manually fire `id` (used by `graphorin triggers fire`, Phase 15). */\n fire(id: string, payload?: unknown): Promise<void>;\n /**\n * Flip the persistent `disabled` flag (IP-17). Disabling cancels the\n * armed timer but keeps the trigger registered + persisted; enabling\n * recomputes the next fire from now and re-arms. The destructive\n * removal is `unregister(...)`.\n */\n setDisabled(id: string, disabled: boolean): Promise<TriggerState>;\n /** AsyncIterable lifecycle event stream. */\n events(): AsyncIterable<SchedulerEvent>;\n /** Notify the scheduler that the user / runtime is no longer idle. */\n recordActivity(): void;\n}\n\n/** @stable */\nexport function createScheduler(options: CreateSchedulerOptions): Scheduler {\n return new SchedulerImpl(options);\n}\n\n/** Module-scoped flag - one WARN per process. */\nlet LIB_MODE_WARNED = false;\n\n/**\n * Test-only helper. Drops the per-process WARN-once flag so the next\n * `register(...)` call in lib mode emits the warning again.\n *\n * @internal\n */\nexport function _resetLibModeWarningForTesting(): void {\n LIB_MODE_WARNED = false;\n}\n\nconst DEFAULT_CATCHUP_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Node clamps `setTimeout` delays beyond `2^31 - 1` ms to 1 ms - a\n * quarterly cron would fire immediately in a hot loop (RP-15). Delays\n * above this arm an intermediate wake-up that re-schedules instead.\n */\nconst MAX_TIMEOUT_MS = 2 ** 31 - 1;\n\nclass SchedulerImpl implements Scheduler {\n readonly #store: TriggerStore;\n readonly #mode: 'lib' | 'server';\n readonly #now: () => number;\n readonly #setTimeout: (cb: () => void, ms: number) => unknown;\n readonly #clearTimeout: (handle: unknown) => void;\n #started = false;\n #disposed = false;\n #lastActivity: number;\n #handles: Map<string, unknown> = new Map();\n #parsedCron: Map<string, ParsedCron> = new Map();\n #declarations: Map<string, TriggerDeclaration> = new Map();\n #eventQueue: SchedulerEvent[] = [];\n #eventResolvers: Array<(value: IteratorResult<SchedulerEvent>) => void> = [];\n #closedEvents = false;\n\n constructor(options: CreateSchedulerOptions) {\n this.#store = options.store;\n this.#mode = options.mode ?? 'lib';\n this.#now = options.now ?? Date.now;\n this.#setTimeout = options.setTimeout ?? ((cb, ms) => globalThis.setTimeout(cb, ms));\n this.#clearTimeout =\n options.clearTimeout ?? ((h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>));\n this.#lastActivity = this.#now();\n if (options._resetLibModeFlag) LIB_MODE_WARNED = false;\n }\n\n async register(declaration: TriggerDeclaration): Promise<TriggerState> {\n if (this.#mode === 'lib' && declaration.options.acknowledgeLibMode !== true) {\n if (!LIB_MODE_WARNED) {\n LIB_MODE_WARNED = true;\n console.warn(\n `[graphorin/triggers] running in library mode - triggers fire only while the parent process is alive. ` +\n `Pass { acknowledgeLibMode: true } to suppress this warning.`,\n );\n this.#publish({ type: 'lib-mode-warning', id: declaration.id });\n }\n }\n\n if (declaration.kind === 'cron') {\n this.#parsedCron.set(declaration.id, parseCron(declaration.spec));\n }\n this.#declarations.set(declaration.id, declaration);\n\n const now = this.#now();\n const existing = await this.#store.get(declaration.id);\n const nextFireMs = this.#computeNextFire(declaration, existing, now);\n const state: TriggerState = {\n id: declaration.id,\n kind: declaration.kind,\n spec: declaration.spec,\n callbackRef: declaration.id,\n ...(existing?.lastFiredAt !== undefined ? { lastFiredAt: existing.lastFiredAt } : {}),\n ...(nextFireMs !== null ? { nextFireAt: new Date(nextFireMs).toISOString() } : {}),\n missedFires: 0,\n disabled: existing?.disabled === true,\n catchupPolicy: declaration.options.catchupPolicy ?? 'none',\n maxCatchupRuns: declaration.options.maxCatchupRuns ?? 1,\n catchupWindowMs: declaration.options.catchupWindowMs ?? DEFAULT_CATCHUP_WINDOW_MS,\n ...(declaration.options.tags !== undefined ? { tags: declaration.options.tags } : {}),\n createdAt: existing !== null ? existing.createdAt : new Date(now).toISOString(),\n ...(existing !== null ? { updatedAt: new Date(now).toISOString() } : {}),\n };\n await this.#store.upsert(state);\n\n this.#publish({ type: 'registered', id: declaration.id, kind: declaration.kind });\n\n if (existing !== null && (declaration.kind === 'cron' || declaration.kind === 'interval')) {\n await this.#applyCatchup(state, existing, now);\n }\n\n if (this.#started) this.#schedule(declaration.id, state);\n return state;\n }\n\n async unregister(id: string): Promise<void> {\n this.#cancelHandle(id);\n this.#declarations.delete(id);\n this.#parsedCron.delete(id);\n await this.#store.remove(id);\n }\n\n async list(): Promise<readonly TriggerState[]> {\n return this.#store.list();\n }\n\n async start(): Promise<void> {\n if (this.#started || this.#disposed) return;\n this.#started = true;\n this.#publish({ type: 'started' });\n const states = await this.#store.list();\n for (const state of states) {\n if (state.disabled) continue;\n this.#schedule(state.id, state);\n }\n }\n\n async stop(): Promise<void> {\n if (!this.#started) return;\n this.#started = false;\n for (const id of [...this.#handles.keys()]) this.#cancelHandle(id);\n this.#publish({ type: 'stopped' });\n this.#closedEvents = true;\n while (this.#eventResolvers.length > 0) {\n const resolver = this.#eventResolvers.shift();\n resolver?.({ done: true, value: undefined });\n }\n }\n\n async emit(eventName: string, payload?: unknown): Promise<void> {\n for (const decl of this.#declarations.values()) {\n if (decl.kind === 'event' && decl.spec === eventName) {\n await this.fire(decl.id, payload);\n }\n }\n }\n\n async fire(id: string, payload?: unknown): Promise<void> {\n const decl = this.#declarations.get(id);\n if (decl === undefined) {\n throw new Error(`[graphorin/triggers] no trigger registered with id '${id}'`);\n }\n const firedAt = this.#now();\n this.#publish({ type: 'fire-start', id, firedAt });\n const start = firedAt;\n try {\n await decl.callback(payload);\n const durationMs = this.#now() - start;\n this.#publish({ type: 'fire-end', id, durationMs });\n const state = await this.#store.get(id);\n // RP-13: compute the next fire from THIS fire's timestamp - the\n // persisted state still carries the PREVIOUS lastFiredAt, which\n // for interval triggers yields `prev + interval ≈ now` and an\n // immediate duplicate via the clamped-to-0 delay. Idle triggers\n // never self-reschedule: their next window starts only when\n // `recordActivity()` observes new activity (a fire-driven re-arm\n // would compute a stale `lastActivity + idleMs`, clamp to 0 and\n // refire in a loop).\n const nextFireMs =\n state !== null && decl.kind !== 'idle'\n ? this.#computeNextFire(\n decl,\n { ...state, lastFiredAt: new Date(firedAt).toISOString() },\n this.#now(),\n )\n : null;\n await this.#store.recordFire(\n id,\n new Date(firedAt).toISOString(),\n nextFireMs !== null ? new Date(nextFireMs).toISOString() : undefined,\n );\n if (this.#started && decl.kind !== 'idle') {\n const refreshed = await this.#store.get(id);\n if (refreshed !== null) this.#schedule(id, refreshed);\n }\n } catch (err) {\n const durationMs = this.#now() - start;\n this.#publish({ type: 'fire-error', id, error: err, durationMs });\n // RP-14: the one-shot timer is consumed by this fire - recompute\n // and persist the next fire WITHOUT recording a successful fire\n // (lastFiredAt stays put), and re-arm, so a single callback\n // failure cannot permanently silence a daily cron.\n try {\n const state = await this.#store.get(id);\n if (state !== null && decl.kind !== 'idle') {\n const nextFireMs = this.#computeNextFire(\n decl,\n { ...state, lastFiredAt: new Date(firedAt).toISOString() },\n this.#now(),\n );\n const updated: TriggerState = {\n ...state,\n ...(nextFireMs !== null ? { nextFireAt: new Date(nextFireMs).toISOString() } : {}),\n updatedAt: new Date(this.#now()).toISOString(),\n };\n await this.#store.upsert(updated);\n if (this.#started) this.#schedule(id, updated);\n }\n } catch {\n // Best-effort re-arm - a store failure here must not become an\n // unhandled rejection out of the timer callback.\n }\n }\n }\n\n async setDisabled(id: string, disabled: boolean): Promise<TriggerState> {\n const state = await this.#store.get(id);\n if (state === null) {\n throw new Error(`[graphorin/triggers] no trigger registered with id '${id}'`);\n }\n const nowIso = new Date(this.#now()).toISOString();\n if (disabled) {\n this.#cancelHandle(id);\n const updated: TriggerState = { ...state, disabled: true, updatedAt: nowIso };\n await this.#store.upsert(updated);\n return updated;\n }\n // Re-enable: recompute the next fire from NOW (a stale persisted\n // nextFireAt from before the disable would otherwise clamp to 0 and\n // fire immediately), then re-arm.\n const decl = this.#declarations.get(id);\n const nextMs =\n decl !== undefined\n ? this.#computeNextFire(decl, { ...state, lastFiredAt: nowIso }, this.#now())\n : null;\n const updated: TriggerState = {\n ...state,\n disabled: false,\n ...(nextMs !== null ? { nextFireAt: new Date(nextMs).toISOString() } : {}),\n updatedAt: nowIso,\n };\n await this.#store.upsert(updated);\n if (this.#started) this.#schedule(id, updated);\n return updated;\n }\n\n recordActivity(): void {\n this.#lastActivity = this.#now();\n // periphery (P-14): never (re)arm idle timers on a scheduler that\n // is not started - a stopped scheduler firing callbacks is a\n // lifecycle violation. The activity timestamp is still recorded so\n // a later start() computes the idle window correctly.\n if (!this.#started) return;\n // Reschedule idle triggers so they treat \"now\" as the start of\n // their idle window.\n for (const decl of this.#declarations.values()) {\n if (decl.kind !== 'idle') continue;\n this.#cancelHandle(decl.id);\n const idleMs = Number.parseInt(decl.spec, 10);\n const handle = this.#setTimeout(() => {\n void this.fire(decl.id);\n }, idleMs);\n this.#handles.set(decl.id, handle);\n }\n }\n\n events(): AsyncIterable<SchedulerEvent> {\n const queue = this.#eventQueue;\n const resolvers = this.#eventResolvers;\n const me = this;\n return {\n [Symbol.asyncIterator](): AsyncIterator<SchedulerEvent> {\n return {\n async next(): Promise<IteratorResult<SchedulerEvent>> {\n if (queue.length > 0) {\n const value = queue.shift() as SchedulerEvent;\n return { done: false, value };\n }\n if (me.#closedEvents) return { done: true, value: undefined };\n return new Promise((resolve) => resolvers.push(resolve));\n },\n async return(): Promise<IteratorResult<SchedulerEvent>> {\n return { done: true, value: undefined };\n },\n };\n },\n };\n }\n\n // ---------------------------------------------------------------------------\n\n #publish(event: SchedulerEvent): void {\n if (this.#closedEvents) return;\n if (this.#eventResolvers.length > 0) {\n const resolver = this.#eventResolvers.shift();\n resolver?.({ done: false, value: event });\n return;\n }\n this.#eventQueue.push(event);\n }\n\n #computeNextFire(\n decl: TriggerDeclaration,\n state: TriggerState | null,\n now: number,\n ): number | null {\n switch (decl.kind) {\n case 'cron': {\n const parsed = this.#parsedCron.get(decl.id) ?? parseCron(decl.spec);\n const next = nextFireAfter(parsed, new Date(now));\n return next === null ? null : next.getTime();\n }\n case 'interval': {\n const intervalMs = Number.parseInt(decl.spec, 10);\n const last = state?.lastFiredAt !== undefined ? Date.parse(state.lastFiredAt) : now;\n const next = last + intervalMs;\n // periphery-11: after downtime `last + interval` is in the past;\n // the schedule clamp turned that into an IMMEDIATE fire even\n // under `catchupPolicy: 'none'` (\"drop missed fires\"). Advance\n // to the next FUTURE boundary on the original cadence - missed\n // boundaries are the catch-up machinery's business, not the\n // base schedule's. (Cron already behaves this way:\n // `nextFireAfter(now)` is always future.)\n if (next <= now && Number.isFinite(intervalMs) && intervalMs > 0) {\n const missedBoundaries = Math.floor((now - last) / intervalMs) + 1;\n return last + missedBoundaries * intervalMs;\n }\n return next;\n }\n case 'idle': {\n const idleMs = Number.parseInt(decl.spec, 10);\n return this.#lastActivity + idleMs;\n }\n case 'event':\n return null;\n }\n }\n\n async #applyCatchup(state: TriggerState, existing: TriggerState, now: number): Promise<void> {\n if (state.catchupPolicy === 'none') return;\n const decl = this.#declarations.get(state.id);\n if (decl === undefined || (decl.kind !== 'cron' && decl.kind !== 'interval')) return;\n const lastFired = existing.lastFiredAt !== undefined ? Date.parse(existing.lastFiredAt) : null;\n if (lastFired === null) return;\n if (lastFired < now - state.catchupWindowMs) return;\n\n // RP-12: count the REAL missed fires by walking the schedule from\n // the last successful fire to now - a restart with zero crossed\n // boundaries must apply zero catch-up (the old code fired `1` /\n // `maxCatchupRuns` times unconditionally).\n const SCAN_CAP = 1000;\n let missed = 0;\n if (decl.kind === 'cron') {\n const parsed = this.#parsedCron.get(decl.id) ?? parseCron(decl.spec);\n let cursor = lastFired;\n while (missed < SCAN_CAP) {\n const next = nextFireAfter(parsed, new Date(cursor));\n if (next === null || next.getTime() > now) break;\n cursor = next.getTime();\n missed += 1;\n }\n } else {\n const intervalMs = Number.parseInt(decl.spec, 10);\n missed = Math.floor((now - lastFired) / intervalMs);\n }\n if (missed <= 0) return;\n\n const toFire = state.catchupPolicy === 'last' ? 1 : Math.min(missed, state.maxCatchupRuns);\n for (let i = 0; i < toFire; i++) {\n await this.fire(state.id);\n }\n // The overflow beyond what we re-ran is recorded for health/CLI.\n const excess = missed - toFire;\n if (excess > 0) {\n const refreshed = await this.#store.get(state.id);\n if (refreshed !== null) {\n await this.#store.upsert({ ...refreshed, missedFires: excess });\n }\n }\n this.#publish({ type: 'catchup-applied', id: state.id, missed });\n }\n\n #schedule(id: string, state: TriggerState): void {\n this.#cancelHandle(id);\n if (state.disabled) return;\n const decl = this.#declarations.get(id);\n if (decl === undefined) return;\n if (decl.kind === 'event') return;\n\n let delay: number | null = null;\n if (state.nextFireAt !== undefined) {\n delay = Date.parse(state.nextFireAt) - this.#now();\n } else {\n const next = this.#computeNextFire(decl, state, this.#now());\n delay = next === null ? null : next - this.#now();\n }\n if (delay === null) return;\n if (delay < 0) delay = 0;\n\n if (delay > MAX_TIMEOUT_MS) {\n // RP-15: chunk the wait - the intermediate wake-up re-reads the\n // freshest state and re-schedules (it never fires the callback).\n const handle = this.#setTimeout(() => {\n void this.#store\n .get(id)\n .then((s) => {\n if (s !== null) this.#schedule(id, s);\n })\n .catch(() => {});\n }, MAX_TIMEOUT_MS);\n this.#handles.set(id, handle);\n return;\n }\n\n const handle = this.#setTimeout(() => {\n void this.fire(id);\n }, delay);\n this.#handles.set(id, handle);\n }\n\n #cancelHandle(id: string): void {\n const handle = this.#handles.get(id);\n if (handle !== undefined) {\n this.#clearTimeout(handle);\n this.#handles.delete(id);\n }\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":["VERSION: string","pkg.version","#store","#mode","#now","#setTimeout","#clearTimeout","#lastActivity","#publish","#parsedCron","#declarations","#computeNextFire","state: TriggerState","#applyCatchup","#started","#schedule","#cancelHandle","#disposed","#handles","#closedEvents","#eventResolvers","updated: TriggerState","updated","#eventQueue","event","delay: number | null","handle"],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @graphorin/triggers - durable cron / interval / idle / event\n * trigger scheduler for the Graphorin framework.\n *\n * @packageDocumentation\n */\n\nimport type { TriggerState, TriggerStore } from '@graphorin/core/contracts';\nimport { nextFireAfter, type ParsedCron, parseCron } from './cron.js';\n\nexport { CronParseError, nextFireAfter, type ParsedCron, parseCron } from './cron.js';\n\n/** Canonical version constant, derived from `package.json` at build time. */\nimport pkg from '../package.json' with { type: 'json' };\n\nexport const VERSION: string = pkg.version;\n\n/**\n * Catch-up policy applied when a trigger missed one or more fires\n * while the scheduler was offline.\n *\n * - `'none'` - drop missed fires (default; safest for personal-assistant scenarios).\n * - `'last'` - fire once on resume (best for cron-style daily jobs).\n * - `'all'` - fire each missed run up to `maxCatchupRuns` within `catchupWindowMs`.\n *\n * @stable\n */\nexport type CatchupPolicy = 'none' | 'last' | 'all';\n\n/** @stable */\nexport type TriggerKind = 'cron' | 'interval' | 'idle' | 'event';\n\n/** @stable */\nexport interface TriggerOptions {\n readonly catchupPolicy?: CatchupPolicy;\n readonly maxCatchupRuns?: number;\n /**\n * How far back (from the last successful fire) misses are honored.\n * Catch-up counts REAL missed fires (RP-12), so the window must\n * exceed the trigger period - a 24h window can never catch up a\n * daily cron whose boundary is itself 24h after the last fire.\n * Default 24h.\n */\n readonly catchupWindowMs?: number;\n readonly tags?: ReadonlyArray<string>;\n /**\n * Suppress the one-time per-process library-mode WARN. Library\n * callers acknowledging that triggers fire only as long as the\n * process lives pass `true` here.\n */\n readonly acknowledgeLibMode?: boolean;\n}\n\n/**\n * Trigger callback. Receives an optional `payload` for `event`\n * triggers; for cron / interval / idle triggers `payload` is\n * `undefined`.\n *\n * @stable\n */\nexport type TriggerCallback = (payload?: unknown) => void | Promise<void>;\n\n/**\n * Public trigger declaration emitted by the helper functions\n * (`cron(...)`, `interval(...)`, `idle(...)`, `event(...)`).\n *\n * @stable\n */\nexport interface TriggerDeclaration {\n readonly id: string;\n readonly kind: TriggerKind;\n readonly spec: string;\n readonly callback: TriggerCallback;\n readonly options: TriggerOptions;\n}\n\n/**\n * Build a cron trigger declaration. The expression is validated\n * eagerly - a malformed cron expression throws at registration time,\n * not at first fire.\n *\n * @stable\n */\nexport function cron(\n id: string,\n expression: string,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n parseCron(expression);\n return { id, kind: 'cron', spec: expression, callback, options };\n}\n\n/** @stable */\nexport function interval(\n id: string,\n intervalMs: number,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n if (!Number.isFinite(intervalMs) || intervalMs <= 0) {\n throw new Error(`[graphorin/triggers] interval(${id}): intervalMs must be > 0`);\n }\n return {\n id,\n kind: 'interval',\n spec: String(intervalMs),\n callback,\n options,\n };\n}\n\n/** @stable */\nexport function idle(\n id: string,\n idleMs: number,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n if (!Number.isFinite(idleMs) || idleMs <= 0) {\n throw new Error(`[graphorin/triggers] idle(${id}): idleMs must be > 0`);\n }\n return {\n id,\n kind: 'idle',\n spec: String(idleMs),\n callback,\n options,\n };\n}\n\n/** @stable */\nexport function event(\n id: string,\n eventName: string,\n callback: TriggerCallback,\n options: TriggerOptions = {},\n): TriggerDeclaration {\n if (eventName.length === 0) {\n throw new Error(`[graphorin/triggers] event(${id}): eventName must not be empty`);\n }\n return {\n id,\n kind: 'event',\n spec: eventName,\n callback,\n options,\n };\n}\n\n/**\n * Lifecycle event emitted by {@link Scheduler.events}. Useful for\n * tests and for piping into observability without monkey-patching.\n *\n * @stable\n */\nexport type SchedulerEvent =\n | { readonly type: 'started' }\n | { readonly type: 'stopped' }\n | { readonly type: 'registered'; readonly id: string; readonly kind: TriggerKind }\n | { readonly type: 'fire-start'; readonly id: string; readonly firedAt: number }\n | { readonly type: 'fire-end'; readonly id: string; readonly durationMs: number }\n | {\n readonly type: 'fire-error';\n readonly id: string;\n readonly error: unknown;\n readonly durationMs: number;\n }\n | { readonly type: 'catchup-applied'; readonly id: string; readonly missed: number }\n | { readonly type: 'lib-mode-warning'; readonly id: string };\n\n/**\n * Options for {@link createScheduler}.\n *\n * @stable\n */\nexport interface CreateSchedulerOptions {\n readonly store: TriggerStore;\n /** Default `'lib'`. Server mode skips the lib-mode warning. */\n readonly mode?: 'lib' | 'server';\n /** Override the wall clock - used by tests. */\n readonly now?: () => number;\n /**\n * Override `setTimeout`. The callback receives the chosen delay in\n * milliseconds; the return value is the handle the scheduler\n * later passes to `clearTimeout`. Tests inject a controllable timer.\n */\n readonly setTimeout?: (cb: () => void, ms: number) => unknown;\n readonly clearTimeout?: (handle: unknown) => void;\n /**\n * Resets the per-process WARN-once flag. Used by the test suite to\n * verify the warning fires exactly once per run.\n *\n * @internal\n */\n readonly _resetLibModeFlag?: boolean;\n}\n\n/**\n * Public Scheduler surface.\n *\n * @stable\n */\nexport interface Scheduler {\n register(declaration: TriggerDeclaration): Promise<TriggerState>;\n unregister(id: string): Promise<void>;\n list(): Promise<readonly TriggerState[]>;\n start(): Promise<void>;\n stop(): Promise<void>;\n /** Emit `eventName` to every registered `event` trigger. */\n emit(eventName: string, payload?: unknown): Promise<void>;\n /** Manually fire `id` (used by `graphorin triggers fire`, Phase 15). */\n fire(id: string, payload?: unknown): Promise<void>;\n /**\n * Flip the persistent `disabled` flag (IP-17). Disabling cancels the\n * armed timer but keeps the trigger registered + persisted; enabling\n * recomputes the next fire from now and re-arms. The destructive\n * removal is `unregister(...)`.\n */\n setDisabled(id: string, disabled: boolean): Promise<TriggerState>;\n /** AsyncIterable lifecycle event stream. */\n events(): AsyncIterable<SchedulerEvent>;\n /** Notify the scheduler that the user / runtime is no longer idle. */\n recordActivity(): void;\n}\n\n/** @stable */\nexport function createScheduler(options: CreateSchedulerOptions): Scheduler {\n return new SchedulerImpl(options);\n}\n\n/** Module-scoped flag - one WARN per process. */\nlet LIB_MODE_WARNED = false;\n\n/**\n * Test-only helper. Drops the per-process WARN-once flag so the next\n * `register(...)` call in lib mode emits the warning again.\n *\n * @internal\n */\nexport function _resetLibModeWarningForTesting(): void {\n LIB_MODE_WARNED = false;\n}\n\nconst DEFAULT_CATCHUP_WINDOW_MS = 24 * 60 * 60 * 1000;\n\n/**\n * Node clamps `setTimeout` delays beyond `2^31 - 1` ms to 1 ms - a\n * quarterly cron would fire immediately in a hot loop (RP-15). Delays\n * above this arm an intermediate wake-up that re-schedules instead.\n */\nconst MAX_TIMEOUT_MS = 2 ** 31 - 1;\n\nclass SchedulerImpl implements Scheduler {\n readonly #store: TriggerStore;\n readonly #mode: 'lib' | 'server';\n readonly #now: () => number;\n readonly #setTimeout: (cb: () => void, ms: number) => unknown;\n readonly #clearTimeout: (handle: unknown) => void;\n #started = false;\n #disposed = false;\n #lastActivity: number;\n #handles: Map<string, unknown> = new Map();\n #parsedCron: Map<string, ParsedCron> = new Map();\n #declarations: Map<string, TriggerDeclaration> = new Map();\n #eventQueue: SchedulerEvent[] = [];\n #eventResolvers: Array<(value: IteratorResult<SchedulerEvent>) => void> = [];\n #closedEvents = false;\n\n constructor(options: CreateSchedulerOptions) {\n this.#store = options.store;\n this.#mode = options.mode ?? 'lib';\n this.#now = options.now ?? Date.now;\n this.#setTimeout = options.setTimeout ?? ((cb, ms) => globalThis.setTimeout(cb, ms));\n this.#clearTimeout =\n options.clearTimeout ?? ((h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>));\n this.#lastActivity = this.#now();\n if (options._resetLibModeFlag) LIB_MODE_WARNED = false;\n }\n\n async register(declaration: TriggerDeclaration): Promise<TriggerState> {\n if (this.#mode === 'lib' && declaration.options.acknowledgeLibMode !== true) {\n if (!LIB_MODE_WARNED) {\n LIB_MODE_WARNED = true;\n console.warn(\n `[graphorin/triggers] running in library mode - triggers fire only while the parent process is alive. ` +\n `Pass { acknowledgeLibMode: true } to suppress this warning.`,\n );\n this.#publish({ type: 'lib-mode-warning', id: declaration.id });\n }\n }\n\n if (declaration.kind === 'cron') {\n this.#parsedCron.set(declaration.id, parseCron(declaration.spec));\n }\n this.#declarations.set(declaration.id, declaration);\n\n const now = this.#now();\n const existing = await this.#store.get(declaration.id);\n const nextFireMs = this.#computeNextFire(declaration, existing, now);\n const state: TriggerState = {\n id: declaration.id,\n kind: declaration.kind,\n spec: declaration.spec,\n callbackRef: declaration.id,\n ...(existing?.lastFiredAt !== undefined ? { lastFiredAt: existing.lastFiredAt } : {}),\n ...(nextFireMs !== null ? { nextFireAt: new Date(nextFireMs).toISOString() } : {}),\n missedFires: 0,\n disabled: existing?.disabled === true,\n catchupPolicy: declaration.options.catchupPolicy ?? 'none',\n maxCatchupRuns: declaration.options.maxCatchupRuns ?? 1,\n catchupWindowMs: declaration.options.catchupWindowMs ?? DEFAULT_CATCHUP_WINDOW_MS,\n ...(declaration.options.tags !== undefined ? { tags: declaration.options.tags } : {}),\n createdAt: existing !== null ? existing.createdAt : new Date(now).toISOString(),\n ...(existing !== null ? { updatedAt: new Date(now).toISOString() } : {}),\n };\n await this.#store.upsert(state);\n\n this.#publish({ type: 'registered', id: declaration.id, kind: declaration.kind });\n\n if (existing !== null && (declaration.kind === 'cron' || declaration.kind === 'interval')) {\n await this.#applyCatchup(state, existing, now);\n }\n\n if (this.#started) this.#schedule(declaration.id, state);\n return state;\n }\n\n async unregister(id: string): Promise<void> {\n this.#cancelHandle(id);\n this.#declarations.delete(id);\n this.#parsedCron.delete(id);\n await this.#store.remove(id);\n }\n\n async list(): Promise<readonly TriggerState[]> {\n return this.#store.list();\n }\n\n async start(): Promise<void> {\n if (this.#started || this.#disposed) return;\n this.#started = true;\n this.#publish({ type: 'started' });\n const states = await this.#store.list();\n for (const state of states) {\n if (state.disabled) continue;\n this.#schedule(state.id, state);\n }\n }\n\n async stop(): Promise<void> {\n if (!this.#started) return;\n this.#started = false;\n for (const id of [...this.#handles.keys()]) this.#cancelHandle(id);\n this.#publish({ type: 'stopped' });\n this.#closedEvents = true;\n while (this.#eventResolvers.length > 0) {\n const resolver = this.#eventResolvers.shift();\n resolver?.({ done: true, value: undefined });\n }\n }\n\n async emit(eventName: string, payload?: unknown): Promise<void> {\n for (const decl of this.#declarations.values()) {\n if (decl.kind === 'event' && decl.spec === eventName) {\n await this.fire(decl.id, payload);\n }\n }\n }\n\n async fire(id: string, payload?: unknown): Promise<void> {\n const decl = this.#declarations.get(id);\n if (decl === undefined) {\n throw new Error(`[graphorin/triggers] no trigger registered with id '${id}'`);\n }\n const firedAt = this.#now();\n this.#publish({ type: 'fire-start', id, firedAt });\n const start = firedAt;\n try {\n await decl.callback(payload);\n const durationMs = this.#now() - start;\n this.#publish({ type: 'fire-end', id, durationMs });\n const state = await this.#store.get(id);\n // RP-13: compute the next fire from THIS fire's timestamp - the\n // persisted state still carries the PREVIOUS lastFiredAt, which\n // for interval triggers yields `prev + interval ≈ now` and an\n // immediate duplicate via the clamped-to-0 delay. Idle triggers\n // never self-reschedule: their next window starts only when\n // `recordActivity()` observes new activity (a fire-driven re-arm\n // would compute a stale `lastActivity + idleMs`, clamp to 0 and\n // refire in a loop).\n const nextFireMs =\n state !== null && decl.kind !== 'idle'\n ? this.#computeNextFire(\n decl,\n { ...state, lastFiredAt: new Date(firedAt).toISOString() },\n this.#now(),\n )\n : null;\n await this.#store.recordFire(\n id,\n new Date(firedAt).toISOString(),\n nextFireMs !== null ? new Date(nextFireMs).toISOString() : undefined,\n );\n if (this.#started && decl.kind !== 'idle') {\n const refreshed = await this.#store.get(id);\n if (refreshed !== null) this.#schedule(id, refreshed);\n }\n } catch (err) {\n const durationMs = this.#now() - start;\n this.#publish({ type: 'fire-error', id, error: err, durationMs });\n // RP-14: the one-shot timer is consumed by this fire - recompute\n // and persist the next fire WITHOUT recording a successful fire\n // (lastFiredAt stays put), and re-arm, so a single callback\n // failure cannot permanently silence a daily cron.\n try {\n const state = await this.#store.get(id);\n if (state !== null && decl.kind !== 'idle') {\n const nextFireMs = this.#computeNextFire(\n decl,\n { ...state, lastFiredAt: new Date(firedAt).toISOString() },\n this.#now(),\n );\n const updated: TriggerState = {\n ...state,\n ...(nextFireMs !== null ? { nextFireAt: new Date(nextFireMs).toISOString() } : {}),\n updatedAt: new Date(this.#now()).toISOString(),\n };\n await this.#store.upsert(updated);\n if (this.#started) this.#schedule(id, updated);\n }\n } catch {\n // Best-effort re-arm - a store failure here must not become an\n // unhandled rejection out of the timer callback.\n }\n }\n }\n\n async setDisabled(id: string, disabled: boolean): Promise<TriggerState> {\n const state = await this.#store.get(id);\n if (state === null) {\n throw new Error(`[graphorin/triggers] no trigger registered with id '${id}'`);\n }\n const nowIso = new Date(this.#now()).toISOString();\n if (disabled) {\n this.#cancelHandle(id);\n const updated: TriggerState = { ...state, disabled: true, updatedAt: nowIso };\n await this.#store.upsert(updated);\n return updated;\n }\n // Re-enable: recompute the next fire from NOW (a stale persisted\n // nextFireAt from before the disable would otherwise clamp to 0 and\n // fire immediately), then re-arm.\n const decl = this.#declarations.get(id);\n const nextMs =\n decl !== undefined\n ? this.#computeNextFire(decl, { ...state, lastFiredAt: nowIso }, this.#now())\n : null;\n const updated: TriggerState = {\n ...state,\n disabled: false,\n ...(nextMs !== null ? { nextFireAt: new Date(nextMs).toISOString() } : {}),\n updatedAt: nowIso,\n };\n await this.#store.upsert(updated);\n if (this.#started) this.#schedule(id, updated);\n return updated;\n }\n\n recordActivity(): void {\n this.#lastActivity = this.#now();\n // periphery (P-14): never (re)arm idle timers on a scheduler that\n // is not started - a stopped scheduler firing callbacks is a\n // lifecycle violation. The activity timestamp is still recorded so\n // a later start() computes the idle window correctly.\n if (!this.#started) return;\n // Reschedule idle triggers so they treat \"now\" as the start of\n // their idle window.\n for (const decl of this.#declarations.values()) {\n if (decl.kind !== 'idle') continue;\n this.#cancelHandle(decl.id);\n const idleMs = Number.parseInt(decl.spec, 10);\n const handle = this.#setTimeout(() => {\n void this.fire(decl.id);\n }, idleMs);\n this.#handles.set(decl.id, handle);\n }\n }\n\n events(): AsyncIterable<SchedulerEvent> {\n const queue = this.#eventQueue;\n const resolvers = this.#eventResolvers;\n const me = this;\n return {\n [Symbol.asyncIterator](): AsyncIterator<SchedulerEvent> {\n return {\n async next(): Promise<IteratorResult<SchedulerEvent>> {\n if (queue.length > 0) {\n const value = queue.shift() as SchedulerEvent;\n return { done: false, value };\n }\n if (me.#closedEvents) return { done: true, value: undefined };\n return new Promise((resolve) => resolvers.push(resolve));\n },\n async return(): Promise<IteratorResult<SchedulerEvent>> {\n return { done: true, value: undefined };\n },\n };\n },\n };\n }\n\n // ---------------------------------------------------------------------------\n\n #publish(event: SchedulerEvent): void {\n if (this.#closedEvents) return;\n if (this.#eventResolvers.length > 0) {\n const resolver = this.#eventResolvers.shift();\n resolver?.({ done: false, value: event });\n return;\n }\n this.#eventQueue.push(event);\n }\n\n #computeNextFire(\n decl: TriggerDeclaration,\n state: TriggerState | null,\n now: number,\n ): number | null {\n switch (decl.kind) {\n case 'cron': {\n const parsed = this.#parsedCron.get(decl.id) ?? parseCron(decl.spec);\n const next = nextFireAfter(parsed, new Date(now));\n return next === null ? null : next.getTime();\n }\n case 'interval': {\n const intervalMs = Number.parseInt(decl.spec, 10);\n const last = state?.lastFiredAt !== undefined ? Date.parse(state.lastFiredAt) : now;\n const next = last + intervalMs;\n // periphery-11: after downtime `last + interval` is in the past;\n // the schedule clamp turned that into an IMMEDIATE fire even\n // under `catchupPolicy: 'none'` (\"drop missed fires\"). Advance\n // to the next FUTURE boundary on the original cadence - missed\n // boundaries are the catch-up machinery's business, not the\n // base schedule's. (Cron already behaves this way:\n // `nextFireAfter(now)` is always future.)\n if (next <= now && Number.isFinite(intervalMs) && intervalMs > 0) {\n const missedBoundaries = Math.floor((now - last) / intervalMs) + 1;\n return last + missedBoundaries * intervalMs;\n }\n return next;\n }\n case 'idle': {\n const idleMs = Number.parseInt(decl.spec, 10);\n return this.#lastActivity + idleMs;\n }\n case 'event':\n return null;\n }\n }\n\n async #applyCatchup(state: TriggerState, existing: TriggerState, now: number): Promise<void> {\n if (state.catchupPolicy === 'none') return;\n const decl = this.#declarations.get(state.id);\n if (decl === undefined || (decl.kind !== 'cron' && decl.kind !== 'interval')) return;\n const lastFired = existing.lastFiredAt !== undefined ? Date.parse(existing.lastFiredAt) : null;\n if (lastFired === null) return;\n if (lastFired < now - state.catchupWindowMs) return;\n\n // RP-12: count the REAL missed fires by walking the schedule from\n // the last successful fire to now - a restart with zero crossed\n // boundaries must apply zero catch-up (the old code fired `1` /\n // `maxCatchupRuns` times unconditionally).\n const SCAN_CAP = 1000;\n let missed = 0;\n if (decl.kind === 'cron') {\n const parsed = this.#parsedCron.get(decl.id) ?? parseCron(decl.spec);\n let cursor = lastFired;\n while (missed < SCAN_CAP) {\n const next = nextFireAfter(parsed, new Date(cursor));\n if (next === null || next.getTime() > now) break;\n cursor = next.getTime();\n missed += 1;\n }\n } else {\n const intervalMs = Number.parseInt(decl.spec, 10);\n missed = Math.floor((now - lastFired) / intervalMs);\n }\n if (missed <= 0) return;\n\n const toFire = state.catchupPolicy === 'last' ? 1 : Math.min(missed, state.maxCatchupRuns);\n for (let i = 0; i < toFire; i++) {\n await this.fire(state.id);\n }\n // The overflow beyond what we re-ran is recorded for health/CLI.\n const excess = missed - toFire;\n if (excess > 0) {\n const refreshed = await this.#store.get(state.id);\n if (refreshed !== null) {\n await this.#store.upsert({ ...refreshed, missedFires: excess });\n }\n }\n this.#publish({ type: 'catchup-applied', id: state.id, missed });\n }\n\n #schedule(id: string, state: TriggerState): void {\n this.#cancelHandle(id);\n if (state.disabled) return;\n const decl = this.#declarations.get(id);\n if (decl === undefined) return;\n if (decl.kind === 'event') return;\n\n let delay: number | null = null;\n if (state.nextFireAt !== undefined) {\n delay = Date.parse(state.nextFireAt) - this.#now();\n } else {\n const next = this.#computeNextFire(decl, state, this.#now());\n delay = next === null ? null : next - this.#now();\n }\n if (delay === null) return;\n if (delay < 0) delay = 0;\n\n if (delay > MAX_TIMEOUT_MS) {\n // RP-15: chunk the wait - the intermediate wake-up re-reads the\n // freshest state and re-schedules (it never fires the callback).\n const handle = this.#setTimeout(() => {\n void this.#store\n .get(id)\n .then((s) => {\n if (s !== null) this.#schedule(id, s);\n })\n .catch(() => {});\n }, MAX_TIMEOUT_MS);\n this.#handles.set(id, handle);\n return;\n }\n\n const handle = this.#setTimeout(() => {\n void this.fire(id);\n }, delay);\n this.#handles.set(id, handle);\n }\n\n #cancelHandle(id: string): void {\n const handle = this.#handles.get(id);\n if (handle !== undefined) {\n this.#clearTimeout(handle);\n this.#handles.delete(id);\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ //#region package.json
2
+ var version = "0.7.0";
3
+
4
+ //#endregion
5
+ export { version };
6
+ //# sourceMappingURL=package.js.map
@@ -0,0 +1 @@
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.6.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.0.0"
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
- "import": "./dist/index.js"
38
+ "default": "./dist/index.js"
38
39
  },
39
40
  "./cron": {
40
41
  "types": "./dist/cron.d.ts",
41
- "import": "./dist/cron.js"
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.6.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.6.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
+ }