@gobing-ai/ts-infra 0.4.59 → 0.4.62

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.
Files changed (43) hide show
  1. package/README.md +14 -4
  2. package/dist/application/index.d.ts.map +1 -1
  3. package/dist/application/index.js +3 -0
  4. package/dist/application/types.d.ts +11 -2
  5. package/dist/application/types.d.ts.map +1 -1
  6. package/dist/application-node.d.ts.map +1 -1
  7. package/dist/application-node.js +34 -4
  8. package/dist/execution-policy.d.ts +76 -0
  9. package/dist/execution-policy.d.ts.map +1 -0
  10. package/dist/execution-policy.js +123 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/job-queue/db-job-queue.d.ts +14 -1
  15. package/dist/job-queue/db-job-queue.d.ts.map +1 -1
  16. package/dist/job-queue/db-job-queue.js +165 -26
  17. package/dist/job-queue/types.d.ts +47 -7
  18. package/dist/job-queue/types.d.ts.map +1 -1
  19. package/dist/scheduler/cloudflare.d.ts +4 -0
  20. package/dist/scheduler/cloudflare.d.ts.map +1 -1
  21. package/dist/scheduler/cloudflare.js +2 -1
  22. package/dist/scheduler/index.d.ts +1 -1
  23. package/dist/scheduler/index.d.ts.map +1 -1
  24. package/dist/scheduler/node.d.ts +10 -1
  25. package/dist/scheduler/node.d.ts.map +1 -1
  26. package/dist/scheduler/node.js +28 -6
  27. package/dist/scheduler/types.d.ts +20 -3
  28. package/dist/scheduler/types.d.ts.map +1 -1
  29. package/dist/scheduler/wrap-handler.d.ts.map +1 -1
  30. package/dist/scheduler/wrap-handler.js +2 -2
  31. package/package.json +6 -6
  32. package/src/application/index.ts +3 -0
  33. package/src/application/types.ts +16 -3
  34. package/src/application-node.ts +40 -4
  35. package/src/execution-policy.ts +168 -0
  36. package/src/index.ts +3 -1
  37. package/src/job-queue/db-job-queue.ts +184 -25
  38. package/src/job-queue/types.ts +47 -7
  39. package/src/scheduler/cloudflare.ts +3 -1
  40. package/src/scheduler/index.ts +6 -1
  41. package/src/scheduler/node.ts +39 -6
  42. package/src/scheduler/types.ts +34 -5
  43. package/src/scheduler/wrap-handler.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-infra",
3
- "version": "0.4.59",
3
+ "version": "0.4.62",
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.59",
81
+ "@gobing-ai/ts-utils": "^0.4.62",
82
82
  "@logtape/logtape": "^2.0.0"
83
83
  },
84
84
  "peerDependencies": {
85
- "@gobing-ai/ts-db": "^0.4.59",
86
- "@gobing-ai/ts-runtime": "^0.4.59",
85
+ "@gobing-ai/ts-db": "^0.4.62",
86
+ "@gobing-ai/ts-runtime": "^0.4.62",
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.59",
120
- "@gobing-ai/ts-runtime": "^0.4.59",
119
+ "@gobing-ai/ts-db": "^0.4.62",
120
+ "@gobing-ai/ts-runtime": "^0.4.62",
121
121
  "@types/bun": "1.3.14",
122
122
  "@opentelemetry/api": "^1.9.0",
123
123
  "@opentelemetry/sdk-trace-node": "^2.0.0",
@@ -15,6 +15,7 @@ import { EventBus } from '../event-bus/event-bus';
15
15
  import { attachFileObserver } from '../event-bus/file-observer';
16
16
  import type { BusLifecycleEvents, EventMap } from '../event-bus/types';
17
17
  import type { InfraEvents } from '../events';
18
+ import { resolveExecutionTimeoutMs } from '../execution-policy';
18
19
  import { getLogger, type Logger } from '../logger';
19
20
  import { initScheduler } from '../scheduler/factory';
20
21
  import type { SchedulerAdapter } from '../scheduler/types';
@@ -126,6 +127,8 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
126
127
  enabled: schedOpts?.enabled ?? false,
127
128
  autoStart: schedOpts?.autoStart ?? true,
128
129
  jobs: schedOpts?.jobs ?? [],
130
+ // Bootstrap-level execution policy (A21): omitted resolves to unlimited.
131
+ timeoutMs: resolveExecutionTimeoutMs('bootstrap.scheduler', schedOpts?.timeoutMs),
129
132
  };
