@open-mercato/shared 0.6.7-develop.6828.1.ab1620a63e → 0.6.7-develop.6842.1.8cd8a8883c

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 (40) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +1 -1
  3. package/dist/lib/bootstrap/dynamicLoader.js +54 -14
  4. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  5. package/dist/lib/di/container.js +12 -1
  6. package/dist/lib/di/container.js.map +2 -2
  7. package/dist/lib/i18n/config.js +1 -1
  8. package/dist/lib/i18n/config.js.map +2 -2
  9. package/dist/lib/i18n/server.js +4 -4
  10. package/dist/lib/i18n/server.js.map +3 -3
  11. package/dist/lib/logger/extension.js +29 -0
  12. package/dist/lib/logger/extension.js.map +7 -0
  13. package/dist/lib/logger/index.js +47 -3
  14. package/dist/lib/logger/index.js.map +3 -3
  15. package/dist/lib/modules/registry.js +5 -1
  16. package/dist/lib/modules/registry.js.map +2 -2
  17. package/dist/lib/redis/connection.js +7 -2
  18. package/dist/lib/redis/connection.js.map +2 -2
  19. package/dist/lib/telemetry/runtime.js +35 -0
  20. package/dist/lib/telemetry/runtime.js.map +7 -0
  21. package/dist/lib/version.js +1 -1
  22. package/dist/lib/version.js.map +1 -1
  23. package/package.json +2 -2
  24. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  25. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  26. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +192 -0
  27. package/src/lib/bootstrap/dynamicLoader.ts +111 -32
  28. package/src/lib/di/__tests__/container-app-di-absent.test.ts +17 -1
  29. package/src/lib/di/container.ts +19 -1
  30. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  31. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  32. package/src/lib/i18n/config.ts +2 -2
  33. package/src/lib/i18n/server.ts +6 -2
  34. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  35. package/src/lib/logger/extension.ts +59 -0
  36. package/src/lib/logger/index.ts +59 -1
  37. package/src/lib/modules/registry.ts +10 -0
  38. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  39. package/src/lib/redis/connection.ts +10 -7
  40. package/src/lib/telemetry/runtime.ts +72 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * Integration guard for #4526/#4693: compileAndImport must await its dynamic
