@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4

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 (224) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +62 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +190 -70
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/bootstrap/factory.js +18 -13
  15. package/dist/lib/bootstrap/factory.js.map +2 -2
  16. package/dist/lib/commands/command-bus.js +9 -3
  17. package/dist/lib/commands/command-bus.js.map +2 -2
  18. package/dist/lib/commands/command-interceptor-runner.js +9 -4
  19. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  20. package/dist/lib/commands/errors.js +25 -4
  21. package/dist/lib/commands/errors.js.map +2 -2
  22. package/dist/lib/commands/index.js +7 -1
  23. package/dist/lib/commands/index.js.map +2 -2
  24. package/dist/lib/crud/enricher-runner.js +2 -11
  25. package/dist/lib/crud/enricher-runner.js.map +2 -2
  26. package/dist/lib/crud/factory.js +104 -23
  27. package/dist/lib/crud/factory.js.map +2 -2
  28. package/dist/lib/crud/interceptor-runner.js +2 -2
  29. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  30. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  31. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  32. package/dist/lib/crud/types.js +4 -0
  33. package/dist/lib/crud/types.js.map +3 -3
  34. package/dist/lib/data/consistency.js +19 -0
  35. package/dist/lib/data/consistency.js.map +7 -0
  36. package/dist/lib/data/engine.js +37 -11
  37. package/dist/lib/data/engine.js.map +2 -2
  38. package/dist/lib/db/entityMetadata.js +26 -0
  39. package/dist/lib/db/entityMetadata.js.map +7 -0
  40. package/dist/lib/db/pg-errors.js +42 -0
  41. package/dist/lib/db/pg-errors.js.map +2 -2
  42. package/dist/lib/di/container.js +48 -6
  43. package/dist/lib/di/container.js.map +2 -2
  44. package/dist/lib/email/send.js +56 -0
  45. package/dist/lib/email/send.js.map +2 -2
  46. package/dist/lib/encryption/find.js +22 -0
  47. package/dist/lib/encryption/find.js.map +2 -2
  48. package/dist/lib/encryption/likeFilterWarning.js +66 -0
  49. package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
  50. package/dist/lib/encryption/subscriber.js +2 -10
  51. package/dist/lib/encryption/subscriber.js.map +2 -2
  52. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  53. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  54. package/dist/lib/i18n/config.js +1 -1
  55. package/dist/lib/i18n/config.js.map +2 -2
  56. package/dist/lib/i18n/dictionary-cache.js +5 -0
  57. package/dist/lib/i18n/dictionary-cache.js.map +2 -2
  58. package/dist/lib/i18n/server.js +4 -4
  59. package/dist/lib/i18n/server.js.map +3 -3
  60. package/dist/lib/logger/extension.js +29 -0
  61. package/dist/lib/logger/extension.js.map +7 -0
  62. package/dist/lib/logger/index.js +47 -3
  63. package/dist/lib/logger/index.js.map +3 -3
  64. package/dist/lib/modules/registry.js +36 -6
  65. package/dist/lib/modules/registry.js.map +2 -2
  66. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  67. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  68. package/dist/lib/query/ciphertext-search-warning.js +51 -0
  69. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  70. package/dist/lib/query/engine.js +60 -43
  71. package/dist/lib/query/engine.js.map +2 -2
  72. package/dist/lib/query/types.js.map +1 -1
  73. package/dist/lib/redis/connection.js +7 -2
  74. package/dist/lib/redis/connection.js.map +2 -2
  75. package/dist/lib/search/auto-indexing.js +14 -0
  76. package/dist/lib/search/auto-indexing.js.map +7 -0
  77. package/dist/lib/search/availability.js +107 -0
  78. package/dist/lib/search/availability.js.map +7 -0
  79. package/dist/lib/search/config.js +61 -2
  80. package/dist/lib/search/config.js.map +2 -2
  81. package/dist/lib/search/tokenLookup.js +46 -0
  82. package/dist/lib/search/tokenLookup.js.map +7 -0
  83. package/dist/lib/search/tokenize.js +23 -11
  84. package/dist/lib/search/tokenize.js.map +2 -2
  85. package/dist/lib/telemetry/runtime.js +35 -0
  86. package/dist/lib/telemetry/runtime.js.map +7 -0
  87. package/dist/lib/version.js +1 -1
  88. package/dist/lib/version.js.map +1 -1
  89. package/dist/lib/webhooks/body.js +57 -0
  90. package/dist/lib/webhooks/body.js.map +7 -0
  91. package/dist/lib/webhooks/inbound-types.js +1 -0
  92. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  93. package/dist/lib/webhooks/index.js +13 -1
  94. package/dist/lib/webhooks/index.js.map +2 -2
  95. package/dist/lib/webhooks/verify.js +12 -7
  96. package/dist/lib/webhooks/verify.js.map +2 -2
  97. package/dist/modules/analytics.js.map +2 -2
  98. package/dist/modules/overrides.js +50 -1
  99. package/dist/modules/overrides.js.map +2 -2
  100. package/dist/modules/payment_gateways/types.js +8 -1
  101. package/dist/modules/payment_gateways/types.js.map +2 -2
  102. package/dist/modules/registry.js +1 -0
  103. package/dist/modules/registry.js.map +2 -2
  104. package/dist/modules/widgets/extension-points.js +96 -0
  105. package/dist/modules/widgets/extension-points.js.map +7 -0
  106. package/dist/security/enabledModulesRegistry.js +56 -11
  107. package/dist/security/enabledModulesRegistry.js.map +2 -2
  108. package/dist/security/featurePolicy.js +62 -0
  109. package/dist/security/featurePolicy.js.map +7 -0
  110. package/jest.config.cjs +20 -10
  111. package/package.json +19 -9
  112. package/scripts/versionSource.cjs +65 -0
  113. package/src/lib/__tests__/versionSource.test.ts +64 -0
  114. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  115. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  116. package/src/lib/ai/llm-provider.ts +33 -0
  117. package/src/lib/ai/safety-identifier.ts +73 -0
  118. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  119. package/src/lib/auth/__tests__/server.test.ts +23 -0
  120. package/src/lib/auth/organizationScope.ts +65 -0
  121. package/src/lib/auth/server.ts +23 -2
  122. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  123. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  124. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  125. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  126. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  127. package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
  128. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  129. package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
  130. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  131. package/src/lib/bootstrap/dynamicLoader.ts +346 -96
  132. package/src/lib/bootstrap/factory.ts +21 -15
  133. package/src/lib/bootstrap/types.ts +6 -0
  134. package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
  135. package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
  136. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
  137. package/src/lib/commands/command-bus.ts +9 -3
  138. package/src/lib/commands/command-interceptor-runner.ts +27 -6
  139. package/src/lib/commands/command-interceptor.ts +8 -0
  140. package/src/lib/commands/errors.ts +67 -2
  141. package/src/lib/commands/index.ts +7 -1
  142. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  143. package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
  144. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  145. package/src/lib/crud/enricher-runner.ts +2 -11
  146. package/src/lib/crud/factory.ts +157 -22
  147. package/src/lib/crud/interceptor-runner.ts +2 -2
  148. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  149. package/src/lib/crud/types.ts +3 -0
  150. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  151. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  152. package/src/lib/data/consistency.ts +17 -0
  153. package/src/lib/data/engine.ts +50 -16
  154. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  155. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  156. package/src/lib/db/entityMetadata.ts +43 -0
  157. package/src/lib/db/pg-errors.ts +58 -0
  158. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  159. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  160. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  161. package/src/lib/di/container.ts +72 -8
  162. package/src/lib/email/__tests__/send.test.ts +37 -1
  163. package/src/lib/email/send.ts +79 -0
  164. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
  165. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  166. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  167. package/src/lib/encryption/find.ts +22 -0
  168. package/src/lib/encryption/likeFilterWarning.ts +102 -0
  169. package/src/lib/encryption/subscriber.ts +2 -14
  170. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  171. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  172. package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
  173. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  174. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  175. package/src/lib/i18n/config.ts +2 -2
  176. package/src/lib/i18n/dictionary-cache.ts +5 -0
  177. package/src/lib/i18n/server.ts +6 -2
  178. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  179. package/src/lib/logger/extension.ts +59 -0
  180. package/src/lib/logger/index.ts +59 -1
  181. package/src/lib/modules/__tests__/registry.test.ts +26 -0
  182. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  183. package/src/lib/modules/registry.ts +46 -7
  184. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  185. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  186. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
  187. package/src/lib/query/__tests__/engine.test.ts +213 -13
  188. package/src/lib/query/ciphertext-search-warning.ts +106 -0
  189. package/src/lib/query/engine.ts +102 -61
  190. package/src/lib/query/types.ts +4 -5
  191. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  192. package/src/lib/redis/connection.ts +10 -7
  193. package/src/lib/search/__tests__/availability.test.ts +211 -0
  194. package/src/lib/search/__tests__/config.test.ts +177 -0
  195. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  196. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  197. package/src/lib/search/auto-indexing.ts +22 -0
  198. package/src/lib/search/availability.ts +232 -0
  199. package/src/lib/search/config.ts +106 -8
  200. package/src/lib/search/tokenLookup.ts +133 -0
  201. package/src/lib/search/tokenize.ts +33 -11
  202. package/src/lib/telemetry/runtime.ts +72 -0
  203. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  204. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  205. package/src/lib/webhooks/body.ts +70 -0
  206. package/src/lib/webhooks/inbound-types.ts +125 -0
  207. package/src/lib/webhooks/index.ts +19 -1
  208. package/src/lib/webhooks/verify.ts +17 -9
  209. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  210. package/src/modules/__tests__/registry.test.ts +10 -2
  211. package/src/modules/analytics.ts +7 -0
  212. package/src/modules/customer-auth.ts +1 -0
  213. package/src/modules/encryption.ts +3 -0
  214. package/src/modules/entities.ts +15 -0
  215. package/src/modules/navigation/backendChrome.ts +15 -0
  216. package/src/modules/overrides.ts +103 -0
  217. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  218. package/src/modules/payment_gateways/types.ts +41 -0
  219. package/src/modules/registry.ts +3 -0
  220. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  221. package/src/modules/widgets/extension-points.ts +524 -0
  222. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  223. package/src/security/enabledModulesRegistry.ts +64 -12
  224. package/src/security/featurePolicy.ts +89 -0