130
133
  const eventsEnabled = options.config?.events?.enabled ?? true;
131
134
  const eventsLifecycle = options.config?.events?.lifecycle ?? true;
@@ -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, SchedulerJobConfig } from '../scheduler/types';
16
+ import type { ScheduledAction, SchedulerAdapter, SchedulerJobConfig } from '../scheduler/types';
17
17
  import type { PluginHost } from './plugins/host';
18
18
  import type { Plugin } from './plugins/types';
19
19
 
@@ -81,7 +81,7 @@ export interface SchedulerOptions {
81
81
  /** Injected adapter (skips noop default when provided). */
82
82
  adapter?: SchedulerAdapter;
83
83
  /** Cron entries to register: `[cron, action][]`. */
84
- entries?: Array<[string, () => Promise<void>]>;
84
+ entries?: Array<[string, ScheduledAction]>;
85
85
  /** Start scheduler immediately after registration. Default `true` when enabled. */
86
86
  autoStart?: boolean;
87
87
  /**
@@ -91,6 +91,13 @@ export interface SchedulerOptions {
91
91
  * job `command` itself.
92
92
  */
93
93
  jobs?: readonly SchedulerJobConfig[];
94
+ /**
95
+ * Bootstrap-level execution policy (A21): a positive integer ms deadline
96
+ * or explicit `null` = unlimited; omitted resolves to unlimited. Per-job
97
+ * `timeoutMs` overrides this default through the shared first-match-wins
98
+ * resolution.
99
+ */
100
+ timeoutMs?: number | null;
94
101
  }
95
102
 
96
103
  // ── Resolved bootstrap config ─────────────────────────────────────────────
@@ -113,7 +120,13 @@ export interface ApplicationBootstrapConfig {
113
120
  filePath?: string;
114
121
  };
115
122
  readonly telemetry: { enabled: boolean; serviceName: string; environment: string; dbStatementDebug: boolean };
116
- readonly scheduler: { enabled: boolean; autoStart: boolean; jobs: readonly SchedulerJobConfig[] };
123
+ readonly scheduler: {
124
+ enabled: boolean;
125
+ autoStart: boolean;
126
+ jobs: readonly SchedulerJobConfig[];
127
+ /** Resolved bootstrap execution policy; `null` = unlimited. */
128
+ timeoutMs: number | null;
129
+ };
117
130
  }
118
131
 
119
132
  // ── Injected services ─────────────────────────────────────────────────────
@@ -39,6 +39,7 @@ import type {
39
39
  SchedulerOptions,
40
40
  TelemetryOptions,
41
41
  } from './application/types';
42
+ import { resolveExecutionTimeoutMs } from './execution-policy';
42
43
  import { parseCronExpression } from './scheduler/cron';
43
44
  import type { SchedulerJobConfig } from './scheduler/types';
44
45
  import { NodeSchedulerAdapter } from './scheduler-node';
@@ -97,7 +98,22 @@ function validateAppConfig<TAppConfig>(
97
98
  throw new ConfigValidationError(`Unsupported validator shape for section "${section}"`);
98
99
  }
99
100
 