5
+ * import so that an import-time rejection settles inside the try block.
6
+ * Returning the promise unawaited let the rejection escape the surrounding try,
7
+ * which made the MikroORM v7 generated-cache recovery in the catch dead code for
8
+ * exactly the failure it exists to repair. The one-line fix shipped in #4540;
9
+ * this file is the end-to-end coverage for it.
10
+ *
11
+ * Two sibling suites cover this area and neither subsumes the other:
12
+ *
13
+ * - dynamicLoader.cacheRecovery.test.ts mocks ../generatedCacheRecovery in full
14
+ * and asserts how the loader *reacts* to a rejecting import (recovery is
15
+ * invoked once, the retry is capped at one, the error propagates when no
16
+ * recovery applies).
17
+ * - generatedCacheRecovery.test.ts exercises the recovery module directly,
18
+ * without going through the loader.
19
+ * - This file runs the REAL recovery module from the loader's catch, so the
20
+ * loader ↔ recovery seam is covered end to end: the on-disk marker, its
21
+ * `runtime-import-error` reason and the deleted-file list are asserted against
22
+ * what the production module actually writes. Nothing else in the repository
23
+ * reaches that reason.
24
+ *
25
+ * The fixture reproduces the window the catch exists for: a stale compiled file
26
+ * that the startup scan (ensureMikroOrmV7GeneratedCacheCompatibility) cannot see
27
+ * because it appears only once loading is already under way. The staged .mjs
28
+ * therefore carries no decorator import at scan time — it writes a stale sibling
29
+ * and then rejects with the error Node raises for a v6 decorator import, which
30
+ * is what the import-time recovery is meant to repair.
31
+ *
32
+ * The assertions stop at the recovery boundary on purpose. Jest's module
33
+ * registry keeps serving a file it has already evaluated, so the guarded retry
34
+ * re-runs the rejecting module from memory no matter what recovery wrote to
35
+ * disk — the outcome of the retry is a property of the test runner, not of the
36
+ * loader. What #4526 broke, and what this guards, is that the catch runs at
37
+ * all: before the fix the rejection escaped the try, so no import-time recovery
38
+ * ever happened.
39
+ */
40
+ import crypto from 'node:crypto'
41
+ import fs from 'node:fs'
42
+ import os from 'node:os'
43
+ import path from 'node:path'
44
+ import { loadBootstrapData } from '../dynamicLoader'
45
+
46
+ const STALE_SIBLING_BASENAME = 'stale-entity.generated.mjs'
47
+
48
+ /**
49
+ * The decorator import is assembled at runtime rather than written literally:
50
+ * a literal would make this fixture itself match the startup scan's staleness
51
+ * pattern, so the scan would delete the cache before the import ever runs and
52
+ * the import-time path under test would never be exercised.
53
+ */
54
+ const REJECTING_COMPILED_SOURCE = [
55
+ "const nodeFs = require('node:fs')",
56
+ "const nodePath = require('node:path')",
57
+ "const quote = String.fromCharCode(39)",
58
+ "const staleImport = 'import { Entity, PrimaryKey } from ' + quote + '@mikro-orm/core' + quote",
59
+ `nodeFs.writeFileSync(nodePath.join(__dirname, ${JSON.stringify(STALE_SIBLING_BASENAME)}), staleImport + '\\nexport const stale = true\\n')`,
60
+ `throw new Error('The requested module ' + quote + '@mikro-orm/core' + quote + ' does not provide an export named ' + quote + 'Entity' + quote)`,
61
+ ].join('\n')
62
+
63
+ /**
64
+ * Jest resolves the loader's dynamic import through its own CommonJS registry,
65
+ * so a staged .mjs has to be CommonJS to evaluate at all — esbuild's real ESM
66
+ * output fails under the runner with "Unexpected token 'export'". The .ts
67
+ * sources stay honest ESM: they are what the loader recompiles from once
68
+ * recovery has deleted the cache.
69
+ */
70
+ const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
71
+ 'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
72
+ 'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
73
+ 'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
74
+ 'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
75
+ }
76
+
77
+ function contentHash(content: Buffer | string): string {
78
+ return crypto.createHash('sha256').update(content).digest('hex')
79
+ }
80
+
81
+ /**
82
+ * Replace a compiled cache entry with staged content the runner can evaluate,
83
+ * keeping the loader's own cache metadata authoritative.
84
+ *
85
+ * The loader validates a cache entry by hashing (#4724): it recompiles unless
86
+ * the sibling `.cache.json` matches both the source and the compiled output.
87
+ * Only `outputHash` is rewritten here — `version`, `inputHash` and
88
+ * `dependencies` stay exactly as the loader wrote them, so the fixture models a
89
+ * cache that is internally consistent and simply stale, and it cannot drift out
90
+ * of step with the cache format the way a hand-built metadata file would.
91
+ */
92
+ function stageCompiledCache(generatedDir: string, baseName: string, compiled: string) {
93
+ const jsPath = path.join(generatedDir, `${baseName}.mjs`)
94
+ const metadataPath = `${jsPath}.cache.json`
95
+
96
+ fs.writeFileSync(jsPath, compiled)
97
+ const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
98
+ metadata.outputHash = contentHash(fs.readFileSync(jsPath))
99
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata))
100
+ }
101
+
102
+ function hasCacheMetadata(generatedDir: string, baseName: string): boolean {
103
+ return fs.existsSync(path.join(generatedDir, `${baseName}.mjs.cache.json`))
104
+ }
105
+
106
+ describe('compileAndImport — generated-cache recovery is reachable (#4526)', () => {
107
+ let appRoot: string
108
+ let generatedDir: string
109
+ let warnSpy: jest.SpyInstance
110
+
111
+ /**
112
+ * Let the loader compile the generated sources so it writes real cache
113
+ * metadata for each of them, replacing each compiled output with the
114
+ * runner-evaluable staged content as it appears.
115
+ *
116
+ * The loader writes a module's metadata before importing it, so an import
117
+ * failing here (esbuild's ESM output under Jest's CommonJS registry) is
118
+ * expected and irrelevant — the metadata is the only thing this step is
119
+ * after. That failure does abort the rest of the load, though, so one pass
120
+ * only reaches the modules the loader got to: entities.ids first and alone,
121
+ * then the remainder together. Repeating the pass lets each newly staged
122
+ * module unblock the next batch, and the loop ends on the first pass that
123
+ * loads cleanly, which is precisely when every module is staged.
124
+ *
125
+ * jest.resetModules() between passes is load-bearing. Jest keys its registry
126
+ * on the resolved path and ignores the loader's `?cache=` query, so without
127
+ * the reset a module would keep being served from its first (ESM, failing)
128
+ * evaluation and no later pass could make progress.
129
+ *
130
+ * None of these failures carries a decorator-export error, so no recovery
131
+ * runs and no marker is written during setup; both tests below would fail
132
+ * loudly if one were.
133
+ */
134
+ async function primeAndStageCompiledCaches() {
135
+ const baseNames = Object.keys(GENERATED_MODULES)
136
+ for (let pass = 0; pass <= baseNames.length; pass += 1) {
137
+ let loadedCleanly = true
138
+ await loadBootstrapData(appRoot).catch(() => { loadedCleanly = false })
139
+ jest.resetModules()
140
+ if (loadedCleanly) return
141
+ for (const baseName of baseNames) {
142
+ if (!hasCacheMetadata(generatedDir, baseName)) continue
143
+ stageCompiledCache(generatedDir, baseName, GENERATED_MODULES[baseName].compiled)
144
+ }
145
+ }
146
+ throw new Error('[internal] loader never reached a fully staged generated cache')
147
+ }
148
+
149
+ beforeEach(async () => {
150
+ appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4526-'))
151
+ generatedDir = path.join(appRoot, '.mercato', 'generated')
152
+ fs.mkdirSync(generatedDir, { recursive: true })
153
+ fs.writeFileSync(
154
+ path.join(appRoot, 'tsconfig.json'),
155
+ JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext' } }),
156
+ )
157
+ for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
158
+ fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source.ts)
159
+ }
160
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
161
+
162
+ await primeAndStageCompiledCaches()
163
+ })
164
+
165
+ afterEach(() => {
166
+ warnSpy.mockRestore()
167
+ fs.rmSync(appRoot, { recursive: true, force: true })
168
+ })
169
+
170
+ it('runs cache recovery when the compiled cache rejects on import', async () => {
171
+ const rejectingPath = path.join(generatedDir, 'entities.ids.generated.mjs')
172
+ stageCompiledCache(generatedDir, 'entities.ids.generated', REJECTING_COMPILED_SOURCE)
173
+
174
+ await loadBootstrapData(appRoot).catch(() => undefined)
175
+
176
+ const markerPath = path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json')
177
+ expect(fs.existsSync(markerPath)).toBe(true)
178
+
179
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'))
180
+ expect(marker.reason).toBe('runtime-import-error')
181
+ expect(marker.deletedFiles).toContain(rejectingPath)
182
+ expect(marker.deletedFiles).toContain(path.join(generatedDir, STALE_SIBLING_BASENAME))
183
+
184
+ expect(fs.readFileSync(rejectingPath, 'utf8')).not.toContain('does not provide an export named')
185
+ })
186
+
187
+ it('does not run recovery when the compiled cache imports cleanly', async () => {
188
+ await loadBootstrapData(appRoot)
189
+
190
+ expect(fs.existsSync(path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json'))).toBe(false)
191
+ })
192
+ })
@@ -1,4 +1,5 @@
1
1
  import type { BootstrapData } from './types'
