@gobing-ai/ts-infra 0.2.7 → 0.2.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @gobing-ai/ts-infra
2
2
 
3
- Infrastructure backbone — typed event bus, job queue (types), cron scheduler, OpenTelemetry telemetry, HTTP API client, and structured logging. Designed to be wired together at bootstrap via `RuntimeContext`.
3
+ Infrastructure backbone — typed event bus, DB-backed job queue, cron scheduler, OpenTelemetry telemetry, HTTP API client, and structured logging.
4
4
 
5
5
  ## Overview
6
6
 
@@ -10,10 +10,10 @@ Infrastructure backbone — typed event bus, job queue (types), cron scheduler,
10
10
  |-----------|--------|---------|
11
11
  | **Event Bus** | `event-bus/` | Typed pub/sub with sync + async dispatch, lifecycle self-observability |
12
12
  | **Events** | `events/` | Typed event map pattern + system bus factory |
13
- | **Job Queue** | `job-queue/` | Types for DB-backed job processing (`JobQueue`, `QueueConsumer`, `Job`) |
13
+ | **Job Queue** | `job-queue/` | DB-backed enqueue and consume flow (`DBJobQueue`, `DBQueueConsumer`) plus queue interfaces |
14
14
  | **Scheduler** | `scheduler/` | Cron-like scheduled actions — Node (interval), Cloudflare (Cron Triggers), Noop (test) |
15
15
  | **Telemetry** | `telemetry/` | OpenTelemetry SDK wrapper — tracing (`traceAsync`), metrics (17 instruments), SQL sanitizer |
16
- | **API Client** | `api-client.ts` | Typed HTTP client with OTel tracing, retry, timeout, error handling |
16
+ | **API Client** | `api-client.ts` | Typed HTTP client with OTel tracing, timeout, and error handling |
17
17
  | **Logger** | `logger.ts` | Structured JSON logger with levels, child loggers, and mute toggle |
18
18
 
19
19
  ## Architecture
@@ -51,6 +51,18 @@ classDiagram
51
51
  +stop() Promise~void~
52
52
  +stats() Promise~QueueStats~
53
53
  }
54
+ class DBJobQueue~T~ {
55
+ +enqueue(type, payload, opts?) Promise~string~
56
+ +enqueueBatch(jobs) Promise~string[]~
57
+ +stats() Promise~QueueStats~
58
+ }
59
+ class DBQueueConsumer~T~ {
60
+ +register(type, handler) void
61
+ +start() Promise~void~
62
+ +stop() Promise~void~
63
+ +processOnce() Promise~number~
64
+ +stats() Promise~QueueStats~
65
+ }
54
66
  class Job~T~ {
55
67
  <<interface>>
56
68
  }
@@ -141,6 +153,8 @@ classDiagram
141
153
  SchedulerFactory --> CloudflareSchedulerAdapter
142
154
  SchedulerFactory --> NoopSchedulerAdapter
143
155
  EventBus --> JobQueue : "async dispatch"
156
+ DBJobQueue --> JobQueue : "implements"
157
+ DBQueueConsumer --> QueueConsumer : "implements"
144
158
  EventBus --> Logger : "self-observability"
145
159
  APIClient --> Tracing : "traceAsync"
146
160
  APIClient --> Metrics : "counters + histograms"
@@ -182,7 +196,11 @@ bus.once('user.signed_up', () => console.log('one-time'));
182
196
  **Lifecycle events** — inject a second `EventBus` to observe bus internals:
183
197
 