100
- // ── Scheduler job validation (task 0734) ────────────────────────────────────
101
+ // ── Scheduler job validation (task 0734) ────────────────────────────
102
+
103
+ /**
104
+ * Resolve and validate the bootstrap-level scheduler execution policy (A21),
105
+ * rethrowing invalid values as {@link ConfigValidationError} with the exact
106
+ * config path so startup aborts before the user `start` callback.
107
+ */
108
+ function resolveBootstrapSchedulerTimeout(value: number | null | undefined): number | null {
109
+ try {
110
+ return resolveExecutionTimeoutMs('bootstrap.scheduler', value);
111
+ } catch {
112
+ throw new ConfigValidationError(
113
+ `bootstrap.scheduler.timeoutMs must be a positive integer or null; received ${String(value)}`,
114
+ );
115
+ }
116
+ }
101
117
 
102
118
  /** Max `intervalMinutes` so that `intervalMinutes * 60_000` fits in the platform timer maximum. */
103
119
  const MAX_INTERVAL_MINUTES = Math.floor(MAX_TIMEOUT_MS / 60_000);
@@ -131,6 +147,22 @@ function normalizeSchedulerJobs(raw: unknown): readonly SchedulerJobConfig[] {
131
147
  const command = typeof entry.command === 'string' ? entry.command.trim() : '';
132
148
  const cron = typeof entry.cron === 'string' ? entry.cron.trim() : '';
133
149
  const interval = entry.intervalMinutes;
150
+ // Per-job execution policy (A21): undefined inherits, explicit null is
151
+ // unlimited, otherwise a positive integer. Validated here so a bad
152
+ // value aborts startup with the exact job path.
153
+ const rawTimeout = entry.timeoutMs;
154
+ let timeoutMs: number | null | undefined;
155
+ if (rawTimeout !== undefined) {
156
+ if (
157
+ rawTimeout !== null &&
158
+ (typeof rawTimeout !== 'number' || !Number.isInteger(rawTimeout) || rawTimeout <= 0)
159
+ ) {
160
+ throw new ConfigValidationError(
161
+ `bootstrap.scheduler.jobs.${index}.timeoutMs must be a positive integer or null`,
162
+ );
163
+ }
164
+ timeoutMs = rawTimeout as number | null;
165
+ }
134
166
 
135
167
  if (name === '') {
136
168
  throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.name must be a non-empty string`);
@@ -165,7 +197,7 @@ function normalizeSchedulerJobs(raw: unknown): readonly SchedulerJobConfig[] {
165
197
  `bootstrap.scheduler.jobs.${index}.intervalMinutes must be an integer in 1..${MAX_INTERVAL_MINUTES}`,
166
198
  );
167
199
  }
168
- jobs.push({ name, command, intervalMinutes: interval as number });
200
+ jobs.push({ name, command, intervalMinutes: interval as number, timeoutMs });
169
201
  } else {
170
202
  if (cron === '') {
171
203
  throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.cron must be a non-empty string`);
@@ -176,7 +208,7 @@ function normalizeSchedulerJobs(raw: unknown): readonly SchedulerJobConfig[] {
176
208
  const detail = error instanceof Error ? error.message : String(error);
177
209
  throw new ConfigValidationError(`bootstrap.scheduler.jobs.${index}.cron: ${detail}`);
178
210
  }
179
- jobs.push({ name, command, cron });
211
+ jobs.push({ name, command, cron, timeoutMs });
180
212
  }
181
213
  });
182
214
 
@@ -369,9 +401,13 @@ export async function runNodeApplication<TAppConfig = unknown, TEvents extends E
369
401
  enabled: schedulerOpts.enabled === true,
370
402
  autoStart: schedulerOpts.autoStart,
371
403
  jobs: normalizeSchedulerJobs((schedulerOpts as Partial<SchedulerOptions>).jobs),
404
+ // Bootstrap-level execution policy (A21): validated with the exact
405
+ // config path so a bad value aborts startup before the user callback.
406
+ timeoutMs: resolveBootstrapSchedulerTimeout(schedulerOpts.timeoutMs),
372
407
  };
