@open-mercato/shared 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7577.1.477f2486e5

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 (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -4
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/bootstrap/dynamicLoader.js +46 -6
  6. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  7. package/dist/lib/bootstrap/factory.js +4 -3
  8. package/dist/lib/bootstrap/factory.js.map +2 -2
  9. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
  10. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
  11. package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
  12. package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
  13. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
  14. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
  15. package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
  16. package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
  17. package/dist/lib/catalog-visibility/index.js +4 -0
  18. package/dist/lib/catalog-visibility/index.js.map +2 -2
  19. package/dist/lib/commands/command-bus.js +45 -13
  20. package/dist/lib/commands/command-bus.js.map +2 -2
  21. package/dist/lib/commands/types.js.map +2 -2
  22. package/dist/lib/crud/exporters.js +1 -1
  23. package/dist/lib/crud/exporters.js.map +2 -2
  24. package/dist/lib/crud/ids.js +13 -1
  25. package/dist/lib/crud/ids.js.map +2 -2
  26. package/dist/lib/db/advisoryLock.js +172 -0
  27. package/dist/lib/db/advisoryLock.js.map +7 -0
  28. package/dist/lib/db/pg-errors.js +14 -0
  29. package/dist/lib/db/pg-errors.js.map +2 -2
  30. package/dist/lib/localization/index.js +9 -1
  31. package/dist/lib/localization/index.js.map +2 -2
  32. package/dist/lib/localization/translatable-fields.js +26 -0
  33. package/dist/lib/localization/translatable-fields.js.map +2 -2
  34. package/dist/lib/number.js +6 -0
  35. package/dist/lib/number.js.map +2 -2
  36. package/dist/lib/openapi/generator.js +84 -67
  37. package/dist/lib/openapi/generator.js.map +2 -2
  38. package/dist/lib/query/engine.js +45 -10
  39. package/dist/lib/query/engine.js.map +2 -2
  40. package/dist/lib/query/join-utils.js +1 -1
  41. package/dist/lib/query/join-utils.js.map +2 -2
  42. package/dist/lib/query/overlap.js +58 -0
  43. package/dist/lib/query/overlap.js.map +7 -0
  44. package/dist/lib/query/types.js.map +2 -2
  45. package/dist/lib/url.js +24 -0
  46. package/dist/lib/url.js.map +2 -2
  47. package/dist/lib/version.js +1 -1
  48. package/dist/lib/version.js.map +1 -1
  49. package/dist/lib/webhooks/body.js +7 -3
  50. package/dist/lib/webhooks/body.js.map +2 -2
  51. package/dist/lib/webhooks/index.js +2 -0
  52. package/dist/lib/webhooks/index.js.map +2 -2
  53. package/dist/modules/integrations/types.js.map +2 -2
  54. package/dist/modules/notifications/safeHref.js +20 -0
  55. package/dist/modules/notifications/safeHref.js.map +7 -0
  56. package/dist/modules/notifications/types.js +4 -0
  57. package/dist/modules/notifications/types.js.map +3 -3
  58. package/dist/modules/payment_gateways/types.js +14 -1
  59. package/dist/modules/payment_gateways/types.js.map +2 -2
  60. package/dist/modules/registry.js.map +2 -2
  61. package/dist/modules/search.js.map +2 -2
  62. package/dist/modules/widgets/injection-loader.js +14 -3
  63. package/dist/modules/widgets/injection-loader.js.map +2 -2
  64. package/package.json +9 -2
  65. package/src/lib/__tests__/number.test.ts +24 -1
  66. package/src/lib/__tests__/url.cross-site.test.ts +140 -0
  67. package/src/lib/auth/__tests__/server.test.ts +14 -0
  68. package/src/lib/auth/server.ts +1 -1
  69. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
  70. package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
  71. package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
  72. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
  75. package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
  76. package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
  77. package/src/lib/bootstrap/dynamicLoader.ts +63 -5
  78. package/src/lib/bootstrap/factory.ts +8 -3
  79. package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
  80. package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
  81. package/src/lib/bootstrap/types.ts +5 -1
  82. package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
  83. package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
  84. package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
  85. package/src/lib/catalog-visibility/index.ts +2 -0
  86. package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
  87. package/src/lib/commands/command-bus.ts +42 -10
  88. package/src/lib/commands/command-interceptor.ts +4 -0
  89. package/src/lib/commands/types.ts +14 -0
  90. package/src/lib/crud/__tests__/exporters.test.ts +18 -0
  91. package/src/lib/crud/__tests__/ids.test.ts +34 -1
  92. package/src/lib/crud/exporters.ts +1 -1
  93. package/src/lib/crud/ids.ts +24 -0
  94. package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
  95. package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
  96. package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
  97. package/src/lib/db/advisoryLock.ts +271 -0
  98. package/src/lib/db/pg-errors.ts +35 -0
  99. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
  100. package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
  101. package/src/lib/localization/index.ts +8 -1
  102. package/src/lib/localization/translatable-fields.ts +46 -0
  103. package/src/lib/number.ts +13 -0
  104. package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
  105. package/src/lib/openapi/generator.ts +106 -77
  106. package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
  107. package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
  108. package/src/lib/query/__tests__/overlap.test.ts +219 -0
  109. package/src/lib/query/engine.ts +58 -8
  110. package/src/lib/query/join-utils.ts +1 -1
  111. package/src/lib/query/overlap.ts +106 -0
  112. package/src/lib/query/types.ts +13 -1
  113. package/src/lib/url.ts +30 -0
  114. package/src/lib/webhooks/__tests__/body.test.ts +48 -0
  115. package/src/lib/webhooks/body.ts +11 -4
  116. package/src/lib/webhooks/index.ts +1 -0
  117. package/src/modules/integrations/types.ts +6 -0
  118. package/src/modules/notifications/safeHref.ts +19 -0
  119. package/src/modules/notifications/types.ts +7 -0
  120. package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
  121. package/src/modules/payment_gateways/types.ts +52 -6
  122. package/src/modules/registry.ts +5 -0
  123. package/src/modules/search.ts +39 -0
  124. package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
  125. package/src/modules/widgets/injection-loader.ts +33 -3
@@ -133,7 +133,7 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
133
133
  loadBootstrapDataInNode(appRoot)
134
134
  const after = readCacheMetadata(appRoot, 'entities')
135
135
 
136
- expect(after.version).toBe(4)
136
+ expect(after.version).toBe(5)
137
137
  expect(after.inputHash).not.toBe(before.inputHash)
138
138
  })
