@open-mercato/shared 0.6.6-develop.6460.1.f94c74174c → 0.6.6-develop.6471.1.9ab5bdd79a

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 (164) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -0
  3. package/dist/lib/commands/command-bus.js +4 -2
  4. package/dist/lib/commands/command-bus.js.map +2 -2
  5. package/dist/lib/commands/command-interceptor-runner.js +4 -2
  6. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  7. package/dist/lib/commands/flush.js +3 -4
  8. package/dist/lib/commands/flush.js.map +2 -2
  9. package/dist/lib/commands/helpers.js +3 -1
  10. package/dist/lib/commands/helpers.js.map +2 -2
  11. package/dist/lib/commands/registry.js +5 -3
  12. package/dist/lib/commands/registry.js.map +2 -2
  13. package/dist/lib/commands/scope.js +5 -3
  14. package/dist/lib/commands/scope.js.map +2 -2
  15. package/dist/lib/crud/advanced-filter-integration.js +3 -1
  16. package/dist/lib/crud/advanced-filter-integration.js.map +2 -2
  17. package/dist/lib/crud/cache.js +3 -1
  18. package/dist/lib/crud/cache.js.map +2 -2
  19. package/dist/lib/crud/custom-fields.js +4 -2
  20. package/dist/lib/crud/custom-fields.js.map +2 -2
  21. package/dist/lib/crud/enricher-runner.js +6 -10
  22. package/dist/lib/crud/enricher-runner.js.map +2 -2
  23. package/dist/lib/crud/factory.js +10 -8
  24. package/dist/lib/crud/factory.js.map +2 -2
  25. package/dist/lib/crud/interceptor-registry.js +3 -3
  26. package/dist/lib/crud/interceptor-registry.js.map +2 -2
  27. package/dist/lib/crud/mutation-guard.js +4 -2
  28. package/dist/lib/crud/mutation-guard.js.map +2 -2
  29. package/dist/lib/crud/optimistic-lock.js +5 -6
  30. package/dist/lib/crud/optimistic-lock.js.map +2 -2
  31. package/dist/lib/crud/route-mutation-guard.js +3 -1
  32. package/dist/lib/crud/route-mutation-guard.js.map +2 -2
  33. package/dist/lib/crud/sync-event-runner.js +3 -1
  34. package/dist/lib/crud/sync-event-runner.js.map +2 -2
  35. package/dist/lib/data/engine.js +5 -5
  36. package/dist/lib/data/engine.js.map +2 -2
  37. package/dist/lib/db/mikro.js +5 -3
  38. package/dist/lib/db/mikro.js.map +2 -2
  39. package/dist/lib/di/container.js +3 -1
  40. package/dist/lib/di/container.js.map +2 -2
  41. package/dist/lib/encryption/aes.js +3 -1
  42. package/dist/lib/encryption/aes.js.map +2 -2
  43. package/dist/lib/encryption/entityFields.js +3 -1
  44. package/dist/lib/encryption/entityFields.js.map +2 -2
  45. package/dist/lib/encryption/entityIds.js +3 -1
  46. package/dist/lib/encryption/entityIds.js.map +2 -2
  47. package/dist/lib/encryption/kms.js +25 -32
  48. package/dist/lib/encryption/kms.js.map +2 -2
  49. package/dist/lib/encryption/subscriber.js +3 -1
  50. package/dist/lib/encryption/subscriber.js.map +2 -2
  51. package/dist/lib/encryption/tenantDataEncryptionService.js +3 -1
  52. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  53. package/dist/lib/indexers/error-log.js +4 -2
  54. package/dist/lib/indexers/error-log.js.map +2 -2
  55. package/dist/lib/indexers/status-log.js +5 -3
  56. package/dist/lib/indexers/status-log.js.map +2 -2
  57. package/dist/lib/logger/index.js +28 -0
  58. package/dist/lib/logger/index.js.map +7 -0
  59. package/dist/lib/logger/level.js +43 -0
  60. package/dist/lib/logger/level.js.map +7 -0
  61. package/dist/lib/logger/transport.console.js +39 -0
  62. package/dist/lib/logger/transport.console.js.map +7 -0
  63. package/dist/lib/logger/transport.js +17 -0
  64. package/dist/lib/logger/transport.js.map +7 -0
  65. package/dist/lib/logger/transport.pretty.js +85 -0
  66. package/dist/lib/logger/transport.pretty.js.map +7 -0
  67. package/dist/lib/logger/transport.server.js +85 -0
  68. package/dist/lib/logger/transport.server.js.map +7 -0
  69. package/dist/lib/middleware/page-executor.js +3 -1
  70. package/dist/lib/middleware/page-executor.js.map +2 -2
  71. package/dist/lib/modules/registry.js +3 -1
  72. package/dist/lib/modules/registry.js.map +2 -2
  73. package/dist/lib/profiler/index.js +3 -2
  74. package/dist/lib/profiler/index.js.map +2 -2
  75. package/dist/lib/query/engine.js +5 -6
  76. package/dist/lib/query/engine.js.map +2 -2
  77. package/dist/lib/query/query-extension-runner.js +5 -11
  78. package/dist/lib/query/query-extension-runner.js.map +2 -2
  79. package/dist/lib/redis/connection.js +3 -1
  80. package/dist/lib/redis/connection.js.map +2 -2
  81. package/dist/lib/url.js +7 -5
  82. package/dist/lib/url.js.map +2 -2
  83. package/dist/lib/version.js +1 -1
  84. package/dist/lib/version.js.map +1 -1
  85. package/dist/modules/analytics.js +3 -1
  86. package/dist/modules/analytics.js.map +2 -2
  87. package/dist/modules/dashboard/widgets.js +3 -1
  88. package/dist/modules/dashboard/widgets.js.map +2 -2
  89. package/dist/modules/events/factory.js +5 -3
  90. package/dist/modules/events/factory.js.map +2 -2
  91. package/dist/modules/overrides.js +16 -34
  92. package/dist/modules/overrides.js.map +2 -2
  93. package/dist/modules/registry.js +3 -1
  94. package/dist/modules/registry.js.map +2 -2
  95. package/dist/modules/search.js +3 -1
  96. package/dist/modules/search.js.map +2 -2
  97. package/dist/modules/widgets/injection-loader.js +6 -6
  98. package/dist/modules/widgets/injection-loader.js.map +2 -2
  99. package/dist/modules/widgets/injection-position.js +3 -1
  100. package/dist/modules/widgets/injection-position.js.map +2 -2
  101. package/package.json +7 -2
  102. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +19 -6
  103. package/src/lib/commands/__tests__/flush.test.ts +24 -15
  104. package/src/lib/commands/__tests__/normalizeAuthorUserId.test.ts +14 -2
  105. package/src/lib/commands/__tests__/registry.test.ts +17 -2
  106. package/src/lib/commands/__tests__/scope.test.ts +21 -9
  107. package/src/lib/commands/command-bus.ts +5 -2
  108. package/src/lib/commands/command-interceptor-runner.ts +5 -2
  109. package/src/lib/commands/flush.ts +4 -5
  110. package/src/lib/commands/helpers.ts +9 -2
  111. package/src/lib/commands/registry.ts +6 -3
  112. package/src/lib/commands/scope.ts +6 -3
  113. package/src/lib/crud/__tests__/optimistic-lock.test.ts +21 -6
  114. package/src/lib/crud/__tests__/route-mutation-guard.test.ts +17 -2
  115. package/src/lib/crud/__tests__/sync-event-runner.test.ts +17 -3
  116. package/src/lib/crud/advanced-filter-integration.ts +4 -1
  117. package/src/lib/crud/cache.ts +4 -1
  118. package/src/lib/crud/custom-fields.ts +5 -2
  119. package/src/lib/crud/enricher-runner.ts +7 -10
  120. package/src/lib/crud/factory.ts +11 -10
  121. package/src/lib/crud/interceptor-registry.ts +4 -3
  122. package/src/lib/crud/mutation-guard.ts +5 -2
  123. package/src/lib/crud/optimistic-lock.ts +6 -10
  124. package/src/lib/crud/route-mutation-guard.ts +4 -1
  125. package/src/lib/crud/sync-event-runner.ts +4 -1
  126. package/src/lib/data/__tests__/engine.event-validation.test.ts +26 -15
  127. package/src/lib/data/engine.ts +6 -6
  128. package/src/lib/db/mikro.ts +6 -3
  129. package/src/lib/di/container.ts +4 -1
  130. package/src/lib/encryption/aes.ts +4 -2
  131. package/src/lib/encryption/entityFields.ts +5 -1
  132. package/src/lib/encryption/entityIds.ts +4 -1
  133. package/src/lib/encryption/kms.ts +27 -33
  134. package/src/lib/encryption/subscriber.ts +4 -2
  135. package/src/lib/encryption/tenantDataEncryptionService.ts +4 -2
  136. package/src/lib/indexers/__tests__/status-log.test.ts +22 -10
  137. package/src/lib/indexers/error-log.ts +5 -2
  138. package/src/lib/indexers/status-log.ts +6 -3
  139. package/src/lib/logger/__tests__/logger.test.ts +539 -0
  140. package/src/lib/logger/index.ts +33 -0
  141. package/src/lib/logger/level.ts +50 -0
  142. package/src/lib/logger/transport.console.ts +41 -0
  143. package/src/lib/logger/transport.pretty.ts +94 -0
  144. package/src/lib/logger/transport.server.ts +125 -0
  145. package/src/lib/logger/transport.ts +17 -0
  146. package/src/lib/middleware/page-executor.ts +4 -1
  147. package/src/lib/modules/__tests__/registry.test.ts +28 -21
  148. package/src/lib/modules/registry.ts +4 -1
  149. package/src/lib/profiler/index.ts +4 -2
  150. package/src/lib/query/__tests__/query-extension-runner.test.ts +21 -7
  151. package/src/lib/query/engine.ts +7 -10
  152. package/src/lib/query/query-extension-runner.ts +6 -11
  153. package/src/lib/redis/connection.ts +5 -1
  154. package/src/lib/url.ts +10 -5
  155. package/src/modules/__tests__/overrides.test.ts +17 -3
  156. package/src/modules/__tests__/route-overrides.test.ts +29 -20
  157. package/src/modules/analytics.ts +5 -1
  158. package/src/modules/dashboard/widgets.ts +4 -1
  159. package/src/modules/events/factory.ts +6 -3
  160. package/src/modules/overrides.ts +18 -34
  161. package/src/modules/registry.ts +4 -1
  162. package/src/modules/search.ts +4 -1
  163. package/src/modules/widgets/injection-loader.ts +7 -6
  164. package/src/modules/widgets/injection-position.ts +5 -1
