@gobing-ai/ts-infra 0.3.1 → 0.3.2

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 (101) hide show
  1. package/README.md +52 -29
  2. package/dist/api-client.d.ts +13 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +15 -4
  5. package/dist/event-bus/default-observers.d.ts +53 -0
  6. package/dist/event-bus/default-observers.d.ts.map +1 -0
  7. package/dist/event-bus/default-observers.js +107 -0
  8. package/dist/event-bus/event-bus.d.ts.map +1 -1
  9. package/dist/event-bus/event-bus.js +1 -0
  10. package/dist/event-bus/file-observer.d.ts +25 -0
  11. package/dist/event-bus/file-observer.d.ts.map +1 -0
  12. package/dist/event-bus/file-observer.js +110 -0
  13. package/dist/event-bus/index.d.ts +2 -0
  14. package/dist/event-bus/index.d.ts.map +1 -1
  15. package/dist/event-bus/index.js +2 -0
  16. package/dist/event-bus/types.d.ts +6 -0
  17. package/dist/event-bus/types.d.ts.map +1 -1
  18. package/dist/events.d.ts +100 -0
  19. package/dist/events.d.ts.map +1 -0
  20. package/dist/events.js +12 -0
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -5
  24. package/dist/job-queue/db-job-queue.d.ts.map +1 -1
  25. package/dist/job-queue/db-job-queue.js +45 -34
  26. package/dist/job-queue/index.d.ts +0 -1
  27. package/dist/job-queue/index.d.ts.map +1 -1
  28. package/dist/job-queue/index.js +0 -1
  29. package/dist/job-queue/types.d.ts +7 -0
  30. package/dist/job-queue/types.d.ts.map +1 -1
  31. package/dist/job-queue-db.d.ts +2 -0
  32. package/dist/job-queue-db.d.ts.map +1 -0
  33. package/dist/job-queue-db.js +1 -0
  34. package/dist/logger.d.ts +39 -7
  35. package/dist/logger.d.ts.map +1 -1
  36. package/dist/logger.js +76 -73
  37. package/dist/scheduler/action.d.ts +97 -1
  38. package/dist/scheduler/action.d.ts.map +1 -1
  39. package/dist/scheduler/action.js +111 -0
  40. package/dist/scheduler/cloudflare.d.ts +6 -0
  41. package/dist/scheduler/cloudflare.d.ts.map +1 -1
  42. package/dist/scheduler/cloudflare.js +6 -0
  43. package/dist/scheduler/factory.d.ts +2 -0
  44. package/dist/scheduler/factory.d.ts.map +1 -1
  45. package/dist/scheduler/factory.js +2 -0
  46. package/dist/scheduler/index.d.ts +2 -2
  47. package/dist/scheduler/index.d.ts.map +1 -1
  48. package/dist/scheduler/index.js +2 -2
  49. package/dist/scheduler/node.d.ts +4 -0
  50. package/dist/scheduler/node.d.ts.map +1 -1
  51. package/dist/scheduler/node.js +4 -0
  52. package/dist/scheduler/noop.d.ts +1 -0
  53. package/dist/scheduler/noop.d.ts.map +1 -1
  54. package/dist/scheduler/noop.js +1 -0
  55. package/dist/scheduler/wrap-handler.d.ts +18 -0
  56. package/dist/scheduler/wrap-handler.d.ts.map +1 -0
  57. package/dist/scheduler/wrap-handler.js +41 -0
  58. package/dist/scheduler-cloudflare.d.ts +2 -0
  59. package/dist/scheduler-cloudflare.d.ts.map +1 -0
  60. package/dist/scheduler-cloudflare.js +1 -0
  61. package/dist/scheduler-node.d.ts +2 -0
  62. package/dist/scheduler-node.d.ts.map +1 -0
  63. package/dist/scheduler-node.js +1 -0
  64. package/dist/telemetry/config.d.ts +4 -0
  65. package/dist/telemetry/config.d.ts.map +1 -1
  66. package/dist/telemetry/metrics.d.ts +19 -0
  67. package/dist/telemetry/metrics.d.ts.map +1 -1
  68. package/dist/telemetry/metrics.js +19 -0
  69. package/dist/telemetry/sdk.d.ts +4 -0
  70. package/dist/telemetry/sdk.d.ts.map +1 -1
  71. package/dist/telemetry/sdk.js +4 -0
  72. package/dist/telemetry/tracing.d.ts +12 -0
  73. package/dist/telemetry/tracing.d.ts.map +1 -1
  74. package/dist/telemetry/tracing.js +12 -0
  75. package/package.json +19 -2
  76. package/src/api-client.ts +15 -4
  77. package/src/event-bus/default-observers.ts +117 -0
  78. package/src/event-bus/event-bus.ts +1 -0
  79. package/src/event-bus/file-observer.ts +142 -0
  80. package/src/event-bus/index.ts +7 -0
  81. package/src/event-bus/types.ts +6 -0
  82. package/src/events.ts +108 -0
  83. package/src/index.ts +44 -7
  84. package/src/job-queue/db-job-queue.ts +50 -38
  85. package/src/job-queue/index.ts +0 -1
  86. package/src/job-queue/types.ts +7 -0
  87. package/src/job-queue-db.ts +1 -0
  88. package/src/logger.ts +102 -77
  89. package/src/scheduler/action.ts +164 -1
  90. package/src/scheduler/cloudflare.ts +6 -0
  91. package/src/scheduler/factory.ts +2 -0
  92. package/src/scheduler/index.ts +13 -2
  93. package/src/scheduler/node.ts +4 -0
  94. package/src/scheduler/noop.ts +1 -0
  95. package/src/scheduler/wrap-handler.ts +50 -0
  96. package/src/scheduler-cloudflare.ts +1 -0
  97. package/src/scheduler-node.ts +1 -0
  98. package/src/telemetry/config.ts +4 -0
  99. package/src/telemetry/metrics.ts +19 -0
  100. package/src/telemetry/sdk.ts +4 -0
  101. package/src/telemetry/tracing.ts +12 -0
