@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (195) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +57 -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 +117 -43
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/commands/command-bus.js +5 -1
  15. package/dist/lib/commands/command-bus.js.map +2 -2
  16. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  17. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/enricher-runner.js +2 -11
  19. package/dist/lib/crud/enricher-runner.js.map +2 -2
  20. package/dist/lib/crud/factory.js +72 -9
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/interceptor-runner.js +2 -2
  23. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  24. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  25. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  26. package/dist/lib/crud/types.js +4 -0
  27. package/dist/lib/crud/types.js.map +3 -3
  28. package/dist/lib/data/consistency.js +19 -0
  29. package/dist/lib/data/consistency.js.map +7 -0
  30. package/dist/lib/data/engine.js +37 -11
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/db/entityMetadata.js +26 -0
  33. package/dist/lib/db/entityMetadata.js.map +7 -0
  34. package/dist/lib/db/pg-errors.js +42 -0
  35. package/dist/lib/db/pg-errors.js.map +2 -2
  36. package/dist/lib/di/container.js +48 -6
  37. package/dist/lib/di/container.js.map +2 -2
  38. package/dist/lib/email/send.js +56 -0
  39. package/dist/lib/email/send.js.map +2 -2
  40. package/dist/lib/encryption/subscriber.js +2 -10
  41. package/dist/lib/encryption/subscriber.js.map +2 -2
  42. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  43. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  44. package/dist/lib/i18n/config.js +1 -1
  45. package/dist/lib/i18n/config.js.map +2 -2
  46. package/dist/lib/i18n/server.js +4 -4
  47. package/dist/lib/i18n/server.js.map +3 -3
  48. package/dist/lib/logger/extension.js +29 -0
  49. package/dist/lib/logger/extension.js.map +7 -0
  50. package/dist/lib/logger/index.js +47 -3
  51. package/dist/lib/logger/index.js.map +3 -3
  52. package/dist/lib/modules/registry.js +5 -1
  53. package/dist/lib/modules/registry.js.map +2 -2
  54. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  55. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  56. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  57. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  58. package/dist/lib/query/engine.js +54 -40
  59. package/dist/lib/query/engine.js.map +2 -2
  60. package/dist/lib/query/types.js.map +1 -1
  61. package/dist/lib/redis/connection.js +7 -2
  62. package/dist/lib/redis/connection.js.map +2 -2
  63. package/dist/lib/search/auto-indexing.js +14 -0
  64. package/dist/lib/search/auto-indexing.js.map +7 -0
  65. package/dist/lib/search/availability.js +107 -0
  66. package/dist/lib/search/availability.js.map +7 -0
  67. package/dist/lib/search/config.js +61 -2
  68. package/dist/lib/search/config.js.map +2 -2
  69. package/dist/lib/search/tokenLookup.js +46 -0
  70. package/dist/lib/search/tokenLookup.js.map +7 -0
  71. package/dist/lib/search/tokenize.js +23 -11
  72. package/dist/lib/search/tokenize.js.map +2 -2
  73. package/dist/lib/telemetry/runtime.js +35 -0
  74. package/dist/lib/telemetry/runtime.js.map +7 -0
  75. package/dist/lib/version.js +1 -1
  76. package/dist/lib/version.js.map +1 -1
  77. package/dist/lib/webhooks/body.js +57 -0
  78. package/dist/lib/webhooks/body.js.map +7 -0
  79. package/dist/lib/webhooks/inbound-types.js +1 -0
  80. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  81. package/dist/lib/webhooks/index.js +13 -1
  82. package/dist/lib/webhooks/index.js.map +2 -2
  83. package/dist/lib/webhooks/verify.js +12 -7
  84. package/dist/lib/webhooks/verify.js.map +2 -2
  85. package/dist/modules/analytics.js.map +2 -2
  86. package/dist/modules/overrides.js +50 -1
  87. package/dist/modules/overrides.js.map +2 -2
  88. package/dist/modules/payment_gateways/types.js +8 -1
  89. package/dist/modules/payment_gateways/types.js.map +2 -2
  90. package/dist/modules/registry.js +1 -0
  91. package/dist/modules/registry.js.map +2 -2
  92. package/dist/modules/widgets/extension-points.js +96 -0
  93. package/dist/modules/widgets/extension-points.js.map +7 -0
  94. package/dist/security/enabledModulesRegistry.js +56 -11
  95. package/dist/security/enabledModulesRegistry.js.map +2 -2
  96. package/dist/security/featurePolicy.js +62 -0
  97. package/dist/security/featurePolicy.js.map +7 -0
  98. package/jest.config.cjs +2 -1
  99. package/package.json +19 -9
  100. package/scripts/versionSource.cjs +65 -0
  101. package/src/lib/__tests__/versionSource.test.ts +46 -0
  102. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  103. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  104. package/src/lib/ai/llm-provider.ts +33 -0
  105. package/src/lib/ai/safety-identifier.ts +73 -0
  106. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  107. package/src/lib/auth/__tests__/server.test.ts +23 -0
  108. package/src/lib/auth/organizationScope.ts +65 -0
  109. package/src/lib/auth/server.ts +23 -2
  110. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  111. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  112. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  113. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  114. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  115. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  116. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  117. package/src/lib/bootstrap/dynamicLoader.ts +213 -65
  118. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  119. package/src/lib/commands/command-bus.ts +5 -1
  120. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  121. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  122. package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
  123. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  124. package/src/lib/crud/enricher-runner.ts +2 -11
  125. package/src/lib/crud/factory.ts +82 -6
  126. package/src/lib/crud/interceptor-runner.ts +2 -2
  127. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  128. package/src/lib/crud/types.ts +3 -0
  129. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  130. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  131. package/src/lib/data/consistency.ts +17 -0
  132. package/src/lib/data/engine.ts +50 -16
  133. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  134. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  135. package/src/lib/db/entityMetadata.ts +43 -0
  136. package/src/lib/db/pg-errors.ts +58 -0
  137. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  138. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  139. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  140. package/src/lib/di/container.ts +72 -8
  141. package/src/lib/email/__tests__/send.test.ts +37 -1
  142. package/src/lib/email/send.ts +79 -0
  143. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  144. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  145. package/src/lib/encryption/subscriber.ts +2 -14
  146. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  147. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  148. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  149. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  150. package/src/lib/i18n/config.ts +2 -2
  151. package/src/lib/i18n/server.ts +6 -2
  152. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  153. package/src/lib/logger/extension.ts +59 -0
  154. package/src/lib/logger/index.ts +59 -1
  155. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  156. package/src/lib/modules/registry.ts +10 -0
  157. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  158. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  159. package/src/lib/query/__tests__/engine.test.ts +65 -13
  160. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  161. package/src/lib/query/engine.ts +75 -58
  162. package/src/lib/query/types.ts +4 -5
  163. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  164. package/src/lib/redis/connection.ts +10 -7
  165. package/src/lib/search/__tests__/availability.test.ts +211 -0
  166. package/src/lib/search/__tests__/config.test.ts +177 -0
  167. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  168. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  169. package/src/lib/search/auto-indexing.ts +22 -0
  170. package/src/lib/search/availability.ts +232 -0
  171. package/src/lib/search/config.ts +106 -8
  172. package/src/lib/search/tokenLookup.ts +133 -0
  173. package/src/lib/search/tokenize.ts +33 -11
  174. package/src/lib/telemetry/runtime.ts +72 -0
  175. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  176. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  177. package/src/lib/webhooks/body.ts +70 -0
  178. package/src/lib/webhooks/inbound-types.ts +125 -0
  179. package/src/lib/webhooks/index.ts +19 -1
  180. package/src/lib/webhooks/verify.ts +17 -9
  181. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  182. package/src/modules/__tests__/registry.test.ts +10 -2
  183. package/src/modules/analytics.ts +7 -0
  184. package/src/modules/customer-auth.ts +1 -0
  185. package/src/modules/encryption.ts +3 -0
  186. package/src/modules/navigation/backendChrome.ts +15 -0
  187. package/src/modules/overrides.ts +103 -0
  188. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  189. package/src/modules/payment_gateways/types.ts +41 -0
  190. package/src/modules/registry.ts +3 -0
  191. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  192. package/src/modules/widgets/extension-points.ts +524 -0
  193. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  194. package/src/security/enabledModulesRegistry.ts +64 -12
  195. package/src/security/featurePolicy.ts +89 -0
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import {
6
+ createClientOnlyStubPlugin,
7
+ encodeJsStringLiteral,
8
+ isClientOnlyModulePath,
9
+ renderClientOnlyModuleStub,
10
+ } from '../clientOnlyModules'
11
+ import { createCliBundlePlugins } from '../dynamicLoader'
12
+
13
+ const CHART_IMPORT = '@open-mercato/ui/backend/charts'
14
+
15
+ function createServerHelperFixture(tempDir: string): string {
16
+ const moduleDir = path.join(tempDir, 'src', 'modules', 'demo')
17
+ fs.mkdirSync(moduleDir, { recursive: true })
18
+
19
+ fs.writeFileSync(
20
+ path.join(moduleDir, 'http.client.ts'),
21
+ ['export const httpClient = { fetchOrders: async () => [] }', ''].join('\n'),
22
+ )
23
+
24
+ const entry = path.join(tempDir, 'modules.cli.generated.ts')
25
+ fs.writeFileSync(
26
+ entry,
27
+ [
28
+ "import { httpClient } from './src/modules/demo/http.client'",
29
+ 'export const modules = [{',
30
+ " id: 'demo',",
31
+ ' workers: [{ handler: () => httpClient.fetchOrders() }],',
32
+ '}]',
33
+ '',
34
+ ].join('\n'),
35
+ )
36
+
37
+ return entry
38
+ }
39
+
40
+ function createAppModuleFixture(tempDir: string): string {
41
+ const widgetDir = path.join(tempDir, 'src', 'modules', 'demo', 'widgets', 'dashboard', 'sales')
42
+ fs.mkdirSync(widgetDir, { recursive: true })
43
+
44
+ fs.writeFileSync(
45
+ path.join(widgetDir, 'widget.client.tsx'),
46
+ [
47
+ '"use client"',
48
+ `import { BarChart } from '${CHART_IMPORT}'`,
49
+ 'export default function DemoWidget() { return BarChart }',
50
+ '',
51
+ ].join('\n'),
52
+ )
53
+
54
+ const entry = path.join(tempDir, 'modules.cli.generated.ts')
55
+ fs.writeFileSync(
56
+ entry,
57
+ [
58
+ 'export const modules = [{',
59
+ " id: 'demo',",
60
+ ' dashboardWidgets: [{',
61
+ " moduleId: 'demo',",
62
+ " key: 'demo:sales:widget',",
63
+ " loader: () => import('./src/modules/demo/widgets/dashboard/sales/widget.client').then((mod) => mod.default ?? mod),",
64
+ ' }],',
65
+ '}]',
66
+ '',
67
+ ].join('\n'),
68
+ )
69
+
70
+ return entry
71
+ }
72
+
73
+ async function bundle(entry: string, outfile: string, withStubPlugin: boolean): Promise<string> {
74
+ const esbuild = await import('esbuild')
75
+ await esbuild.build({
76
+ entryPoints: [entry],
77
+ outfile,
78
+ bundle: true,
79
+ format: 'esm',
80
+ platform: 'node',
81
+ target: 'node18',
82
+ external: ['@open-mercato/*'],
83
+ plugins: withStubPlugin ? [createClientOnlyStubPlugin()] : [],
84
+ })
85
+ return fs.readFileSync(outfile, 'utf8')
86
+ }
87
+
88
+ describe('isClientOnlyModulePath', () => {
89
+ it('matches local client modules with and without an extension', () => {
90
+ expect(isClientOnlyModulePath('./widget.client')).toBe(true)
91
+ expect(isClientOnlyModulePath('./widget.client.tsx')).toBe(true)
92
+ expect(isClientOnlyModulePath('../dashboard/sales/widget.client.ts')).toBe(true)
93
+ expect(isClientOnlyModulePath('@/modules/demo/widgets/dashboard/sales/widget.client')).toBe(true)
94
+ expect(isClientOnlyModulePath('./notifications.client.ts')).toBe(true)
95
+ })
96
+
97
+ it('leaves bare package specifiers to the external-import plugin', () => {
98
+ expect(isClientOnlyModulePath('@open-mercato/ui/backend/charts')).toBe(false)
99
+ expect(isClientOnlyModulePath('@open-mercato/core/modules/demo/widget.client')).toBe(false)
100
+ expect(isClientOnlyModulePath('react')).toBe(false)
101
+ })
102
+
103
+ it('does not match server modules that merely mention client', () => {
104
+ expect(isClientOnlyModulePath('./widget.ts')).toBe(false)
105
+ expect(isClientOnlyModulePath('./clientFactory.ts')).toBe(false)
106
+ expect(isClientOnlyModulePath('./client/index.ts')).toBe(false)
107
+ })
108
+
109
+ it('does not match the generated notification-renderer registry', () => {
110
+ expect(isClientOnlyModulePath('@/.mercato/generated/notifications.client.generated')).toBe(false)
111
+ expect(isClientOnlyModulePath('@/.mercato/generated/notifications.client.generated.ts')).toBe(false)
112
+ })
113
+ })
114
+
115
+ describe('encodeJsStringLiteral', () => {
116
+ it('escapes quotes and backslashes so the literal cannot be terminated early', () => {
117
+ expect(encodeJsStringLiteral('say "hi"')).toBe('"say \\"hi\\""')
118
+ expect(encodeJsStringLiteral('a\\b')).toBe('"a\\\\b"')
119
+ })
120
+
121
+ it('escapes every character outside printable ASCII, including the separators JSON leaves raw', () => {
122
+ expect(encodeJsStringLiteral('a\u2028b')).toBe('"a\\u2028b"')
123
+ expect(encodeJsStringLiteral('a\u2029b')).toBe('"a\\u2029b"')
124
+ expect(encodeJsStringLiteral('a\nb')).toBe('"a\\u000ab"')
125
+ })
126
+
127
+ it('leaves no unescaped delimiter that could break out of the literal', () => {
128
+ const hostile = './widget.client"); globalThis.pwned = true; ("'
129
+ const encoded = encodeJsStringLiteral(hostile)
130
+
131
+ expect(encoded.startsWith('"')).toBe(true)
132
+ expect(encoded.endsWith('"')).toBe(true)
133
+ expect(encoded.slice(1, -1)).not.toMatch(/(^|[^\\])"/)
134
+ expect(JSON.parse(encoded)).toBe(hostile)
135
+ })
136
+ })
137
+
138
+ describe('renderClientOnlyModuleStub', () => {
139
+ it('exports a default that throws when the browser component is actually used', () => {
140
+ const stub = renderClientOnlyModuleStub('./widget.client')
141
+ expect(stub).toContain('export default clientOnlyModuleUnavailable')
142
+ expect(stub).toContain('./widget.client')
143
+ })
144
+ })
145
+
146
+ describe('createCliBundlePlugins', () => {
147
+ it('registers the client-only stub ahead of the alias and external plugins', () => {
148
+ expect(createCliBundlePlugins('/tmp/app-root').map((plugin) => plugin.name)).toEqual([
149
+ 'client-only-stub',
150
+ 'alias-resolver',
151
+ 'external-non-json',
152
+ ])
153
+ })
154
+ })
155
+
156
+ describe('CLI bundle graph', () => {
157
+ let tempDir: string
158
+
159
+ beforeEach(() => {
160
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-mercato-client-only-'))
161
+ })
162
+
163
+ afterEach(() => {
164
+ fs.rmSync(tempDir, { recursive: true, force: true })
165
+ })
166
+
167
+ it('keeps browser-only widget imports out of the CLI bundle', async () => {
168
+ const entry = createAppModuleFixture(tempDir)
169
+ const output = await bundle(entry, path.join(tempDir, 'stubbed.mjs'), true)
170
+
171
+ expect(output).not.toContain(CHART_IMPORT)
172
+ expect(output).toContain('clientOnlyModuleUnavailable')
173
+ })
174
+
175
+ it('without the plugin the same fixture hoists the browser-only import (regression guard)', async () => {
176
+ const entry = createAppModuleFixture(tempDir)
177
+ const output = await bundle(entry, path.join(tempDir, 'plain.mjs'), false)
178
+
179
+ expect(output).toContain(CHART_IMPORT)
180
+ })
181
+
182
+ it('leaves statically imported server helpers alone even when they are named *.client', async () => {
183
+ const entry = createServerHelperFixture(tempDir)
184
+ const output = await bundle(entry, path.join(tempDir, 'server-helper.mjs'), true)
185
+
186
+ expect(output).toContain('fetchOrders')
187
+ expect(output).not.toContain('clientOnlyModuleUnavailable')
188
+ })
189
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * The CLI bundle's `@/` alias must mirror what the app tsconfig maps, because the dynamic
5
+ * bootstrap path compiles app sources (`src/di.ts`) and generated registries that import app
6
+ * modules through the alias. The tsconfig maps `@/.mercato/*` to the app root and every other
7
+ * `@/*` to the app's `src/` directory; resolving everything against the app root silently sends
8
+ * `@/lib/x` to a path that does not exist.
9
+ */
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+
14
+ import { createCliBundlePlugins } from '../dynamicLoader'
15
+
16
+ async function bundleWithCliPlugins(appRoot: string, entry: string): Promise<string> {
17
+ const esbuild = await import('esbuild')
18
+ const outfile = path.join(appRoot, 'bundle.mjs')
19
+ await esbuild.build({
20
+ entryPoints: [entry],
21
+ outfile,
22
+ absWorkingDir: appRoot,
23
+ bundle: true,
24
+ format: 'esm',
25
+ platform: 'node',
26
+ target: 'node18',
27
+ plugins: createCliBundlePlugins(appRoot),
28
+ })
29
+ return fs.readFileSync(outfile, 'utf8')
30
+ }
31
+
32
+ describe('createCliBundlePlugins — @/ alias resolution', () => {
33
+ let appRoot: string
34
+
35
+ beforeEach(() => {
36
+ appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-alias-resolver-'))
37
+ fs.mkdirSync(path.join(appRoot, 'src', 'lib'), { recursive: true })
38
+ fs.mkdirSync(path.join(appRoot, 'src', 'modules', 'demo'), { recursive: true })
39
+ fs.mkdirSync(path.join(appRoot, '.mercato', 'generated'), { recursive: true })
40
+
41
+ fs.writeFileSync(
42
+ path.join(appRoot, 'src', 'lib', 'marker.ts'),
43
+ "export const srcLibMarker = 'resolved-from-src-lib'\n",
44
+ )
45
+ fs.writeFileSync(
46
+ path.join(appRoot, 'src', 'modules', 'demo', 'index.ts'),
47
+ "export const srcModuleMarker = 'resolved-from-src-module-index'\n",
48
+ )
49
+ fs.writeFileSync(
50
+ path.join(appRoot, '.mercato', 'generated', 'registry.generated.ts'),
51
+ "export const generatedMarker = 'resolved-from-generated'\n",
52
+ )
53
+ })
54
+
55
+ afterEach(() => {
56
+ fs.rmSync(appRoot, { recursive: true, force: true })
57
+ })
58
+
59
+ it('resolves @/* against the app src directory and @/.mercato/* against the app root', async () => {
60
+ const entry = path.join(appRoot, '.mercato', 'generated', 'entry.ts')
61
+ fs.writeFileSync(
62
+ entry,
63
+ [
64
+ "import { srcLibMarker } from '@/lib/marker'",
65
+ "import { srcModuleMarker } from '@/modules/demo'",
66
+ "import { generatedMarker } from '@/.mercato/generated/registry.generated'",
67
+ 'export const markers = [srcLibMarker, srcModuleMarker, generatedMarker]',
68
+ '',
69
+ ].join('\n'),
70
+ )
71
+
72
+ const output = await bundleWithCliPlugins(appRoot, entry)
73
+
74
+ expect(output).toContain('resolved-from-src-lib')
75
+ expect(output).toContain('resolved-from-src-module-index')
76
+ expect(output).toContain('resolved-from-generated')
77
+ })
78
+
79
+ it('prefers an app-root match when the specifier has no src counterpart', async () => {
80
+ fs.writeFileSync(
81
+ path.join(appRoot, 'root-only.ts'),
82
+ "export const rootOnlyMarker = 'resolved-from-app-root'\n",
83
+ )
84
+ const entry = path.join(appRoot, '.mercato', 'generated', 'entry.ts')
85
+ fs.writeFileSync(
86
+ entry,
87
+ [
88
+ "import { rootOnlyMarker } from '@/root-only'",
89
+ 'export const markers = [rootOnlyMarker]',
90
+ '',
91
+ ].join('\n'),
92
+ )
93
+
94
+ const output = await bundleWithCliPlugins(appRoot, entry)
95
+
96
+ expect(output).toContain('resolved-from-app-root')
97
+ })
98
+ })
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @jest-environment node
3
+ *
4
+ * Regression guard: the worker/CLI/scheduler bootstrap path (bootstrapFromAppRoot) must register
5
+ * the app-level DI hook (`src/di.ts`) the same way the Next.js runtime does through its own
6
+ * `src/bootstrap.ts`. Without it, `getAppDiRegistrar()` stays null in those processes, so the app's
7
+ * registrations silently never run there and every request container falls back to a doomed
8
+ * `import('@/di')` — the `@/` alias does not exist in a plain Node ESM process, so it throws
9
+ * ERR_MODULE_NOT_FOUND once per created container (once per queued job).
10
+ *
11
+ * Like the #4327 guard next to it, this test authors both the `.ts` sources and fresh compiled
12
+ * `.mjs` siblings so compileAndImport takes its cache path and never invokes esbuild. The `.mjs`
13
+ * stubs use module.exports because Jest's CJS runtime handles the dynamic import().
14
+ *
15
+ * `bootstrapFromAppRoot` reaches the factory through `import('./factory.js')`, a specifier with no
16
+ * on-disk counterpart under Jest's CJS resolver, so it is mocked virtually.
17
+ */
18
+ jest.mock('../../logger', () => {
19
+ const logger = {
20
+ debug: jest.fn(),
21
+ info: jest.fn(),
22
+ warn: jest.fn(),
23
+ error: jest.fn(),
24
+ child: () => logger,
25
+ }
26
+ return { createLogger: () => logger }
27
+ })
28
+
29
+ const createBootstrapMock = jest.fn(() => () => {})
30
+
31
+ jest.mock(
32
+ '../factory.js',
33
+ () => ({
34
+ createBootstrap: (...args: unknown[]) => createBootstrapMock(...(args as [])),
35
+ waitForAsyncRegistration: async () => {},
36
+ }),
37
+ { virtual: true },
38
+ )
39
+
40
+ import fs from 'node:fs'
41
+ import os from 'node:os'
42
+ import path from 'node:path'
43
+ import crypto from 'node:crypto'
44
+ import { createLogger } from '../../logger'
45
+ import { bootstrapFromAppRoot } from '../dynamicLoader'
46
+
47
+ const mockedLogger = createLogger('shared') as unknown as {
48
+ debug: jest.Mock
49
+ error: jest.Mock
50
+ }
51
+
52
+ const APP_DI_CALL_COUNTER_KEY = '__omTestAppDiRegisterCalls__'
53
+
54
+ const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
55
+ 'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
56
+ 'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
57
+ 'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
58
+ 'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
59
+ }
60
+
61
+ const APP_TSCONFIG = JSON.stringify({
62
+ compilerOptions: {
63
+ experimentalDecorators: true,
64
+ emitDecoratorMetadata: true,
65
+ useDefineForClassFields: false,
66
+ target: 'ES2022',
67
+ },
68
+ })
69
+
70
+ const APP_DI_TS = 'export function register() {}'
71
+
72
+ const APP_DI_COMPILED = [
73
+ 'module.exports = {',
74
+ ' register: () => {',
75
+ ` globalThis['${APP_DI_CALL_COUNTER_KEY}'] = (globalThis['${APP_DI_CALL_COUNTER_KEY}'] ?? 0) + 1`,
76
+ ' },',
77
+ '}',
78
+ ].join('\n')
79
+
80
+ function hash(content: string): string {
81
+ return crypto.createHash('sha256').update(content).digest('hex')
82
+ }
83
+
84
+ function writeCompiledPair(
85
+ appRoot: string,
86
+ sourcePath: string,
87
+ compiledPath: string,
88
+ source: { ts: string; compiled: string },
89
+ ) {
90
+ fs.writeFileSync(sourcePath, source.ts)
91
+ fs.writeFileSync(compiledPath, source.compiled)
92
+ const sourceRelativePath = path.relative(appRoot, sourcePath).split(path.sep).join('/')
93
+ fs.writeFileSync(`${compiledPath}.cache.json`, JSON.stringify({
94
+ version: 4,
95
+ inputHash: hash(JSON.stringify({
96
+ version: 4,
97
+ sourceHash: hash(source.ts),
98
+ tsconfigHashes: {
99
+ 'tsconfig.json': hash(APP_TSCONFIG),
100
+ },
101
+ })),
102
+ outputHash: hash(source.compiled),
103
+ dependencies: {
104
+ [sourceRelativePath]: hash(source.ts),
105
+ },
106
+ }))
107
+ }
108
+
109
+ const createdAppRoots: string[] = []
110
+
111
+ function createAppRoot(appDi: { ts: string; compiled: string } | null): string {
112
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-app-di-'))
113
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
114
+ fs.mkdirSync(generatedDir, { recursive: true })
115
+ fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), APP_TSCONFIG)
116
+
117
+ for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
118
+ writeCompiledPair(
119
+ appRoot,
120
+ path.join(generatedDir, `${baseName}.ts`),
121
+ path.join(generatedDir, `${baseName}.mjs`),
122
+ source,
123
+ )
124
+ }
125
+
126
+ if (appDi) {
127
+ fs.mkdirSync(path.join(appRoot, 'src'), { recursive: true })
128
+ writeCompiledPair(
129
+ appRoot,
130
+ path.join(appRoot, 'src', 'di.ts'),
131
+ path.join(generatedDir, 'app-di.compiled.mjs'),
132
+ appDi,
133
+ )
134
+ }
135
+
136
+ createdAppRoots.push(appRoot)
137
+ return appRoot
138
+ }
139
+
140
+ function appDiRegistrarArgument(): unknown {
141
+ const [, options] = createBootstrapMock.mock.calls[0] as unknown as [unknown, Record<string, unknown>]
142
+ return options?.appDiRegistrar
143
+ }
144
+
145
+ describe('bootstrapFromAppRoot — app-level DI reaches worker/CLI processes', () => {
146
+ afterAll(() => {
147
+ for (const root of createdAppRoots) {
148
+ fs.rmSync(root, { recursive: true, force: true })
149
+ }
150
+ })
151
+
152
+ beforeEach(() => {
153
+ createBootstrapMock.mockClear()
154
+ mockedLogger.debug.mockClear()
155
+ mockedLogger.error.mockClear()
156
+ delete (globalThis as Record<string, unknown>)[APP_DI_CALL_COUNTER_KEY]
157
+ })
158
+
159
+ it('passes the app DI register() from src/di.ts to createBootstrap', async () => {
160
+ const appRoot = createAppRoot({ ts: APP_DI_TS, compiled: APP_DI_COMPILED })
161
+
162
+ await bootstrapFromAppRoot(appRoot)
163
+
164
+ const registrar = appDiRegistrarArgument()
165
+ expect(typeof registrar).toBe('function')
166
+
167
+ await (registrar as (container: unknown) => unknown)({})
168
+ expect((globalThis as Record<string, unknown>)[APP_DI_CALL_COUNTER_KEY]).toBe(1)
169
+ expect(mockedLogger.error).not.toHaveBeenCalled()
170
+ })
171
+
172
+ it('bootstraps without an app DI registrar when src/di.ts is absent', async () => {
173
+ const appRoot = createAppRoot(null)
174
+
175
+ await bootstrapFromAppRoot(appRoot)
176
+
177
+ expect(createBootstrapMock).toHaveBeenCalledTimes(1)
178
+ expect(appDiRegistrarArgument()).toBeUndefined()
179
+ expect(mockedLogger.error).not.toHaveBeenCalled()
180
+ })
181
+
182
+ it('reports an error and keeps bootstrapping when src/di.ts fails to load', async () => {
183
+ const appRoot = createAppRoot({
184
+ ts: APP_DI_TS,
185
+ compiled: "throw new Error('src/di.ts is broken')",
186
+ })
187
+
188
+ await bootstrapFromAppRoot(appRoot)
189
+
190
+ expect(createBootstrapMock).toHaveBeenCalledTimes(1)
191
+ expect(appDiRegistrarArgument()).toBeUndefined()
192
+ expect(mockedLogger.error).toHaveBeenCalledTimes(1)
193
+ const [message, fields] = mockedLogger.error.mock.calls[0] as [string, Record<string, unknown>]
194
+ expect(message).toContain('Failed to load the app-level DI module')
195
+ expect((fields.err as Error).message).toContain('src/di.ts is broken')
196
+ })
197
+
198
+ it('reports an error and keeps bootstrapping when src/di.ts exports no register()', async () => {
199
+ const appRoot = createAppRoot({
200
+ ts: APP_DI_TS,
201
+ compiled: 'module.exports = {}',
202
+ })
203
+
204
+ await bootstrapFromAppRoot(appRoot)
205
+
206
+ expect(createBootstrapMock).toHaveBeenCalledTimes(1)
207
+ expect(appDiRegistrarArgument()).toBeUndefined()
208
+ expect(mockedLogger.error).toHaveBeenCalledTimes(1)
209
+ expect(mockedLogger.error.mock.calls[0][0]).toContain('exports no register()')
210
+ })
211
+ })
@@ -13,13 +13,34 @@
13
13
  * The .mjs stubs use module.exports because Jest's CJS runtime handles the