2
+ import type { AppDiRegistrar } from '../di/container'
2
3
  import { findAppRoot, type AppRoot } from './appResolver'
3
4
  import { registerEntityIds } from '../encryption/entityIds'
4
5
  import { createLogger } from '../logger'
@@ -41,22 +42,29 @@ class GeneratedFileNotFoundError extends Error {
41
42
  * list, which would silently reintroduce #4623.
42
43
  */
43
44
  export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
44
- // Plugin to resolve @/ alias to app root (works for @app modules)
45
+ // Plugin to resolve the @/ alias the way the app tsconfig maps it:
46
+ // `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
45
47
  const aliasPlugin: import('esbuild').Plugin = {
46
48
  name: 'alias-resolver',
47
49
  setup(build) {
48
- // Resolve @/ alias to app root
49
50
  build.onResolve({ filter: /^@\// }, (args) => {
50
- const resolved = path.join(appRoot, args.path.slice(2))
51
- // Try with .ts extension if base path doesn't exist
52
- if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
53
- return { path: resolved + '.ts' }
51
+ const rest = args.path.slice('@/'.length)
52
+ const bases = rest.startsWith('.mercato/')
53
+ ? [path.join(appRoot, rest)]
54
+ : [path.join(appRoot, 'src', rest), path.join(appRoot, rest)]
55
+ for (const base of bases) {
56
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) {
57
+ return { path: base }
58
+ }
59
+ for (const suffix of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
60
+ if (fs.existsSync(base + suffix)) {
61
+ return { path: base + suffix }
62
+ }
63
+ }
54
64
  }
55
- // Also check for /index.ts if it's a directory
56
- if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
57
- return { path: path.join(resolved, 'index.ts') }
58
- }
59
- return { path: resolved }
65
+ // Nothing matched — hand esbuild the literal mapping so it reports the
66
+ // missing file against the path the app author actually wrote.
67
+ return { path: path.join(appRoot, rest) }
60
68
  })
61
69
  },