184
198
  ```ts
185
- const lifecycleBus = new EventBus<BusLifecycleEvents>();
199
+ type LifecycleEvents = {
200
+ 'bus.emit.done': (detail: { event: string; syncCount: number; asyncCount: number; emitDurationMs: number }) => void;
201
+ };
202
+
203
+ const lifecycleBus = new EventBus<LifecycleEvents>();
186
204
  lifecycleBus.on('bus.emit.done', (detail) => {
187
205
  // { event, syncCount, asyncCount, emitDurationMs, errors }
188
206
  metrics.recordEmit(detail);
@@ -191,6 +209,45 @@ lifecycleBus.on('bus.emit.done', (detail) => {
191
209
  const bus = new EventBus<AppEvents>({ lifecycleBus });
192
210
  ```
193
211
 
212
+ ### Job Queue — DB-backed async work
213
+
214
+ `DBJobQueue` and `DBQueueConsumer` run over `@gobing-ai/ts-db`'s `QueueJobDao`. Use this when event handlers, schedulers, or API handlers need durable background work with retries.
215
+
216
+ ```ts
217
+ import { createDbAdapter, QueueJobDao } from '@gobing-ai/ts-db';
218
+ import { DBJobQueue, DBQueueConsumer } from '@gobing-ai/ts-infra';
219
+
220
+ const db = await createDbAdapter({ driver: 'bun-sqlite', url: './jobs.db' });
221
+ const dao = new QueueJobDao(db);
222
+
223
+ const queue = new DBJobQueue<{ to: string; subject: string }>(dao);
224
+ await queue.enqueue(
225
+ 'send-email',
226
+ { to: 'alice@example.com', subject: 'Welcome' },
227
+ { maxRetries: 5, delay: 1_000, ttlMs: 86_400_000 },
228
+ );
229
+
230
+ const consumer = new DBQueueConsumer<{ to: string; subject: string }>(dao, {
231
+ batchSize: 10,
232
+ maxConcurrency: 4,
233
+ visibilityTimeout: 30_000,
234
+ });
235
+
236
+ consumer.register('send-email', async (job) => {
237
+ await sendEmail(job.payload.to, job.payload.subject);
238
+ });
239
+
240
+ await consumer.start();
241
+ ```
242
+
243
+ For scheduled drains and tests, call `processOnce()` instead of starting the polling loop:
244
+
245
+ ```ts
246
+ const processed = await consumer.processOnce();
247
+ ```
248
+
249
+ The consumer claims ready jobs, resets stuck processing jobs after the visibility timeout, retries failed jobs with exponential backoff, and marks expired jobs failed through `QueueJobDao`.
250
+
194
251
  ### Scheduler — cron-like actions
195
252
 
196
253
  ```ts
@@ -269,9 +326,7 @@ const reqLog = log.child({ requestId: 'req-123' });
269
326
  reqLog.error('Validation failed', { field: 'email' });
270
327
  // → {...,"category":"auth","requestId":"req-123","field":"email"}
271
328
 
272
- // Mute during tests
273
- import { setLoggerMuted } from './logger.js'; // internal import
274
- setLoggerMuted(true);
329
+ // In tests, prefer injecting a logger boundary or initializing at a quiet level.
275
330
  ```
276
331
 
277
332
  ### Telemetry — OpenTelemetry
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { EventBus, type EventMap, type SubscribeOptions } from './event-bus/inde
3
3
  export type { AppEvents, AppInternalEvents } from './events/index';
4
4
  export { createSystemBus } from './events/index';
5
5
  export type { EnqueueOptions, Job, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig, QueueStats, } from './job-queue/index';
6
+ export { DBJobQueue, DBQueueConsumer } from './job-queue/index';
6
7
  export { getLogger, initializeLogger, type Logger, type LogLevel } from './logger';
7
8
  export { CloudflareSchedulerAdapter, getSchedulerAdapter, initScheduler, NodeSchedulerAdapter, NoopSchedulerAdapter, type ScheduledAction, type SchedulerAdapter, setSchedulerAdapter, } from './scheduler/index';