14
14
  * dynamic import() and does not transform .mjs files; the loader consumes the
15
15
  * named exports identically either way.
16
+ *
17
+ * The third case guards #4491: the compatibility fallback to an empty list must
18
+ * stay silent for an absent registry, but must report a registry that exists and
19
+ * fails to load — otherwise the #4327 condition can recur with no diagnostics.
16
20
  */
21
+ jest.mock('../../logger', () => {
22
+ const logger = {
23
+ debug: jest.fn(),
24
+ info: jest.fn(),
25
+ warn: jest.fn(),
26
+ error: jest.fn(),
27
+ child: () => logger,
28
+ }
29
+ return { createLogger: () => logger }
30
+ })
31
+
17
32
  import fs from 'node:fs'
18
33
  import os from 'node:os'
19
34
  import path from 'node:path'
35
+ import { createLogger } from '../../logger'
20
36
  import crypto from 'node:crypto'
21
37
  import { loadBootstrapData } from '../dynamicLoader'
22
38
 
39
+ const mockedLogger = createLogger('shared') as unknown as {
40
+ debug: jest.Mock
41
+ error: jest.Mock
42
+ }
43
+
23
44
  const GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
24
45
  'entities.ids.generated': { ts: 'export const E = {}', compiled: 'module.exports = { E: {} }' },
