@geekmidas/services 1.0.3 → 1.1.0

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 (70) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +24 -0
  3. package/dist/{ServiceDiscovery-ykolgkIj.d.mts → ServiceDiscovery-BO8W-RY8.d.cts} +2 -2
  4. package/dist/{ServiceDiscovery-ykolgkIj.d.mts.map → ServiceDiscovery-BO8W-RY8.d.cts.map} +1 -1
  5. package/dist/{ServiceDiscovery-Dvqa-Q1_.d.cts → ServiceDiscovery-CRq_bGbi.d.mts} +2 -2
  6. package/dist/{ServiceDiscovery-Dvqa-Q1_.d.cts.map → ServiceDiscovery-CRq_bGbi.d.mts.map} +1 -1
  7. package/dist/{ServiceDiscovery-tZ6VKHIZ.mjs → ServiceDiscovery-DO_uDfuG.mjs} +2 -2
  8. package/dist/{ServiceDiscovery-tZ6VKHIZ.mjs.map → ServiceDiscovery-DO_uDfuG.mjs.map} +1 -1
  9. package/dist/{ServiceDiscovery-C5x1wcN1.cjs → ServiceDiscovery-m3XkNh32.cjs} +2 -2
  10. package/dist/{ServiceDiscovery-C5x1wcN1.cjs.map → ServiceDiscovery-m3XkNh32.cjs.map} +1 -1
  11. package/dist/{context-BpYagzpr.mjs → context-C9DCrFWA.mjs} +64 -4
  12. package/dist/context-C9DCrFWA.mjs.map +1 -0
  13. package/dist/{context-CaeISj3o.d.cts → context-CU-87vsM.d.mts} +2 -2
  14. package/dist/context-CU-87vsM.d.mts.map +1 -0
  15. package/dist/{context-CoyHq8lH.cjs → context-Cf2Ig5qr.cjs} +70 -4
  16. package/dist/context-Cf2Ig5qr.cjs.map +1 -0
  17. package/dist/{context-HGC2PJzv.d.mts → context-D2owD3ac.d.cts} +2 -2
  18. package/dist/context-D2owD3ac.d.cts.map +1 -0
  19. package/dist/context.cjs +1 -1
  20. package/dist/context.d.cts +2 -2
  21. package/dist/context.d.mts +2 -2
  22. package/dist/context.mjs +1 -1
  23. package/dist/index.cjs +2 -2
  24. package/dist/index.d.cts +3 -3
  25. package/dist/index.d.mts +3 -3
  26. package/dist/index.mjs +2 -2
  27. package/dist/middy.cjs +120 -0
  28. package/dist/middy.cjs.map +1 -0
  29. package/dist/middy.d.cts +146 -0
  30. package/dist/middy.d.cts.map +1 -0
  31. package/dist/middy.d.mts +146 -0
  32. package/dist/middy.d.mts.map +1 -0
  33. package/dist/middy.mjs +118 -0
  34. package/dist/middy.mjs.map +1 -0
  35. package/dist/trpc.cjs +66 -0
  36. package/dist/trpc.cjs.map +1 -0
  37. package/dist/trpc.d.cts +86 -0
  38. package/dist/trpc.d.cts.map +1 -0
  39. package/dist/trpc.d.mts +86 -0
  40. package/dist/trpc.d.mts.map +1 -0
  41. package/dist/trpc.mjs +65 -0
  42. package/dist/trpc.mjs.map +1 -0
  43. package/dist/{types-CcHmCx_U.d.mts → types-Bgk-0NOO.d.cts} +9 -2
  44. package/dist/types-Bgk-0NOO.d.cts.map +1 -0
  45. package/dist/{types-D7d_yeU5.d.cts → types-BoyOCa57.d.mts} +9 -2
  46. package/dist/types-BoyOCa57.d.mts.map +1 -0
  47. package/docs/request-scoped-logging.md +167 -0
  48. package/package.json +37 -1
  49. package/src/__tests__/context.spec.ts +180 -4
  50. package/src/__tests__/middy.spec.ts +218 -0
  51. package/src/__tests__/trpc.spec.ts +218 -0
  52. package/src/context.ts +104 -8
  53. package/src/middy.ts +226 -0
  54. package/src/trpc.ts +190 -0
  55. package/src/types.ts +7 -0
  56. package/tsdown.config.ts +11 -1
  57. package/dist/ServiceDiscovery.cjs +0 -4
  58. package/dist/ServiceDiscovery.d.cts +0 -3
  59. package/dist/ServiceDiscovery.d.mts +0 -3
  60. package/dist/ServiceDiscovery.mjs +0 -4
  61. package/dist/context-BpYagzpr.mjs.map +0 -1
  62. package/dist/context-CaeISj3o.d.cts.map +0 -1
  63. package/dist/context-CoyHq8lH.cjs.map +0 -1
  64. package/dist/context-HGC2PJzv.d.mts.map +0 -1
  65. package/dist/types-CcHmCx_U.d.mts.map +0 -1
  66. package/dist/types-D7d_yeU5.d.cts.map +0 -1
  67. package/dist/types.cjs +0 -0
  68. package/dist/types.d.cts +0 -2
  69. package/dist/types.d.mts +0 -2
  70. package/dist/types.mjs +0 -0