8
9
  export { addSpanAttributes, addSpanEvent, extractSqlOperation, getActiveSpan, getDbOperationDuration, getDbOperationErrors, getDbOperationTotal, getEventbusEmitsTotal, getEventbusErrorsTotal, getHttpClientRequestDuration, getHttpClientRequestErrors, getHttpClientRequestTotal, getHttpServerRequestDuration, getHttpServerRequestErrors, getHttpServerRequestTotal, getQueueJobCompletedTotal, getQueueJobEnqueuedTotal, getQueueJobFailedTotal, getQueueJobProcessingDuration, getSchedulerJobDuration, getSchedulerJobExecutedTotal, getSchedulerJobFailedTotal, getTelemetryConfig, getTracer, initMetrics, initTelemetry, isTelemetryEnabled, sanitizeSql, shutdownMetrics, shutdownTelemetry, type TelemetryConfig, type TelemetryConfigPartial, traceAsync, traceSync, withSpan, } from './telemetry/index';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAG9F,OAAO,EAAE,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAGnF,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGjD,YAAY,EACR,cAAc,EACd,GAAG,EACH,UAAU,EACV,QAAQ,EACR,aAAa,EACb,mBAAmB,EACnB,UAAU,GACb,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,KAAK,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAGnF,OAAO,EACH,0BAA0B,EAC1B,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,mBAAmB,GACtB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACH,iBAAiB,EACjB,YAAY,EACZ,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,4BAA4B,EAC5B,0BAA0B,EAC1B,yBAAyB,EACzB,4BAA4B,EAC5B,0BAA0B,EAC1B,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,6BAA6B,EAC7B,uBAAuB,EACvB,4BAA4B,EAC5B,0BAA0B,EAC1B,kBAAkB,EAClB,SAAS,EACT,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,UAAU,EACV,SAAS,EACT,QAAQ,GACX,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,KAAK,eAAe,EAAE,QAAQ,EAAE,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAG9F,OAAO,EAAE,QAAQ,EAAE,KAAK,QAAQ,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAGnF,YAAY,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,YAAY,EACR,cAAc,EACd,GAAG,EACH,UAAU,EACV,QAAQ,EACR,aAAa,EACb,mBAAmB,EACnB,UAAU,GACb,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGhE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,KAAK,MAAM,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAGnF,OAAO,EACH,0BAA0B,EAC1B,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,mBAAmB,GACtB,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACH,iBAAiB,EACjB,YAAY,EACZ,mBAAmB,EACnB,aAAa,EACb,sBAAsB,EACtB,oBAAoB,EACpB,mBAAmB,EACnB,qBAAqB,EACrB,sBAAsB,EACtB,4BAA4B,EAC5B,0BAA0B,EAC1B,yBAAyB,EACzB,4BAA4B,EAC5B,0BAA0B,EAC1B,yBAAyB,EACzB,yBAAyB,EACzB,wBAAwB,EACxB,sBAAsB,EACtB,6BAA6B,EAC7B,uBAAuB,EACvB,4BAA4B,EAC5B,0BAA0B,EAC1B,kBAAkB,EAClB,SAAS,EACT,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,iBAAiB,EACjB,KAAK,eAAe,EACpB,KAAK,sBAAsB,EAC3B,UAAU,EACV,SAAS,EACT,QAAQ,GACX,MAAM,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { APIClient, APIError } from './api-client.js';
3
3
  // Event Bus
4
4
  export { EventBus } from './event-bus/index.js';
5
5
  export { createSystemBus } from './events/index.js';
6
+ // Job Queue
7
+ export { DBJobQueue, DBQueueConsumer } from './job-queue/index.js';
6
8
  // Logger
7
9
  export { getLogger, initializeLogger } from './logger.js';
8
10
  // Scheduler