373
408
  if (schedulerConfig.enabled) {
374
- schedulerConfig.adapter = schedulerOpts.adapter ?? new NodeSchedulerAdapter();
409
+ schedulerConfig.adapter =
410
+ schedulerOpts.adapter ?? new NodeSchedulerAdapter({ timeoutMs: schedulerConfig.timeoutMs });
375
411
  if (schedulerOpts.entries) {
376
412
  schedulerConfig.entries = schedulerOpts.entries;
377
413
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Shared execution-deadline policy for background work (feature A21).
3
+ *
4
+ * One nullable policy (`number | null`) and one clock per execution: a finite
5
+ * deadline arms a single timer that requests cancellation through an
6
+ * `AbortController`; the executor always awaits the handler's settlement, even
7
+ * when the handler ignores the abort signal. Both the queue consumer and the
8
+ * Node scheduler run their work through this seam so deadline, cancellation,
9
+ * and timing semantics stay identical across surfaces.
10
+ *
11
+ * Resolution is first-match-wins down the scope chain: an explicit job value
12
+ * (including explicit `null` = unlimited) beats the consumer default; absence
13
+ * (`undefined`) inherits; an absent inherited value is unlimited. A scope
14
+ * never *negates* an inherited finite deadline by being omitted — only an
15
+ * explicit `null` opts out.
16
+ */
17
+
18
+ /** Execution deadline policy: positive integer milliseconds, or `null` = unlimited. */
19
+ export type ExecutionDeadlineMs = number | null;
20
+
21
+ /** Cancellation context handed to job and scheduler handlers. */
22
+ export interface ExecutionContext {
23
+ /** Fires when the deadline expires or the caller's signal cancels first. */
24
+ readonly signal: AbortSignal;
25
+ /** The resolved policy for this execution; `null` means unlimited. */
26
+ readonly deadlineMs: ExecutionDeadlineMs;
27
+ /**
28
+ * Why cancellation was requested: `'timeout'` when the execution deadline
29
+ * expired, `'cancelled'` when the caller's signal fired first. `undefined`
30
+ * while the work is not (yet) being cancelled.
31
+ */
32
+ readonly cancellationReason: 'timeout' | 'cancelled' | undefined;
33
+ }
34
+
35
+ /** Outcome of an execution run through {@link runWithExecutionDeadline}. */
36
+ export interface ExecutionOutcome {
37
+ /** `'timeout'` and `'cancelled'` still mean the handler settled (or was awaited to settlement). */
38
+ outcome: 'completed' | 'timeout' | 'cancelled' | 'error';
39
+ /** The handler's error when `outcome === 'error'`. */
40
+ error?: unknown;
41
+ elapsedMs: number;
42
+ /** True when the deadline expired, even if the handler ignored the abort and finished late. */
43
+ timedOut: boolean;
44
+ }
45
+
46
+ /** Options for {@link runWithExecutionDeadline}. */
47
+ export interface ExecutionDeadlineOptions {
48
+ /** Resolved deadline for this run; `undefined` is treated as unlimited. */
49
+ timeoutMs?: number | null;
50
+ /** Caller-controlled cancellation composed into the same controller. */
51
+ signal?: AbortSignal;
52
+ }
53
+
54
+ /**
55
+ * Resolve the leaf execution policy for one scope.
56
+ *
57
+ * `value === undefined` inherits `inherited` (itself `null` when absent);
58
+ * `value === null` explicitly opts out of this scope's deadline; a positive
59
+ * integer wins outright. Anything else is a configuration error.
60
+ */
61
+ export function resolveExecutionTimeoutMs(
62
+ scope: string,
63
+ value: number | null | undefined,
64
+ inherited?: number | null,
65
+ ): number | null {
66
+ if (value === undefined) {
67
+ return inherited ?? null;
68
+ }
69
+ if (value === null) {
70
+ return null;
71
+ }
72
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
73
+ throw new RangeError(`${scope} timeoutMs must be a positive integer or null; received ${String(value)}`);
74
+ }
75
+ return value;
76
+ }
77
+
78
+ /**
79
+ * A reusable unlimited context for callers that execute actions without
80
+ * arming a ts-infra deadline — notably the Cloudflare adapter, whose ticks
81
+ * run under the Workers runtime's own limits (A21).
82
+ */
83
+ export function unlimitedExecutionContext(): ExecutionContext {
84
+ return {
85
+ signal: new AbortController().signal,
86
+ deadlineMs: null,
87
+ cancellationReason: undefined,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Run `action` under one shared deadline clock.
93
+ *
94
+ * A finite `timeoutMs` arms exactly one timer that aborts the context's
95
+ * controller (`cancellationReason: 'timeout'`); an aborted caller signal
96
+ * aborts the same controller (`cancellationReason: 'cancelled'`). The handler
97
+ * is always awaited — including after abort, so an uncooperative handler that
98
+ * finishes late is still reported as timed out, never as a successful
99
+ * cancellation — and handler rejections surface as `{ outcome: 'error' }`.
100
+ */
101
+ export async function runWithExecutionDeadline<T>(
102
+ action: (context: ExecutionContext) => Promise<T>,
103
+ options: ExecutionDeadlineOptions,
104
+ ): Promise<ExecutionOutcome & { result?: T }> {
105
+ const deadlineMs = options.timeoutMs ?? null;
106
+ const controller = new AbortController();
107
+ const cancellation: { reason: 'timeout' | 'cancelled' | undefined } = { reason: undefined };
108
+
109
+ const context: ExecutionContext = {
110
+ signal: controller.signal,
111
+ deadlineMs,
112
+ get cancellationReason() {
113
+ return cancellation.reason;
114
+ },
115
+ };
116
+
117
+ if (options.signal !== undefined) {
118
+ if (options.signal.aborted) {
119
+ cancellation.reason = 'cancelled';
120
+ controller.abort();
121
+ } else {
122
+ const abortFromCaller = (): void => {
123
+ if (cancellation.reason === undefined) cancellation.reason = 'cancelled';
124
+ controller.abort();
125
+ };
126
+ options.signal.addEventListener('abort', abortFromCaller, { once: true });
127
+ }
128
+ }
129
+
130
+ const startMs = performance.now();
131
+ let timer: ReturnType<typeof setTimeout> | undefined;
132
+ if (deadlineMs !== null) {
133
+ timer = setTimeout(() => {
134
+ if (cancellation.reason === undefined) cancellation.reason = 'timeout';
135
+ controller.abort();
136
+ }, deadlineMs);
137
+ }
138
+
139
+ let result: T | undefined;
140
+ let error: unknown;
141
+ let failed = false;
142
+ try {
143
+ result = await action(context);
144
+ } catch (caught) {
145
+ error = caught;
146
+ failed = true;
147
+ } finally {
148
+ if (timer !== undefined) clearTimeout(timer);
149
+ }
150
+
151
+ const elapsedMs = performance.now() - startMs;
152
+ const timedOut = cancellation.reason === 'timeout';
153
+ if (failed) {
154
+ return { outcome: 'error', error, elapsedMs, timedOut };
155
+ }
156
+ if (timedOut) {
157
+ return {
158
+ outcome: 'timeout',
159
+ error: new Error(`execution deadline (${String(deadlineMs)}ms) expired`),
160
+ elapsedMs,
161
+ timedOut,
162
+ };
163
+ }
164
+ if (cancellation.reason === 'cancelled') {
165
+ return { outcome: 'cancelled', elapsedMs, timedOut };
166
+ }
167
+ return { outcome: 'completed', result, elapsedMs, timedOut };
168
+ }
package/src/index.ts CHANGED
@@ -43,7 +43,9 @@ export type {
43
43
  SchedulerJobExecutedDetail,
44
44
  WithEventSeverity,
45
45
  } from './events';
46
-
46
+ // Execution policy (A21) — shared deadline/cancellation seam for background work
47
+ export type { ExecutionContext, ExecutionDeadlineMs, ExecutionOutcome } from './execution-policy';
48
+ export { resolveExecutionTimeoutMs, runWithExecutionDeadline } from './execution-policy';
47
49
  export type {
48
50
  EnqueueOptions,
49
51
  Job,