@@ -1,16 +1,137 @@
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'
5
+ import { createLogger } from '../logger'
4
6
  import {
5
7
  ensureMikroOrmV7GeneratedCacheCompatibility,
6
8
  recoverMikroOrmV7GeneratedCacheFromImportError,
7
9
  } from './generatedCacheRecovery'
10
+ import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './clientOnlyModules'
8
11
  import path from 'node:path'
9
12
  import fs from 'node:fs'
10
13
  import crypto from 'node:crypto'
11
14
  import { createRequire } from 'node:module'
12
15
  import { pathToFileURL } from 'node:url'
13
16
 
17
+ let activeBootstrapLoads = 0
18
+ let esbuildRuntime: typeof import('esbuild') | null = null
19
+ let esbuildStopPromise: Promise<void> | null = null
20
+
21
+ const logger = createLogger('shared').child({ component: 'bootstrap' })
22
+
23
+ async function getEsbuildRuntime(): Promise<typeof import('esbuild')> {
24
+ if (esbuildStopPromise) await esbuildStopPromise
25
+ if (esbuildRuntime) return esbuildRuntime
26
+
27
+ const loadedRuntime = await import('esbuild')
28
+ esbuildRuntime ??= loadedRuntime
29
+ return esbuildRuntime
30
+ }
31
+
32
+ async function withEsbuildLifecycle<T>(load: () => Promise<T>): Promise<T> {
33
+ activeBootstrapLoads += 1
34
+
35
+ try {
36
+ return await load()
37
+ } finally {
38
+ activeBootstrapLoads -= 1
39
+ if (activeBootstrapLoads === 0 && esbuildRuntime) {
40
+ // esbuild keeps a helper process alive after build(). Bootstrap compilation
41
+ // is a bounded phase, so release it once every concurrent loader is done.
42
+ // A later build() call transparently starts a fresh helper process.
43
+ const runtimeToStop = esbuildRuntime
44
+ esbuildRuntime = null
45
+ const stopPromise = runtimeToStop.stop().catch((err) => {
46
+ logger.warn('Failed to stop the bootstrap compiler service', { err })
47
+ })
48
+ esbuildStopPromise = stopPromise
49
+ try {
50
+ await stopPromise
51
+ } finally {
52
+ if (esbuildStopPromise === stopPromise) esbuildStopPromise = null
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Thrown when an expected generated source file is absent.
60
+ *
61
+ * Optional registries treat this as the supported compatibility case (an app
62
+ * that never generated the file), which is what makes it distinguishable from
63
+ * a file that exists but fails to compile or import.
64
+ */
65
+ class GeneratedFileNotFoundError extends Error {
66
+ readonly filePath: string
67
+
68
+ constructor(filePath: string) {
69
+ super(`Generated file not found: ${filePath}`)
70
+ this.name = 'GeneratedFileNotFoundError'
71
+ this.filePath = filePath
72
+ }
73
+ }
74
+
75
+ /**
76
+ * esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
77
+ * first so it wins over the alias and external plugins for `*.client` dynamic imports.
78
+ *
79
+ * Exported so the wiring itself is testable: a test that only exercises
80
+ * `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
81
+ * list, which would silently reintroduce #4623.
82
+ */
83
+ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
84
+ // Plugin to resolve the @/ alias the way the app tsconfig maps it:
85
+ // `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
86
+ const aliasPlugin: import('esbuild').Plugin = {
87
+ name: 'alias-resolver',
88
+ setup(build) {
89
+ build.onResolve({ filter: /^@\// }, (args) => {
90
+ const rest = args.path.slice('@/'.length)
91
+ const bases = rest.startsWith('.mercato/')
92
+ ? [path.join(appRoot, rest)]
93
+ : [path.join(appRoot, 'src', rest), path.join(appRoot, rest)]
94
+ for (const base of bases) {
95
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) {
96
+ return { path: base }
97
+ }
98
+ for (const suffix of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
99
+ if (fs.existsSync(base + suffix)) {
100
+ return { path: base + suffix }
101
+ }
102
+ }
103
+ }
104
+ // Nothing matched — hand esbuild the literal mapping so it reports the
105
+ // missing file against the path the app author actually wrote.
106
+ return { path: path.join(appRoot, rest) }
107
+ })
108
+ },
109
+ }
110
+
111
+ // Plugin to mark non-JSON package imports as external
112
+ const externalNonJsonPlugin: import('esbuild').Plugin = {
113
+ name: 'external-non-json',
114
+ setup(build) {
115
+ // Mark all package imports as external EXCEPT JSON files
116
+ // Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
117
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
118
+ // Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
119
+ if (/^[a-zA-Z]:/.test(args.path)) {
120
+ return null // Let esbuild handle it
121
+ }
122
+ // If it's a JSON file, let esbuild bundle it
123
+ if (args.path.endsWith('.json')) {
124
+ return null // Let esbuild handle it
125
+ }
126
+ // Otherwise mark as external
127
+ return { path: args.path, external: true }
128
+ })
129
+ },
130
+ }
131
+
132
+ return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
133
+ }
134
+
14
135
  const DYNAMIC_LOADER_CACHE_VERSION = 4
