@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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Internal cron expression grammar + matching (task 0734).
3
+ *
4
+ * This module is deliberately NOT exported from package.json — it is a shared
5
+ * internal seam between `application-node.ts` (SchedulerJobConfig validation)
6
+ * and `scheduler/node.ts` (adapter matching), so both consume the same grammar.
7
+ * The public surface remains `NodeSchedulerAdapter` and `SchedulerJobConfig`.
8
+ *
9
+ * Supported grammar (five fields, whitespace-separated):
10
+ * minute 0-59
11
+ * hour 0-23
12
+ * day-of-month 1-31
13
+ * month 1-12
14
+ * day-of-week 0-7 (0 and 7 are both Sunday)
15
+ *
16
+ * Each field accepts a wildcard, a step-N wildcard form, a comma-separated list
17
+ * of numbers or inclusive non-wrapping ranges, or a single number. Names/macros
18
+ * (`MON`, `JAN`, `@daily`) and operators (`?`, `L`, `W`, `#`) are rejected.
19
+ *
20
+ * Matching uses local wall-clock fields (ADR / task 0734 R2): the fall-back hour
21
+ * can fire twice at two distinct instants; a nonexistent spring-forward minute
22
+ * never fires. No per-job timezone.
23
+ */
24
+
25
+ /** A single cron field: either a wildcard (whole range) or an explicit value set. */
26
+ export interface CronFieldSpec {
27
+ /** True when the whole field is `*` (matches every value in its range). */
28
+ readonly wildcard: boolean;
29
+ /** Allowed values when restricted. Day-of-week 7 is normalized into 0. */
30
+ readonly values: ReadonlySet<number>;
31
+ }
32
+
33
+ /** A parsed five-field cron expression. */
34
+ export interface CronExpression {
35
+ readonly minute: CronFieldSpec;
36
+ readonly hour: CronFieldSpec;
37
+ readonly dayOfMonth: CronFieldSpec;
38
+ readonly month: CronFieldSpec;
39
+ readonly dayOfWeek: CronFieldSpec;
40
+ /** The original expression, for error messages and metrics. */
41
+ readonly source: string;
42
+ }
43
+
44
+ /** Upper bound on the forward minute scan (8 calendar years, upper-capped at 366 days). */
45
+ const MAX_SCAN_MINUTES = 8 * 366 * 24 * 60;
46
+
47
+ const DAYS_OF_WEEK = 7; // 0-7 accepted on input, 7 aliases to 0
48
+
49
+ /**
50
+ * Parse a single cron field into its wildcard/values spec.
51
+ *
52
+ * Throws `RangeError` for anything outside the supported grammar: names/macros,
53
+ * operators, zero steps, empty list members, descending ranges, non-integer
54
+ * values, or out-of-range values. Nothing silently falls back (task 0060 F7).
55
+ */
56
+ function parseField(raw: string, min: number, max: number): CronFieldSpec {
57
+ const trimmed = raw.trim();
58
+ if (trimmed === '*') {
59
+ return { wildcard: true, values: new Set<number>() };
60
+ }
61
+
62
+ // Names ('MON', 'JAN'), macros ('@daily'), and '?' are unsupported operators.
63
+ if (/^[A-Za-z@?]/.test(trimmed)) {
64
+ throw new RangeError(`unsupported cron field "${raw}": names/macros are not supported`);
65
+ }
66
+
67
+ // Whole-field step form: */N
68
+ const stepMatch = trimmed.match(/^\*\/(\d+)$/);
69
+ if (stepMatch) {
70
+ const step = Number(stepMatch[1]);
71
+ if (step <= 0) {
72
+ throw new RangeError(`unsupported cron field "${raw}": step must be a positive integer`);
73
+ }
74
+ const values = new Set<number>();
75
+ for (let v = min; v <= max; v += step) {
76
+ values.add(v);
77
+ }
78
+ return { wildcard: false, values };
79
+ }
80
+
81
+ // Comma-separated list of numbers or inclusive non-wrapping ranges.
82
+ const members = trimmed.split(',');
83
+ if (members.some((m) => m.trim() === '')) {
84
+ throw new RangeError(`unsupported cron field "${raw}": empty list member`);
85
+ }
86
+
87
+ const values = new Set<number>();
88
+ for (const rawMember of members) {
89
+ const member = rawMember.trim();
90
+ if (member.includes('-')) {
91
+ const rangeParts = member.split('-');
92
+ if (rangeParts.length !== 2 || rangeParts.some((p) => p === '' || !/^\d+$/.test(p))) {
93
+ throw new RangeError(`unsupported cron field "${raw}": invalid range "${rawMember}"`);
94
+ }
95
+ const lo = Number(rangeParts[0]);
96
+ const hi = Number(rangeParts[1]);
97
+ if (lo > hi) {
98
+ throw new RangeError(`unsupported cron field "${raw}": descending range "${rawMember}"`);
99
+ }
100
+ if (lo < min || hi > max) {
101
+ throw new RangeError(`cron field "${raw}" value out of range [${min}-${max}] in range "${rawMember}"`);
102
+ }
103
+ for (let v = lo; v <= hi; v++) {
104
+ values.add(v);
105
+ }
106
+ } else {
107
+ if (!/^\d+$/.test(member)) {
108
+ throw new RangeError(`unsupported cron field "${raw}": invalid value "${rawMember}"`);
109
+ }
110
+ const v = Number(member);
111
+ if (v < min || v > max) {
112
+ throw new RangeError(`cron field "${raw}" value out of range [${min}-${max}]: ${rawMember}`);
113
+ }
114
+ values.add(v);
115
+ }
116
+ }
117
+
118
+ return { wildcard: false, values };
119
+ }
120
+
121
+ /**
122
+ * Parse a five-field cron expression into its spec.
123
+ *
124
+ * Throws `RangeError` on a wrong field count, any unsupported field grammar, or
125
+ * out-of-range values. Day-of-week `7` normalizes to `0` (Sunday).
126
+ */
127
+ export function parseCronExpression(source: string): CronExpression {
128
+ const trimmed = source.trim();
129
+ const parts = trimmed.split(/\s+/);
130
+ if (parts.length !== 5) {
131
+ throw new RangeError(`unsupported cron expression "${source}": expected exactly 5 fields, got ${parts.length}`);
132
+ }
133
+
134
+ const [rawMinute, rawHour, rawDom, rawMonth, rawDow] = parts as [string, string, string, string, string];
135
+
136
+ const minute = parseField(rawMinute, 0, 59);
137
+ const hour = parseField(rawHour, 0, 23);
138
+ const dayOfMonth = parseField(rawDom, 1, 31);
139
+ const month = parseField(rawMonth, 1, 12);
140
+ let dayOfWeek = parseField(rawDow, 0, DAYS_OF_WEEK);
141
+
142
+ // Standard cron: 7 is an alias for 0 (Sunday).
143
+ if (!dayOfWeek.wildcard && dayOfWeek.values.has(DAYS_OF_WEEK)) {
144
+ const values = new Set(dayOfWeek.values);
145
+ values.delete(DAYS_OF_WEEK);
146
+ values.add(0);
147
+ dayOfWeek = { wildcard: false, values };
148
+ }
149
+
150
+ return { minute, hour, dayOfMonth, month, dayOfWeek, source: trimmed };
151
+ }
152
+
153
+ /**
154
+ * Whether the instant matches the expression (local wall-clock fields).
155
+ *
156
+ * Day semantics follow standard cron: when both day fields are wildcards both
157
+ * pass; when one is a wildcard the restricted field must match; when both are
158
+ * restricted, either may match (OR).
159
+ */
160
+ export function matchesCron(date: Date, expr: CronExpression): boolean {
161
+ const minute = date.getMinutes();
162
+ if (!expr.minute.wildcard && !expr.minute.values.has(minute)) return false;
163
+ const hour = date.getHours();
164
+ if (!expr.hour.wildcard && !expr.hour.values.has(hour)) return false;
165
+ const month = date.getMonth() + 1;
166
+ if (!expr.month.wildcard && !expr.month.values.has(month)) return false;
167
+
168
+ const domWild = expr.dayOfMonth.wildcard;
169
+ const dowWild = expr.dayOfWeek.wildcard;
170
+ if (domWild && dowWild) return true;
171
+ const domMatch = domWild || expr.dayOfMonth.values.has(date.getDate());
172
+ const dowMatch = dowWild || expr.dayOfWeek.values.has(date.getDay());
173
+ if (domWild) return dowMatch;
174
+ if (dowWild) return domMatch;
175
+ return domMatch || dowMatch;
176
+ }
177
+
178
+ /**
179
+ * Compute the next matching minute strictly after `nowMs`, in local time.
180
+ *
181
+ * Starts at the next epoch-minute boundary and scans forward in one-minute
182
+ * steps (fixed epoch increments, local wall-clock fields read per instant), so
183
+ * DST fall-back selects both distinct instants and spring-forward skips
184
+ * nonexistent minutes. Bounded to eight calendar years so an unsatisfiable
185
+ * expression fails here (at registration) rather than silently never firing.
186
+ *
187
+ * # ponytail: O(minutes-to-next) forward scan, fine for small job counts; a
188
+ * field-jumping search would matter only if startup cost with hundreds of jobs
189
+ * ever shows up in measurements.
190
+ */
191
+ export function nextCronTime(expr: CronExpression, nowMs: number): Date {
192
+ // Next epoch-minute boundary strictly after now (never the current minute).
193
+ let candidate = Math.floor(nowMs / 60_000) * 60_000 + 60_000;
194
+ const limit = candidate + MAX_SCAN_MINUTES * 60_000;
195
+ while (candidate < limit) {
196
+ const instant = new Date(candidate);
197
+ if (matchesCron(instant, expr)) {
198
+ return instant;
199
+ }
200
+ candidate += 60_000;
201
+ }
202
+ throw new RangeError(`unsupported cron expression "${expr.source}": no matching time within 8 years`);
203
+ }
@@ -12,5 +12,5 @@ export {
12
12
  } from './action';