62
70
  }
@@ -296,14 +304,33 @@ function cacheIsValid(
296
304
  && dependenciesAreValid(appRoot, metadata.dependencies)
297
305
  }
298
306
 
307
+ /**
308
+ * Options for `compileAndImport`.
309
+ *
310
+ * Both paths default to the generated-registry layout (`<appRoot>/.mercato/generated/<file>.ts`
311
+ * compiled to a `.mjs` sibling). Sources that live elsewhere in the app — `src/di.ts` — MUST pass
312
+ * both explicitly: the default app root is derived by walking three directories up from the source,
313
+ * which only holds inside `.mercato/generated`.
314
+ */
315
+ type CompileAndImportOptions = {
316
+ appRoot?: string
317
+ outFile?: string
318
+ allowRecovery?: boolean
319
+ }
320
+
299
321
  /**
300
322
  * Compile a TypeScript file to JavaScript using esbuild bundler.
301
323
  * This bundles the file and all its dependencies, handling JSON imports properly.
302
- * The compiled file is written next to the source file with a .mjs extension.
324
+ * The compiled file is written next to the source file with a .mjs extension unless
325
+ * `outFile` says otherwise.
303
326
  */
304
- async function compileAndImport(tsPath: string, allowRecovery: boolean = true): Promise<Record<string, unknown>> {
305
- const jsPath = tsPath.replace(/\.ts$/, '.mjs')
306
- const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))
327
+ async function compileAndImport(
328
+ tsPath: string,
329
+ options: CompileAndImportOptions = {},
330
+ ): Promise<Record<string, unknown>> {
331
+ const allowRecovery = options.allowRecovery ?? true
332
+ const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
333
+ const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
307
334
  const appTsconfig = path.join(appRoot, 'tsconfig.json')
308
335
  const metadataPath = cacheMetadataPath(jsPath)
309
336
 
@@ -322,6 +349,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
322
349
  const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
323
350
 
324
351
  if (needsCompile) {
352
+ fs.mkdirSync(path.dirname(jsPath), { recursive: true })
325
353
  // Dynamically import esbuild only when needed
326
354
  const esbuild = await import('esbuild')
327
355
 
@@ -367,7 +395,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
367
395
  throw error
368
396
  }
369
397
 
370
- return compileAndImport(tsPath, false)
398
+ return compileAndImport(tsPath, { ...options, allowRecovery: false })
371
399
  }
372
400
  }
373
401
 
@@ -405,20 +433,7 @@ async function loadOptionalGeneratedModule(
405
433
  }
406
434
  }
407
435
 
408
- /**
409
- * Dynamically load bootstrap data from a resolved app directory.
410
- *
411
- * IMPORTANT: This only works in unbundled contexts (CLI, tsx).
412
- * Do NOT use this in Next.js bundled code - use static imports instead.
413
- *
414
- * For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
415
- * CLI commands are discovered separately via the CLI module system.
416
- *
417
- * @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
418
- * @returns The loaded bootstrap data
419
- * @throws Error if app root cannot be found or generated files are missing
420
- */
421
- export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
436
+ function resolveAppRootOrThrow(appRoot?: string): AppRoot {
422
437
  const resolved: AppRoot | null = appRoot
423
438
  ? {
424
439
  generatedDir: path.join(appRoot, '.mercato', 'generated'),
@@ -435,6 +450,68 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
435
450
  )
436
451
  }
