@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.
@@ -1,43 +1,50 @@
1
1
  /**
2
- * Node.js scheduler adapter using a simple setInterval-based approach.
3
- * No external cron library dependency cron expressions are parsed minimally.
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
- /** Simple helper to parse cron-like interval strings into milliseconds. */
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
- if (num > 0)
16
- return num;
27
+ return num > 0 ? num : undefined;
17
28
  }
18
- else {
19
- const parts = trimmed.split(/\s+/);
20
- // Only the documented 5-field forms are every-N-minutes: "* * * * *" and
21
- // "*/N * * * *". A first-field wildcard alone ("* 3 * * *") is real cron
22
- // and must not silently fire every 60s (task 0060 F7).
23
- const restWild = parts.length === 5 && parts[1] === '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*';
24
- if (restWild && parts[0] === '*') {
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
- // Real cron field expressions ("0 3 * * *") are NOT supported — silently running
34
- // them every minute would badly misfire. 0032 made that a warn; task 0060 F7 makes
35
- // it a hard error so an unsupported schedule can never fire at the wrong cadence.
36
- throw new RangeError(`Unsupported cron expression: "${cron}" — supported forms are a positive millisecond number, "* * * * *", or "*/N * * * *" with N > 0`);
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 using a setInterval-based approach.
40
- * No external cron library dependency cron expressions are parsed minimally.
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 start(),
60
- // where it would otherwise create a silently-wrong interval (task 0060 F7).
61
- parseInterval(cron);
62
- this.entries.push({ cron, action });
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
- const last = this.entries[this.entries.length - 1];
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
- clearInterval(entry.timer);
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. clearInterval
87
- // cancels future ticks but not one already executing; without this wait a tick
88
- // mid-action when stop() is called would keep running after stop() resolves,
89
- // tearing down a half-written row or half-flushed batch (ADR-024).
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
- const interval = parseInterval(entry.cron);
99
- // setInterval discards the async handler's returned promise, so capture it
100
- // here for stop() to drain. _onScheduledTick never rejects (try/catch),
101
- // so the cleanup runs on both paths with no unhandled-rejection risk.
102
- entry.timer = setInterval(() => {
103
- const tick = this._onScheduledTick(entry);
104
- this.inflight.add(tick);
105
- const cleanup = () => {
106
- this.inflight.delete(tick);
107
- };
108
- tick.then(cleanup, cleanup);
109
- }, interval);
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.49",
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.49",
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.49",
86
- "@gobing-ai/ts-runtime": "^0.4.49",
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.49",
120
- "@gobing-ai/ts-runtime": "^0.4.49",
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",
@@ -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
@@ -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 ─────────────────────────────────────────────────────
@@ -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
- const schedulerConfig: SchedulerOptions = {};
274
- if (schedulerOpts.enabled === true) {
275
- schedulerConfig.enabled = true;
276
- schedulerConfig.autoStart = schedulerOpts.autoStart;
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;