13
13
  export { initScheduler } from './factory';
14
14
  export { NoopSchedulerAdapter } from './noop';
15
- export type { ScheduledAction, SchedulerAdapter } from './types';
15
+ export type { ScheduledAction, SchedulerAdapter, SchedulerJobConfig } from './types';
16
16
  export { wrapScheduledHandler } from './wrap-handler';
@@ -1,6 +1,9 @@
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
 
6
9
  import { settleWithin } from '../internals/drain';
@@ -9,49 +12,65 @@ import {
9
12
  getSchedulerJobExecutedTotal,
10
13
  getSchedulerJobFailedTotal,
11
14
  } from '../telemetry/metrics';
15
+ import { type CronExpression, nextCronTime, parseCronExpression } from './cron';
12
16
  import type { ScheduledAction, SchedulerAdapter } from './types';
13
17
 
14
- /** Simple helper to parse cron-like interval strings into milliseconds. */
15
- function parseInterval(cron: string): number {
16
- // Support simple patterns: "* * * * *" (every minute), "*/5 * * * *" (every 5 min)
17
- // Also support direct ms strings like "60000"
18
+ /** Platform timer maximum for `setTimeout` (2^31 - 1 ms). Longer delays are chunked. */
19
+ const MAX_TIMEOUT = 2_147_483_647;
20
+
21
+ /**
22
+ * Parse the three legacy interval cadences, returning milliseconds, or
23
+ * `undefined` when the string is real cron (or invalid) and must route to the
24
+ * cron grammar instead.
25
+ *
26
+ * Legacy forms preserved verbatim (task 0734 R2): a positive millisecond
27
+ * number, `* * * * *` (60s), and the step-N wildcard form (N*60s) — all
28
+ * measured from adapter start as setInterval cadences.
29
+ */
30
+ function parseInterval(cron: string): number | undefined {
18
31
  const trimmed = cron.trim();
19
32
  const num = Number(trimmed);
20
33
  if (trimmed !== '' && !Number.isNaN(num)) {
21
34
  // Guard against 0/negative intervals — setInterval would spin hot.
22
- if (num > 0) return num;
23
- } else {
24
- const parts = trimmed.split(/\s+/);
25
- // Only the documented 5-field forms are every-N-minutes: "* * * * *" and
26
- // "*/N * * * *". A first-field wildcard alone ("* 3 * * *") is real cron
27
- // and must not silently fire every 60s (task 0060 F7).
28
- const restWild =
29
- parts.length === 5 && parts[1] === '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*';
30
- if (restWild && parts[0] === '*') {
31
- return 60_000;
32
- }
35
+ return num > 0 ? num : undefined;
36
+ }
33
37
 
34
- const nField = restWild ? parts[0] : parts.length === 1 ? parts[0] : undefined;
35
- const match = nField?.match(/^\*\/(\d+)$/);
36
- if (match && Number(match[1]) > 0) {
37
- return Number(match[1]) * 60_000;
38
- }
38
+ const parts = trimmed.split(/\s+/);
39
+ // Only the documented 5-field forms are every-N-minutes: "* * * * *" and
40
+ // "*/N * * * *". A first-field wildcard alone ("* 3 * * *") is real cron
41
+ // and must not silently fire every 60s (task 0060 F7).
42
+ const restWild = parts.length === 5 && parts[1] === '*' && parts[2] === '*' && parts[3] === '*' && parts[4] === '*';
43
+ if (restWild && parts[0] === '*') {
44
+ return 60_000;
39
45
  }
40
46
 
41
- // Real cron field expressions ("0 3 * * *") are NOT supported — silently running
42
- // them every minute would badly misfire. 0032 made that a warn; task 0060 F7 makes
43
- // it a hard error so an unsupported schedule can never fire at the wrong cadence.
44
- throw new RangeError(
45
- `Unsupported cron expression: "${cron}" — supported forms are a positive millisecond number, "* * * * *", or "*/N * * * *" with N > 0`,
46
- );
47
- }
47
+ const nField = restWild ? parts[0] : parts.length === 1 ? parts[0] : undefined;
48
+ const match = nField?.match(/^\*\/(\d+)$/);
49
+ if (match && Number(match[1]) > 0) {
50
+ return Number(match[1]) * 60_000;
51
+ }
48
52
 
49
- interface ScheduledEntry {
50
- cron: string;
51
- action: ScheduledAction;
52
- timer?: ReturnType<typeof setInterval>;
53
+ return undefined;
53
54
  }
54
55
 
56
+ /** A registered entry: an interval cadence or a self-rescheduling cron tick. */
57
+ type ScheduledEntry =
58
+ | {
59
+ kind: 'interval';
60
+ cron: string;
61
+ action: ScheduledAction;
62
+ intervalMs: number;
63
+ timer?: ReturnType<typeof setInterval>;
64
+ }
65
+ | {
66
+ kind: 'cron';
67
+ cron: string;
68
+ action: ScheduledAction;
69
+ expr: CronExpression;
70
+ target: number;
71
+ timer?: ReturnType<typeof setTimeout>;
72
+ };
73
+
55
74
  /** Constructor options for {@link NodeSchedulerAdapter}. */
56
75
  export interface NodeSchedulerAdapterConfig {
57
76
  /**
@@ -60,11 +79,17 @@ export interface NodeSchedulerAdapterConfig {
60
79
  * Defaults to 30000 (matching `DBQueueConsumer`).
61
80
  */
62
81
  readonly drainTimeoutMs?: number;
82
+ /**
83
+ * Deterministic clock seam (task 0734 R2). Returns epoch milliseconds;
84
+ * defaults to `Date.now`. Used for cron target computation and re-checks.
85
+ */
86
+ readonly now?: () => number;
63
87
  }
64
88
 
65
89
  /**
66
- * Scheduler adapter for Node.js using a setInterval-based approach.
67
- * No external cron library dependency cron expressions are parsed minimally.
90
+ * Scheduler adapter for Node.js. Interval entries use `setInterval`; real cron
91
+ * entries self-reschedule with `setTimeout` so ticks never overlap and
92
+ * occurrences missed while a tick runs are skipped (task 0734 R2).
68
93
  *
69
94
  * `stop()` drains in-flight ticks, bounded by `drainTimeoutMs` (ADR-024): an
70
95
  * action already running when `stop()` is called is awaited rather than torn
@@ -73,32 +98,44 @@ export interface NodeSchedulerAdapterConfig {
73
98
  export class NodeSchedulerAdapter implements SchedulerAdapter {
74
99
  private readonly entries: ScheduledEntry[] = [];
75
100
  private readonly drainTimeoutMs: number;
101
+ private readonly now: () => number;
76
102
  private running = false;
77
103
  private readonly inflight = new Set<Promise<void>>();
78
104
 
79
105
  constructor(config: NodeSchedulerAdapterConfig = {}) {
80
- const { drainTimeoutMs } = config;
106
+ const { drainTimeoutMs, now } = config;
81
107
  if (drainTimeoutMs !== undefined && (!Number.isFinite(drainTimeoutMs) || drainTimeoutMs < 0)) {
82
108
  throw new RangeError(
83
109
  `NodeSchedulerAdapter drainTimeoutMs must be a non-negative finite number; received ${drainTimeoutMs}`,
84
110
  );
85
111
  }
86
112
  this.drainTimeoutMs = drainTimeoutMs ?? 30_000;
113
+ this.now = now ?? (() => Date.now());
87
114
  }
88
115
 
89
116
  register(cron: string, action: ScheduledAction): void {
90
- // Fail at registration time: an unsupported expression must never reach start(),
91
- // where it would otherwise create a silently-wrong interval (task 0060 F7).
92
- parseInterval(cron);
93
- this.entries.push({ cron, action });
117
+ // Fail at registration time: an unsupported expression must never reach
118
+ // start(), where it would otherwise create a silently-wrong interval
119
+ // (task 0060 F7) or a never-firing cron (task 0734 R1).
120
+ const entry = this.parseEntry(cron, action);
121
+ this.entries.push(entry);
94
122
  if (this.running) {
95
- const last = this.entries[this.entries.length - 1];
96
- if (last) {
97
- this.startEntry(last);
98
- }
123
+ this.startEntry(entry);
99
124
  }
100
125
  }
101
126
 
127
+ private parseEntry(cron: string, action: ScheduledAction): ScheduledEntry {
128
+ const intervalMs = parseInterval(cron);
129
+ if (intervalMs !== undefined) {
130
+ return { kind: 'interval', cron, action, intervalMs };
131
+ }
132
+ // Real five-field cron. Validate and verify a next occurrence exists at
133
+ // registration (bounded scan) so an unsatisfiable expression fails here.
134
+ const expr = parseCronExpression(cron);
135
+ const target = nextCronTime(expr, this.now()).getTime();
136
+ return { kind: 'cron', cron, action, expr, target };
137
+ }
138
+
102
139
  async start(): Promise<void> {
103
140
  if (this.running) return;
104
141
 
@@ -111,16 +148,21 @@ export class NodeSchedulerAdapter implements SchedulerAdapter {
111
148
  async stop(): Promise<void> {
112
149
  this.running = false;
113
150
  for (const entry of this.entries) {
114
- if (entry.timer) {
115
- clearInterval(entry.timer);
151
+ if (entry.timer !== undefined) {
152
+ if (entry.kind === 'interval') {
153
+ clearInterval(entry.timer);
154
+ } else {
155
+ clearTimeout(entry.timer);
156
+ }
116
157
  entry.timer = undefined;
117
158
  }
118
159
  }
119
160
 
120
- // Drain in-flight ticks, bounded by a shared absolute deadline. clearInterval
121
- // cancels future ticks but not one already executing; without this wait a tick
122
- // mid-action when stop() is called would keep running after stop() resolves,
123
- // tearing down a half-written row or half-flushed batch (ADR-024).
161
+ // Drain in-flight ticks, bounded by a shared absolute deadline. Clearing
162
+ // timers cancels future ticks but not one already executing; without this
163
+ // wait a tick mid-action when stop() is called would keep running after
164
+ // stop() resolves, tearing down a half-written row or half-flushed batch
165
+ // (ADR-024).
124
166
  const deadline = Date.now() + this.drainTimeoutMs;
125
167
  for (const p of [...this.inflight]) {
126
168
  await settleWithin(p, deadline);
@@ -128,20 +170,77 @@ export class NodeSchedulerAdapter implements SchedulerAdapter {
128
170
  }
129
171
 
130
172
  private startEntry(entry: ScheduledEntry): void {
131
- if (entry.timer) return;
132
-
133
- const interval = parseInterval(entry.cron);
134
- // setInterval discards the async handler's returned promise, so capture it
135
- // here for stop() to drain. _onScheduledTick never rejects (try/catch),
136
- // so the cleanup runs on both paths with no unhandled-rejection risk.
137
- entry.timer = setInterval(() => {
138
- const tick = this._onScheduledTick(entry);
139
- this.inflight.add(tick);
140
- const cleanup = (): void => {
141
- this.inflight.delete(tick);
142
- };
143
- tick.then(cleanup, cleanup);
144
- }, interval);
173
+ if (entry.timer !== undefined) return;
174
+
175
+ if (entry.kind === 'interval') {
176
+ entry.timer = setInterval(() => {
177
+ const tick = this._onScheduledTick(entry);
178
+ this.inflight.add(tick);
179
+ const cleanup = (): void => {
180
+ this.inflight.delete(tick);
181
+ };
182
+ tick.then(cleanup, cleanup);
183
+ }, entry.intervalMs);
184
+ } else {
185
+ this.armCron(entry);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Arm the cron entry's next `setTimeout`, chunking any delay beyond the
191
+ * platform timer maximum. On a chunk wake the wall clock is re-checked
192
+ * before firing, so an injected/real clock shift never fires early.
193
+ */
194
+ private armCron(entry: Extract<ScheduledEntry, { kind: 'cron' }>): void {
195
+ if (!this.running) return;
196
+
197
+ let delay = entry.target - this.now();
198
+ if (delay <= 0) {
199
+ // Target already passed (clock shift): recompute strictly after now.
200
+ entry.target = nextCronTime(entry.expr, this.now()).getTime();
201
+ delay = entry.target - this.now();
202
+ }
203
+
204
+ if (delay > MAX_TIMEOUT) {
205
+ // Delay exceeds the platform maximum — arm a safe chunk; on wake,
206
+ // re-check now() before firing.
207
+ entry.timer = setTimeout(() => {
208
+ this.armCron(entry);
209
+ }, MAX_TIMEOUT);
210
+ return;
211
+ }
212
+
213
+ entry.timer = setTimeout(() => {
214
+ void this.fireCron(entry);
215
+ }, delay);
216
+ }
217
+
218
+ /**
219
+ * Fire a cron tick: re-check the wall clock against the target, run the
220
+ * action once, then recompute the next occurrence strictly from `now()` and
221
+ * re-arm only while still running. Ticks never overlap and occurrences
222
+ * missed while a tick runs are skipped (task 0734 R2).
223
+ */
224
+ private async fireCron(entry: Extract<ScheduledEntry, { kind: 'cron' }>): Promise<void> {
225
+ entry.timer = undefined;
226
+
227
+ if (this.now() < entry.target) {
228
+ // Not actually time yet (early chunk wake / clock shift) — re-arm.
229
+ this.armCron(entry);
230
+ return;
231
+ }
232
+
233
+ const tick = this._onScheduledTick(entry);
234
+ this.inflight.add(tick);
235
+ const cleanup = (): void => {
236
+ this.inflight.delete(tick);
237
+ };
238
+ await tick.then(cleanup, cleanup);
239
+
240
+ if (this.running) {
241
+ entry.target = nextCronTime(entry.expr, this.now()).getTime();
242
+ this.armCron(entry);
243
+ }
145
244
  }
146
245
 
147
246
  private async _onScheduledTick(entry: ScheduledEntry): Promise<void> {
@@ -17,3 +17,16 @@ export interface SchedulerAdapter {
17
17
  start(): Promise<void>;
18
18
  stop(): Promise<void>;
19
19
  }
20
+
21
+ /**
22
+ * A declarative scheduler job (task 0734). Exactly one of `intervalMinutes` or
23
+ * `cron` is present — the schedule XOR is enforced by the Node subpath's
24
+ * `normalizeSchedulerJobs` before the user `start` callback runs.
25
+ *
26
+ * The `command` is declarative data: ts-infra validates and exposes it but
27
+ * never executes it. The consuming application binds it to a queue-backed
28
+ * command handler.
29
+ */
30
+ export type SchedulerJobConfig =
31
+ | { readonly name: string; readonly command: string; readonly intervalMinutes: number; readonly cron?: never }
32
+ | { readonly name: string; readonly command: string; readonly cron: string; readonly intervalMinutes?: never };