437
452
 
453
+ return resolved
454
+ }
455
+
456
+ /**
457
+ * Load the app-level DI registrar (`src/di.ts`) for the dynamic bootstrap path.
458
+ *
459
+ * The Next.js runtime imports `@/di` statically from its own `src/bootstrap.ts` and hands the
460
+ * registrar to `createBootstrap`. Worker, scheduler and CLI processes bootstrap through
461
+ * `bootstrapFromAppRoot` instead, where the `@/` alias does not exist — so without this the app's
462
+ * DI registrations silently never ran there, and every request container paid a failed
463
+ * `import('@/di')` resolution (the compatibility fallback in `lib/di/container.ts`).
464
+ *
465
+ * An absent `src/di.ts` is the supported case and resolves to `null` quietly. A file that exists
466
+ * but cannot be compiled, imported, or does not export `register` is reported at error level and
467
+ * still resolves to `null`, so a broken app DI module degrades the same way a broken generated
468
+ * registry does (#4327, #4491) instead of taking the whole process down.
469
+ */
470
+ async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null> {
471
+ const tsPath = path.join(appDir, 'src', 'di.ts')
472
+ if (!fs.existsSync(tsPath)) {
473
+ logger.debug('App-level DI module not present, skipping its registrations', { filePath: tsPath })
474
+ return null
475
+ }
476
+
477
+ try {
478
+ const appDiModule = await compileAndImport(tsPath, {
479
+ appRoot: appDir,
480
+ outFile: path.join(appDir, '.mercato', 'generated', 'app-di.compiled.mjs'),
481
+ })
482
+ const register = appDiModule.register
483
+ if (typeof register !== 'function') {
484
+ logger.error('App-level DI module exports no register(); its registrations are skipped', {
485
+ filePath: tsPath,
486
+ })
487
+ return null
488
+ }
489
+ return register as AppDiRegistrar
490
+ } catch (error) {
491
+ logger.error('Failed to load the app-level DI module; its registrations are skipped', {
492
+ filePath: tsPath,
493
+ err: error,
494
+ })
495
+ return null
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Dynamically load bootstrap data from a resolved app directory.
501
+ *
502
+ * IMPORTANT: This only works in unbundled contexts (CLI, tsx).
503
+ * Do NOT use this in Next.js bundled code - use static imports instead.
504
+ *
505
+ * For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
506
+ * CLI commands are discovered separately via the CLI module system.
507
+ *
508
+ * @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
509
+ * @returns The loaded bootstrap data
510
+ * @throws Error if app root cannot be found or generated files are missing
511
+ */
512
+ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
513
+ const resolved = resolveAppRootOrThrow(appRoot)
514
+
438
515
  const { generatedDir } = resolved
439
516
 
440
517
  ensureMikroOrmV7GeneratedCacheCompatibility(resolved.appDir)
@@ -506,8 +583,10 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
506
583
  */
507
584
  export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
508
585
  const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
509
- const data = await loadBootstrapData(appRoot)
510
- const bootstrap = createBootstrap(data)
586
+ const resolved = resolveAppRootOrThrow(appRoot)
587
+ const data = await loadBootstrapData(resolved.appDir)
588
+ const appDiRegistrar = await loadAppDiRegistrar(resolved.appDir)
589
+ const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
511
590
  bootstrap()
512
591
  // In CLI context, wait for async registrations (UI widgets, search configs, etc.)
513
592
  await waitForAsyncRegistration()
@@ -84,9 +84,10 @@ jest.mock(
84
84
  )
85
85
 
86
86
  const mockWarn = jest.fn()
87
+ const mockDebug = jest.fn()
87
88
  jest.mock('../../logger', () => ({
88
89
  createLogger: () => ({
89
- debug: jest.fn(),
90
+ debug: (...args: unknown[]) => mockDebug(...args),
90
91
  info: jest.fn(),
91
92
  warn: (...args: unknown[]) => mockWarn(...args),
92
93
  error: jest.fn(),
@@ -107,6 +108,7 @@ describe('app-level DI override hook when @/di is absent', () => {
107
108
  registerAppDiRegistrar(null)
108
109
  registerDiRegistrars([])
109
110
  mockWarn.mockClear()
111
+ mockDebug.mockClear()
110
112
  })
111
113
 
112
114
  it('creates the container without warning', async () => {
@@ -117,6 +119,20 @@ describe('app-level DI override hook when @/di is absent', () => {
117
119
  expect(mockWarn).not.toHaveBeenCalled()
118
120
  })
119
121
 
122
+ // Worker and CLI processes create one request container per job against built package
123
+ // output, where the app's `@/` alias does not exist. Retrying the doomed import per
124
+ // container repeats a failed module resolution — and its log line — once per job.
125
+ it('stops retrying the unresolvable @/di import after the first container', async () => {
126
+ await createRequestContainer()
127
+ await createRequestContainer()
128
+ await createRequestContainer()
129
+
130
+ const unresolvableLogs = mockDebug.mock.calls.filter(
131
+ ([message]) => message === 'App-level DI override module (@/di) not resolvable; skipping',
132
+ )
133
+ expect(unresolvableLogs).toHaveLength(1)
134
+ })
135
+
120
136
  it('warns once when @/di fails because a nested app alias is missing', async () => {
121
137
  const nestedAliasError = Object.assign(new Error("Cannot find module '@/di/helpers'"), {
122
138
  code: 'MODULE_NOT_FOUND',
@@ -26,6 +26,11 @@ const GLOBAL_KEY = '__openMercatoDiRegistrars__'
26
26
  const APP_DI_REGISTRAR_KEY = '__openMercatoAppDiRegistrar__'
27
27
  const APP_DI_LOAD_WARNING_KEY = '__openMercatoAppDiLoadWarningEmitted__'
28
28
  const APP_DI_REGISTER_WARNING_KEY = '__openMercatoAppDiRegisterWarningEmitted__'
29
+ // Set once the legacy `@/di` fallback proves the specifier is unresolvable in this process.
30
+ // Worker/CLI processes run against built package output where the app's `@/` alias does not
31
+ // exist, so retrying the import per request container only repeats a failed module resolution
32
+ // (and its log line) once per job.
33
+ const APP_DI_MODULE_UNRESOLVABLE_KEY = '__openMercatoAppDiModuleUnresolvable__'
29
34
  // Phase 5 — process-scoped bootstrap cache. The cache/event-bus/encryption
30
35
  // services bootstrap() creates are inherently process-scoped (they hold
31
36
  // state across requests). Caching them on globalThis after the first
@@ -120,6 +125,9 @@ export function registerDiRegistrars(registrars: DiRegistrar[]) {
120
125
  // Force re-bootstrap on HMR — module subscribers may have changed.
121
126
  ;(globalThis as any)[BOOTSTRAP_CACHE_KEY] = null
122
127
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
128
+ // An app that gains a src/di.ts mid-session reloads through here, so the negative
129
+ // resolution result must not outlive the reload.
130
+ ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
123
131
  }
124
132
 
125
133
  export function getDiRegistrars(): DiRegistrar[] {
@@ -145,6 +153,15 @@ export function resetBootstrapCache(): void {
145
153
  ;(globalThis as any)[ENCRYPTION_ENABLED_KEY] = undefined
146
154
  ;(globalThis as Record<string, unknown>)[APP_DI_LOAD_WARNING_KEY] = undefined
147
155
  ;(globalThis as Record<string, unknown>)[APP_DI_REGISTER_WARNING_KEY] = undefined
156
+ ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = undefined
157
+ }
158
+
159
+ function isAppDiModuleUnresolvable(): boolean {
160
+ return (globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] === true
161
+ }
162
+
163
+ function markAppDiModuleUnresolvable(): void {
164
+ ;(globalThis as Record<string, unknown>)[APP_DI_MODULE_UNRESOLVABLE_KEY] = true
148
165
  }
149
166
 
150
167
  function isAppDiModuleNotFound(error: unknown): boolean {
@@ -267,7 +284,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
267
284
  } catch (error) {
268
285
  logger.error('App-level DI registrar failed', { err: error })
269
286
  }
270
- } else {
287
+ } else if (!isAppDiModuleUnresolvable()) {
271
288
  // Backward-compatible fallback for apps that have not adopted explicit wiring.
272
289
  try {
273
290
  // @ts-ignore - @/di only exists in app context, not in packages
@@ -286,6 +303,7 @@ export async function createRequestContainer(): Promise<AppContainer> {
286
303
  }
287
304
  } catch (err) {
288
305
  if (isAppDiModuleNotFound(err)) {
306
+ markAppDiModuleUnresolvable()
289
307
  logger.debug('App-level DI override module (@/di) not resolvable; skipping', { err })
290
308
  } else {
291
309
  warnAppDiFailureOnce(
@@ -0,0 +1,8 @@
1
+ import { defaultLocale, locales, type Locale } from '../config'
2
+
3
+ describe('supported application locales', () => {
4
+ it('registers Korean without changing the default locale', () => {
5
+ expect(locales).toEqual<Locale[]>(['en', 'pl', 'es', 'de', 'ko'])
6
+ expect(defaultLocale).toBe('en')
7
+ })
8
+ })
@@ -0,0 +1,29 @@
1
+ import { invalidateDictionaryCache, loadDictionary, registerModules, resolveTranslations } from '../server'
2
+ import type { Module } from '../../../modules/registry'
3
+
4
+ const REGISTRY_GLOBAL_KEY = '__openMercatoModulesRegistry__'
5
+
6
+ describe('loadDictionary without a bootstrapped module registry', () => {
7
+ beforeEach(() => {
8
+ delete (globalThis as Record<string, unknown>)[REGISTRY_GLOBAL_KEY]
9
+ invalidateDictionaryCache()
10
+ })
11
+
12
+ it('resolves the app dictionary instead of throwing a bootstrap error', async () => {
13
+ await expect(loadDictionary('en')).resolves.toEqual(expect.any(Object))
14
+ })
15
+
16
+ it('lets a route handler translate its response before bootstrap runs', async () => {
17
+ const { translate } = await resolveTranslations()
18
+
19
+ expect(translate('storage_s3.upload.errors.unsupported', 'Unsupported file type')).toBe('Unsupported file type')
20
+ })
21
+
22
+ it('picks up module translations once bootstrap registers them', async () => {
23
+ await loadDictionary('en')
24
+
25
+ registerModules([{ id: 'demo', translations: { en: { 'demo.hello': 'Hello' } } }] satisfies Module[])
26
+
27
+ await expect(loadDictionary('en')).resolves.toMatchObject({ 'demo.hello': 'Hello' })
28
+ })
29
+ })
@@ -1,4 +1,4 @@
1
- export type Locale = 'en' | 'pl' | 'es' | 'de'
1
+ export type Locale = 'en' | 'pl' | 'es' | 'de' | 'ko'
2
2
 
3
- export const locales: Locale[] = ['en', 'pl', 'es', 'de']
3
+ export const locales: Locale[] = ['en', 'pl', 'es', 'de', 'ko']
4
4
  export const defaultLocale: Locale = 'en'
@@ -2,7 +2,7 @@ import { defaultLocale, locales, type Locale } from './config'
2
2
  import type { Dict } from './context'
3
3
  import { resolveForcedLocale, resolveLocaleFromAcceptLanguage } from './locale'
4
4
  import { createFallbackTranslator, createTranslator } from './translate'
5
- import { getModules } from '../modules/registry'
5
+ import { tryGetModules } from '../modules/registry'
6
6
  import { loadAppDictionary } from './app-dictionaries'
7
7
  import { getCachedDictionary, setCachedDictionary } from './dictionary-cache'
8
8
 
@@ -61,7 +61,11 @@ export async function loadDictionary(locale: Locale): Promise<Dict> {
61
61
  // Load from registry instead of @/ import (works in standalone packages)
62
62
  const baseRaw = await loadAppDictionary(locale)
63
63
  const merged: Dict = { ...flattenDictionary(baseRaw) }
64
- const modules = getModules()
64
+ // Route handlers translate their responses, so they resolve a dictionary even
65
+ // when they are exercised in isolation without a bootstrapped registry. The
66
+ // app dictionary alone is the right degraded answer there — `registerModules`
67
+ // invalidates this cache, so a later bootstrap still gets the merged result.
68
+ const modules = tryGetModules() ?? []
65
69
  for (const m of modules) {
66
70
  const dict = m.translations?.[locale]
67
71
  if (dict) Object.assign(merged, flattenDictionary(dict))
@@ -3,7 +3,9 @@ import {
3
3
  createLogger,
4
4
  getLogLevel,
5
5
  isLevelEnabled,
6
+ registerLoggerExtension,
6
7
  resetLogLevelCache,
8
+ resetLoggerExtension,
7
9
  resetLoggerRegistry,
8
10
  } from '../index'
9
11
  import { resolveLevel, type LogLevel } from '../level'
@@ -65,6 +67,7 @@ function resetLoggerState(): void {
65
67
  resetLoggerRegistry()
66
68
  resetServerLoggerCache()
67
69
  resetLogPrettyCache()
70
+ resetLoggerExtension()
68
71
  }
69
72
 
70
73
  function forcePinoTransport(): void {
@@ -241,6 +244,80 @@ describe('structured logging facade', () => {
241
244
  })
242
245
  })
243
246
 
247
+ describe('process-wide logger extension', () => {
248
+ beforeEach(() => {
249
+ forcePinoTransport()
250
+ })
251
+
252
+ it('keeps one local line and observes one correlated record with child bindings', () => {
253
+ const fake = createFakePinoHarness()
254
+ mockPinoLoader(fake.factory)
255
+ const records: unknown[] = []
256
+ registerLoggerExtension({
257
+ enrich: () => ({ trace_id: 'trace-1', span_id: 'span-1' }),
258
+ emit: (record) => records.push(record),
259
+ })
260
+
261
+ createLogger('orders')
262
+ .child({ module: 'sales' })
263
+ .info('Order placed', { orderId: 'o-1' })
264
+
265
+ expect(fake.calls).toEqual([{
266
+ level: 'info',
267
+ args: [{
268
+ orderId: 'o-1',
269
+ trace_id: 'trace-1',
270
+ span_id: 'span-1',
271
+ }, 'Order placed'],
272
+ }])
273
+ expect(records).toHaveLength(1)
274
+ expect(records[0]).toMatchObject({
275
+ level: 'info',
276
+ namespace: 'orders',
277
+ message: 'Order placed',
278
+ fields: {
279
+ module: 'sales',
280
+ orderId: 'o-1',
281
+ trace_id: 'trace-1',
282
+ span_id: 'span-1',
283
+ },
284
+ })
285
+ })
286
+
287
+ it('applies the shared level gate to local and remote output', () => {
288
+ process.env.OM_LOG_LEVEL = 'warn'
289
+ resetLogLevelCache()
290
+ const fake = createFakePinoHarness()
291
+ mockPinoLoader(fake.factory)
292
+ const emit = jest.fn()
293
+ registerLoggerExtension({ emit })
294
+ const logger = createLogger('gated-extension')
295
+
296
+ logger.info('quiet')
297
+ logger.warn('visible')
298
+
299
+ expect(fake.calls.map((call) => call.level)).toEqual(['warn'])
300
+ expect(emit).toHaveBeenCalledTimes(1)
301
+ expect(emit.mock.calls[0][0]).toMatchObject({ level: 'warn', message: 'visible' })
302
+ })
303
+
304
+ it('isolates extension failures from application logging', () => {
305
+ const fake = createFakePinoHarness()
306
+ mockPinoLoader(fake.factory)
307
+ registerLoggerExtension({
308
+ enrich: () => {
309
+ throw new Error('context failed')
310
+ },
311
+ emit: () => {
312
+ throw new Error('sink failed')
313
+ },
314
+ })
315
+
316
+ expect(() => createLogger('resilient-extension').error('Still local')).not.toThrow()
317
+ expect(fake.calls).toEqual([{ level: 'error', args: [{}, 'Still local'] }])
318
+ })
319
+ })
320
+
244
321
  describe('isomorphism', () => {
245
322
  beforeEach(() => {
246
323
  forcePinoTransport()