@@ -0,0 +1,40 @@
1
+ import type { QueueJobDao, QueueStats } from '@gobing-ai/ts-db';
2
+ import type { EnqueueOptions, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig } from './types';
3
+ /** DB-backed job queue implementation over `@gobing-ai/ts-db`'s `QueueJobDao`. */
4
+ export declare class DBJobQueue<T = unknown> implements JobQueue<T> {
5
+ readonly dao: QueueJobDao;
6
+ constructor(dao: QueueJobDao);
7
+ enqueue(type: string, payload: T, options?: EnqueueOptions): Promise<string>;
8
+ enqueueBatch(jobs: Array<{
9
+ type: string;
10
+ payload: T;
11
+ } & EnqueueOptions>): Promise<string[]>;
12
+ stats(): Promise<QueueStats>;
13
+ }
14
+ /** DB-backed queue consumer with polling, retry, and visibility-timeout handling. */
15
+ export declare class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
16
+ private readonly dao;
17
+ private readonly handlers;
18
+ private readonly pollInterval;
19
+ private readonly batchSize;
20
+ private readonly maxConcurrency;
21
+ private readonly visibilityTimeout;
22
+ private readonly baseDelay;
23
+ private readonly maxDelay;
24
+ private readonly drainTimeoutMs;
25
+ private timer;
26
+ private running;
27
+ private inFlight;
28
+ constructor(dao: QueueJobDao, config?: QueueConsumerConfig);
29
+ register(type: string, handler: JobHandler<T>): void;
30
+ start(): Promise<void>;
31
+ stop(): Promise<void>;
32
+ stats(): Promise<QueueStats>;
33
+ /** Process one batch immediately. Useful for tests, schedulers, and manual drains. */
34
+ processOnce(): Promise<number>;
35
+ private schedule;
36
+ private poll;
37
+ private processJob;
38
+ private failOrRetry;
39
+ }
40
+ //# sourceMappingURL=db-job-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db-job-queue.d.ts","sourceRoot":"","sources":["../../src/job-queue/db-job-queue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAkB,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAChF,OAAO,KAAK,EAAE,cAAc,EAAO,UAAU,EAAE,QAAQ,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAE7G,kFAAkF;AAClF,qBAAa,UAAU,CAAC,CAAC,GAAG,OAAO,CAAE,YAAW,QAAQ,CAAC,CAAC,CAAC;IAC3C,QAAQ,CAAC,GAAG,EAAE,WAAW;gBAAhB,GAAG,EAAE,WAAW;IAE/B,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC;IAI5E,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAI3F,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;CAGrC;AAED,qFAAqF;AACrF,qBAAa,eAAe,CAAC,CAAC,GAAG,OAAO,CAAE,YAAW,aAAa,CAAC,CAAC,CAAC;IAc7D,OAAO,CAAC,QAAQ,CAAC,GAAG;IAbxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,KAAK,CAA8C;IAC3D,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAK;gBAGA,GAAG,EAAE,WAAW,EACjC,MAAM,GAAE,mBAAwB;IAWpC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI;IAI9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAarB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IAIlC,sFAAsF;IAChF,WAAW,IAAI,OAAO,CAAC,MAAM,CAAC;IAyBpC,OAAO,CAAC,QAAQ;YAMF,IAAI;YASJ,UAAU;YAgBV,WAAW;CAW5B"}
@@ -0,0 +1,144 @@
1
+ /** DB-backed job queue implementation over `@gobing-ai/ts-db`'s `QueueJobDao`. */
2
+ export class DBJobQueue {
3
+ dao;
4
+ constructor(dao) {
5
+ this.dao = dao;
6
+ }
7
+ async enqueue(type, payload, options) {
8
+ return this.dao.enqueue(type, payload, options);
9
+ }
10
+ async enqueueBatch(jobs) {
11
+ return this.dao.enqueueBatch(jobs);
12
+ }
13
+ async stats() {
14
+ return this.dao.getStats();
15
+ }
16
+ }
17
+ /** DB-backed queue consumer with polling, retry, and visibility-timeout handling. */
18
+ export class DBQueueConsumer {
19
+ dao;
20
+ handlers = new Map();
21
+ pollInterval;
22
+ batchSize;
23
+ maxConcurrency;
24
+ visibilityTimeout;
25
+ baseDelay;
26
+ maxDelay;
27
+ drainTimeoutMs;
28
+ timer = null;
29
+ running = false;
30
+ inFlight = 0;
31
+ constructor(dao, config = {}) {
32
+ this.dao = dao;
33
+ this.pollInterval = config.pollInterval ?? 1_000;
34
+ this.batchSize = config.batchSize ?? 10;
35
+ this.maxConcurrency = config.maxConcurrency ?? this.batchSize;
36
+ this.visibilityTimeout = config.visibilityTimeout ?? 30_000;
37
+ this.baseDelay = config.baseDelay ?? 1_000;
38
+ this.maxDelay = config.maxDelay ?? 60_000;
39
+ this.drainTimeoutMs = config.drainTimeoutMs ?? 30_000;
40
+ }
41
+ register(type, handler) {
42
+ this.handlers.set(type, handler);
43
+ }
44
+ async start() {
45
+ if (this.running)
46
+ return;
47
+ this.running = true;
48
+ this.schedule(0);
49
+ }
50
+ async stop() {
51
+ this.running = false;
52
+ if (this.timer !== null) {
53
+ clearTimeout(this.timer);
54
+ this.timer = null;
55
+ }
56
+ const deadline = Date.now() + this.drainTimeoutMs;
57
+ while (this.inFlight > 0 && Date.now() < deadline) {
58
+ await sleep(10);
59
+ }
60
+ }
61
+ async stats() {
62
+ return this.dao.getStats();
63
+ }
64
+ /** Process one batch immediately. Useful for tests, schedulers, and manual drains. */
65
+ async processOnce() {
66
+ await this.dao.resetStuckJobs(this.visibilityTimeout);
67
+ await this.dao.failExpiredJobs();
68
+ const jobs = await this.dao.claimReady(this.batchSize);
69
+ let processed = 0;
70
+ for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
71
+ const batch = jobs.slice(index, index + this.maxConcurrency);
72
+ await Promise.all(batch.map(async (job) => {
73
+ this.inFlight += 1;
74
+ try {
75
+ await this.processJob(job);
76
+ processed += 1;
77
+ }
78
+ finally {
79
+ this.inFlight -= 1;
80
+ }
81
+ }));
82
+ }
83
+ return processed;
84
+ }
85
+ schedule(delay) {
86
+ this.timer = setTimeout(() => {
87
+ void this.poll();
88
+ }, delay);
89
+ }
90
+ async poll() {
91
+ if (!this.running)
92
+ return;
93
+ try {
94
+ await this.processOnce();
95
+ }
96
+ finally {
97
+ if (this.running)
98
+ this.schedule(this.pollInterval);
99
+ }
100
+ }
101
+ async processJob(record) {
102
+ const job = toJob(record);
103
+ const handler = this.handlers.get(job.type);
104
+ if (handler === undefined) {
105
+ await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`));
106
+ return;
107
+ }
108
+ try {
109
+ await handler(job);
110
+ await this.dao.markCompleted(job.id);
111
+ }
112
+ catch (error) {
113
+ await this.failOrRetry(job, error);
114
+ }
115
+ }
116
+ async failOrRetry(job, error) {
117
+ const attempts = job.attempts + 1;
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ if (attempts >= job.maxRetries) {
120
+ await this.dao.markFailed(job.id, attempts, message);
121
+ return;
122
+ }
123
+ const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
124
+ await this.dao.markForRetry(job.id, attempts, message, Date.now() + delay);
125
+ }
126
+ }
127
+ function toJob(record) {
128
+ return {
129
+ id: record.id,
130
+ type: record.type,
131
+ payload: JSON.parse(record.payload),
132
+ status: record.status,
133
+ attempts: record.attempts,
134
+ maxRetries: record.maxRetries,
135
+ createdAt: record.createdAt,
136
+ updatedAt: record.updatedAt,
137
+ nextRetryAt: record.nextRetryAt,
138
+ lastError: record.lastError,
139
+ processingAt: record.processingAt,
140
+ };
141
+ }
142
+ function sleep(ms) {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
@@ -1,2 +1,3 @@
1
+ export { DBJobQueue, DBQueueConsumer } from './db-job-queue';
1
2
  export type { EnqueueOptions, Job, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig, QueueStats, } from './types';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/job-queue/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACR,cAAc,EACd,GAAG,EACH,UAAU,EACV,QAAQ,EACR,aAAa,EACb,mBAAmB,EACnB,UAAU,GACb,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/job-queue/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAC7D,YAAY,EACR,cAAc,EACd,GAAG,EACH,UAAU,EACV,QAAQ,EACR,aAAa,EACb,mBAAmB,EACnB,UAAU,GACb,MAAM,SAAS,CAAC"}
@@ -0,0 +1 @@
1
+ export { DBJobQueue, DBQueueConsumer } from './db-job-queue.js';
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Job queue types for async work processing with retry.
3
3
  *
4
- * **Types only** — the DB-backed `DbQueue` and `DbConsumer` implementations
5
- * that drive the job-queue subsystem live in `@spur/core`. These interfaces
6
- * are provided here so `EventBus` and other infra components can reference
7
- * the queue contract without a circular dependency on the DB layer.
4
+ * The concrete DB-backed implementations live beside these interfaces in
5
+ * `DBJobQueue` and `DBQueueConsumer`.
8
6
  */
9
7
  export interface Job<T = unknown> {
10
8
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/job-queue/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,WAAW,GAAG,CAAC,CAAC,GAAG,OAAO;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,CAAC;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ,CAAC,CAAC,GAAG,OAAO;IACjC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7E,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC/F;AAED,MAAM,MAAM,UAAU,CAAC,CAAC,GAAG,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAErE,MAAM,WAAW,UAAU;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa,CAAC,CAAC,GAAG,OAAO;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IACrD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAChC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/job-queue/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,GAAG,CAAC,CAAC,GAAG,OAAO;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,CAAC,CAAC;IACX,MAAM,EAAE,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,CAAC;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,QAAQ,CAAC,CAAC,GAAG,OAAO;IACjC,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7E,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,CAAC,CAAA;KAAE,GAAG,cAAc,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAC/F;AAED,MAAM,MAAM,UAAU,CAAC,CAAC,GAAG,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAErE,MAAM,WAAW,UAAU;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAChC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa,CAAC,CAAC,GAAG,OAAO;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IACrD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAChC"}
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * Job queue types for async work processing with retry.
3
3
  *
4
- * **Types only** — the DB-backed `DbQueue` and `DbConsumer` implementations
5
- * that drive the job-queue subsystem live in `@spur/core`. These interfaces
6
- * are provided here so `EventBus` and other infra components can reference
7
- * the queue contract without a circular dependency on the DB layer.
4
+ * The concrete DB-backed implementations live beside these interfaces in
5
+ * `DBJobQueue` and `DBQueueConsumer`.
8
6
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-infra",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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",
@@ -50,8 +50,8 @@
50
50
  "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"
51
51
  },
52
52
  "dependencies": {
53
- "@gobing-ai/ts-db": "^0.2.7",
54
- "@gobing-ai/ts-runtime": "^0.2.7"
53
+ "@gobing-ai/ts-db": "^0.2.8",
54
+ "@gobing-ai/ts-runtime": "^0.2.8"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "@opentelemetry/api": "^1.9.0"
package/src/index.ts CHANGED
@@ -7,8 +7,6 @@ export { EventBus, type EventMap, type SubscribeOptions } from './event-bus/inde
7
7
  // Events
8
8
  export type { AppEvents, AppInternalEvents } from './events/index';
9
9
  export { createSystemBus } from './events/index';
10
-
11
- // Job Queue
12
10
  export type {
13
11
  EnqueueOptions,
14
12
  Job,
@@ -18,6 +16,8 @@ export type {
18
16
  QueueConsumerConfig,
19
17
  QueueStats,
20
18
  } from './job-queue/index';
19
+ // Job Queue
20
+ export { DBJobQueue, DBQueueConsumer } from './job-queue/index';
21
21
 
22
22
  // Logger
23
23
  export { getLogger, initializeLogger, type Logger, type LogLevel } from './logger';
@@ -0,0 +1,163 @@
1
+ import type { QueueJobDao, QueueJobRecord, QueueStats } from '@gobing-ai/ts-db';
2
+ import type { EnqueueOptions, Job, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig } from './types';
3
+
4
+ /** DB-backed job queue implementation over `@gobing-ai/ts-db`'s `QueueJobDao`. */
5
+ export class DBJobQueue<T = unknown> implements JobQueue<T> {
6
+ constructor(readonly dao: QueueJobDao) {}
7
+
8
+ async enqueue(type: string, payload: T, options?: EnqueueOptions): Promise<string> {
9
+ return this.dao.enqueue(type, payload, options);
10
+ }
11
+
12
+ async enqueueBatch(jobs: Array<{ type: string; payload: T } & EnqueueOptions>): Promise<string[]> {
13
+ return this.dao.enqueueBatch(jobs);
14
+ }
15
+
16
+ async stats(): Promise<QueueStats> {
17
+ return this.dao.getStats();
18
+ }
19
+ }
20
+
21
+ /** DB-backed queue consumer with polling, retry, and visibility-timeout handling. */
22
+ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
23
+ private readonly handlers = new Map<string, JobHandler<T>>();
24
+ private readonly pollInterval: number;
25
+ private readonly batchSize: number;
26
+ private readonly maxConcurrency: number;
27
+ private readonly visibilityTimeout: number;
28
+ private readonly baseDelay: number;
29
+ private readonly maxDelay: number;
30
+ private readonly drainTimeoutMs: number;
31
+ private timer: ReturnType<typeof setTimeout> | null = null;
32
+ private running = false;
33
+ private inFlight = 0;
34
+
35
+ constructor(
36
+ private readonly dao: QueueJobDao,
37
+ config: QueueConsumerConfig = {},
38
+ ) {
39
+ this.pollInterval = config.pollInterval ?? 1_000;
40
+ this.batchSize = config.batchSize ?? 10;
41
+ this.maxConcurrency = config.maxConcurrency ?? this.batchSize;
42
+ this.visibilityTimeout = config.visibilityTimeout ?? 30_000;
43
+ this.baseDelay = config.baseDelay ?? 1_000;
44
+ this.maxDelay = config.maxDelay ?? 60_000;
45
+ this.drainTimeoutMs = config.drainTimeoutMs ?? 30_000;
46
+ }
47
+
48
+ register(type: string, handler: JobHandler<T>): void {
49
+ this.handlers.set(type, handler);
50
+ }
51
+
52
+ async start(): Promise<void> {
53
+ if (this.running) return;
54
+ this.running = true;
55
+ this.schedule(0);
56
+ }
57
+
58
+ async stop(): Promise<void> {
59
+ this.running = false;
60
+ if (this.timer !== null) {
61
+ clearTimeout(this.timer);
62
+ this.timer = null;
63
+ }
64
+
65
+ const deadline = Date.now() + this.drainTimeoutMs;
66
+ while (this.inFlight > 0 && Date.now() < deadline) {
67
+ await sleep(10);
68
+ }
69
+ }
70
+
71
+ async stats(): Promise<QueueStats> {
72
+ return this.dao.getStats();
73
+ }
74
+
75
+ /** Process one batch immediately. Useful for tests, schedulers, and manual drains. */
76
+ async processOnce(): Promise<number> {
77
+ await this.dao.resetStuckJobs(this.visibilityTimeout);
78
+ await this.dao.failExpiredJobs();
79
+
80
+ const jobs = await this.dao.claimReady(this.batchSize);
81
+ let processed = 0;
82
+
83
+ for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
84
+ const batch = jobs.slice(index, index + this.maxConcurrency);
85
+ await Promise.all(
86
+ batch.map(async (job) => {
87
+ this.inFlight += 1;
88
+ try {
89
+ await this.processJob(job);
90
+ processed += 1;
91
+ } finally {
92
+ this.inFlight -= 1;
93
+ }
94
+ }),
95
+ );
96
+ }
97
+
98
+ return processed;
99
+ }
100
+
101
+ private schedule(delay: number): void {
102
+ this.timer = setTimeout(() => {
103
+ void this.poll();
104
+ }, delay);
105
+ }
106
+
107
+ private async poll(): Promise<void> {
108
+ if (!this.running) return;
109
+ try {
110
+ await this.processOnce();
111
+ } finally {
112
+ if (this.running) this.schedule(this.pollInterval);
113
+ }
114
+ }
115
+
116
+ private async processJob(record: QueueJobRecord): Promise<void> {
117
+ const job = toJob<T>(record);
118
+ const handler = this.handlers.get(job.type);
119
+ if (handler === undefined) {
120
+ await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`));
121
+ return;
122
+ }
123
+
124
+ try {
125
+ await handler(job);
126
+ await this.dao.markCompleted(job.id);
127
+ } catch (error) {
128
+ await this.failOrRetry(job, error);
129
+ }
130
+ }
131
+
132
+ private async failOrRetry(job: Job<T>, error: unknown): Promise<void> {
133
+ const attempts = job.attempts + 1;
134
+ const message = error instanceof Error ? error.message : String(error);
135
+ if (attempts >= job.maxRetries) {
136
+ await this.dao.markFailed(job.id, attempts, message);
137
+ return;
138
+ }
139
+
140
+ const delay = Math.min(this.maxDelay, this.baseDelay * 2 ** Math.max(0, attempts - 1));
141
+ await this.dao.markForRetry(job.id, attempts, message, Date.now() + delay);
142
+ }
143
+ }
144
+
145
+ function toJob<T>(record: QueueJobRecord): Job<T> {
146
+ return {
147
+ id: record.id,
148
+ type: record.type,
149
+ payload: JSON.parse(record.payload) as T,
150
+ status: record.status as Job<T>['status'],
151
+ attempts: record.attempts,
152
+ maxRetries: record.maxRetries,
153
+ createdAt: record.createdAt,
154
+ updatedAt: record.updatedAt,
155
+ nextRetryAt: record.nextRetryAt,
156
+ lastError: record.lastError,
157
+ processingAt: record.processingAt,
158
+ };
159
+ }
160
+
161
+ function sleep(ms: number): Promise<void> {
162
+ return new Promise((resolve) => setTimeout(resolve, ms));
163
+ }
@@ -1,3 +1,4 @@
1
+ export { DBJobQueue, DBQueueConsumer } from './db-job-queue';
1
2
  export type {
2
3
  EnqueueOptions,
3
4
  Job,
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Job queue types for async work processing with retry.
3
3
  *
4
- * **Types only** — the DB-backed `DbQueue` and `DbConsumer` implementations
5
- * that drive the job-queue subsystem live in `@spur/core`. These interfaces
6
- * are provided here so `EventBus` and other infra components can reference
7
- * the queue contract without a circular dependency on the DB layer.
4
+ * The concrete DB-backed implementations live beside these interfaces in
5
+ * `DBJobQueue` and `DBQueueConsumer`.
8
6
  */
9
7
 
10
8
  export interface Job<T = unknown> {