139
139
 
@@ -287,6 +287,6 @@ describe('dynamic loader app tsconfig and content-addressed cache', () => {
287
287
 
288
288
  loadBootstrapDataInNode(appRoot)
289
289
 
290
- expect(readCacheMetadata(appRoot, 'entities').version).toBe(4)
290
+ expect(readCacheMetadata(appRoot, 'entities').version).toBe(5)
291
291
  })
292
292
  })
@@ -62,7 +62,8 @@ describe('partitioned bootstrap registration', () => {
62
62
  })
63
63
 
64
64
  it('keeps API-only bootstrap from replacing core injection widgets', async () => {
65
- const apiBootstrap = createBootstrap(emptyBootstrapData, {
65
+ const entry = { moduleId: 'example', key: 'example:widget', source: 'app' as const, loader: async () => ({}) as never }
66
+ const apiBootstrap = createBootstrap({ ...emptyBootstrapData, injectionWidgetEntries: [entry] }, {
66
67
  registrationKey: 'api-only',
67
68
  skipUiRegistries: true,
68
69
  skipCoreInjectionWidgets: true,
@@ -71,11 +72,22 @@ describe('partitioned bootstrap registration', () => {
71
72
  apiBootstrap()
72
73
  await waitForAsyncRegistration()
73
74
 
74
- expect(registerCoreInjectionWidgetsMock).not.toHaveBeenCalled()
75
- // The raw widget entries still travel with the tables so a `key`-spelled injection
76
- // override resolves to the `widgetId` the slots reference (#5152), even though this
77
- // bootstrap deliberately skips registering the widgets themselves.
78
- expect(registerCoreInjectionTablesMock).toHaveBeenCalledWith([], [])
75
+ // The API partition still contributes its entries — module API routes resolve widget
76
+ // contributions on the request path — but in `merge` mode, so it can never shrink a
77
+ // registry the full page bootstrap already published in the same process.
78
+ expect(registerCoreInjectionWidgetsMock).toHaveBeenCalledWith([entry], { mode: 'merge' })
79
+ // The raw widget entries also travel with the tables so a `key`-spelled injection
80
+ // override resolves to the `widgetId` the slots reference (#5152).
81
+ expect(registerCoreInjectionTablesMock).toHaveBeenCalledWith([], [entry])
79
82
  expect(registerEnabledModuleIdsMock).toHaveBeenCalledTimes(1)
80
83
  })
84
+
85
+ it('lets the full bootstrap replace the registry outright', async () => {
86
+ const fullBootstrap = createBootstrap(emptyBootstrapData, { registrationKey: 'full-only' })
87
+
88
+ fullBootstrap()
89
+ await waitForAsyncRegistration()
90
+
91
+ expect(registerCoreInjectionWidgetsMock).toHaveBeenCalledWith([], undefined)
92
+ })
81
93
  })
@@ -0,0 +1,174 @@
1
+ import {
2
+ installNextSubpathResolveHook,
3
+ resetNextSubpathResolveHookForTests,
4
+ resolveNextSubpath,
5
+ resolveNextSubpathAsync,
6
+ } from '../next-subpath-resolve-hook'
7
+
8
+ function notFound(specifier: string): Error & { code: string } {
9
+ return Object.assign(new Error(`Cannot find module '${specifier}'`), { code: 'ERR_MODULE_NOT_FOUND' })
10
+ }
11
+
12
+ describe('resolveNextSubpath (#6118)', () => {
13
+ const context = { parentURL: 'file:///app/route.js' }
14
+
15
+ it('leaves specifiers other than next/<subpath> to the default resolver', () => {
16
+ const nextResolve = jest.fn(() => ({ url: 'file:///app/node_modules/zod/index.js' }))
17
+
18
+ const result = resolveNextSubpath('zod', context, nextResolve)
19
+
20
+ expect(result.url).toBe('file:///app/node_modules/zod/index.js')
21
+ expect(nextResolve).toHaveBeenCalledTimes(1)
22
+ expect(nextResolve).toHaveBeenCalledWith('zod', context)
23
+ })
24
+
25
+ it('does not touch a next/<subpath> that resolves on its own', () => {
26
+ // The day `next` ships an exports map the hook must step aside.
27
+ const nextResolve = jest.fn(() => ({ url: 'file:///app/node_modules/next/server.js' }))
28
+
29
+ resolveNextSubpath('next/server', context, nextResolve)
30
+
31
+ expect(nextResolve).toHaveBeenCalledTimes(1)
32
+ })
33
+
34
+ it('retries a bare next/<subpath> with the .js suffix when Node cannot resolve it', () => {
35
+ // `next` has no exports map and Node ESM does no extension guessing, so
36
+ // `next/server` fails while `next/server.js` is a real file.
37
+ const nextResolve = jest
38
+ .fn()
39
+ .mockImplementationOnce(() => { throw notFound('/app/node_modules/next/server') })
40
+ .mockImplementationOnce(() => ({ url: 'file:///app/node_modules/next/server.js' }))
41
+
42
+ const result = resolveNextSubpath('next/server', context, nextResolve)
43
+
44
+ expect(result.url).toBe('file:///app/node_modules/next/server.js')
45
+ expect(nextResolve).toHaveBeenNthCalledWith(1, 'next/server', context)
46
+ expect(nextResolve).toHaveBeenNthCalledWith(2, 'next/server.js', context)
47
+ })
48
+
49
+ it('covers the other subpaths route modules import', () => {
50
+ for (const subpath of ['next/headers', 'next/navigation', 'next/cache']) {
51
+ const nextResolve = jest
52
+ .fn()
53
+ .mockImplementationOnce(() => { throw notFound(subpath) })
54
+ .mockImplementationOnce((resolvedSpecifier: string) => ({
55
+ url: `file:///app/node_modules/${resolvedSpecifier}`,
56
+ }))
57
+ expect(resolveNextSubpath(subpath, context, nextResolve).url).toBe(`file:///app/node_modules/${subpath}.js`)
58
+ }
59
+ })
60
+
61
+ it('does not retry deeper or already-suffixed specifiers', () => {
62
+ for (const specifier of ['next/server.js', 'next/dist/server/web/spec-extension/response', 'next']) {
63
+ const nextResolve = jest.fn(() => { throw notFound(specifier) })
64
+ expect(() => resolveNextSubpath(specifier, context, nextResolve)).toThrow(/Cannot find module/)
65
+ expect(nextResolve).toHaveBeenCalledTimes(1)
66
+ }
67
+ })
68
+
69
+ it('rethrows the original error when the .js form is missing too', () => {
70
+ const original = notFound('/app/node_modules/next/server')
71
+ const nextResolve = jest
72
+ .fn()
73
+ .mockImplementationOnce(() => { throw original })
74
+ .mockImplementationOnce(() => { throw notFound('/app/node_modules/next/server.js') })
75
+
76
+ expect(() => resolveNextSubpath('next/server', context, nextResolve)).toThrow(original)
77
+ })
78
+
79
+ it('rethrows errors that are not ERR_MODULE_NOT_FOUND without retrying', () => {
80
+ const syntaxError = Object.assign(new Error('bad package.json'), { code: 'ERR_INVALID_PACKAGE_CONFIG' })
81
+ const nextResolve = jest.fn(() => { throw syntaxError })
82
+
83
+ expect(() => resolveNextSubpath('next/server', context, nextResolve)).toThrow(syntaxError)
84
+ expect(nextResolve).toHaveBeenCalledTimes(1)
85
+ })
86
+ })
87
+
88
+ describe('installNextSubpathResolveHook', () => {
89
+ beforeEach(() => {
90
+ resetNextSubpathResolveHookForTests()
91
+ })
92
+
93
+ it('registers the resolve hook once per process', () => {
94
+ const register = jest.fn()
95
+
96
+ expect(installNextSubpathResolveHook(register, null)).toBe(true)
97
+ expect(installNextSubpathResolveHook(register, null)).toBe(false)
98
+
99
+ expect(register).toHaveBeenCalledTimes(1)
100
+ expect(register).toHaveBeenCalledWith({ resolve: resolveNextSubpath })
101
+ })
102
+
103
+ it('registers an asynchronous hook on Node 22.0-22.14', () => {
104
+ const registerHookModule = jest.fn()
105
+
106
+ expect(installNextSubpathResolveHook(null, registerHookModule)).toBe(true)
107
+ expect(registerHookModule).toHaveBeenCalledWith(
108
+ expect.objectContaining({ href: expect.stringMatching(/next-subpath-resolve-hook-worker\.js$/) }),
109
+ expect.stringMatching(/^file:/),
110
+ )
111
+ })
112
+
113
+ it('is a no-op on a runtime without either registration API', () => {
114
+ expect(installNextSubpathResolveHook(null, null)).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe('resolveNextSubpathAsync', () => {
119
+ const context = { parentURL: 'file:///app/route.js' }
120
+
121
+ it('retries a bare next/<subpath> with the .js suffix', async () => {
122
+ const nextResolve = jest
123
+ .fn()
124
+ .mockRejectedValueOnce(notFound('/app/node_modules/next/server'))
125
+ .mockResolvedValueOnce({ url: 'file:///app/node_modules/next/server.js' })
126
+
127
+ const result = await resolveNextSubpathAsync('next/server', context, nextResolve)
128
+
129
+ expect(result.url).toBe('file:///app/node_modules/next/server.js')
130
+ expect(nextResolve).toHaveBeenNthCalledWith(1, 'next/server', context)
131
+ expect(nextResolve).toHaveBeenNthCalledWith(2, 'next/server.js', context)
132
+ })
133
+ })
134
+
135
+ describe('installNextSubpathResolveHook against the runtime node:module', () => {
136
+ afterEach(() => {
137
+ jest.dontMock('node:module')
138
+ jest.resetModules()
139
+ })
140
+
141
+ it('installs through node:module.register on Node 22.0-22.14', () => {
142
+ // A named import of `registerHooks` would throw while the module loads on
143
+ // such a runtime; the helper must load and use the older registration API.
144
+ jest.resetModules()
145
+ const register = jest.fn()
146
+ jest.doMock('node:module', () => ({ register }))
147
+ let installed: boolean | undefined
148
+ jest.isolateModules(() => {
149
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
150
+ const hookModule = require('../next-subpath-resolve-hook') as typeof import('../next-subpath-resolve-hook')
151
+ installed = hookModule.installNextSubpathResolveHook()
152
+ })
153
+ expect(installed).toBe(true)
154
+ expect(register).toHaveBeenCalledTimes(1)
155
+ expect(register.mock.calls[0][0]).toEqual(
156
+ expect.objectContaining({ href: expect.stringMatching(/next-subpath-resolve-hook-worker\.js$/) }),
157
+ )
158
+ })
159
+
160
+ it('installs through node:module.registerHooks when the runtime has it', () => {
161
+ jest.resetModules()
162
+ const register = jest.fn()
163
+ jest.doMock('node:module', () => ({ registerHooks: register }))
164
+ let installed: boolean | undefined
165
+ jest.isolateModules(() => {
166
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
167
+ const hookModule = require('../next-subpath-resolve-hook') as typeof import('../next-subpath-resolve-hook')
168
+ installed = hookModule.installNextSubpathResolveHook()
169
+ })
170
+ expect(installed).toBe(true)
171
+ expect(register).toHaveBeenCalledTimes(1)
172
+ expect(register.mock.calls[0][0]).toEqual({ resolve: expect.any(Function) })
173
+ })
174
+ })
@@ -16,7 +16,7 @@ import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './client
16
16
  import path from 'node:path'
17
17
  import fs from 'node:fs'
18
18
  import crypto from 'node:crypto'
19
- import { createRequire } from 'node:module'
19
+ import { createRequire, isBuiltin } from 'node:module'
20
20
  import { pathToFileURL } from 'node:url'
21
21
 
22
22
  let activeBootstrapLoads = 0
@@ -77,6 +77,44 @@ class GeneratedFileNotFoundError extends Error {
77
77
  }
78
78
  }
79
79
 
80
+ function splitBareSpecifier(specifier: string): { packageName: string; subpath: string } | null {
81
+ const segments = specifier.split('/')
82
+ const nameLength = specifier.startsWith('@') ? 2 : 1
83
+ if (segments.length <= nameLength || segments.slice(0, nameLength).some((segment) => !segment)) return null
84
+ return {
85
+ packageName: segments.slice(0, nameLength).join('/'),
86
+ subpath: segments.slice(nameLength).join('/'),
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Rewrites an externalized `pkg/subpath` specifier to the file Node's ESM resolver can load
92
+ * when `pkg` publishes no `exports` map (#6993). Builtins, packages with `exports`, subpaths
93
+ * that already carry an extension and anything that fails to resolve keep the original
94
+ * specifier, so the bundle never gets worse than before.
95
+ */
96
+ function rewriteLegacySubpathSpecifier(
97
+ specifier: string,
98
+ appRequire: NodeRequire,
99
+ onRewrite: (manifestPath: string) => void,
100
+ ): string {
101
+ if (isBuiltin(specifier)) return specifier
102
+ const parts = splitBareSpecifier(specifier)
103
+ if (!parts || path.posix.extname(parts.subpath)) return specifier
104
+ try {
105
+ const manifestPath = appRequire.resolve(`${parts.packageName}/package.json`)
106
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { exports?: unknown }
107
+ if (manifest.exports != null) return specifier
108
+ const resolvedFile = appRequire.resolve(specifier)
109
+ const relativeFile = path.relative(path.dirname(manifestPath), resolvedFile)
110
+ if (!relativeFile || relativeFile.startsWith('..') || path.isAbsolute(relativeFile)) return specifier
111
+ onRewrite(manifestPath)
112
+ return `${parts.packageName}/${relativeFile.split(path.sep).join('/')}`
113
+ } catch {
114
+ return specifier
115
+ }
116
+ }
117
+
80
118
  /**
81
119
  * esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
82
120
  * first so it wins over the alias and external plugins for `*.client` dynamic imports.
@@ -85,7 +123,10 @@ class GeneratedFileNotFoundError extends Error {
85
123
  * `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
86
124
  * list, which would silently reintroduce #4623.
87
125
  */
88
- export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
126
+ export function createCliBundlePlugins(
127
+ appRoot: string,
128
+ options: { onLegacySubpathRewrite?: (manifestPath: string) => void } = {},
129
+ ): import('esbuild').Plugin[] {
89
130
  // Plugin to resolve the @/ alias the way the app tsconfig maps it:
90
131
  // `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
91
132
  const aliasPlugin: import('esbuild').Plugin = {
@@ -113,6 +154,19 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
113
154
  },
114
155
  }
115
156
 
157
+ // Node's ESM resolver only maps an extensionless package subpath (`next/link`) when the
158
+ // package publishes an `exports` map; legacy packages need the real file path instead.
159
+ const appRequire = createRequire(path.join(appRoot, 'package.json'))
160
+ const externalSpecifierCache = new Map<string, string>()
161
+ const onLegacySubpathRewrite = options.onLegacySubpathRewrite ?? (() => undefined)
162
+ const resolveExternalSpecifier = (specifier: string): string => {
163
+ const cached = externalSpecifierCache.get(specifier)
164
+ if (cached !== undefined) return cached
165
+ const resolved = rewriteLegacySubpathSpecifier(specifier, appRequire, onLegacySubpathRewrite)
166
+ externalSpecifierCache.set(specifier, resolved)
167
+ return resolved
168
+ }
169
+
116
170
  // Plugin to mark non-JSON package imports as external
117
171
  const externalNonJsonPlugin: import('esbuild').Plugin = {
118
172
  name: 'external-non-json',
@@ -129,7 +183,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
129
183
  return null // Let esbuild handle it
130
184
  }
131
185
  // Otherwise mark as external
132
- return { path: args.path, external: true }
186
+ return { path: resolveExternalSpecifier(args.path), external: true }
133
187
  })
134
188
  },
135
189
  }
@@ -137,7 +191,7 @@ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugi
137
191
  return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
138
192
  }
139
193
 
140
- const DYNAMIC_LOADER_CACHE_VERSION = 4
194
+ const DYNAMIC_LOADER_CACHE_VERSION = 5
141
195
 
142
196
  type DynamicLoaderCacheMetadata = {
143
197
  version: number
@@ -433,6 +487,7 @@ async function compileAppSourceFileWithActiveEsbuild(
433
487
  // Dynamically import esbuild only when needed
434
488
  const esbuild = await getEsbuildRuntime()
435
489
 
490
+ const rewrittenPackageManifests = new Set<string>()
436
491
  // Use esbuild.build with bundling to handle JSON imports
437
492
  const result = await esbuild.build({
438
493
  entryPoints: [tsPath],
@@ -444,7 +499,9 @@ async function compileAppSourceFileWithActiveEsbuild(
444
499
  platform: 'node',
445
500
  target: 'node18',
446
501
  tsconfig: appTsconfig,
447
- plugins: createCliBundlePlugins(appRoot),
502
+ plugins: createCliBundlePlugins(appRoot, {
503
+ onLegacySubpathRewrite: (manifestPath) => rewrittenPackageManifests.add(manifestPath),
504
+ }),
448
505
  // Allow JSON imports
449
506
  loader: { '.json': 'json' },
450
507
  })
@@ -455,6 +512,7 @@ async function compileAppSourceFileWithActiveEsbuild(
455
512
  dependencies: {
456
513
  ...collectDependencyHashes(appRoot, result.metafile.inputs),
457
514
  ...hashFilesRelativeTo(appRoot, tsconfigPaths),
515
+ ...hashFilesRelativeTo(appRoot, [...rewrittenPackageManifests]),
458
516
  },
459
517
  }
460
518
  fs.writeFileSync(metadataPath, JSON.stringify(metadata))
@@ -143,9 +143,14 @@ async function registerWidgetsAndOptionalPackages(data: BootstrapData, options:
143
143
  // browser-facing UI registries. API-only bootstraps avoid loading @open-mercato/ui.
144
144
  try {
145
145
  const coreInjection = await import('@open-mercato/core/modules/widgets/lib/injection')
146
- if (!options.skipCoreInjectionWidgets) {
147
- coreInjection.registerCoreInjectionWidgets(data.injectionWidgetEntries)
148
- }
146
+ // A partitioned runtime still needs its own entries — server-side resolvers read this
147
+ // registry on request paths — it just must not shrink what a fuller partition already
148
+ // published in the same process. Merge does both; skipping registration altogether left
149
+ // API-only processes with an unregistered registry that throws on first read.
150
+ coreInjection.registerCoreInjectionWidgets(
151
+ data.injectionWidgetEntries,
152
+ options.skipCoreInjectionWidgets ? { mode: 'merge' } : undefined,
153
+ )
149
154
  coreInjection.registerCoreInjectionTables(data.injectionTables, data.injectionWidgetEntries)
150
155
  coreInjection.registerEnabledModuleIds(
151
156
  data.modules.map((module) => module.id).filter((id): id is string => typeof id === 'string' && id.length > 0),
@@ -0,0 +1,3 @@
1
+ import { resolveNextSubpathAsync } from './next-subpath-resolve-hook'
2
+
3
+ export const resolve = resolveNextSubpathAsync
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Node ESM resolution for `next/<subpath>` outside the Next bundler (#6118, #7102).
3
+ *
4
+ * Installed by the `mercato` CLI entry, so every CLI command and worker it
5
+ * runs can load package dists that import `next/dynamic`, `next/link`,
6
+ * `next/server` … The `next/dynamic` imports in particular MUST keep the bare
7
+ * specifier: Next's `next_dynamic` compiler transform only matches it exactly.
8
+ *
9
+ * `mercato ai_assistant mcp:serve-http` (and `mcp:serve`, `mcp:dev`,
10
+ * `mcp:list-tools`) run in plain Node, and the AI API operation runner loads
11
+ * API route modules with a runtime `import()`. Those modules import
12
+ * `NextResponse` from `'next/server'`: a bare package subpath that only a
13
+ * bundler resolves, because `next` ships no `exports` map and Node's ESM
14
+ * resolver does no extension guessing. Every route with that import (287 of
15
+ * them on the 0.7.0 packages) therefore fails to load with
16
+ * `ERR_MODULE_NOT_FOUND … node_modules/next/server`, and every tool backed by
17
+ * such a route returns "Failed to load route module".
18
+ *
19
+ * `next/server.js`, `next/headers.js`, `next/navigation.js` … do exist as files,
20
+ * which is why `@open-mercato/shared` imports `'next/headers.js'`. Rather than
21
+ * rewriting several hundred imports across every package (and every
22
+ * third-party module), this hook retries a failed bare `next/<subpath>` with
23
+ * the `.js` suffix. It only runs in the processes that install it, only for
24
+ * `next/*` specifiers, and only after the normal resolution failed, so it
25
+ * becomes a no-op the day `next` publishes an `exports` map.
26
+ */
27
+ import * as nodeModule from 'node:module'
28
+
29
+ type ResolveContext = Record<string, unknown>
30
+ type ResolveResult = { url: string; format?: string | null | undefined; shortCircuit?: boolean }
31
+ type NextResolve = (specifier: string, context?: ResolveContext) => ResolveResult
32
+ type AsyncNextResolve = (specifier: string, context?: ResolveContext) => ResolveResult | Promise<ResolveResult>
33
+
34
+ /** Bare `next/<one segment>` specifiers, e.g. `next/server`, `next/headers`. */
35
+ const NEXT_BARE_SUBPATH = /^next\/[A-Za-z0-9_-]+$/
36
+
37
+ let installed = false
38
+
39
+ function isModuleNotFound(error: unknown): boolean {
40
+ return Boolean(error) && typeof error === 'object' && (error as { code?: unknown }).code === 'ERR_MODULE_NOT_FOUND'
41
+ }
42
+
43
+ /**
44
+ * The resolve hook itself. Exported so it can be tested without touching the
45
+ * process-global hook chain.
46
+ */
47
+ export function resolveNextSubpath(
48
+ specifier: string,
49
+ context: ResolveContext,
50
+ nextResolve: NextResolve,
51
+ ): ResolveResult {
52
+ if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)
53
+ try {
54
+ return nextResolve(specifier, context)
55
+ } catch (error) {
56
+ if (!isModuleNotFound(error)) throw error
57
+ try {
58
+ return nextResolve(`${specifier}.js`, context)
59
+ } catch {
60
+ // Neither form resolves (Next is not installed at all): surface the
61
+ // original error, which names the specifier the caller wrote.
62
+ throw error
63
+ }
64
+ }
65
+ }
66
+
67
+ export async function resolveNextSubpathAsync(
68
+ specifier: string,
69
+ context: ResolveContext,
70
+ nextResolve: AsyncNextResolve,
71
+ ): Promise<ResolveResult> {
72
+ if (!NEXT_BARE_SUBPATH.test(specifier)) return nextResolve(specifier, context)
73
+ try {
74
+ return await nextResolve(specifier, context)
75
+ } catch (error) {
76
+ if (!isModuleNotFound(error)) throw error
77
+ try {
78
+ return await nextResolve(`${specifier}.js`, context)
79
+ } catch {
80
+ throw error
81
+ }
82
+ }
83
+ }
84
+
85
+ type RegisterHooks = (hooks: { resolve: typeof resolveNextSubpath }) => unknown
86
+ type RegisterHookModule = (specifier: string | URL, parentURL?: string | URL) => unknown
87
+
88
+ /**
89
+ * `module.registerHooks` exists from Node 22.15. It is read off the namespace
90
+ * rather than imported by name: in the published ESM build a named import of an
91
+ * export the running Node lacks fails while the module links, before the check
92
+ * in `installNextSubpathResolveHook` can run, and the package still declares
93
+ * `node >= 22.0.0`.
94
+ */
95
+ function runtimeRegisterHooks(): RegisterHooks | undefined {
96
+ const candidate = (nodeModule as unknown as { registerHooks?: unknown }).registerHooks
97
+ return typeof candidate === 'function' ? (candidate as RegisterHooks) : undefined
98
+ }
99
+
100
+ function runtimeRegisterHookModule(): RegisterHookModule | undefined {
101
+ const candidate = (nodeModule as unknown as { register?: unknown }).register
102
+ return typeof candidate === 'function' ? (candidate as RegisterHookModule) : undefined
103
+ }
104
+
105
+ /**
106
+ * Install the hook once per process. Returns `true` when it was installed by
107
+ * this call, `false` when it already was or when the runtime has neither
108
+ * registration API. Node 22.15+ uses the synchronous hook API; earlier
109
+ * supported Node 22 releases use the asynchronous hook API.
110
+ */
111
+ export function installNextSubpathResolveHook(
112
+ registerHooks: RegisterHooks | null | undefined = runtimeRegisterHooks(),
113
+ registerHookModule: RegisterHookModule | null | undefined = runtimeRegisterHookModule(),
114
+ ): boolean {
115
+ if (installed) return false
116
+ if (typeof registerHooks === 'function') {
117
+ registerHooks({ resolve: resolveNextSubpath })
118
+ } else if (typeof registerHookModule === 'function') {
119
+ registerHookModule(new URL('./next-subpath-resolve-hook-worker.js', import.meta.url), import.meta.url)
120
+ } else {
121
+ return false
122
+ }
123
+ installed = true
124
+ return true
125
+ }
126
+
127
+ /** Test seam: forget that the hook was installed. */
128
+ export function resetNextSubpathResolveHookForTests(): void {
129
+ installed = false
130
+ }
@@ -73,7 +73,11 @@ export interface BootstrapOptions {
73
73
  registrationKey?: string
74
74
  /** Skip browser-facing dashboard/injection registries in API-only runtimes. */
75
75
  skipUiRegistries?: boolean
76
- /** Keep an existing core injection-widget registry intact in partitioned runtimes. */
76
+ /**
77
+ * Keep an existing core injection-widget registry intact in partitioned runtimes: this
78
+ * bootstrap's entries are merged in rather than published as the whole registry, so a
79
+ * partition that registers second never shrinks what the first one already exposed.
80
+ */
77
81
  skipCoreInjectionWidgets?: boolean
78
82
  onRegistrationComplete?: () => void
79
83
  appDiRegistrar?: AppDiRegistrar
@@ -0,0 +1,121 @@
1
+ import { canonicalizeEffectiveScope } from '../canonicalizeEffectiveScope'
2
+ import { hashEffectiveScope } from '../hashEffectiveScope'
3
+ import type { AssortmentScope, EffectiveAssortmentScope } from '../types'
4
+ import { mulberry32, randomAssortmentScope } from './testFixtures'
5
+
6
+ function shuffle<T>(rng: () => number, items: T[]): T[] {
7
+ const copy = [...items]
8
+ for (let index = copy.length - 1; index > 0; index -= 1) {
9
+ const swap = Math.floor(rng() * (index + 1))
10
+ const held = copy[index]
11
+ copy[index] = copy[swap]
12
+ copy[swap] = held
13
+ }
14
+ return copy
15
+ }
16
+
17
+ function shuffleScope(rng: () => number, scope: AssortmentScope): AssortmentScope {
18
+ const entries = shuffle(rng, Object.entries(scope)).map(([key, value]) => {
19
+ if (key === 'allOf') {
20
+ return [key, shuffle(rng, (value as AssortmentScope[]).map((nested) => shuffleScope(rng, nested)))]
21
+ }
22
+ return [key, shuffle(rng, value as string[])]
23
+ })
24
+ return Object.fromEntries(entries) as AssortmentScope
25
+ }
26
+
27
+ function randomEffective(rng: () => number): EffectiveAssortmentScope {
28
+ if (rng() < 0.1) return null
29
+ const branches: AssortmentScope[] = []
30
+ const count = Math.floor(rng() * 4)
31
+ for (let index = 0; index < count; index += 1) {
32
+ const branch = randomAssortmentScope(rng)
33
+ if (rng() < 0.3) {
34
+ branch.allOf = [randomAssortmentScope(rng), randomAssortmentScope(rng)]
35
+ }
36
+ branches.push(branch)
37
+ }
38
+ return branches
39
+ }
40
+
41
+ describe('canonicalizeEffectiveScope', () => {
42
+ it('distinguishes unrestricted (null) from deny-all ([])', () => {
43
+ expect(canonicalizeEffectiveScope(null)).toBe('null')
44
+ expect(canonicalizeEffectiveScope([])).toBe('[]')
45
+ expect(hashEffectiveScope(null)).not.toBe(hashEffectiveScope([]))
46
+ })
47
+
48
+ it('treats empty arrays as absent keys', () => {
49
+ expect(canonicalizeEffectiveScope([{ categoryIds: [], tagIds: ['t1'], excludeTagIds: [] }])).toBe(
50
+ canonicalizeEffectiveScope([{ tagIds: ['t1'] }]),
51
+ )
52
+ expect(canonicalizeEffectiveScope([{ categoryIds: [], allOf: [] }])).toBe(canonicalizeEffectiveScope([{}]))
53
+ })
54
+
55
+ it('keeps an empty branch (matches everything) distinct from no branches', () => {
56
+ expect(canonicalizeEffectiveScope([{}])).toBe('[{}]')
57
+ expect(canonicalizeEffectiveScope([{}])).not.toBe(canonicalizeEffectiveScope([]))
58
+ })
59
+
60
+ it('is invariant to key order, id order and duplicate ids', () => {
61
+ const left: EffectiveAssortmentScope = [{ tagIds: ['b', 'a', 'a'], categoryIds: ['y', 'x'] }]
62
+ const right: EffectiveAssortmentScope = [{ categoryIds: ['x', 'y'], tagIds: ['a', 'b'] }]
63
+ expect(canonicalizeEffectiveScope(left)).toBe(canonicalizeEffectiveScope(right))
64
+ expect(hashEffectiveScope(left)).toBe(hashEffectiveScope(right))
65
+ })
66
+
67
+ it('is invariant to branch order and collapses identical branches', () => {
68
+ const forward: EffectiveAssortmentScope = [{ categoryIds: ['a'] }, { tagIds: ['b'] }]
69
+ const reversed: EffectiveAssortmentScope = [{ tagIds: ['b'] }, { categoryIds: ['a'] }, { categoryIds: ['a'] }]
70
+ expect(canonicalizeEffectiveScope(forward)).toBe(canonicalizeEffectiveScope(reversed))
71
+ })
72
+
73
+ it('canonicalizes nested allOf entries and their order', () => {
74
+ const left: EffectiveAssortmentScope = [
75
+ { categoryIds: ['a'], allOf: [{ tagIds: ['t2', 't1'] }, { excludeProductIds: ['p1'], allOf: [{ categoryIds: ['c2', 'c1'] }] }] },
76
+ ]
77
+ const right: EffectiveAssortmentScope = [
78
+ { allOf: [{ allOf: [{ categoryIds: ['c1', 'c2'] }], excludeProductIds: ['p1'] }, { tagIds: ['t1', 't2'] }], categoryIds: ['a'] },
79
+ ]
80
+ expect(canonicalizeEffectiveScope(left)).toBe(canonicalizeEffectiveScope(right))
81
+ })
82
+
83
+ it('distinguishes scopes that match differently', () => {
84
+ expect(canonicalizeEffectiveScope([{ categoryIds: ['a'] }])).not.toBe(canonicalizeEffectiveScope([{ tagIds: ['a'] }]))
85
+ expect(canonicalizeEffectiveScope([{ categoryIds: ['a'], tagIds: ['b'] }])).not.toBe(
86
+ canonicalizeEffectiveScope([{ categoryIds: ['a'] }, { tagIds: ['b'] }]),
87
+ )
88
+ expect(canonicalizeEffectiveScope([{ categoryIds: ['a'] }])).not.toBe(
89
+ canonicalizeEffectiveScope([{ excludeCategoryIds: ['a'] }]),
90
+ )
91
+ })
92
+ })
93
+
94
+ describe('hashEffectiveScope', () => {
95
+ it('returns 16 lowercase hex characters', () => {
96
+ expect(hashEffectiveScope([{ categoryIds: ['a'] }])).toMatch(/^[0-9a-f]{16}$/)
97
+ expect(hashEffectiveScope(null)).toMatch(/^[0-9a-f]{16}$/)
98
+ })
99
+
100
+ it('property: shuffling branch, key and id order preserves the hash', () => {
101
+ for (let seed = 1; seed <= 200; seed += 1) {
102
+ const rng = mulberry32(seed)
103
+ const scope = randomEffective(rng)
104
+ const shuffled = scope === null ? null : shuffle(rng, scope.map((branch) => shuffleScope(rng, branch)))
105
+ expect(hashEffectiveScope(shuffled)).toBe(hashEffectiveScope(scope))
106
+ }
107
+ })
108
+
109
+ it('property: equal canonical form if and only if equal hash across random pairs', () => {
110
+ const rng = mulberry32(4242)
111
+ const scopes: EffectiveAssortmentScope[] = []
112
+ for (let index = 0; index < 60; index += 1) scopes.push(randomEffective(rng))
113
+ for (const left of scopes) {
114
+ for (const right of scopes) {
115
+ const sameCanonical = canonicalizeEffectiveScope(left) === canonicalizeEffectiveScope(right)
116
+ const sameHash = hashEffectiveScope(left) === hashEffectiveScope(right)
117
+ expect(sameHash).toBe(sameCanonical)
118
+ }
119
+ }
120
+ })
121
+ })