15
136
 
16
137
  type DynamicLoaderCacheMetadata = {
@@ -167,6 +288,7 @@ function collectDependencyHashes(
167
288
  ): Record<string, string> {
168
289
  return Object.fromEntries(
169
290
  Object.keys(inputs)
291
+ .filter((inputPath) => !inputPath.startsWith(`${CLIENT_ONLY_STUB_NAMESPACE}:`))
170
292
  .map((inputPath) => {
171
293
  const absolutePath = path.isAbsolute(inputPath)
172
294
  ? inputPath
@@ -222,21 +344,74 @@ function cacheIsValid(
222
344
  }
223
345
 
224
346
  /**
225
- * Compile a TypeScript file to JavaScript using esbuild bundler.
226
- * This bundles the file and all its dependencies, handling JSON imports properly.
227
- * The compiled file is written next to the source file with a .mjs extension.
347
+ * Options for `compileAndImport`.
348
+ *
349
+ * Both paths default to the generated-registry layout (`<appRoot>/.mercato/generated/<file>.ts`
350
+ * compiled to a `.mjs` sibling). Sources that live elsewhere in the app — `src/di.ts` — MUST pass
351
+ * both explicitly: the default app root is derived by walking three directories up from the source,
352
+ * which only holds inside `.mercato/generated`.
353
+ */
354
+ type CompileAndImportOptions = {
355
+ appRoot?: string
356
+ outFile?: string
357
+ allowRecovery?: boolean
358
+ }
359
+
360
+ /**
361
+ * Options for `compileAppSourceFile`.
362
+ *
363
+ * `appRoot` anchors the tsconfig, the `@/` alias resolution and the dependency
364
+ * cache; `outFile` is the absolute path of the artifact to write. `format`
365
+ * selects the module system of that artifact — `'cjs'` exists for the Jest
366
+ * runtime, which cannot `import()` an ESM sibling.
367
+ */
368
+ export type CompileAppSourceOptions = {
369
+ appRoot: string
370
+ outFile: string
371
+ format?: 'esm' | 'cjs'
372
+ }
373
+
374
+ /**
375
+ * Compile one app-owned TypeScript source and its relative import graph into a
376
+ * single JavaScript artifact, leaving every package import external.
377
+ *
378
+ * This is the only supported way to load app source (`apps/<app>/src/**`,
379
+ * `.mercato/generated/**`) from a plain Node process. Those files are never
380
+ * compiled to `dist`, and Node's own type stripping cannot load them: it
381
+ * requires explicit file extensions on relative specifiers and rejects the
382
+ * decorator and enum syntax the entities and DI files use.
383
+ *
384
+ * The artifact is cached against the content of the entry, its whole bundled
385
+ * dependency graph, and the tsconfig chain, so an edit anywhere in the graph
386
+ * invalidates it.
387
+ *
388
+ * The build runs inside the shared esbuild lifecycle. Callers outside a
389
+ * bootstrap load — the generated-registry loader compiling an `@app` module —
390
+ * would otherwise hold a build on a service another scope is entitled to
391
+ * `stop()`, and would leave the helper process running afterwards. Nesting is
392
+ * safe: the scope only releases the service when the last participant exits.
228
393
  */
229
- async function compileAndImport(tsPath: string, allowRecovery: boolean = true): Promise<Record<string, unknown>> {
230
- const jsPath = tsPath.replace(/\.ts$/, '.mjs')
231
- const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))
394
+ export async function compileAppSourceFile(
395
+ tsPath: string,
396
+ options: CompileAppSourceOptions,
397
+ ): Promise<string> {
398
+ return withEsbuildLifecycle(() => compileAppSourceFileWithActiveEsbuild(tsPath, options))
399
+ }
400
+
401
+ async function compileAppSourceFileWithActiveEsbuild(
402
+ tsPath: string,
403
+ options: CompileAppSourceOptions,
404
+ ): Promise<string> {
405
+ const { appRoot, outFile } = options
406
+ const format = options.format ?? 'esm'
232
407
  const appTsconfig = path.join(appRoot, 'tsconfig.json')
233
- const metadataPath = cacheMetadataPath(jsPath)
408
+ const metadataPath = cacheMetadataPath(outFile)
234
409
 
235
410
  const tsExists = fs.existsSync(tsPath)
236
411
  const tsconfigExists = fs.existsSync(appTsconfig)
237
412
 
238
413
  if (!tsExists) {
239
- throw new Error(`Generated file not found: ${tsPath}`)
414
+ throw new GeneratedFileNotFoundError(tsPath)
240
415
  }
241
416
  if (!tsconfigExists) {
242
417
  throw new Error(`App TypeScript config not found: ${appTsconfig}`)
@@ -244,79 +419,59 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
244
419
 
245
420
  const tsconfigPaths = collectTsconfigPaths(appTsconfig)
246
421
  const expectedInputHash = cacheInputHash(tsPath, appRoot, tsconfigPaths)
247
- const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
248
-
249
- if (needsCompile) {
250
- // Dynamically import esbuild only when needed
251
- const esbuild = await import('esbuild')
252
-
253
- // Plugin to resolve @/ alias to app root (works for @app modules)
254
- const aliasPlugin: import('esbuild').Plugin = {
255
- name: 'alias-resolver',
256
- setup(build) {
257
- // Resolve @/ alias to app root
258
- build.onResolve({ filter: /^@\// }, (args) => {
259
- const resolved = path.join(appRoot, args.path.slice(2))
260
- // Try with .ts extension if base path doesn't exist
261
- if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
262
- return { path: resolved + '.ts' }
263
- }
264
- // Also check for /index.ts if it's a directory
265
- if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
266
- return { path: path.join(resolved, 'index.ts') }
267
- }
268
- return { path: resolved }
269
- })
270
- },
271
- }
272
422
 
273
- // Plugin to mark non-JSON package imports as external
274
- const externalNonJsonPlugin: import('esbuild').Plugin = {
275
- name: 'external-non-json',
276
- setup(build) {
277
- // Mark all package imports as external EXCEPT JSON files
278
- // Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
279
- build.onResolve({ filter: /^[^./]/ }, (args) => {
280
- // Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
281
- if (/^[a-zA-Z]:/.test(args.path)) {
282
- return null // Let esbuild handle it
283
- }
284
- // If it's a JSON file, let esbuild bundle it
285
- if (args.path.endsWith('.json')) {
286
- return null // Let esbuild handle it
287
- }
288
- // Otherwise mark as external
289
- return { path: args.path, external: true }
290
- })
291
- },
292
- }
423
+ if (cacheIsValid(appRoot, outFile, metadataPath, expectedInputHash)) {
424
+ return outFile
425
+ }
293
426
 
294
- // Use esbuild.build with bundling to handle JSON imports
295
- const result = await esbuild.build({
296
- entryPoints: [tsPath],
297
- outfile: jsPath,
298
- absWorkingDir: appRoot,
299
- bundle: true,
300
- metafile: true,
301
- format: 'esm',
302
- platform: 'node',
303
- target: 'node18',
304
- tsconfig: appTsconfig,
305
- plugins: [aliasPlugin, externalNonJsonPlugin],
306
- // Allow JSON imports
307
- loader: { '.json': 'json' },
308
- })
309
- const metadata: DynamicLoaderCacheMetadata = {
310
- version: DYNAMIC_LOADER_CACHE_VERSION,
311
- inputHash: expectedInputHash,
312
- outputHash: contentHash(fs.readFileSync(jsPath)),
313
- dependencies: {
314
- ...collectDependencyHashes(appRoot, result.metafile.inputs),
315
- ...hashFilesRelativeTo(appRoot, tsconfigPaths),
316
- },
317
- }
318
- fs.writeFileSync(metadataPath, JSON.stringify(metadata))
427
+ fs.mkdirSync(path.dirname(outFile), { recursive: true })
428
+ // Dynamically import esbuild only when needed
429
+ const esbuild = await getEsbuildRuntime()
430
+
431
+ // Use esbuild.build with bundling to handle JSON imports
432
+ const result = await esbuild.build({
433
+ entryPoints: [tsPath],
434
+ outfile: outFile,
435
+ absWorkingDir: appRoot,
436
+ bundle: true,
437
+ metafile: true,
438
+ format,
439
+ platform: 'node',
440
+ target: 'node18',
441
+ tsconfig: appTsconfig,
442
+ plugins: createCliBundlePlugins(appRoot),
443
+ // Allow JSON imports
444
+ loader: { '.json': 'json' },
445
+ })
446
+ const metadata: DynamicLoaderCacheMetadata = {
447
+ version: DYNAMIC_LOADER_CACHE_VERSION,
448
+ inputHash: expectedInputHash,
449
+ outputHash: contentHash(fs.readFileSync(outFile)),
450
+ dependencies: {
451
+ ...collectDependencyHashes(appRoot, result.metafile.inputs),
452
+ ...hashFilesRelativeTo(appRoot, tsconfigPaths),
453
+ },
319
454
  }
455
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata))
456
+
457
+ return outFile
458
+ }
459
+
460
+ /**
461
+ * Compile a TypeScript file to JavaScript using esbuild bundler.
462
+ * This bundles the file and all its dependencies, handling JSON imports properly.
463
+ * The compiled file is written next to the source file with a .mjs extension unless
464
+ * `outFile` says otherwise.
465
+ */
466
+ async function compileAndImport(
467
+ tsPath: string,
468
+ options: CompileAndImportOptions = {},
469
+ ): Promise<Record<string, unknown>> {
470
+ const allowRecovery = options.allowRecovery ?? true
471
+ const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
472
+ const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
473
+
474
+ await compileAppSourceFile(tsPath, { appRoot, outFile: jsPath })
320
475
 
321
476
  // Import the compiled JavaScript
322
477
  try {
@@ -333,25 +488,45 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
333
488
  throw error
334
489
  }
335
490
 
336
- return compileAndImport(tsPath, false)
491
+ return compileAndImport(tsPath, { ...options, allowRecovery: false })
337
492
  }
338
493
  }
339
494
 
340
495
 
341
496
  /**
342
- * Dynamically load bootstrap data from a resolved app directory.
343
- *
344
- * IMPORTANT: This only works in unbundled contexts (CLI, tsx).
345
- * Do NOT use this in Next.js bundled code - use static imports instead.
346
- *
347
- * For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
348
- * CLI commands are discovered separately via the CLI module system.
497
+ * Load a generated registry that older apps may not have generated yet.
349
498
  *
350
- * @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
351
- * @returns The loaded bootstrap data
352
- * @throws Error if app root cannot be found or generated files are missing
499
+ * An absent source file is the supported compatibility case and resolves to
500
+ * `fallback` quietly. Any other failure — a compile error, a broken import, a
501
+ * runtime throw at module scope — still resolves to `fallback` so bootstrap
502
+ * keeps working, but is reported at error level: a registry that silently
503
+ * degrades to nothing is exactly how command interceptors stopped applying in
504
+ * worker/CLI processes (#4327, #4491).
353
505
  */
354
- export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
506
+ async function loadOptionalGeneratedModule(
507
+ tsPath: string,
508
+ fallback: Record<string, unknown>,
509
+ ): Promise<Record<string, unknown>> {
510
+ try {
511
+ return await compileAndImport(tsPath)
512
+ } catch (error) {
513
+ if (error instanceof GeneratedFileNotFoundError) {
514
+ logger.debug('Optional generated registry not present, using empty fallback', {
515
+ file: path.basename(tsPath),
516
+ })
517
+ return fallback
518
+ }
519
+
520
+ logger.error('Failed to load generated registry, continuing without its entries', {
521
+ file: path.basename(tsPath),
522
+ filePath: tsPath,
523
+ err: error,
524
+ })
525
+ return fallback
526
+ }
527
+ }
528
+
529
+ function resolveAppRootOrThrow(appRoot?: string): AppRoot {
355
530
  const resolved: AppRoot | null = appRoot
356
531
  ? {
357
532
  generatedDir: path.join(appRoot, '.mercato', 'generated'),
@@ -368,6 +543,68 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
368
543
  )
369
544
  }
370
545
 
546
+ return resolved
547
+ }
548
+
549
+ /**
550
+ * Load the app-level DI registrar (`src/di.ts`) for the dynamic bootstrap path.
551
+ *
552
+ * The Next.js runtime imports `@/di` statically from its own `src/bootstrap.ts` and hands the
553
+ * registrar to `createBootstrap`. Worker, scheduler and CLI processes bootstrap through
554
+ * `bootstrapFromAppRoot` instead, where the `@/` alias does not exist — so without this the app's
555
+ * DI registrations silently never ran there, and every request container paid a failed
556
+ * `import('@/di')` resolution (the compatibility fallback in `lib/di/container.ts`).
557
+ *
558
+ * An absent `src/di.ts` is the supported case and resolves to `null` quietly. A file that exists
559
+ * but cannot be compiled, imported, or does not export `register` is reported at error level and
560
+ * still resolves to `null`, so a broken app DI module degrades the same way a broken generated
561
+ * registry does (#4327, #4491) instead of taking the whole process down.
562
+ */
563
+ async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null> {
564
+ const tsPath = path.join(appDir, 'src', 'di.ts')
565
+ if (!fs.existsSync(tsPath)) {
566
+ logger.debug('App-level DI module not present, skipping its registrations', { filePath: tsPath })
567
+ return null
568
+ }
569
+
570
+ try {
571
+ const appDiModule = await compileAndImport(tsPath, {
572
+ appRoot: appDir,
573
+ outFile: path.join(appDir, '.mercato', 'generated', 'app-di.compiled.mjs'),
574
+ })
575
+ const register = appDiModule.register
576
+ if (typeof register !== 'function') {
577
+ logger.error('App-level DI module exports no register(); its registrations are skipped', {
578
+ filePath: tsPath,
579
+ })
580
+ return null
581
+ }
582
+ return register as AppDiRegistrar
583
+ } catch (error) {
584
+ logger.error('Failed to load the app-level DI module; its registrations are skipped', {
585
+ filePath: tsPath,
586
+ err: error,
587
+ })
588
+ return null
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Dynamically load bootstrap data from a resolved app directory.
594
+ *
595
+ * IMPORTANT: This only works in unbundled contexts (CLI, tsx).
596
+ * Do NOT use this in Next.js bundled code - use static imports instead.
597
+ *
598
+ * For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
599
+ * CLI commands are discovered separately via the CLI module system.
600
+ *
601
+ * @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
602
+ * @returns The loaded bootstrap data
603
+ * @throws Error if app root cannot be found or generated files are missing
604
+ */
605
+ async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<BootstrapData> {
606
+ const resolved = resolveAppRootOrThrow(appRoot)
607
+
371
608
  const { generatedDir } = resolved
372
609
 
373
610
  ensureMikroOrmV7GeneratedCacheCompatibility(resolved.appDir)
@@ -392,10 +629,12 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
392
629
  compileAndImport(path.join(generatedDir, 'modules.cli.generated.ts')),
393
630
  compileAndImport(path.join(generatedDir, 'entities.generated.ts')),
394
631
  compileAndImport(path.join(generatedDir, 'di.generated.ts')),
395
- compileAndImport(path.join(generatedDir, 'search.generated.ts')).catch(() => ({ searchModuleConfigs: [] })),
396
- compileAndImport(path.join(generatedDir, 'command-loaders.generated.ts')).catch(() => ({ commandLoaderEntries: [] })),
397
- compileAndImport(path.join(generatedDir, 'command-interceptors.generated.ts')).catch(() => ({ commandInterceptorEntries: [] })),
398
- compileAndImport(path.join(generatedDir, 'workflows.generated.ts')).catch(() => ({ allCodeWorkflows: [] })),
632
+ loadOptionalGeneratedModule(path.join(generatedDir, 'search.generated.ts'), { searchModuleConfigs: [] }),
633
+ loadOptionalGeneratedModule(path.join(generatedDir, 'command-loaders.generated.ts'), { commandLoaderEntries: [] }),
634
+ loadOptionalGeneratedModule(path.join(generatedDir, 'command-interceptors.generated.ts'), {
635
+ commandInterceptorEntries: [],
636
+ }),
637
+ loadOptionalGeneratedModule(path.join(generatedDir, 'workflows.generated.ts'), { allCodeWorkflows: [] }),
399
638
  ])
400
639
 
401
640
  return {
@@ -423,6 +662,10 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
423
662
  }
424
663
  }
