@gobing-ai/ts-infra 0.4.49 → 0.4.51
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/README.md +18 -4
- package/dist/application/index.d.ts +1 -0
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +1 -0
- package/dist/application/types.d.ts +9 -1
- package/dist/application/types.d.ts.map +1 -1
- package/dist/application-node.d.ts.map +1 -1
- package/dist/application-node.js +86 -5
- package/dist/scheduler/cron.d.ts +70 -0
- package/dist/scheduler/cron.d.ts.map +1 -0
- package/dist/scheduler/cron.js +175 -0
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/node.d.ts +28 -4
- package/dist/scheduler/node.d.ts.map +1 -1
- package/dist/scheduler/node.js +130 -55
- package/dist/scheduler/types.d.ts +20 -0
- package/dist/scheduler/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/application/index.ts +2 -0
- package/src/application/types.ts +9 -2
- package/src/application-node.ts +102 -5
- package/src/scheduler/cron.ts +203 -0
- package/src/scheduler/index.ts +1 -1
- package/src/scheduler/node.ts +163 -64
- package/src/scheduler/types.ts +13 -0
package/dist/scheduler/node.js
CHANGED
|
@@ -1,43 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js scheduler adapter
|
|
3
|
-
*
|
|
2
|
+
* Node.js scheduler adapter — interval-based for the three legacy cadence
|
|
3
|
+
* forms, self-rescheduling setTimeout for real five-field cron (task 0734).
|
|
4
|
+
*
|
|
5
|
+
* No external cron library dependency — cron expressions are parsed by the
|
|
6
|
+
* internal `scheduler/cron.ts` grammar shared with `application-node.ts`.
|
|
4
7
|
*/
|
|
5
8
|
import { settleWithin } from '../internals/drain.js';
|
|
6
9
|
import { getSchedulerJobDuration, getSchedulerJobExecutedTotal, getSchedulerJobFailedTotal, } from '../telemetry/metrics.js';
|
|
7
|
-
|
|
10
|
+
import { nextCronTime, parseCronExpression } from './cron.js';
|
|
11
|
+
/** Platform timer maximum for `setTimeout` (2^31 - 1 ms). Longer delays are chunked. */
|
|
12
|
+
const MAX_TIMEOUT = 2_147_483_647;
|
|
13
|
+
/**
|
|
14
|
+
* Parse the three legacy interval cadences, returning milliseconds, or
|
|
15
|
+
* `undefined` when the string is real cron (or invalid) and must route to the
|
|
16
|
+
* cron grammar instead.
|
|
17
|
+
*
|
|
18
|
+
* Legacy forms preserved verbatim (task 0734 R2): a positive millisecond
|
|
19
|
+
* number, `* * * * *` (60s), and the step-N wildcard form (N*60s) — all
|
|
20
|
+
* measured from adapter start as setInterval cadences.
|
|
21
|
+
*/
|
|
8
22
|
function parseInterval(cron) {
|
|
9
|
-
// Support simple patterns: "* * * * *" (every minute), "*/5 * * * *" (every 5 min)
|
|
10
|
-
// Also support direct ms strings like "60000"
|
|
11
23
|
const trimmed = cron.trim();
|
|
12
24
|
const num = Number(trimmed);
|
|
13
25
|
if (trimmed !== '' && !Number.isNaN(num)) {
|
|
14
26
|
// Guard against 0/negative intervals — setInterval would spin hot.
|
|
15
|
-
|
|
16
|
-
return num;
|
|
27
|
+
return num > 0 ? num : undefined;
|
|
17
28
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return 60_000;
|
|
26
|
-
}
|
|
27
|
-
const nField = restWild ? parts[0] : parts.length === 1 ? parts[0] : undefined;
|
|
28
|
-
const match = nField?.match(/^\*\/(\d+)$/);
|
|
29
|
-
if (match && Number(match[1]) > 0) {
|
|
30
|
-
return Number(match[1]) * 60_000;
|
|
31
|
-
}
|
|
29
|
+
const parts = trimmed.split(/\s+/);
|
|
30
|
+
// Only the documented 5-field forms are every-N-minutes: "* * * * *" and
|
|
31
|
+
// "*/N * * * *". A first-field wildcard alone ("* 3 * * *") is real cron
|
|
32
|
+
// and must not silently fire every 60s (task 0060 F7).
|
|
33
|
+
const restWild = parts.length === 5 && parts[1] === '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*';
|
|
34
|
+
if (restWild && parts[0] === '*') {
|
|
35
|
+
return 60_000;
|
|
32
36
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const nField = restWild ? parts[0] : parts.length === 1 ? parts[0] : undefined;
|
|
38
|
+
const match = nField?.match(/^\*\/(\d+)$/);
|
|
39
|
+
if (match && Number(match[1]) > 0) {
|
|
40
|
+
return Number(match[1]) * 60_000;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
37
43
|
}
|
|
38
44
|
/**
|
|
39
|
-
* Scheduler adapter for Node.js
|
|
40
|
-
*
|
|
45
|
+
* Scheduler adapter for Node.js. Interval entries use `setInterval`; real cron
|
|
46
|
+
* entries self-reschedule with `setTimeout` so ticks never overlap and
|
|
47
|
+
* occurrences missed while a tick runs are skipped (task 0734 R2).
|
|
41
48
|
*
|
|
42
49
|
* `stop()` drains in-flight ticks, bounded by `drainTimeoutMs` (ADR-024): an
|
|
43
50
|
* action already running when `stop()` is called is awaited rather than torn
|
|
@@ -46,27 +53,38 @@ function parseInterval(cron) {
|
|
|
46
53
|
export class NodeSchedulerAdapter {
|
|
47
54
|
entries = [];
|
|
48
55
|
drainTimeoutMs;
|
|
56
|
+
now;
|
|
49
57
|
running = false;
|
|
50
58
|
inflight = new Set();
|
|
51
59
|
constructor(config = {}) {
|
|
52
|
-
const { drainTimeoutMs } = config;
|
|
60
|
+
const { drainTimeoutMs, now } = config;
|
|
53
61
|
if (drainTimeoutMs !== undefined && (!Number.isFinite(drainTimeoutMs) || drainTimeoutMs < 0)) {
|
|
54
62
|
throw new RangeError(`NodeSchedulerAdapter drainTimeoutMs must be a non-negative finite number; received ${drainTimeoutMs}`);
|
|
55
63
|
}
|
|
56
64
|
this.drainTimeoutMs = drainTimeoutMs ?? 30_000;
|
|
65
|
+
this.now = now ?? (() => Date.now());
|
|
57
66
|
}
|
|
58
67
|
register(cron, action) {
|
|
59
|
-
// Fail at registration time: an unsupported expression must never reach
|
|
60
|
-
// where it would otherwise create a silently-wrong interval
|
|
61
|
-
|
|
62
|
-
this.
|
|
68
|
+
// Fail at registration time: an unsupported expression must never reach
|
|
69
|
+
// start(), where it would otherwise create a silently-wrong interval
|
|
70
|
+
// (task 0060 F7) or a never-firing cron (task 0734 R1).
|
|
71
|
+
const entry = this.parseEntry(cron, action);
|
|
72
|
+
this.entries.push(entry);
|
|
63
73
|
if (this.running) {
|
|
64
|
-
|
|
65
|
-
if (last) {
|
|
66
|
-
this.startEntry(last);
|
|
67
|
-
}
|
|
74
|
+
this.startEntry(entry);
|
|
68
75
|
}
|
|
69
76
|
}
|
|
77
|
+
parseEntry(cron, action) {
|
|
78
|
+
const intervalMs = parseInterval(cron);
|
|
79
|
+
if (intervalMs !== undefined) {
|
|
80
|
+
return { kind: 'interval', cron, action, intervalMs };
|
|
81
|
+
}
|
|
82
|
+
// Real five-field cron. Validate and verify a next occurrence exists at
|
|
83
|
+
// registration (bounded scan) so an unsatisfiable expression fails here.
|
|
84
|
+
const expr = parseCronExpression(cron);
|
|
85
|
+
const target = nextCronTime(expr, this.now()).getTime();
|
|
86
|
+
return { kind: 'cron', cron, action, expr, target };
|
|
87
|
+
}
|
|
70
88
|
async start() {
|
|
71
89
|
if (this.running)
|
|
72
90
|
return;
|
|
@@ -78,35 +96,92 @@ export class NodeSchedulerAdapter {
|
|
|
78
96
|
async stop() {
|
|
79
97
|
this.running = false;
|
|
80
98
|
for (const entry of this.entries) {
|
|
81
|
-
if (entry.timer) {
|
|
82
|
-
|
|
99
|
+
if (entry.timer !== undefined) {
|
|
100
|
+
if (entry.kind === 'interval') {
|
|
101
|
+
clearInterval(entry.timer);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
clearTimeout(entry.timer);
|
|
105
|
+
}
|
|
83
106
|
entry.timer = undefined;
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
|
-
// Drain in-flight ticks, bounded by a shared absolute deadline.
|
|
87
|
-
// cancels future ticks but not one already executing; without this
|
|
88
|
-
// mid-action when stop() is called would keep running after
|
|
89
|
-
// tearing down a half-written row or half-flushed batch
|
|
109
|
+
// Drain in-flight ticks, bounded by a shared absolute deadline. Clearing
|
|
110
|
+
// timers cancels future ticks but not one already executing; without this
|
|
111
|
+
// wait a tick mid-action when stop() is called would keep running after
|
|
112
|
+
// stop() resolves, tearing down a half-written row or half-flushed batch
|
|
113
|
+
// (ADR-024).
|
|
90
114
|
const deadline = Date.now() + this.drainTimeoutMs;
|
|
91
115
|
for (const p of [...this.inflight]) {
|
|
92
116
|
await settleWithin(p, deadline);
|
|
93
117
|
}
|
|
94
118
|
}
|
|
95
119
|
startEntry(entry) {
|
|
96
|
-
if (entry.timer)
|
|
120
|
+
if (entry.timer !== undefined)
|
|
97
121
|
return;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
if (entry.kind === 'interval') {
|
|
123
|
+
entry.timer = setInterval(() => {
|
|
124
|
+
const tick = this._onScheduledTick(entry);
|
|
125
|
+
this.inflight.add(tick);
|
|
126
|
+
const cleanup = () => {
|
|
127
|
+
this.inflight.delete(tick);
|
|
128
|
+
};
|
|
129
|
+
tick.then(cleanup, cleanup);
|
|
130
|
+
}, entry.intervalMs);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
this.armCron(entry);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Arm the cron entry's next `setTimeout`, chunking any delay beyond the
|
|
138
|
+
* platform timer maximum. On a chunk wake the wall clock is re-checked
|
|
139
|
+
* before firing, so an injected/real clock shift never fires early.
|
|
140
|
+
*/
|
|
141
|
+
armCron(entry) {
|
|
142
|
+
if (!this.running)
|
|
143
|
+
return;
|
|
144
|
+
let delay = entry.target - this.now();
|
|
145
|
+
if (delay <= 0) {
|
|
146
|
+
// Target already passed (clock shift): recompute strictly after now.
|
|
147
|
+
entry.target = nextCronTime(entry.expr, this.now()).getTime();
|
|
148
|
+
delay = entry.target - this.now();
|
|
149
|
+
}
|
|
150
|
+
if (delay > MAX_TIMEOUT) {
|
|
151
|
+
// Delay exceeds the platform maximum — arm a safe chunk; on wake,
|
|
152
|
+
// re-check now() before firing.
|
|
153
|
+
entry.timer = setTimeout(() => {
|
|
154
|
+
this.armCron(entry);
|
|
155
|
+
}, MAX_TIMEOUT);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
entry.timer = setTimeout(() => {
|
|
159
|
+
void this.fireCron(entry);
|
|
160
|
+
}, delay);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Fire a cron tick: re-check the wall clock against the target, run the
|
|
164
|
+
* action once, then recompute the next occurrence strictly from `now()` and
|
|
165
|
+
* re-arm only while still running. Ticks never overlap and occurrences
|
|
166
|
+
* missed while a tick runs are skipped (task 0734 R2).
|
|
167
|
+
*/
|
|
168
|
+
async fireCron(entry) {
|
|
169
|
+
entry.timer = undefined;
|
|
170
|
+
if (this.now() < entry.target) {
|
|
171
|
+
// Not actually time yet (early chunk wake / clock shift) — re-arm.
|
|
172
|
+
this.armCron(entry);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const tick = this._onScheduledTick(entry);
|
|
176
|
+
this.inflight.add(tick);
|
|
177
|
+
const cleanup = () => {
|
|
178
|
+
this.inflight.delete(tick);
|
|
179
|
+
};
|
|
180
|
+
await tick.then(cleanup, cleanup);
|
|
181
|
+
if (this.running) {
|
|
182
|
+
entry.target = nextCronTime(entry.expr, this.now()).getTime();
|
|
183
|
+
this.armCron(entry);
|
|
184
|
+
}
|
|
110
185
|
}
|
|
111
186
|
async _onScheduledTick(entry) {
|
|
112
187
|
const startMs = performance.now();
|
|
@@ -15,4 +15,24 @@ export interface SchedulerAdapter {
|
|
|
15
15
|
start(): Promise<void>;
|
|
16
16
|
stop(): Promise<void>;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* A declarative scheduler job (task 0734). Exactly one of `intervalMinutes` or
|
|
20
|
+
* `cron` is present — the schedule XOR is enforced by the Node subpath's
|
|
21
|
+
* `normalizeSchedulerJobs` before the user `start` callback runs.
|
|
22
|
+
*
|
|
23
|
+
* The `command` is declarative data: ts-infra validates and exposes it but
|
|
24
|
+
* never executes it. The consuming application binds it to a queue-backed
|
|
25
|
+
* command handler.
|
|
26
|
+
*/
|
|
27
|
+
export type SchedulerJobConfig = {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly command: string;
|
|
30
|
+
readonly intervalMinutes: number;
|
|
31
|
+
readonly cron?: never;
|
|
32
|
+
} | {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly command: string;
|
|
35
|
+
readonly cron: string;
|
|
36
|
+
readonly intervalMinutes?: never;
|
|
37
|
+
};
|
|
18
38
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scheduler/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC;IACtD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/scheduler/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,GAAG,IAAI,CAAC;IACtD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACzB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GACxB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,KAAK,CAAA;CAAE,GAC5G;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,eAAe,CAAC,EAAE,KAAK,CAAA;CAAE,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-infra",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.51",
|
|
4
4
|
"description": "@gobing-ai/ts-infra — Infrastructure backbone: event bus, job queue, scheduler, telemetry, API client, and logging.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -78,12 +78,12 @@
|
|
|
78
78
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-infra-v<version> && git push --tags' && exit 1"
|
|
79
79
|
},
|
|
80
80
|
"dependencies": {
|
|
81
|
-
"@gobing-ai/ts-utils": "^0.4.
|
|
81
|
+
"@gobing-ai/ts-utils": "^0.4.51",
|
|
82
82
|
"@logtape/logtape": "^2.0.0"
|
|
83
83
|
},
|
|
84
84
|
"peerDependencies": {
|
|
85
|
-
"@gobing-ai/ts-db": "^0.4.
|
|
86
|
-
"@gobing-ai/ts-runtime": "^0.4.
|
|
85
|
+
"@gobing-ai/ts-db": "^0.4.51",
|
|
86
|
+
"@gobing-ai/ts-runtime": "^0.4.51",
|
|
87
87
|
"@opentelemetry/api": "^1.9.0",
|
|
88
88
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
89
89
|
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
@@ -116,8 +116,8 @@
|
|
|
116
116
|
}
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
|
-
"@gobing-ai/ts-db": "^0.4.
|
|
120
|
-
"@gobing-ai/ts-runtime": "^0.4.
|
|
119
|
+
"@gobing-ai/ts-db": "^0.4.51",
|
|
120
|
+
"@gobing-ai/ts-runtime": "^0.4.51",
|
|
121
121
|
"@types/bun": "1.3.14",
|
|
122
122
|
"@opentelemetry/api": "^1.9.0",
|
|
123
123
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
package/src/application/index.ts
CHANGED
|
@@ -125,6 +125,7 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
125
125
|
const schedulerConfig: ApplicationBootstrapConfig['scheduler'] = {
|
|
126
126
|
enabled: schedOpts?.enabled ?? false,
|
|
127
127
|
autoStart: schedOpts?.autoStart ?? true,
|
|
128
|
+
jobs: schedOpts?.jobs ?? [],
|
|
128
129
|
};
|
|
129
130
|
const eventsEnabled = options.config?.events?.enabled ?? true;
|
|
130
131
|
const eventsLifecycle = options.config?.events?.lifecycle ?? true;
|
|
@@ -259,6 +260,7 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
259
260
|
|
|
260
261
|
export type { BusLifecycleEvents, EventMap } from '../event-bus/types';
|
|
261
262
|
export type { InfraEvents } from '../events';
|
|
263
|
+
export type { SchedulerJobConfig } from '../scheduler/types';
|
|
262
264
|
export type { PluginHost } from './plugins/host';
|
|
263
265
|
export type { Plugin, PluginSummary } from './plugins/types';
|
|
264
266
|
// Re-export types
|
package/src/application/types.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { FileObserverWriter } from '../event-bus/file-observer';
|
|
|
13
13
|
import type { BusLifecycleEvents, EventMap } from '../event-bus/types';
|
|
14
14
|
import type { InfraEvents } from '../events';
|
|
15
15
|
import type { Logger, LogLevel } from '../logger';
|
|
16
|
-
import type { SchedulerAdapter } from '../scheduler/types';
|
|
16
|
+
import type { SchedulerAdapter, SchedulerJobConfig } from '../scheduler/types';
|
|
17
17
|
import type { PluginHost } from './plugins/host';
|
|
18
18
|
import type { Plugin } from './plugins/types';
|
|
19
19
|
|
|
@@ -84,6 +84,13 @@ export interface SchedulerOptions {
|
|
|
84
84
|
entries?: Array<[string, () => Promise<void>]>;
|
|
85
85
|
/** Start scheduler immediately after registration. Default `true` when enabled. */
|
|
86
86
|
autoStart?: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Declarative scheduler jobs (task 0734). Validated and normalized by the
|
|
89
|
+
* Node subpath (`runNodeApplication`) before the user `start` callback;
|
|
90
|
+
* forwarded as data into the resolved config. ts-infra never executes the
|
|
91
|
+
* job `command` itself.
|
|
92
|
+
*/
|
|
93
|
+
jobs?: readonly SchedulerJobConfig[];
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
// ── Resolved bootstrap config ─────────────────────────────────────────────
|
|
@@ -106,7 +113,7 @@ export interface ApplicationBootstrapConfig {
|
|
|
106
113
|
filePath?: string;
|
|
107
114
|
};
|
|
108
115
|
readonly telemetry: { enabled: boolean; serviceName: string; environment: string; dbStatementDebug: boolean };
|
|
109
|
-
readonly scheduler: { enabled: boolean; autoStart: boolean };
|
|
116
|
+
readonly scheduler: { enabled: boolean; autoStart: boolean; jobs: readonly SchedulerJobConfig[] };
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
// ── Injected services ─────────────────────────────────────────────────────
|
package/src/application-node.ts
CHANGED
|
@@ -39,6 +39,8 @@ import type {
|
|
|
39
39
|
SchedulerOptions,
|
|
40
40
|
TelemetryOptions,
|
|
41
41
|
} from './application/types';
|
|
42
|
+
import { parseCronExpression } from './scheduler/cron';
|
|
43
|
+
import type { SchedulerJobConfig } from './scheduler/types';
|
|
42
44
|
import { NodeSchedulerAdapter } from './scheduler-node';
|
|
43
45
|
import { initNodeTelemetry, shutdownNodeTelemetry } from './telemetry/otel-node';
|
|
44
46
|
|
|
@@ -52,6 +54,9 @@ export class ConfigValidationError extends Error {
|
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/** Platform timer maximum for `setTimeout` (2^31 - 1 ms). */
|
|
58
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
59
|
+
|
|
55
60
|
// ── Config validation helper ──────────────────────────────────────────────
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -92,6 +97,92 @@ function validateAppConfig<TAppConfig>(
|
|
|
92
97
|
throw new ConfigValidationError(`Unsupported validator shape for section "${section}"`);
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
// ── Scheduler job validation (task 0734) ────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Max `intervalMinutes` so that `intervalMinutes * 60_000` fits in the platform timer maximum. */
|
|
103
|
+
const MAX_INTERVAL_MINUTES = Math.floor(MAX_TIMEOUT_MS / 60_000);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate and normalize a raw `bootstrap.scheduler.jobs` array.
|
|
107
|
+
*
|
|
108
|
+
* Accepts only object entries, trims `name`/`command`/`cron`, enforces the
|
|
109
|
+
* schedule XOR and `intervalMinutes` bounds, validates cron through the shared
|
|
110
|
+
* internal parser, and rejects duplicate post-trim names case-sensitively.
|
|
111
|
+
* Runs whether the scheduler is enabled or disabled; a disabled scheduler may
|
|
112
|
+
* retain validated job definitions but creates no adapter (R4).
|
|
113
|
+
*
|
|
114
|
+
* Throws `ConfigValidationError` with a `bootstrap.scheduler.jobs.<index>.<field>`
|
|
115
|
+
* path so a bad job aborts startup before the user `start` callback.
|
|
116
|
+
*/
|
|
117
|
+
function normalizeSchedulerJobs(raw: unknown): readonly SchedulerJobConfig[] {
|
|
118
|
+
if (raw === undefined || raw === null) return [];
|
|
119
|
+
if (!Array.isArray(raw)) {
|
|
120
|
+
throw new ConfigValidationError('bootstrap.scheduler.jobs must be an array');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const seen = new Set<string>();
|
|
124
|
+
const jobs: SchedulerJobConfig[] = [];
|
|
125
|
+
raw.forEach((item, index) => {
|
|
126
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
127
|
+
throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index} must be an object`);
|
|
128
|
+
}
|
|
129
|
+
const entry = item as Record<string, unknown>;
|
|
130
|
+
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
|
|
131
|
+
const command = typeof entry.command === 'string' ? entry.command.trim() : '';
|
|
132
|
+
const cron = typeof entry.cron === 'string' ? entry.cron.trim() : '';
|
|
133
|
+
const interval = entry.intervalMinutes;
|
|
134
|
+
|
|
135
|
+
if (name === '') {
|
|
136
|
+
throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.name must be a non-empty string`);
|
|
137
|
+
}
|
|
138
|
+
if (command === '') {
|
|
139
|
+
throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.command must be a non-empty string`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const hasCron = entry.cron !== undefined && entry.cron !== null;
|
|
143
|
+
const hasInterval = interval !== undefined && interval !== null;
|
|
144
|
+
if (hasCron === hasInterval) {
|
|
145
|
+
throw new ConfigValidationError(
|
|
146
|
+
`bootstrap.scheduler.jobs.${index} must have exactly one of intervalMinutes or cron`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (seen.has(name)) {
|
|
151
|
+
throw new ConfigValidationError(
|
|
152
|
+
`bootstrap.scheduler.jobs.${index}.name duplicates the name of an earlier job: "${name}"`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
seen.add(name);
|
|
156
|
+
|
|
157
|
+
if (hasInterval) {
|
|
158
|
+
if (
|
|
159
|
+
typeof interval !== 'number' ||
|
|
160
|
+
!Number.isInteger(interval) ||
|
|
161
|
+
(interval as number) < 1 ||
|
|
162
|
+
(interval as number) > MAX_INTERVAL_MINUTES
|
|
163
|
+
) {
|
|
164
|
+
throw new ConfigValidationError(
|
|
165
|
+
`bootstrap.scheduler.jobs.${index}.intervalMinutes must be an integer in 1..${MAX_INTERVAL_MINUTES}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
jobs.push({ name, command, intervalMinutes: interval as number });
|
|
169
|
+
} else {
|
|
170
|
+
if (cron === '') {
|
|
171
|
+
throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.cron must be a non-empty string`);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
parseCronExpression(cron);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
177
|
+
throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.cron: ${detail}`);
|
|
178
|
+
}
|
|
179
|
+
jobs.push({ name, command, cron });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return jobs;
|
|
184
|
+
}
|
|
185
|
+
|
|
95
186
|
// ── File sink helper ──────────────────────────────────────────────────────
|
|
96
187
|
|
|
97
188
|
function createFileSink(filePath: string): (line: string) => void {
|
|
@@ -264,16 +355,22 @@ export async function runNodeApplication<TAppConfig = unknown, TEvents extends E
|
|
|
264
355
|
const loggingConfig: Partial<LoggingOptions> =
|
|
265
356
|
typeof logFilePath === 'string' ? { ...loggingOpts, fileSink: createFileSink(logFilePath) } : loggingOpts;
|
|
266
357
|
|
|
267
|
-
// ── Scheduler adapter
|
|
358
|
+
// ── Scheduler adapter + jobs ────────────────────────────────────────
|
|
268
359
|
// Honour a caller-supplied `SchedulerOptions.adapter` (documented injection
|
|
269
360
|
// point, application/types.ts:82) instead of unconditionally overwriting
|
|
270
361
|
// it. Only default-construct a NodeSchedulerAdapter when none was provided.
|
|
271
362
|
// `drainTimeoutMs` (ADR-024) is reachable by passing a pre-built adapter;
|
|
272
363
|
// the auto-wired default keeps the 30000 ms bound (CHANGELOG.md:16).
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
364
|
+
//
|
|
365
|
+
// Declarative jobs (task 0734) are validated and normalized whether or not
|
|
366
|
+
// the scheduler is enabled — a disabled scheduler may retain validated job
|
|
367
|
+
// definitions but creates no adapter and runs nothing (R4).
|
|
368
|
+
const schedulerConfig: SchedulerOptions = {
|
|
369
|
+
enabled: schedulerOpts.enabled === true,
|
|
370
|
+
autoStart: schedulerOpts.autoStart,
|
|
371
|
+
jobs: normalizeSchedulerJobs((schedulerOpts as Partial<SchedulerOptions>).jobs),
|
|
372
|
+
};
|
|
373
|
+
if (schedulerConfig.enabled) {
|
|
277
374
|
schedulerConfig.adapter = schedulerOpts.adapter ?? new NodeSchedulerAdapter();
|
|
278
375
|
if (schedulerOpts.entries) {
|
|
279
376
|
schedulerConfig.entries = schedulerOpts.entries;
|