@@ -1,7 +1,40 @@
1
+ import type { Logger } from '@geekmidas/logger';
1
2
  import { ConsoleLogger } from '@geekmidas/logger/console';
2
- import { describe, expect, it } from 'vitest';
3
+ import { describe, expect, it, vi } from 'vitest';
3
4
  import { runWithRequestContext, serviceContext } from '../context';
4
5
 
6
+ /** Minimal spy logger whose `child()` returns itself for easy assertions. */
7
+ function makeSpyLogger(): Logger {
8
+ const logger: Logger = {
9
+ trace: vi.fn(),
10
+ debug: vi.fn(),
11
+ info: vi.fn(),
12
+ warn: vi.fn(),
13
+ error: vi.fn(),
14
+ fatal: vi.fn(),
15
+ child: vi.fn(() => logger),
16
+ };
17
+ return logger;
18
+ }
19
+
20
+ /**
21
+ * A logger that carries MORE than the base `Logger` interface: an extra method
22
+ * (`flush`) and a data property (`level`). Models a richer real-world logger
23
+ * (e.g. pino) so we can assert the request-scoped proxy forwards the full
24
+ * surface, not just the known log methods.
25
+ */
26
+ type ExtendedLogger = Logger & {
27
+ flush: ReturnType<typeof vi.fn>;
28
+ level: string;
29
+ };
30
+
31
+ function makeExtendedSpyLogger(level = 'info'): ExtendedLogger {
32
+ const logger = makeSpyLogger() as ExtendedLogger;
33
+ logger.flush = vi.fn();
34
+ logger.level = level;
35
+ return logger;
36
+ }
37
+
5
38
  describe('Request Context', () => {
6
39
  const logger = new ConsoleLogger({ app: 'test' });
7
40
 
@@ -28,13 +61,156 @@ describe('Request Context', () => {
28
61
  );
29
62
  });
30
63
 
31
- it('should return logger inside request context', async () => {
64
+ it('should delegate to the current request logger', async () => {
65
+ const requestLogger = makeSpyLogger();
32
66
  await runWithRequestContext(
33
- { logger, requestId: 'test-id', startTime: Date.now() },
67
+ {
68
+ logger: requestLogger,
69
+ requestId: 'test-id',
70
+ startTime: Date.now(),
71
+ },
34
72
  async () => {
35
- expect(serviceContext.getLogger()).toBe(logger);
73
+ serviceContext.getLogger().info('hello');
36
74
  },
37
75
  );
76
+ expect(requestLogger.info).toHaveBeenCalledWith('hello');
77
+ });
78
+
79
+ it('captured-once logger follows each request (singleton service fix)', async () => {
80
+ // Mimic a singleton service that grabs the logger ONCE (during its
81
+ // one-time register) and reuses that reference for every request.
82
+ let captured: Logger | undefined;
83
+ const handle = (requestLogger: Logger) =>
84
+ runWithRequestContext(
85
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
86
+ async () => {
87
+ captured ??= serviceContext.getLogger();
88
+ captured.info('handled');
89
+ },
90
+ );
91
+
92
+ const first = makeSpyLogger();
93
+ const second = makeSpyLogger();
94
+ await handle(first);
95
+ await handle(second);
96
+
97
+ // Before the fix, the captured logger stayed bound to `first`, so
98
+ // `second` never saw the call.
99
+ expect(first.info).toHaveBeenCalledTimes(1);
100
+ expect(second.info).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it('child loggers also follow the current request', async () => {
104
+ let capturedChild: Logger | undefined;
105
+ const handle = (requestLogger: Logger) =>
106
+ runWithRequestContext(
107
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
108
+ async () => {
109
+ capturedChild ??= serviceContext
110
+ .getLogger()
111
+ .child({ scope: 'svc' });
112
+ capturedChild.info('scoped');
113
+ },
114
+ );
115
+
116
+ const first = makeSpyLogger();
117
+ const second = makeSpyLogger();
118
+ await handle(first);
119
+ await handle(second);
120
+
121
+ expect(first.child).toHaveBeenCalledWith({ scope: 'svc' });
122
+ expect(second.child).toHaveBeenCalledWith({ scope: 'svc' });
123
+ expect(first.info).toHaveBeenCalledWith('scoped');
124
+ expect(second.info).toHaveBeenCalledWith('scoped');
125
+ });
126
+
127
+ describe('forwards the full logger surface (logger with more)', () => {
128
+ it('forwards an extra method beyond the Logger interface', async () => {
129
+ const requestLogger = makeExtendedSpyLogger();
130
+ await runWithRequestContext(
131
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
132
+ async () => {
133
+ (serviceContext.getLogger() as ExtendedLogger).flush();
134
+ },
135
+ );
136
+ expect(requestLogger.flush).toHaveBeenCalledTimes(1);
137
+ });
138
+
139
+ it('re-resolves an extra method per request when captured once', async () => {
140
+ let captured: ExtendedLogger | undefined;
141
+ const handle = (requestLogger: Logger) =>
142
+ runWithRequestContext(
143
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
144
+ async () => {
145
+ captured ??= serviceContext.getLogger() as ExtendedLogger;
146
+ captured.flush();
147
+ },
148
+ );
149
+
150
+ const first = makeExtendedSpyLogger();
151
+ const second = makeExtendedSpyLogger();
152
+ await handle(first);
153
+ await handle(second);
154
+
155
+ expect(first.flush).toHaveBeenCalledTimes(1);
156
+ expect(second.flush).toHaveBeenCalledTimes(1);
157
+ });
158
+
159
+ it('forwards a data property as the current request logger value', async () => {
160
+ const captureLevel = (requestLogger: Logger) =>
161
+ runWithRequestContext(
162
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
163
+ async () => (serviceContext.getLogger() as ExtendedLogger).level,
164
+ );
165
+
166
+ const debugLogger = makeExtendedSpyLogger('debug');
167
+ const warnLogger = makeExtendedSpyLogger('warn');
168
+
169
+ expect(await captureLevel(debugLogger)).toBe('debug');
170
+ expect(await captureLevel(warnLogger)).toBe('warn');
171
+ });
172
+
173
+ it('detached method reference still targets the current request', async () => {
174
+ const requestLogger = makeExtendedSpyLogger();
175
+ await runWithRequestContext(
176
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
177
+ async () => {
178
+ const { info } = serviceContext.getLogger();
179
+ info('detached');
180
+ },
181
+ );
182
+ expect(requestLogger.info).toHaveBeenCalledWith('detached');
183
+ });
184
+
185
+ it('reflects underlying membership via the `in` operator', async () => {
186
+ const requestLogger = makeExtendedSpyLogger();
187
+ await runWithRequestContext(
188
+ { logger: requestLogger, requestId: 'r', startTime: Date.now() },
189
+ async () => {
190
+ const proxy = serviceContext.getLogger();
191
+ expect('flush' in proxy).toBe(true);
192
+ expect('child' in proxy).toBe(true);
193
+ expect('nope' in proxy).toBe(false);
194
+ },
195
+ );
196
+ });
197
+
198
+ it('is not thenable (safe to return from async / await)', async () => {
199
+ await runWithRequestContext(
200
+ {
201
+ logger: makeExtendedSpyLogger(),
202
+ requestId: 'r',
203
+ startTime: Date.now(),
204
+ },
205
+ async () => {
206
+ const proxy = serviceContext.getLogger();
207
+ expect((proxy as { then?: unknown }).then).toBeUndefined();
208
+ // Awaiting a non-thenable yields the value itself rather than
209
+ // hanging or invoking a spurious `then`.
210
+ expect(await proxy).toBe(proxy);
211
+ },
212
+ );
213
+ });
38
214
  });
39
215
  });
40
216
 
@@ -0,0 +1,218 @@
1
+ import { EnvironmentParser } from '@geekmidas/envkit';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import middy from '@middy/core';
4
+ import type { Context } from 'aws-lambda';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { serviceContext } from '../context';
7
+ import { addServices, requestContext, withServices } from '../middy';
8
+ import { ServiceDiscovery } from '../ServiceDiscovery';
9
+ import type { Service } from '../types';
10
+
11
+ const createMockContext = (): Context =>
12
+ ({
13
+ functionName: 'standalone',
14
+ functionVersion: '1',
15
+ invokedFunctionArn:
16
+ 'arn:aws:lambda:us-east-1:123456789012:function:standalone',
17
+ memoryLimitInMB: '128',
18
+ awsRequestId: 'aws-req-1',
19
+ logGroupName: '/aws/lambda/standalone',
20
+ logStreamName: 'stream',
21
+ getRemainingTimeInMillis: () => 30000,
22
+ done: vi.fn(),
23
+ fail: vi.fn(),
24
+ succeed: vi.fn(),
25
+ callbackWaitsForEmptyEventLoop: true,
26
+ }) as unknown as Context;
27
+
28
+ /**
29
+ * Invoke a Middy-wrapped handler with a mock Lambda context. The service
30
+ * middlewares augment the context type to require `services`, but at runtime
31
+ * they populate it before the handler runs — so the mock starts without it and
32
+ * we invoke through a loosely-typed shim.
33
+ */
34
+ const invoke = (
35
+ handler: (event: any, context: any) => any,
36
+ event: unknown = {},
37
+ ): Promise<unknown> => Promise.resolve(handler(event, createMockContext()));
38
+
39
+ function makeSpyLogger(): Logger {
40
+ const logger: Logger = {
41
+ trace: vi.fn(),
42
+ debug: vi.fn(),
43
+ info: vi.fn(),
44
+ warn: vi.fn(),
45
+ error: vi.fn(),
46
+ fatal: vi.fn(),
47
+ child: vi.fn(() => logger),
48
+ };
49
+ return logger;
50
+ }
51
+
52
+ interface Greeter {
53
+ greet(): string;
54
+ registeredRequestId: string;
55
+ }
56
+
57
+ class GreeterService implements Service<'greeter', Greeter> {
58
+ serviceName = 'greeter' as const;
59
+ async register(): Promise<Greeter> {
60
+ // Service code can read the request context during registration.
61
+ const registeredRequestId = serviceContext.getRequestId();
62
+ return {
63
+ registeredRequestId,
64
+ greet() {
65
+ serviceContext.getLogger().info('greeting');
66
+ return 'hello';
67
+ },
68
+ };
69
+ }
70
+ }
71
+
72
+ interface Counter {
73
+ next(): number;
74
+ }
75
+
76
+ class CounterService implements Service<'counter', Counter> {
77
+ serviceName = 'counter' as const;
78
+ async register(): Promise<Counter> {
79
+ let n = 0;
80
+ return { next: () => ++n };
81
+ }
82
+ }
83
+
84
+ describe('middy adaptor', () => {
85
+ beforeEach(() => {
86
+ // ServiceDiscovery is a process-wide singleton that caches resolved
87
+ // instances; reset so each test starts clean.
88
+ ServiceDiscovery.reset();
89
+ });
90
+
91
+ describe('requestContext', () => {
92
+ it('makes serviceContext available inside a plain handler', async () => {
93
+ const logger = makeSpyLogger();
94
+ let seenRequestId: string | undefined;
95
+ let hadContext = false;
96
+
97
+ const handler = middy(async () => {
98
+ hadContext = serviceContext.hasContext();
99
+ seenRequestId = serviceContext.getRequestId();
100
+ serviceContext.getLogger().info('tick');
101
+ }).use(requestContext({ logger }));
102
+
103
+ await invoke(handler);
104
+
105
+ expect(hadContext).toBe(true);
106
+ expect(seenRequestId).toBe('aws-req-1');
107
+ expect(logger.info).toHaveBeenCalledWith('tick');
108
+ });
109
+
110
+ it('gives each invocation its own fresh context', async () => {
111
+ const seen: string[] = [];
112
+ let counter = 0;
113
+ const handler = middy(async () => {
114
+ seen.push(serviceContext.getRequestId());
115
+ }).use(
116
+ requestContext({
117
+ logger: makeSpyLogger(),
118
+ getRequestId: () => `req-${++counter}`,
119
+ }),
120
+ );
121
+
122
+ await invoke(handler);
123
+ await invoke(handler);
124
+
125
+ // The second invocation must not inherit the first's request id.
126
+ expect(seen).toEqual(['req-1', 'req-2']);
127
+ });
128
+
129
+ it('propagates handler errors', async () => {
130
+ const handler = middy(async () => {
131
+ throw new Error('boom');
132
+ }).use(requestContext({ logger: makeSpyLogger() }));
133
+
134
+ await expect(invoke(handler)).rejects.toThrow('boom');
135
+ });
136
+
137
+ it('derives the request id via getRequestId override', async () => {
138
+ let seen: string | undefined;
139
+ const handler = middy(async () => {
140
+ seen = serviceContext.getRequestId();
141
+ }).use(
142
+ requestContext({
143
+ logger: makeSpyLogger(),
144
+ getRequestId: () => 'custom-id',
145
+ }),
146
+ );
147
+
148
+ await invoke(handler);
149
+ expect(seen).toBe('custom-id');
150
+ });
151
+ });
152
+
153
+ describe('addServices', () => {
154
+ it('resolves services onto event.services (paired with requestContext)', async () => {
155
+ const envParser = new EnvironmentParser({});
156
+ const logger = makeSpyLogger();
157
+ let greeting: string | undefined;
158
+ let counted: number | undefined;
159
+ let registeredRequestId: string | undefined;
160
+
161
+ const handler = middy(async (event: { services: any }) => {
162
+ registeredRequestId = event.services.greeter.registeredRequestId;
163
+ greeting = event.services.greeter.greet();
164
+ counted = event.services.counter.next();
165
+ })
166
+ .use(requestContext({ logger, getRequestId: () => 'shared-id' }))
167
+ .use(
168
+ addServices([new GreeterService(), new CounterService()], {
169
+ envParser,
170
+ }),
171
+ );
172
+
173
+ await invoke(handler);
174
+
175
+ expect(greeting).toBe('hello');
176
+ expect(counted).toBe(1);
177
+ // register() and the service method ran inside the requestContext.
178
+ expect(registeredRequestId).toBe('shared-id');
179
+ expect(logger.info).toHaveBeenCalledWith('greeting');
180
+ });
181
+
182
+ it('resolves context-free services without a requestContext', async () => {
183
+ const envParser = new EnvironmentParser({});
184
+ let counted: number | undefined;
185
+
186
+ const handler = middy(async (event: { services: any }) => {
187
+ counted = event.services.counter.next();
188
+ }).use(addServices([new CounterService()], { envParser }));
189
+
190
+ await invoke(handler);
191
+
192
+ expect(counted).toBe(1);
193
+ });
194
+ });
195
+
196
+ describe('withServices', () => {
197
+ it('establishes context and resolves services out of the box', async () => {
198
+ const envParser = new EnvironmentParser({});
199
+ let greeting: string | undefined;
200
+ let registeredRequestId: string | undefined;
201
+
202
+ const handler = middy(async (event: { services: any }) => {
203
+ registeredRequestId = event.services.greeter.registeredRequestId;
204
+ greeting = event.services.greeter.greet();
205
+ }).use(
206
+ withServices([new GreeterService()], {
207
+ logger: makeSpyLogger(),
208
+ envParser,
209
+ }),
210
+ );
211
+
212
+ await invoke(handler);
213
+
214
+ expect(greeting).toBe('hello');
215
+ expect(registeredRequestId).toBe('aws-req-1');
216
+ });
217
+ });
218
+ });
@@ -0,0 +1,218 @@
1
+ import { EnvironmentParser } from '@geekmidas/envkit';
2
+ import type { Logger } from '@geekmidas/logger';
3
+ import { initTRPC } from '@trpc/server';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { serviceContext } from '../context';
6
+ import { ServiceDiscovery } from '../ServiceDiscovery';
7
+ import {
8
+ createRequestContextMiddleware,
9
+ createServicesMiddleware,
10
+ } from '../trpc';
11
+ import type { Service } from '../types';
12
+
13
+ beforeEach(() => {
14
+ // ServiceDiscovery is a process-wide singleton that caches resolved
15
+ // instances. Reset so each test gets a fresh registry.
16
+ ServiceDiscovery.reset();
17
+ });
18
+
19
+ function makeLogger(): Logger {
20
+ const logger: Logger = {
21
+ debug: vi.fn(),
22
+ info: vi.fn(),
23
+ warn: vi.fn(),
24
+ error: vi.fn(),
25
+ fatal: vi.fn(),
26
+ trace: vi.fn(),
27
+ child: vi.fn(() => logger),
28
+ };
29
+ return logger;
30
+ }
31
+
32
+ describe('createServicesMiddleware (with envParser)', () => {
33
+ it('resolves services and merges them onto ctx', async () => {
34
+ const databaseService = {
35
+ serviceName: 'database' as const,
36
+ register: () => ({ query: (sql: string) => `result: ${sql}` }),
37
+ } satisfies Service<'database', { query: (sql: string) => string }>;
38
+
39
+ const t = initTRPC.context<{ logger: Logger }>().create();
40
+ const withServices = createServicesMiddleware(
41
+ t.middleware,
42
+ new EnvironmentParser({}),
43
+ );
44
+
45
+ const caller = t.router({
46
+ run: t.procedure
47
+ .use(withServices([databaseService]))
48
+ .query(({ ctx }) => ctx.database.query('select 1')),
49
+ });
50
+
51
+ const result = await caller.createCaller({ logger: makeLogger() }).run();
52
+
53
+ expect(result).toBe('result: select 1');
54
+ });
55
+
56
+ it('makes serviceContext.getLogger() readable from inside a service method', async () => {
57
+ let observedLogger: Logger | null = null;
58
+ const dbService = {
59
+ serviceName: 'database' as const,
60
+ register: () => ({
61
+ touch() {
62
+ observedLogger = serviceContext.getLogger();
63
+ observedLogger.info('touched');
64
+ return 'ok';
65
+ },
66
+ }),
67
+ } satisfies Service<'database', { touch: () => string }>;
68
+
69
+ const t = initTRPC.context<{ logger: Logger }>().create();
70
+ const withServices = createServicesMiddleware(
71
+ t.middleware,
72
+ new EnvironmentParser({}),
73
+ );
74
+
75
+ const requestLogger = makeLogger();
76
+
77
+ const caller = t.router({
78
+ run: t.procedure
79
+ .use(withServices([dbService]))
80
+ .query(({ ctx }) => ctx.database.touch()),
81
+ });
82
+
83
+ await caller.createCaller({ logger: requestLogger }).run();
84
+
85
+ // getLogger() returns a request-scoped proxy (not the raw logger), so we
86
+ // assert it delegates to the request logger rather than checking identity.
87
+ expect(observedLogger).not.toBeNull();
88
+ expect(requestLogger.info).toHaveBeenCalledWith('touched');
89
+ });
90
+
91
+ it('throws cleanly when service code runs outside the procedure', () => {
92
+ // Sanity check that serviceContext stays scoped to the procedure call —
93
+ // reads outside any procedure must throw.
94
+ expect(() => serviceContext.getLogger()).toThrow();
95
+ });
96
+ });
97
+
98
+ describe('createServicesMiddleware (context-supplied discovery)', () => {
99
+ it('uses ctx.serviceDiscovery when no envParser is provided', async () => {
100
+ const cacheService = {
101
+ serviceName: 'cache' as const,
102
+ register: () => ({ get: (key: string) => `cached:${key}` }),
103
+ } satisfies Service<'cache', { get: (key: string) => string }>;
104
+
105
+ type Ctx = { logger: Logger; serviceDiscovery: ServiceDiscovery };
106
+ const t = initTRPC.context<Ctx>().create();
107
+ const withServices = createServicesMiddleware<Ctx, object>(t.middleware);
108
+
109
+ const caller = t.router({
110
+ run: t.procedure
111
+ .use(withServices([cacheService]))
112
+ .query(({ ctx }) => ctx.cache.get('k')),
113
+ });
114
+
115
+ const discovery = ServiceDiscovery.getInstance(new EnvironmentParser({}));
116
+
117
+ const result = await caller
118
+ .createCaller({ logger: makeLogger(), serviceDiscovery: discovery })
119
+ .run();
120
+
121
+ expect(result).toBe('cached:k');
122
+ });
123
+ });
124
+
125
+ describe('createRequestContextMiddleware', () => {
126
+ it('exposes ctx.logger via serviceContext.getLogger() for the handler', async () => {
127
+ const t = initTRPC.context<{ logger: Logger }>().create();
128
+ const withRequestContext = createRequestContextMiddleware(t.middleware);
129
+
130
+ let observed: Logger | null = null;
131
+ const caller = t.router({
132
+ run: t.procedure.use(withRequestContext).query(() => {
133
+ observed = serviceContext.getLogger();
134
+ observed.info('handled');
135
+ return 'done';
136
+ }),
137
+ });
138
+
139
+ const requestLogger = makeLogger();
140
+ const result = await caller.createCaller({ logger: requestLogger }).run();
141
+
142
+ expect(result).toBe('done');
143
+ // The request-scoped proxy delegates to ctx.logger.
144
+ expect(observed).not.toBeNull();
145
+ expect(requestLogger.info).toHaveBeenCalledWith('handled');
146
+ });
147
+
148
+ it('auto-generates requestId and startTime when ctx does not provide them', async () => {
149
+ const t = initTRPC.context<{ logger: Logger }>().create();
150
+ const withRequestContext = createRequestContextMiddleware(t.middleware);
151
+
152
+ let requestId: string | null = null;
153
+ let startTime: number | null = null;
154
+
155
+ const caller = t.router({
156
+ run: t.procedure.use(withRequestContext).query(() => {
157
+ requestId = serviceContext.getRequestId();
158
+ startTime = serviceContext.getRequestStartTime();
159
+ return 'ok';
160
+ }),
161
+ });
162
+
163
+ await caller.createCaller({ logger: makeLogger() }).run();
164
+
165
+ expect(requestId).toBeTypeOf('string');
166
+ expect(requestId!.length).toBeGreaterThan(0);
167
+ expect(typeof startTime).toBe('number');
168
+ });
169
+
170
+ it('honors caller-supplied requestId and startTime when present on ctx', async () => {
171
+ type Ctx = { logger: Logger; requestId?: string; startTime?: number };
172
+ const t = initTRPC.context<Ctx>().create();
173
+ const withRequestContext = createRequestContextMiddleware(t.middleware);
174
+
175
+ let observedId: string | null = null;
176
+ let observedStart: number | null = null;
177
+
178
+ const caller = t.router({
179
+ run: t.procedure.use(withRequestContext).query(() => {
180
+ observedId = serviceContext.getRequestId();
181
+ observedStart = serviceContext.getRequestStartTime();
182
+ return 'ok';
183
+ }),
184
+ });
185
+
186
+ await caller
187
+ .createCaller({
188
+ logger: makeLogger(),
189
+ requestId: 'req_abc',
190
+ startTime: 12345,
191
+ })
192
+ .run();
193
+
194
+ expect(observedId).toBe('req_abc');
195
+ expect(observedStart).toBe(12345);
196
+ });
197
+ });
198
+
199
+ describe('tagging for tooling', () => {
200
+ it('attaches the resolved services tuple to the inner middleware', () => {
201
+ const svc = {
202
+ serviceName: 'sample' as const,
203
+ register: () => ({}),
204
+ } satisfies Service<'sample', {}>;
205
+
206
+ const t = initTRPC.context<{ logger: Logger }>().create();
207
+ const withServices = createServicesMiddleware(
208
+ t.middleware,
209
+ new EnvironmentParser({}),
210
+ );
211
+
212
+ const builder = withServices([svc]);
213
+ const middlewares = (
214
+ builder as unknown as { _middlewares: Array<{ _services?: Service[] }> }
215
+ )._middlewares;
216
+ expect(middlewares?.at(-1)?._services).toEqual([svc]);
217
+ });
218
+ });