425
664
 
665
+ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
666
+ return withEsbuildLifecycle(() => loadBootstrapDataWithActiveEsbuild(appRoot))
667
+ }
668
+
426
669
  /**
427
670
  * Create and execute bootstrap in CLI context.
428
671
  *
@@ -437,8 +680,15 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
437
680
  */
438
681
  export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
439
682
  const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
440
- const data = await loadBootstrapData(appRoot)
441
- const bootstrap = createBootstrap(data)
683
+ const resolved = resolveAppRootOrThrow(appRoot)
684
+ // Both loads compile through esbuild, so they share one lifecycle scope: without it
685
+ // `loadBootstrapData` releases the esbuild helper process and `loadAppDiRegistrar`
686
+ // silently starts a second one that nothing ever stops.
687
+ const { data, appDiRegistrar } = await withEsbuildLifecycle(async () => ({
688
+ data: await loadBootstrapData(resolved.appDir),
689
+ appDiRegistrar: await loadAppDiRegistrar(resolved.appDir),
690
+ }))
691
+ const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
442
692
  bootstrap()
443
693
  // In CLI context, wait for async registrations (UI widgets, search configs, etc.)
444
694
  await waitForAsyncRegistration()
@@ -17,7 +17,7 @@ import { registerNotificationHandlers } from '../notifications/handler-registry'
17
17
  import { clearRegisteredIntegrations, registerBundles, registerIntegrations } from '../../modules/integrations/types'