@@ -0,0 +1,539 @@
1
+ import pino from 'pino'
2
+ import {
3
+ createLogger,
4
+ getLogLevel,
5
+ isLevelEnabled,
6
+ resetLogLevelCache,
7
+ resetLoggerRegistry,
8
+ } from '../index'
9
+ import { resolveLevel, type LogLevel } from '../level'
10
+ import { createConsoleLogger } from '../transport.console'
11
+ import { createPrettyLogger, resetLogPrettyCache, resolvePrettyMode } from '../transport.pretty'
12
+ import { resetServerLoggerCache } from '../transport.server'
13
+
14
+ type FakePinoCall = { level: LogLevel; args: unknown[] }
15
+
16
+ type FakePinoHarness = {
17
+ factory: (options: Record<string, unknown>, destination?: unknown) => unknown
18
+ calls: FakePinoCall[]
19
+ childBindings: Record<string, unknown>[]
20
+ options: () => Record<string, unknown> | null
21
+ destination: () => unknown
22
+ }
23
+
24
+ function createFakePinoHarness(): FakePinoHarness {
25
+ const calls: FakePinoCall[] = []
26
+ const childBindings: Record<string, unknown>[] = []
27
+ let recordedOptions: Record<string, unknown> | null = null
28
+ const makeInstance = (): Record<string, unknown> => ({
29
+ debug: (...args: unknown[]) => calls.push({ level: 'debug', args }),
30
+ info: (...args: unknown[]) => calls.push({ level: 'info', args }),
31
+ warn: (...args: unknown[]) => calls.push({ level: 'warn', args }),
32
+ error: (...args: unknown[]) => calls.push({ level: 'error', args }),
33
+ child: (bindings: Record<string, unknown>) => {
34
+ childBindings.push(bindings)
35
+ return makeInstance()
36
+ },
37
+ })
38
+ let recordedDestination: unknown
39
+ return {
40
+ factory: (options, destination) => {
41
+ recordedOptions = options
42
+ recordedDestination = destination
43
+ return makeInstance()
44
+ },
45
+ calls,
46
+ childBindings,
47
+ options: () => recordedOptions,
48
+ destination: () => recordedDestination,
49
+ }
50
+ }
51
+
52
+ const TRACKED_ENV_KEYS = ['OM_LOG_LEVEL', 'NODE_ENV', 'NEXT_RUNTIME', 'OM_LOG_DESTINATION', 'OM_LOG_PRETTY'] as const
53
+
54
+ const originalGetBuiltinModule = process.getBuiltinModule.bind(process)
55
+
56
+ function mockPinoLoader(factory: FakePinoHarness['factory']): jest.SpyInstance {
57
+ return jest.spyOn(process, 'getBuiltinModule').mockImplementation(((id: string) => {
58
+ if (id !== 'node:module') return originalGetBuiltinModule(id)
59
+ return { createRequire: () => () => factory }
60
+ }) as typeof process.getBuiltinModule)
61
+ }
62
+
63
+ function resetLoggerState(): void {
64
+ resetLogLevelCache()
65
+ resetLoggerRegistry()
66
+ resetServerLoggerCache()
67
+ resetLogPrettyCache()
68
+ }
69
+
70
+ function forcePinoTransport(): void {
71
+ process.env.OM_LOG_PRETTY = 'false'
72
+ resetLogPrettyCache()
73
+ resetLoggerRegistry()
74
+ }
75
+
76
+ describe('structured logging facade', () => {
77
+ let savedEnv: Record<string, string | undefined>
78
+
79
+ beforeEach(() => {
80
+ savedEnv = Object.fromEntries(TRACKED_ENV_KEYS.map((key) => [key, process.env[key]]))
81
+ resetLoggerState()
82
+ })
83
+
84
+ afterEach(() => {
85
+ for (const key of TRACKED_ENV_KEYS) {
86
+ const value = savedEnv[key]
87
+ if (value === undefined) delete process.env[key]
88
+ else process.env[key] = value
89
+ }
90
+ delete (globalThis as { window?: unknown }).window
91
+ jest.restoreAllMocks()
92
+ resetLoggerState()
93
+ })
94
+
95
+ describe('level resolution', () => {
96
+ it.each<[string, LogLevel]>([
97
+ ['debug', 'debug'],
98
+ ['info', 'info'],
99
+ ['warn', 'warn'],
100
+ ['error', 'error'],
101
+ ['INFO', 'info'],
102
+ [' Warn ', 'warn'],
103
+ ['ERROR', 'error'],
104
+ ])('resolves OM_LOG_LEVEL=%s to %s', (raw, expected) => {
105
+ expect(resolveLevel({ OM_LOG_LEVEL: raw })).toBe(expected)
106
+ })
107
+
108
+ it('defaults to info in production and debug otherwise when OM_LOG_LEVEL is unset', () => {
109
+ expect(resolveLevel({ NODE_ENV: 'production' })).toBe('info')
110
+ expect(resolveLevel({ NODE_ENV: 'development' })).toBe('debug')
111
+ expect(resolveLevel({ NODE_ENV: 'test' })).toBe('debug')
112
+ expect(resolveLevel({})).toBe('debug')
113
+ })
114
+
115
+ it('treats blank OM_LOG_LEVEL as unset without warning', () => {
116
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
117
+ expect(resolveLevel({ OM_LOG_LEVEL: ' ', NODE_ENV: 'production' })).toBe('info')
118
+ expect(warnSpy).not.toHaveBeenCalled()
119
+ })
120
+
121
+ it('falls back to the NODE_ENV default and warns on a junk OM_LOG_LEVEL', () => {
122
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
123
+ expect(resolveLevel({ OM_LOG_LEVEL: 'verbose', NODE_ENV: 'production' })).toBe('info')
124
+ expect(warnSpy).toHaveBeenCalledTimes(1)
125
+ expect(String(warnSpy.mock.calls[0][0])).toContain('OM_LOG_LEVEL')
126
+ expect(String(warnSpy.mock.calls[0][0])).toContain('verbose')
127
+ })
128
+
129
+ it('warns exactly once for a junk value through the memoized getLogLevel path', () => {
130
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
131
+ process.env.OM_LOG_LEVEL = 'chatty'
132
+ resetLogLevelCache()
133
+ expect(getLogLevel()).toBe('debug')
134
+ getLogLevel()
135
+ getLogLevel()
136
+ expect(warnSpy).toHaveBeenCalledTimes(1)
137
+ })
138
+
139
+ it('memoizes the resolved level until the cache is reset', () => {
140
+ process.env.OM_LOG_LEVEL = 'error'
141
+ resetLogLevelCache()
142
+ expect(getLogLevel()).toBe('error')
143
+ process.env.OM_LOG_LEVEL = 'debug'
144
+ expect(getLogLevel()).toBe('error')
145
+ resetLogLevelCache()
146
+ expect(getLogLevel()).toBe('debug')
147
+ })
148
+
149
+ it('answers isLevelEnabled from the numeric ordering', () => {
150
+ process.env.OM_LOG_LEVEL = 'warn'
151
+ resetLogLevelCache()
152
+ expect(isLevelEnabled('debug')).toBe(false)
153
+ expect(isLevelEnabled('info')).toBe(false)
154
+ expect(isLevelEnabled('warn')).toBe(true)
155
+ expect(isLevelEnabled('error')).toBe(true)
156
+ })
157
+ })
158
+
159
+ describe('console transport', () => {
160
+ it('gates each method by the effective level', () => {
161
+ process.env.OM_LOG_LEVEL = 'warn'
162
+ resetLogLevelCache()
163
+ const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {})
164
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
165
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
166
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
167
+ const logger = createConsoleLogger('gating')
168
+ logger.debug('quiet')
169
+ logger.info('quiet')
170
+ logger.warn('loud')
171
+ logger.error('loud')
172
+ expect(debugSpy).not.toHaveBeenCalled()
173
+ expect(infoSpy).not.toHaveBeenCalled()
174
+ expect(warnSpy).toHaveBeenCalledTimes(1)
175
+ expect(errorSpy).toHaveBeenCalledTimes(1)
176
+ })
177
+
178
+ it('prints the namespace prefix, message, and compact key=value bindings', () => {
179
+ process.env.OM_LOG_LEVEL = 'debug'
180
+ resetLogLevelCache()
181
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
182
+ createConsoleLogger('events').info('Delivering to subscriber', {
183
+ event: 'pos.cart.completed',
184
+ matched: 3,
185
+ })
186
+ expect(infoSpy).toHaveBeenCalledWith('[events] Delivering to subscriber event=pos.cart.completed matched=3')
187
+ })
188
+
189
+ it('prints the stack when fields.err is an Error', () => {
190
+ process.env.OM_LOG_LEVEL = 'debug'
191
+ resetLogLevelCache()
192
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
193
+ const failure = new Error('subscriber exploded')
194
+ createConsoleLogger('events').error('Handler error', { event: 'x.y.z', err: failure })
195
+ expect(errorSpy).toHaveBeenCalledWith('[events] Handler error event=x.y.z', failure.stack)
196
+ })
197
+
198
+ it('merges child bindings with child keys overriding parents across chains', () => {
199
+ process.env.OM_LOG_LEVEL = 'debug'
200
+ resetLogLevelCache()
201
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
202
+ const grandchild = createConsoleLogger('ns')
203
+ .child({ tenantId: 't-1', stage: 'parent' })
204
+ .child({ stage: 'child', subscriberId: 's-1' })
205
+ grandchild.info('line', { attempt: 2 })
206
+ expect(infoSpy).toHaveBeenCalledWith('[ns] line tenantId=t-1 stage=child subscriberId=s-1 attempt=2')
207
+ })
208
+ })
209
+
210
+ describe('transport selection', () => {
211
+ it('uses the console transport when window is defined', () => {
212
+ ;(globalThis as { window?: unknown }).window = {}
213
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
214
+ const builtinSpy = jest.spyOn(process, 'getBuiltinModule')
215
+ createLogger('browser-ns').info('hello')
216
+ expect(infoSpy).toHaveBeenCalledWith('[browser-ns] hello')
217
+ expect(builtinSpy).not.toHaveBeenCalledWith('node:module')
218
+ })
219
+
220
+ it('uses the console transport when NEXT_RUNTIME is edge', () => {
221
+ process.env.NEXT_RUNTIME = 'edge'
222
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
223
+ const builtinSpy = jest.spyOn(process, 'getBuiltinModule')
224
+ createLogger('edge-ns').info('hello')
225
+ expect(infoSpy).toHaveBeenCalledWith('[edge-ns] hello')
226
+ expect(builtinSpy).not.toHaveBeenCalledWith('node:module')
227
+ })
228
+
229
+ it('uses the pino-backed server transport on a plain node runtime when pretty mode is off', () => {
230
+ forcePinoTransport()
231
+ const fake = createFakePinoHarness()
232
+ mockPinoLoader(fake.factory)
233
+ const infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {})
234
+ createLogger('server-ns').info('hello')
235
+ expect(fake.calls).toEqual([{ level: 'info', args: [{}, 'hello'] }])
236
+ expect(infoSpy).not.toHaveBeenCalled()
237
+ })
238
+
239
+ it('reuses the same logger instance per namespace', () => {
240
+ expect(createLogger('reuse-ns')).toBe(createLogger('reuse-ns'))
241
+ })
242
+ })
243
+
244
+ describe('isomorphism', () => {
245
+ beforeEach(() => {
246
+ forcePinoTransport()
247
+ })
248
+
249
+ it('does not touch pino at import time, only on the first server-side log call', () => {
250
+ const fake = createFakePinoHarness()
251
+ const builtinSpy = mockPinoLoader(fake.factory)
252
+ jest.isolateModules(() => {
253
+ const facade = require('../index') as typeof import('../index')
254
+ expect(builtinSpy).not.toHaveBeenCalledWith('node:module')
255
+ const logger = facade.createLogger('lazy-ns')
256
+ expect(builtinSpy).not.toHaveBeenCalledWith('node:module')
257
+ logger.info('first line')
258
+ expect(builtinSpy).toHaveBeenCalledWith('node:module')
259
+ })
260
+ })
261
+
262
+ it('falls back to the console transport when pino cannot be loaded', () => {
263
+ jest.spyOn(process, 'getBuiltinModule').mockImplementation(((id: string) => {
264
+ if (id !== 'node:module') return originalGetBuiltinModule(id)
265
+ throw new Error('builtin unavailable')
266
+ }) as typeof process.getBuiltinModule)
267
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
268
+ createLogger('fallback-ns').warn('degraded', { reason: 'no-pino' })
269
+ expect(warnSpy).toHaveBeenCalledWith('[fallback-ns] degraded reason=no-pino')
270
+ })
271
+ })
272
+
273
+ describe('server transport delegation to pino', () => {
274
+ beforeEach(() => {
275
+ forcePinoTransport()
276
+ })
277
+
278
+ it('reorders message-first facade calls into pino object-first calls', () => {
279
+ const fake = createFakePinoHarness()
280
+ mockPinoLoader(fake.factory)
281
+ const logger = createLogger('reorder-ns')
282
+ logger.info('order created', { tenantId: 't-1', orderId: 'o-2' })
283
+ logger.warn('no fields')
284
+ expect(fake.calls).toEqual([
285
+ { level: 'info', args: [{ tenantId: 't-1', orderId: 'o-2' }, 'order created'] },
286
+ { level: 'warn', args: [{}, 'no fields'] },
287
+ ])
288
+ })
289
+
290
+ it('attaches the namespace as name and forwards child bindings to pino children', () => {
291
+ const fake = createFakePinoHarness()
292
+ mockPinoLoader(fake.factory)
293
+ const child = createLogger('events').child({ event: 'pos.cart.completed' })
294
+ child.debug('Delivering to subscriber', { subscriberId: 's-1' })
295
+ expect(fake.childBindings).toEqual([
296
+ { name: 'events', event: 'pos.cart.completed' },
297
+ ])
298
+ expect(fake.calls).toEqual([
299
+ { level: 'debug', args: [{ subscriberId: 's-1' }, 'Delivering to subscriber'] },
300
+ ])
301
+ })
302
+
303
+ it('configures the pino root with the resolved level and baseline redact paths', () => {
304
+ process.env.OM_LOG_LEVEL = 'warn'
305
+ resetLogLevelCache()
306
+ const fake = createFakePinoHarness()
307
+ mockPinoLoader(fake.factory)
308
+ createLogger('config-ns').error('boom')
309
+ const options = fake.options()
310
+ expect(options?.level).toBe('warn')
311
+ expect(options?.redact).toEqual({
312
+ paths: expect.arrayContaining(['password', '*.password', 'token', '*.token', 'secret', '*.secret', 'authorization', '*.authorization', 'headers.authorization', 'req.headers.authorization']),
313
+ censor: '[Redacted]',
314
+ })
315
+ })
316
+
317
+ it('writes to stdout by default (no destination passed to the pino factory)', () => {
318
+ delete process.env.OM_LOG_DESTINATION
319
+ const fake = createFakePinoHarness()
320
+ mockPinoLoader(fake.factory)
321
+ createLogger('dest-default-ns').info('hello')
322
+ expect(fake.destination()).toBeUndefined()
323
+ })
324
+
325
+ it('passes process.stderr as the pino destination when OM_LOG_DESTINATION=stderr', () => {
326
+ process.env.OM_LOG_DESTINATION = 'stderr'
327
+ const fake = createFakePinoHarness()
328
+ mockPinoLoader(fake.factory)
329
+ createLogger('dest-stderr-ns').info('hello')
330
+ expect(fake.destination()).toBe(process.stderr)
331
+ })
332
+
333
+ it('accepts case-insensitive and padded OM_LOG_DESTINATION tokens', () => {
334
+ process.env.OM_LOG_DESTINATION = ' STDERR '
335
+ const fake = createFakePinoHarness()
336
+ mockPinoLoader(fake.factory)
337
+ createLogger('dest-mixed-ns').info('hello')
338
+ expect(fake.destination()).toBe(process.stderr)
339
+ })
340
+
341
+ it('falls back to the default destination for unrecognized OM_LOG_DESTINATION values', () => {
342
+ process.env.OM_LOG_DESTINATION = 'banana'
343
+ const fake = createFakePinoHarness()
344
+ mockPinoLoader(fake.factory)
345
+ createLogger('dest-junk-ns').info('hello')
346
+ expect(fake.destination()).toBeUndefined()
347
+ })
348
+ })
349
+
350
+ describe('server transport with real pino output', () => {
351
+ beforeEach(() => {
352
+ forcePinoTransport()
353
+ })
354
+
355
+ function captureRealPino(): string[] {
356
+ const lines: string[] = []
357
+ const destination = { write: (line: string) => { lines.push(line) } }
358
+ jest.spyOn(process, 'getBuiltinModule').mockImplementation(((id: string) => {
359
+ if (id !== 'node:module') return originalGetBuiltinModule(id)
360
+ return {
361
+ createRequire: () => () => (options: Record<string, unknown>) => pino(options, destination),
362
+ }
363
+ }) as typeof process.getBuiltinModule)
364
+ return lines
365
+ }
366
+
367
+ it('emits structured JSON with top-level fields, never interpolated into msg', () => {
368
+ const lines = captureRealPino()
369
+ createLogger('json-ns')
370
+ .child({ tenantId: 't-1' })
371
+ .info('order created', { orderId: 'o-9' })
372
+ expect(lines).toHaveLength(1)
373
+ const entry = JSON.parse(lines[0]) as Record<string, unknown>
374
+ expect(entry.name).toBe('json-ns')
375
+ expect(entry.msg).toBe('order created')
376
+ expect(entry.tenantId).toBe('t-1')
377
+ expect(entry.orderId).toBe('o-9')
378
+ })
379
+
380
+ it('serializes fields.err through pino err serializer with the stack', () => {
381
+ const lines = captureRealPino()
382
+ const failure = new Error('kaboom-cause')
383
+ createLogger('err-ns').error('Handler error', { event: 'x.y.z', err: failure })
384
+ const entry = JSON.parse(lines[0]) as { err?: { type?: string; message?: string; stack?: string }; msg?: string; event?: string }
385
+ expect(entry.msg).toBe('Handler error')
386
+ expect(entry.event).toBe('x.y.z')
387
+ expect(entry.err?.type).toBe('Error')
388
+ expect(entry.err?.message).toBe('kaboom-cause')
389
+ expect(entry.err?.stack).toContain('kaboom-cause')
390
+ })
391
+
392
+ it('redacts sensitive top-level and one-level-nested keys', () => {
393
+ const lines = captureRealPino()
394
+ createLogger('redact-ns').warn('credential touch', {
395
+ token: 'top-secret',
396
+ password: 'hunter2',
397
+ user: { token: 'nested-secret', name: 'ada' },
398
+ safeField: 'visible',
399
+ })
400
+ const entry = JSON.parse(lines[0]) as {
401
+ token?: string
402
+ password?: string
403
+ user?: { token?: string; name?: string }
404
+ safeField?: string
405
+ }
406
+ expect(entry.token).toBe('[Redacted]')
407
+ expect(entry.password).toBe('[Redacted]')
408
+ expect(entry.user?.token).toBe('[Redacted]')
409
+ expect(entry.user?.name).toBe('ada')
410
+ expect(entry.safeField).toBe('visible')
411
+ })
412
+ })
413
+
414
+ describe('pretty mode resolution', () => {
415
+ it.each<[string, boolean]>([
416
+ ['1', true],
417
+ ['true', true],
418
+ ['yes', true],
419
+ ['on', true],
420
+ ['0', false],
421
+ ['false', false],
422
+ ['off', false],
423
+ [' TRUE ', true],
424
+ ])('resolves OM_LOG_PRETTY=%s to %s regardless of NODE_ENV', (raw, expected) => {
425
+ expect(resolvePrettyMode({ OM_LOG_PRETTY: raw, NODE_ENV: 'production' })).toBe(expected)
426
+ expect(resolvePrettyMode({ OM_LOG_PRETTY: raw, NODE_ENV: 'development' })).toBe(expected)
427
+ })
428
+
429
+ it('defaults to on outside production and off in production when unset', () => {
430
+ expect(resolvePrettyMode({ NODE_ENV: 'production' })).toBe(false)
431
+ expect(resolvePrettyMode({ NODE_ENV: 'development' })).toBe(true)
432
+ expect(resolvePrettyMode({ NODE_ENV: 'test' })).toBe(true)
433
+ expect(resolvePrettyMode({})).toBe(true)
434
+ })
435
+
436
+ it('treats an unrecognized OM_LOG_PRETTY token as unset', () => {
437
+ expect(resolvePrettyMode({ OM_LOG_PRETTY: 'banana', NODE_ENV: 'production' })).toBe(false)
438
+ expect(resolvePrettyMode({ OM_LOG_PRETTY: 'banana', NODE_ENV: 'development' })).toBe(true)
439
+ })
440
+ })
441
+
442
+ describe('pretty transport', () => {
443
+ const PRETTY_LINE_PATTERN = /^\d{2}:\d{2}:\d{2}\.\d{3} /
444
+
445
+ function captureStream(stream: NodeJS.WriteStream): string[] {
446
+ const chunks: string[] = []
447
+ jest.spyOn(stream, 'write').mockImplementation(((chunk: string) => {
448
+ chunks.push(String(chunk))
449
+ return true
450
+ }) as typeof stream.write)
451
+ return chunks
452
+ }
453
+
454
+ it('is selected on a plain node runtime in non-production without touching pino', () => {
455
+ const builtinSpy = jest.spyOn(process, 'getBuiltinModule')
456
+ const chunks = captureStream(process.stdout)
457
+ createLogger('pretty-select-ns').info('hello', { queue: 'events' })
458
+ expect(chunks).toHaveLength(1)
459
+ expect(chunks[0]).toMatch(PRETTY_LINE_PATTERN)
460
+ expect(chunks[0]).toContain('INFO [pretty-select-ns] hello queue=events')
461
+ expect(builtinSpy).not.toHaveBeenCalledWith('node:module')
462
+ })
463
+
464
+ it('renders timestamp, padded level, namespace, message, and key=value fields', () => {
465
+ const chunks = captureStream(process.stdout)
466
+ createPrettyLogger('queue').info('Job completed', {
467
+ queue: 'events',
468
+ jobId: 'd3e13935-0ccb-4794-ba0a-030872b27fc0',
469
+ })
470
+ expect(chunks).toHaveLength(1)
471
+ expect(chunks[0]).toMatch(
472
+ /^\d{2}:\d{2}:\d{2}\.\d{3} INFO {2}\[queue\] Job completed queue=events jobId=d3e13935-0ccb-4794-ba0a-030872b27fc0\n$/,
473
+ )
474
+ })
475
+
476
+ it('folds a component binding into the namespace scope and drops it from the tail', () => {
477
+ const chunks = captureStream(process.stdout)
478
+ createPrettyLogger('events')
479
+ .child({ component: 'stream', tenantId: 't-1' })
480
+ .warn('Payload skipped', { maxBytes: 4096 })
481
+ expect(chunks[0]).toContain('WARN [events:stream] Payload skipped tenantId=t-1 maxBytes=4096')
482
+ expect(chunks[0]).not.toContain('component=')
483
+ })
484
+
485
+ it('appends the stack on following lines when fields.err is an Error', () => {
486
+ const chunks = captureStream(process.stdout)
487
+ const failure = new Error('pretty kaboom')
488
+ createPrettyLogger('err-ns').error('Handler error', { event: 'x.y.z', err: failure })
489
+ expect(chunks).toHaveLength(1)
490
+ const [firstLine, ...stackLines] = chunks[0].split('\n')
491
+ expect(firstLine).toContain('ERROR [err-ns] Handler error event=x.y.z')
492
+ expect(stackLines.join('\n')).toContain('pretty kaboom')
493
+ })
494
+
495
+ it('gates each method by the effective level', () => {
496
+ process.env.OM_LOG_LEVEL = 'warn'
497
+ resetLogLevelCache()
498
+ const chunks = captureStream(process.stdout)
499
+ const logger = createPrettyLogger('gating-ns')
500
+ logger.debug('quiet')
501
+ logger.info('quiet')
502
+ logger.warn('loud')
503
+ logger.error('loud')
504
+ expect(chunks).toHaveLength(2)
505
+ expect(chunks[0]).toContain('WARN [gating-ns] loud')
506
+ expect(chunks[1]).toContain('ERROR [gating-ns] loud')
507
+ })
508
+
509
+ it('writes to stderr when OM_LOG_DESTINATION=stderr', () => {
510
+ process.env.OM_LOG_DESTINATION = 'stderr'
511
+ const stdoutChunks = captureStream(process.stdout)
512
+ const stderrChunks = captureStream(process.stderr)
513
+ createPrettyLogger('stdio-ns').info('protocol-safe line', { jobId: 'j-1' })
514
+ expect(stdoutChunks).toHaveLength(0)
515
+ expect(stderrChunks).toHaveLength(1)
516
+ expect(stderrChunks[0]).toContain('INFO [stdio-ns] protocol-safe line jobId=j-1')
517
+ })
518
+
519
+ it('emits no ANSI codes when the stream is not a TTY', () => {
520
+ const chunks = captureStream(process.stdout)
521
+ createPrettyLogger('plain-ns').info('no colors')
522
+ expect(chunks[0]).not.toContain('\x1b[')
523
+ })
524
+
525
+ it('colors the timestamp and level when the stream is a TTY', () => {
526
+ const stdoutStream = process.stdout as { isTTY?: boolean }
527
+ const originalIsTty = stdoutStream.isTTY
528
+ stdoutStream.isTTY = true
529
+ try {
530
+ const chunks = captureStream(process.stdout)
531
+ createPrettyLogger('tty-ns').error('colored failure')
532
+ expect(chunks[0]).toContain('\x1b[31mERROR\x1b[0m')
533
+ expect(chunks[0]).toContain('\x1b[2m')
534
+ } finally {
535
+ stdoutStream.isTTY = originalIsTty
536
+ }
537
+ })
538
+ })
539
+ })
@@ -0,0 +1,33 @@
1
+ import { selectTransport } from './transport'
2
+
3
+ export type { LogLevel } from './level'
4
+ export { getLogLevel, isLevelEnabled, resetLogLevelCache, OM_LOG_LEVEL_ENV } from './level'
5
+ export { resetServerLoggerCache, OM_LOG_DESTINATION_ENV } from './transport.server'
6
+ export { resetLogPrettyCache, OM_LOG_PRETTY_ENV } from './transport.pretty'
7
+
8
+ export type LogBindings = Record<string, unknown>
9
+
10
+ export interface Logger {
11
+ debug(msg: string, fields?: LogBindings): void
12
+ info(msg: string, fields?: LogBindings): void
13
+ warn(msg: string, fields?: LogBindings): void
14
+ error(msg: string, fields?: LogBindings): void
15
+ /** Returns a logger with `bindings` merged into every subsequent line. */
16
+ child(bindings: LogBindings): Logger
17
+ }
18
+
19
+ const loggerRegistry = new Map<string, Logger>()
20
+
21
+ /** Create (or reuse) a namespaced logger. `namespace` is attached as `name`. */
22
+ export function createLogger(namespace: string): Logger {
23
+ const existing = loggerRegistry.get(namespace)
24
+ if (existing) return existing
25
+ const created = selectTransport(namespace)
26
+ loggerRegistry.set(namespace, created)
27
+ return created
28
+ }
29
+
30
+ /** Internal: clear cached loggers so tests can re-run transport selection. */
31
+ export function resetLoggerRegistry(): void {
32
+ loggerRegistry.clear()
33
+ }
@@ -0,0 +1,50 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
2
+
3
+ export const OM_LOG_LEVEL_ENV = 'OM_LOG_LEVEL'
4
+
5
+ export const LOG_LEVEL_WEIGHTS: Record<LogLevel, number> = {
6
+ debug: 10,
7
+ info: 20,
8
+ warn: 30,
9
+ error: 40,
10
+ }
11
+
12
+ const VALID_LEVEL_TOKENS: ReadonlySet<string> = new Set(['debug', 'info', 'warn', 'error'])
13
+
14
+ type EnvSource = Record<string, string | undefined>
15
+
16
+ function readProcessEnv(): EnvSource {
17
+ return typeof process === 'undefined' ? {} : process.env
18
+ }
19
+
20
+ function defaultLevelFor(env: EnvSource): LogLevel {
21
+ return env.NODE_ENV === 'production' ? 'info' : 'debug'
22
+ }
23
+
24
+ /** Resolve the effective log level from `OM_LOG_LEVEL`, falling back on `NODE_ENV`. */
25
+ export function resolveLevel(env: EnvSource = readProcessEnv()): LogLevel {
26
+ const raw = env[OM_LOG_LEVEL_ENV]
27
+ if (typeof raw !== 'string' || !raw.trim()) return defaultLevelFor(env)
28
+ const normalized = raw.trim().toLowerCase()
29
+ if (VALID_LEVEL_TOKENS.has(normalized)) return normalized as LogLevel
30
+ const fallback = defaultLevelFor(env)
31
+ console.warn(`[logger] Unrecognized ${OM_LOG_LEVEL_ENV} value "${raw}"; falling back to "${fallback}"`)
32
+ return fallback
33
+ }
34
+
35
+ let cachedLevel: LogLevel | null = null
36
+
37
+ /** Resolve the effective level once, for callers that want to gate expensive work. */
38
+ export function getLogLevel(): LogLevel {
39
+ if (cachedLevel === null) cachedLevel = resolveLevel()
40
+ return cachedLevel
41
+ }
42
+
43
+ export function isLevelEnabled(level: LogLevel): boolean {
44
+ return LOG_LEVEL_WEIGHTS[level] >= LOG_LEVEL_WEIGHTS[getLogLevel()]
45
+ }
46
+
47
+ /** Internal: clear the memoized level so tests can vary `OM_LOG_LEVEL`. */
48
+ export function resetLogLevelCache(): void {
49
+ cachedLevel = null
50
+ }
@@ -0,0 +1,41 @@
1
+ import type { LogBindings, Logger } from './index'
2
+ import { isLevelEnabled, type LogLevel } from './level'
3
+
4
+ function formatBindingValue(value: unknown): string {
5
+ if (typeof value === 'string') return value
6
+ if (value instanceof Error) return value.message
7
+ if (typeof value === 'object' && value !== null) {
8
+ try {
9
+ return JSON.stringify(value) ?? String(value)
10
+ } catch {
11
+ return String(value)
12
+ }
13
+ }
14
+ return String(value)
15
+ }
16
+
17
+ export function formatBindings(bindings: LogBindings): string {
18
+ return Object.entries(bindings)
19
+ .filter(([, value]) => value !== undefined)
20
+ .map(([key, value]) => `${key}=${formatBindingValue(value)}`)
21
+ .join(' ')
22
+ }
23
+
24
+ export function createConsoleLogger(namespace: string, bindings: LogBindings = {}): Logger {
25
+ const emit = (level: LogLevel, msg: string, fields?: LogBindings): void => {
26
+ if (!isLevelEnabled(level)) return
27
+ const { err, ...rest } = { ...bindings, ...fields }
28
+ const suffix = formatBindings(rest)
29
+ const line = suffix ? `[${namespace}] ${msg} ${suffix}` : `[${namespace}] ${msg}`
30
+ if (err instanceof Error) console[level](line, err.stack ?? err.message)
31
+ else if (err !== undefined) console[level](line, err)
32
+ else console[level](line)
33
+ }
34
+ return {
35
+ debug: (msg, fields) => emit('debug', msg, fields),
36
+ info: (msg, fields) => emit('info', msg, fields),
37
+ warn: (msg, fields) => emit('warn', msg, fields),
38
+ error: (msg, fields) => emit('error', msg, fields),
39
+ child: (childBindings) => createConsoleLogger(namespace, { ...bindings, ...childBindings }),
40
+ }
41
+ }