25
46
  'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
@@ -71,21 +92,36 @@ function writeGeneratedModule(generatedDir: string, baseName: string, source: {
71
92
  }))
72
93
  }
73
94
 
95
+ const createdAppRoots: string[] = []
96
+
97
+ function createAppRoot(overrides: Record<string, { ts: string; compiled: string }> = {}): string {
98
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4327-'))
99
+ const generatedDir = path.join(appRoot, '.mercato', 'generated')
100
+ fs.mkdirSync(generatedDir, { recursive: true })
101
+ fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), APP_TSCONFIG)
102
+ for (const [baseName, source] of Object.entries({ ...GENERATED_MODULES, ...overrides })) {
103
+ writeGeneratedModule(generatedDir, baseName, source)
104
+ }
105
+ createdAppRoots.push(appRoot)
106
+ return appRoot
107
+ }
108
+
74
109
  describe('loadBootstrapData — command interceptors reach worker/CLI bootstrap (#4327)', () => {
75
110
  let appRoot: string
76
111
 
77
112
  beforeAll(() => {
78
- appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4327-'))
79
- const generatedDir = path.join(appRoot, '.mercato', 'generated')
80
- fs.mkdirSync(generatedDir, { recursive: true })
81
- fs.writeFileSync(path.join(appRoot, 'tsconfig.json'), APP_TSCONFIG)
82
- for (const [baseName, source] of Object.entries(GENERATED_MODULES)) {
83
- writeGeneratedModule(generatedDir, baseName, source)
84
- }
113
+ appRoot = createAppRoot()
85
114
  })
86
115
 
87
116
  afterAll(() => {
88
- fs.rmSync(appRoot, { recursive: true, force: true })
117
+ for (const root of createdAppRoots) {
118
+ fs.rmSync(root, { recursive: true, force: true })
119
+ }
120
+ })
121
+
122
+ beforeEach(() => {
123
+ mockedLogger.debug.mockClear()
124
+ mockedLogger.error.mockClear()
89
125
  })
90
126
 
91
127
  it('returns commandInterceptorEntries from command-interceptors.generated', async () => {
@@ -105,5 +141,24 @@ describe('loadBootstrapData — command interceptors reach worker/CLI bootstrap
105
141
 
106
142
  expect(data.commandInterceptorEntries).toEqual([])
107
143
  expect(data.commandLoaderEntries).toEqual([])
144
+ expect(mockedLogger.error).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('logs an error when the generated file exists but fails to import (#4491)', async () => {
148
+ const brokenAppRoot = createAppRoot({
149
+ 'command-interceptors.generated': {
150
+ ts: 'export const commandInterceptorEntries = []',
151
+ compiled: "throw new Error('command-interceptors.generated is broken')",
152
+ },
153
+ })
154
+
155
+ const data = await loadBootstrapData(brokenAppRoot)
156
+
157
+ expect(data.commandInterceptorEntries).toEqual([])
158
+ expect(mockedLogger.error).toHaveBeenCalledTimes(1)
159
+ const [message, fields] = mockedLogger.error.mock.calls[0] as [string, Record<string, unknown>]
160
+ expect(message).toContain('Failed to load generated registry')
161
+ expect(fields.file).toBe('command-interceptors.generated.ts')
162
+ expect((fields.err as Error).message).toContain('command-interceptors.generated is broken')
108
163
  })
109
164
  })