18
18
  import { applyComponentOverridesToEntries } from '../../modules/overrides'
19
19
 
20
- let _bootstrapped = false
20
+ const _bootstrappedKeys = new Set<string>()
21
21
 
22
22
  // Store the async registration promise so callers can await it if needed
23
23
  let _asyncRegistrationPromise: Promise<void> | null = null
@@ -34,13 +34,14 @@ let _asyncRegistrationPromise: Promise<void> | null = null
34
34
  */
35
35
  export function createBootstrap(data: BootstrapData, options: BootstrapOptions = {}) {
36
36
  return function bootstrap(): void {
37
+ const registrationKey = options.registrationKey ?? 'default'
37
38
  if (options.appDiRegistrar) {
38
39
  registerAppDiRegistrar(options.appDiRegistrar)
39
40
  }
40
41
  // In development, always re-run registrations to handle HMR
41
42
  // (Module state may be reset when Turbopack reloads packages)
42
- if (_bootstrapped && process.env.NODE_ENV !== 'development') return
43
- _bootstrapped = true
43
+ if (_bootstrappedKeys.has(registrationKey) && process.env.NODE_ENV !== 'development') return
44
+ _bootstrappedKeys.add(registrationKey)
44
45
 
45
46
  // === 1. Foundation: ORM entities and DI registrars ===
46
47
  registerOrmEntities(data.entities)
@@ -138,21 +139,26 @@ export async function waitForAsyncRegistration(): Promise<void> {
138
139
  }
139
140
 
140
141
  async function registerWidgetsAndOptionalPackages(data: BootstrapData, options: BootstrapOptions): Promise<void> {
141
- // Register UI widgets (dynamic imports to avoid circular deps with ui/core packages)
142
+ // Register widget data required by server-side injection independently from
143
+ // browser-facing UI registries. API-only bootstraps avoid loading @open-mercato/ui.
142
144
  try {
143
- const [dashboardRegistry, injectionRegistry, coreInjection] = await Promise.all([
144
- import('@open-mercato/ui/backend/dashboard/widgetRegistry'),
145
- import('@open-mercato/ui/backend/injection/widgetRegistry'),
146
- import('@open-mercato/core/modules/widgets/lib/injection'),
147
- ])
148
-
149
- dashboardRegistry.registerDashboardWidgets(data.dashboardWidgetEntries)
150
- injectionRegistry.registerInjectionWidgets(data.injectionWidgetEntries)
151
- coreInjection.registerCoreInjectionWidgets(data.injectionWidgetEntries)
145
+ const coreInjection = await import('@open-mercato/core/modules/widgets/lib/injection')
146
+ if (!options.skipCoreInjectionWidgets) {
147
+ coreInjection.registerCoreInjectionWidgets(data.injectionWidgetEntries)
148
+ }
152
149
  coreInjection.registerCoreInjectionTables(data.injectionTables)
153
150
  coreInjection.registerEnabledModuleIds(
154
151
  data.modules.map((module) => module.id).filter((id): id is string => typeof id === 'string' && id.length > 0),
155
152
  )
153
+
154
+ if (!options.skipUiRegistries) {
155
+ const [dashboardRegistry, injectionRegistry] = await Promise.all([
156
+ import('@open-mercato/ui/backend/dashboard/widgetRegistry'),
157
+ import('@open-mercato/ui/backend/injection/widgetRegistry'),
158
+ ])
159
+ dashboardRegistry.registerDashboardWidgets(data.dashboardWidgetEntries)
160
+ injectionRegistry.registerInjectionWidgets(data.injectionWidgetEntries)
161
+ }
156
162
  } catch {
157
163
  // UI packages may not be available in all contexts
158
164
  }
@@ -170,12 +176,12 @@ async function registerWidgetsAndOptionalPackages(data: BootstrapData, options:
170
176
  * Check if bootstrap has been called.
171
177
  */
172
178
  export function isBootstrapped(): boolean {
173
- return _bootstrapped
179
+ return _bootstrappedKeys.size > 0
174
180
  }
175
181
 
176
182
  /**
177
183
  * Reset bootstrap state. Useful for testing.
178
184
  */
179
185
  export function resetBootstrapState(): void {
180
- _bootstrapped = false
186
+ _bootstrappedKeys.clear()
181
187
  }
@@ -69,6 +69,12 @@ export interface BootstrapData {
69
69
 
70
70
  export interface BootstrapOptions {
71
71
  skipSearchConfigs?: boolean
72
+ /** Independent registration scope for partitioned runtime bootstraps. */
73
+ registrationKey?: string
74
+ /** Skip browser-facing dashboard/injection registries in API-only runtimes. */
75
+ skipUiRegistries?: boolean
76
+ /** Keep an existing core injection-widget registry intact in partitioned runtimes. */
77
+ skipCoreInjectionWidgets?: boolean
72
78
  onRegistrationComplete?: () => void
73
79
  appDiRegistrar?: AppDiRegistrar
74
80
  }