@@ -1,4 +1,11 @@
1
+ export {
2
+ attachDefaultObservers,
3
+ attachLogObserver,
4
+ attachTelemetryObserver,
5
+ createLifecycleBus,
6
+ } from './default-observers';
1
7
  export { EventBus } from './event-bus';
8
+ export { attachFileObserver, type FileObserverWriter } from './file-observer';
2
9
  export type {
3
10
  AsyncEnqueuedDetail,
4
11
  BusLifecycleEvents,
@@ -2,8 +2,10 @@
2
2
  * Event bus types — shared constraints for typed pub-sub.
3
3
  */
4
4
 
5
+ /** Type constraint for event maps — keys are event names, values are listener signatures. */
5
6
  export type EventMap = Record<string, (...args: never[]) => void>;
6
7
 
8
+ /** Options for subscribing to an event, including async dispatch mode. */
7
9
  export interface SubscribeOptions {
8
10
  /** When true, the handler is dispatched through the async handler path. */
9
11
  async?: boolean;
@@ -11,6 +13,7 @@ export interface SubscribeOptions {
11
13
 
12
14
  // ── Lifecycle events ────────────────────────────────────────────────
13
15
 
16
+ /** Payload emitted on `bus.emit.done` after synchronous handlers complete. */
14
17
  export interface EmitDoneDetail {
15
18
  event: string;
16
19
  syncCount: number;
@@ -20,18 +23,21 @@ export interface EmitDoneDetail {
20
23
  detail?: unknown;
21
24
  }
22
25
 
26
+ /** Payload emitted on `bus.handler.error` when a handler throws. */
23
27
  export interface HandlerErrorDetail {
24
28
  event: string;
25
29
  mode: 'sync' | 'async';
26
30
  error: string;
27
31
  }
28
32
 
33
+ /** Payload emitted on `bus.handler.async.enqueued` when async handlers are scheduled. */
29
34
  export interface AsyncEnqueuedDetail {
30
35
  event: string;
31
36
  jobId: string;
32
37
  handlerCount: number;
33
38
  }
34
39
 
40
+ /** Strongly-typed lifecycle events emitted by the event bus. */
35
41
  export type BusLifecycleEvents = {
36
42
  'bus.emit.done': (detail: EmitDoneDetail) => void;
37
43
  'bus.emit.noop': (detail: { event: string }) => void;
package/src/events.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Infrastructure-level event definitions for ts-infra observability.
3
+ *
4
+ * Mirrors the package-local pattern in `@gobing-ai/ts-ai-runner` (`events.ts`):
5
+ * only the events ts-infra components actually emit live here. Application-domain
6
+ * events (history import, HTTP server, …) belong to the consuming app, not this
7
+ * library. Process events belong to `@gobing-ai/ts-runtime` (the owner of
8
+ * `ProcessExecutor`) and are intentionally not re-exported here.
9
+ *
10
+ * Consume via `EventBus<InfraEvents>` or compose individual maps into a wider
11
+ * app event map.
12
+ */
13
+
14
+ import type { QueueStats } from './job-queue/types';
15
+
16
+ // ── Detail payloads ────────────────────────────────────────────────────────
17
+
18
+ /** Payload for `db.connection.error`. */
19
+ export interface DbConnectionErrorDetail {
20
+ /** Error message. */
21
+ error: string;
22
+ /** Adapter type (e.g. 'sqlite', 'd1'). */
23
+ adapter: string;
24
+ }
25
+
26
+ /** Payload for `queue.job.failed` — a job exhausted retries. */
27
+ export interface QueueJobFailedDetail {
28
+ jobId: string;
29
+ type: string;
30
+ error: string;
31
+ /** Attempt number (0-indexed, incremented after each failure). */
32
+ attempt: number;
33
+ }
34
+
35
+ /** Payload for `queue.job.retrying` — a job will be retried after backoff. */
36
+ export interface QueueJobRetryingDetail {
37
+ jobId: string;
38
+ type: string;
39
+ attempt: number;
40
+ /** When the next retry will fire (epoch ms). */
41
+ nextRetryAt: number;
42
+ }
43
+
44
+ /** Payload for `scheduler.job.executed`. */
45
+ export interface SchedulerJobExecutedDetail {
46
+ /** Job name. */
47
+ name: string;
48
+ /** Wall-clock duration in milliseconds. */
49
+ durationMs: number;
50
+ /** Error message if the job threw. */
51
+ error?: string;
52
+ }
53
+
54
+ /** Payload for `api.request.error`. */
55
+ export interface ApiRequestErrorDetail {
56
+ url: string;
57
+ method: string;
58
+ /** HTTP status code, if a response was received. */
59
+ status?: number;
60
+ error: string;
61
+ }
62
+
63
+ // ── Event maps ─────────────────────────────────────────────────────────────
64
+
65
+ /** Database lifecycle events emitted by DB-facing infra wiring. */
66
+ export type DbEvents = {
67
+ /** Database connection established. */
68
+ 'db.connected': () => void;
69
+ /** Database connection error. */
70
+ 'db.connection.error': (detail: DbConnectionErrorDetail) => void;
71
+ };
72
+
73
+ /** Job-queue lifecycle events emitted by the queue and its consumer. */
74
+ export type QueueEvents = {
75
+ /** A new job was enqueued. */
76
+ 'queue.job.enqueued': (detail: { jobId: string; type: string }) => void;
77
+ /** Consumer polling loop started. */
78
+ 'queue.consumer.started': () => void;
79
+ /** Consumer stopped. */
80
+ 'queue.consumer.stopped': () => void;
81
+ /** A job completed successfully. */
82
+ 'queue.job.completed': (detail: { jobId: string; type: string }) => void;
83
+ /** A job exhausted retries and is permanently failed. */
84
+ 'queue.job.failed': (detail: QueueJobFailedDetail) => void;
85
+ /** A job will be retried after backoff. */
86
+ 'queue.job.retrying': (detail: QueueJobRetryingDetail) => void;
87
+ /** Queue depth snapshot emitted on a poll cycle. */
88
+ 'queue.stats': (detail: QueueStats) => void;
89
+ };
90
+
91
+ /** Scheduler events emitted by scheduler adapters and the handler wrapper. */
92
+ export type SchedulerEvents = {
93
+ /** A scheduled job was executed (success or failure). */
94
+ 'scheduler.job.executed': (detail: SchedulerJobExecutedDetail) => void;
95
+ };
96
+
97
+ /** API-client events. */
98
+ export type ApiClientEvents = {
99
+ /** An API request failed (non-2xx or network error). */
100
+ 'api.request.error': (detail: ApiRequestErrorDetail) => void;
101
+ };
102
+
103
+ /**
104
+ * Aggregate of all infrastructure-level event maps ts-infra emits. Use with
105
+ * `EventBus<InfraEvents>`, or pick individual maps when composing a wider app
106
+ * event map.
107
+ */
108
+ export type InfraEvents = DbEvents & QueueEvents & SchedulerEvents & ApiClientEvents;
package/src/index.ts CHANGED
@@ -2,7 +2,31 @@
2
2
  export { APIClient, type APIClientConfig, APIError, type RequestOptions } from './api-client';
3
3
 
4
4
  // Event Bus
5
- export { EventBus, type EventMap, type SubscribeOptions } from './event-bus/index';
5
+ export {
6
+ attachDefaultObservers,
7
+ attachFileObserver,
8
+ attachLogObserver,
9
+ attachTelemetryObserver,
10
+ createLifecycleBus,
11
+ EventBus,
12
+ type EventMap,
13
+ type FileObserverWriter,
14
+ type SubscribeOptions,
15
+ } from './event-bus/index';
16
+
17
+ // Events (infrastructure-level event maps)
18
+ export type {
19
+ ApiClientEvents,
20
+ ApiRequestErrorDetail,
21
+ DbConnectionErrorDetail,
22
+ DbEvents,
23
+ InfraEvents,
24
+ QueueEvents,
25
+ QueueJobFailedDetail,
26
+ QueueJobRetryingDetail,
27
+ SchedulerEvents,
28
+ SchedulerJobExecutedDetail,
29
+ } from './events';
6
30
 
7
31
  export type {
8
32
  EnqueueOptions,
@@ -13,22 +37,35 @@ export type {
13
37
  QueueConsumerConfig,
14
38
  QueueStats,
15
39
  } from './job-queue/index';
16
- // Job Queue
17
- export { DBJobQueue, DBQueueConsumer } from './job-queue/index';
18
-
19
40
  // Logger
20
- export { getLogger, initializeLogger, type Logger, type LogLevel } from './logger';
41
+ export {
42
+ getLogger,
43
+ type InitLoggerOptions,
44
+ initializeLogger,
45
+ type Logger,
46
+ type LogLevel,
47
+ setLoggerMuted,
48
+ } from './logger';
21
49
 
22
50
  // Scheduler
23
51
  export {
24
- CloudflareSchedulerAdapter,
52
+ ActionRegistry,
53
+ type CreateDefaultRegistryOptions,
54
+ createDefaultRegistry,
25
55
  getSchedulerAdapter,
56
+ HealthPingAction,
57
+ type HealthPingWriter,
26
58
  initScheduler,
27
- NodeSchedulerAdapter,
59
+ LogAction,
28
60
  NoopSchedulerAdapter,
61
+ QueueStatsAction,
62
+ type QueueStatsDaoProvider,
29
63
  type ScheduledAction,
64
+ type SchedulerAction,
30
65
  type SchedulerAdapter,
31
66
  setSchedulerAdapter,
67
+ toScheduledAction,
68
+ wrapScheduledHandler,
32
69
  } from './scheduler/index';
33
70
 
34
71
  // Telemetry
@@ -5,6 +5,7 @@ import {
5
5
  getQueueJobFailedTotal,
6
6
  getQueueJobProcessingDuration,
7
7
  } from '../telemetry/metrics';
8
+ import { addSpanAttributes, traceAsync } from '../telemetry/tracing';
8
9
  import type { EnqueueOptions, Job, JobHandler, JobQueue, QueueConsumer, QueueConsumerConfig } from './types';
9
10
 
10
11
  /** DB-backed job queue implementation over `@gobing-ai/ts-db`'s `QueueJobDao`. */
@@ -84,28 +85,31 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
84
85
 
85
86
  /** Process one batch immediately. Useful for tests, schedulers, and manual drains. */
86
87
  async processOnce(): Promise<number> {
87
- await this.dao.resetStuckJobs(this.visibilityTimeout);
88
- await this.dao.failExpiredJobs();
89
-
90
- const jobs = await this.dao.claimReady(this.batchSize);
91
- let processed = 0;
92
-
93
- for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
94
- const batch = jobs.slice(index, index + this.maxConcurrency);
95
- await Promise.all(
96
- batch.map(async (job) => {
97
- this.inFlight += 1;
98
- try {
99
- await this.processJob(job);
100
- processed += 1;
101
- } finally {
102
- this.inFlight -= 1;
103
- }
104
- }),
105
- );
106
- }
107
-
108
- return processed;
88
+ return traceAsync('queue.poll', async () => {
89
+ await this.dao.resetStuckJobs(this.visibilityTimeout);
90
+ await this.dao.failExpiredJobs();
91
+
92
+ const jobs = await this.dao.claimReady(this.batchSize);
93
+ let processed = 0;
94
+
95
+ for (let index = 0; index < jobs.length; index += this.maxConcurrency) {
96
+ const batch = jobs.slice(index, index + this.maxConcurrency);
97
+ await Promise.all(
98
+ batch.map(async (job) => {
99
+ this.inFlight += 1;
100
+ try {
101
+ await this.processJob(job);
102
+ processed += 1;
103
+ } finally {
104
+ this.inFlight -= 1;
105
+ }
106
+ }),
107
+ );
108
+ }
109
+
110
+ addSpanAttributes({ 'queue.claimed': jobs.length, 'queue.processed': processed });
111
+ return processed;
112
+ });
109
113
  }
110
114
 
111
115
  private schedule(delay: number): void {
@@ -125,22 +129,30 @@ export class DBQueueConsumer<T = unknown> implements QueueConsumer<T> {
125
129
 
126
130
  private async processJob(record: QueueJobRecord): Promise<void> {
127
131
  const job = toJob<T>(record);
128
- const handler = this.handlers.get(job.type);
129
- if (handler === undefined) {
130
- await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`));
131
- return;
132
- }
133
-
134
- const startMs = performance.now();
135
- try {
136
- await handler(job);
137
- await this.dao.markCompleted(job.id);
138
- getQueueJobCompletedTotal().add(1, { type: job.type });
139
- getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
140
- } catch (error) {
141
- getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
142
- await this.failOrRetry(job, error);
143
- }
132
+ return traceAsync('queue.job.process', async () => {
133
+ addSpanAttributes({
134
+ 'queue.job_id': job.id,
135
+ 'queue.job_type': job.type,
136
+ 'queue.job_attempt': job.attempts,
137
+ });
138
+
139
+ const handler = this.handlers.get(job.type);
140
+ if (handler === undefined) {
141
+ await this.failOrRetry(job, new Error(`No handler registered for job type "${job.type}"`));
142
+ return;
143
+ }
144
+
145
+ const startMs = performance.now();
146
+ try {
147
+ await handler(job);
148
+ await this.dao.markCompleted(job.id);
149
+ getQueueJobCompletedTotal().add(1, { type: job.type });
150
+ getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
151
+ } catch (error) {
152
+ getQueueJobProcessingDuration().record(performance.now() - startMs, { type: job.type });
153
+ await this.failOrRetry(job, error);
154
+ }
155
+ });
144
156
  }
145
157
 
146
158
  private async failOrRetry(job: Job<T>, error: unknown): Promise<void> {
@@ -1,4 +1,3 @@
1
- export { DBJobQueue, DBQueueConsumer } from './db-job-queue';
2
1
  export type {
3
2
  EnqueueOptions,
4
3
  Job,
@@ -5,6 +5,7 @@
5
5
  * `DBJobQueue` and `DBQueueConsumer`.
6
6
  */
7
7
 
8
+ /** A queued job with status tracking, retry metadata, and timestamps. */
8
9
  export interface Job<T = unknown> {
9
10
  id: string;
10
11
  type: string;
@@ -19,20 +20,24 @@ export interface Job<T = unknown> {
19
20
  processingAt: number | null;
20
21
  }
21
22
 
23
+ /** Options for enqueuing a job: retry policy, delay, and TTL. */
22
24
  export interface EnqueueOptions {
23
25
  maxRetries?: number;
24
26
  delay?: number;
25
27
  ttlMs?: number;
26
28
  }
27
29
 
30
+ /** Producer interface for the job queue — enqueue single jobs or batches. */
28
31
  export interface JobQueue<T = unknown> {
29
32
  enqueue(type: string, payload: T, options?: EnqueueOptions): Promise<string>;
30
33
  enqueueBatch(jobs: Array<{ type: string; payload: T } & EnqueueOptions>): Promise<string[]>;
31
34
  stats(): Promise<QueueStats>;
32
35
  }
33
36
 
37
+ /** Async handler that processes a single job. */
34
38
  export type JobHandler<T = unknown> = (job: Job<T>) => Promise<void>;
35
39
 
40
+ /** Aggregate statistics for a job queue: counts by status. */
36
41
  export interface QueueStats {
37
42
  pending: number;
38
43
  processing: number;
@@ -40,6 +45,7 @@ export interface QueueStats {
40
45
  failed: number;
41
46
  }
42
47
 
48
+ /** Configuration for a queue consumer: polling, concurrency, and backoff. */
43
49
  export interface QueueConsumerConfig {
44
50
  pollInterval?: number;
45
51
  batchSize?: number;
@@ -50,6 +56,7 @@ export interface QueueConsumerConfig {
50
56
  drainTimeoutMs?: number;
51
57
  }
52
58
 
59
+ /** Consumer interface for the job queue — register handlers and control the processing loop. */
53
60
  export interface QueueConsumer<T = unknown> {
54
61
  register(type: string, handler: JobHandler<T>): void;
55
62
  start(): Promise<void>;
@@ -0,0 +1 @@
1
+ export { DBJobQueue, DBQueueConsumer } from './job-queue/db-job-queue';
package/src/logger.ts CHANGED
@@ -1,20 +1,45 @@
1
1
  /**
2
- * Structured JSON logger with levels (trace/debug/info/warn/error/fatal).
2
+ * Structured logger for ts-infra, backed by LogTape.
3
3
  *
4
- * No external dependencies console-based implementation.
4
+ * LogTape owns level filtering, sink routing, and formatting; this module
5
+ * adapts LogTape's template-string logger to the ts-infra `Logger` contract
6
+ * (`method(msg, data?)` + `child(context)`) so call-sites stay backend-agnostic.
7
+ *
8
+ * ADR-011: ts-infra must not touch `node:fs`/`process.stderr`. The console sink
9
+ * is LogTape's own (`getConsoleSink`); the file sink is supplied by the caller
10
+ * (typically `@gobing-ai/ts-runtime`, which owns FileSystem) via
11
+ * {@link InitLoggerOptions.fileSink}.
5
12
  */
6
13
 
14
+ import {
15
+ ConfigError,
16
+ configure,
17
+ getConsoleSink,
18
+ getJsonLinesFormatter,
19
+ getTextFormatter,
20
+ type LogRecord,
21
+ type Logger as LtLogger,
22
+ getLogger as ltGetLogger,
23
+ reset,
24
+ type Sink,
25
+ } from '@logtape/logtape';
26
+
27
+ /** Log severity levels in ascending order: trace → debug → info → warn → error → fatal. */
7
28
  export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
8
29
 
9
- const LEVEL_ORDER: Record<LogLevel, number> = {
10
- trace: 0,
11
- debug: 1,
12
- info: 2,
13
- warn: 3,
14
- error: 4,
15
- fatal: 5,
16
- };
30
+ /** Root category every ts-infra logger lives under, so one config rule covers them all. */
31
+ const ROOT_CATEGORY = 'app';
32
+
33
+ /** Map the ts-infra level name to LogTape's (`warn` → `warning`). */
34
+ function toLogTapeLevel(level: LogLevel): 'trace' | 'debug' | 'info' | 'warning' | 'error' | 'fatal' {
35
+ return level === 'warn' ? 'warning' : level;
36
+ }
17
37
 
38
+ /**
39
+ * Structured logger interface with level methods and context inheritance
40
+ * via {@link Logger.child}. `data` is attached as structured properties —
41
+ * `msg` is logged verbatim (no template substitution).
42
+ */
18
43
  export interface Logger {
19
44
  trace(msg: string, data?: Record<string, unknown>): void;
20
45
  debug(msg: string, data?: Record<string, unknown>): void;
@@ -25,99 +50,99 @@ export interface Logger {
25
50
  child(context: Record<string, unknown>): Logger;
26
51
  }
27
52
 
28
- class ConsoleLogger implements Logger {
29
- private readonly context: Record<string, unknown>;
30
-
31
- constructor(
32
- private readonly category: string,
33
- context: Record<string, unknown> = {},
34
- ) {
35
- this.context = { category, ...context };
36
- }
53
+ /** Module-level mute flag — LogTape has no global mute, so the adapter gates on it. */
54
+ let muted = false;
37
55
 
38
- private log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
39
- // Read the level dynamically so re-initialization affects cached loggers too.
40
- if (LEVEL_ORDER[level] < LEVEL_ORDER[globalLevel]) return;
41
- if (globalMuted) return;
42
-
43
- const entry = {
44
- level,
45
- message: msg,
46
- timestamp: new Date().toISOString(),
47
- ...this.context,
48
- ...data,
49
- };
56
+ /**
57
+ * Mute or unmute all logger output. Useful for tests. Gating happens in the
58
+ * adapter before delegating to LogTape, so muting is synchronous and global.
59
+ */
60
+ export function setLoggerMuted(value: boolean): void {
61
+ muted = value;
62
+ }
50
63
 
51
- const json = JSON.stringify(entry);
52
- switch (level) {
53
- case 'error':
54
- case 'fatal':
55
- console.error(json);
56
- break;
57
- case 'warn':
58
- console.warn(json);
59
- break;
60
- case 'debug':
61
- case 'trace':
62
- console.debug(json);
63
- break;
64
- default:
65
- console.log(json);
66
- }
67
- }
64
+ /** Adapter: ts-infra `Logger` over a LogTape logger. */
65
+ class LogTapeLogger implements Logger {
66
+ constructor(private readonly inner: LtLogger) {}
68
67
 
69
68
  trace(msg: string, data?: Record<string, unknown>): void {
70
- this.log('trace', msg, data);
69
+ if (!muted) this.inner.trace(msg, data ?? {});
71
70
  }
72
71
  debug(msg: string, data?: Record<string, unknown>): void {
73
- this.log('debug', msg, data);
72
+ if (!muted) this.inner.debug(msg, data ?? {});
74
73
  }
75
74
  info(msg: string, data?: Record<string, unknown>): void {
76
- this.log('info', msg, data);
75
+ if (!muted) this.inner.info(msg, data ?? {});
77
76
  }
78
77
  warn(msg: string, data?: Record<string, unknown>): void {
79
- this.log('warn', msg, data);
78
+ if (!muted) this.inner.warn(msg, data ?? {});
80
79
  }
81
80
  error(msg: string, data?: Record<string, unknown>): void {
82
- this.log('error', msg, data);
81
+ if (!muted) this.inner.error(msg, data ?? {});
83
82
  }
84
83
  fatal(msg: string, data?: Record<string, unknown>): void {
85
- this.log('fatal', msg, data);
84
+ if (!muted) this.inner.fatal(msg, data ?? {});
86
85
  }
87
86
 
88
87
  child(context: Record<string, unknown>): Logger {
89
- return new ConsoleLogger(this.category, { ...this.context, ...context });
88
+ return new LogTapeLogger(this.inner.with(context));
90
89
  }
91
90
  }
92
91
 
93
- const loggers = new Map<string, ConsoleLogger>();
94
-
95
- let globalLevel: LogLevel = 'info';
96
- let globalMuted = false;
97
-
98
- /**
99
- * Mute or unmute all logger console output. Useful for tests.
100
- */
101
- export function setLoggerMuted(muted: boolean): void {
102
- globalMuted = muted;
103
- }
104
-
105
92
  /**
106
- * Get or create a logger for the given category.
93
+ * Get a logger for the given category. Categories are nested under the
94
+ * `app` root so a single `initializeLogger` rule configures them all.
107
95
  */
108
96
  export function getLogger(category: string): Logger {
109
- const existing = loggers.get(category);
110
- if (existing) return existing;
97
+ return new LogTapeLogger(ltGetLogger([ROOT_CATEGORY, category]));
98
+ }
111
99
 
112
- const logger = new ConsoleLogger(category);
113
- loggers.set(category, logger);
114
- return logger;
100
+ /** Options for {@link initializeLogger}. */
101
+ export interface InitLoggerOptions {
102
+ /** Minimum level to emit. Default `info`. */
103
+ level?: LogLevel;
104
+ /** Enable the console sink. Default `true`. */
105
+ console?: boolean;
106
+ /**
107
+ * File sink writer. ts-infra never opens files itself (ADR-011); the
108
+ * caller (e.g. ts-runtime FileSystem) provides a writer that appends one
109
+ * already-formatted line per record. When omitted, no file sink is wired.
110
+ */
111
+ fileSink?: (line: string) => void;
112
+ /** Format records as JSON Lines (`true`) or human-readable text (`false`). Default `true`. */
113
+ json?: boolean;
115
114
  }
116
115
 
117
116
  /**
118
- * Initialize the logger subsystem with a minimum log level.
117
+ * Configure LogTape with console and/or file sinks. Call once at startup;
118
+ * safe to call again to reconfigure — the prior config is reset first so the
119
+ * latest call wins (LogTape throws `ConfigError` on double-configure otherwise).
119
120
  */
120
- export function initializeLogger(level: LogLevel = 'info'): void {
121
- globalLevel = level;
122
- loggers.clear();
121
+ export async function initializeLogger(options: InitLoggerOptions = {}): Promise<void> {
122
+ const { level = 'info', console: enableConsole = true, fileSink, json = true } = options;
123
+ const lowestLevel = toLogTapeLevel(level);
124
+ const formatter = json ? getJsonLinesFormatter() : getTextFormatter();
125
+
126
+ const sinks: Record<string, Sink> = {};
127
+ if (enableConsole) {
128
+ sinks.console = getConsoleSink({ formatter });
129
+ }
130
+ if (fileSink) {
131
+ sinks.file = (record: LogRecord) => {
132
+ fileSink(formatter(record));
133
+ };
134
+ }
135
+
136
+ await reset();
137
+ try {
138
+ await configure({
139
+ sinks,
140
+ loggers: [
141
+ { category: [ROOT_CATEGORY], lowestLevel, sinks: Object.keys(sinks) },
142
+ { category: ['logtape', 'meta'], lowestLevel: 'warning', sinks: [] },
143
+ ],
144
+ });
145
+ } catch (error) {
146
+ if (!(error instanceof ConfigError)) throw error;
147
+ }
123
148
  }