@open-mercato/shared 0.10.1-develop.7709.1.046124599c → 0.10.1-develop.7715.1.9483283afa

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.
@@ -121,8 +121,8 @@ function registerModules(modules) {
121
121
  if (existing !== null && process.env.NODE_ENV === "development") {
122
122
  logger.debug("Modules re-registered (this may occur during HMR)");
123
123
  }
124
- const nextModules = applyModuleOverridesToModules(modules);
125
- const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules);
124
+ const i18nOnlyRegistration = isI18nOnlyRegistration(modules);
125
+ const nextModules = i18nOnlyRegistration ? modules : applyModuleOverridesToModules(modules);
126
126
  const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration;
127
127
  const registeredModules = shouldMergeI18nOnly ? mergeI18nModules(existing, nextModules) : preserveExistingTranslations(existing ?? [], nextModules);
128
128
  setGlobalModules(registeredModules);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/modules/registry.ts"],
4
- "sourcesContent": ["import type { Module } from '@open-mercato/shared/modules/registry'\nimport { applyModuleOverridesToModules } from '@open-mercato/shared/modules/overrides'\nimport type { Locale } from '../i18n/config'\nimport { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from '../i18n/dictionary-cache'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'modules-registry' })\n\n// Registration pattern for publishable packages.\n// Use globalThis to survive tsx/esbuild module duplication where the same\n// registry.ts file can be loaded as multiple module instances when mixing\n// dynamic and static imports \u2014 for example a standalone integration test\n// bootstraps via the source path while a worker handler resolves it through\n// node_modules/@open-mercato/shared/dist/. Mirrors the same workaround used\n// by `getDiRegistrars()` in `../di/container.ts`.\nconst GLOBAL_KEY = '__openMercatoModulesRegistry__'\nconst LISTENERS_GLOBAL_KEY = '__openMercatoModulesRegistryListeners__'\nconst SNAPSHOT_GLOBAL_KEY = '__openMercatoModulesRegistrySnapshot__'\n\n/**\n * A listener may return a promise; `registerModules()` stays synchronous and\n * never awaits it, so the contract is fire-and-forget on both paths \u2014 a\n * synchronous throw and an asynchronous rejection are both observed and logged\n * rather than escaping into the bootstrap frame as an unhandled rejection.\n */\nexport type ModulesRegisteredListener = (modules: Module[]) => void | PromiseLike<void>\n\nconst UNREADABLE_CONTRACT_ENTRY = Symbol('open-mercato.modules.unreadable-contract-entry')\n\ntype RegistrationSnapshotEntry = readonly [id: string, contract: ReadonlyArray<readonly [string, unknown]>]\ntype RegistrationSnapshot = ReadonlyArray<RegistrationSnapshotEntry>\n\ntype ModulesRegistryGlobalScope = typeof globalThis & {\n [GLOBAL_KEY]?: Module[] | null\n [LISTENERS_GLOBAL_KEY]?: Set<ModulesRegisteredListener>\n [SNAPSHOT_GLOBAL_KEY]?: RegistrationSnapshot | null\n}\n\nfunction registryGlobals(): ModulesRegistryGlobalScope {\n return globalThis as ModulesRegistryGlobalScope\n}\n\nfunction getGlobalModules(): Module[] | null {\n return registryGlobals()[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalModules(modules: Module[]): void {\n registryGlobals()[GLOBAL_KEY] = modules\n}\n\nfunction getListeners(): Set<ModulesRegisteredListener> {\n const globalScope = registryGlobals()\n const listeners = globalScope[LISTENERS_GLOBAL_KEY] ?? new Set<ModulesRegisteredListener>()\n globalScope[LISTENERS_GLOBAL_KEY] = listeners\n return listeners\n}\n\n/**\n * Subscribe to module-registry (re-)registrations so caches derived from the\n * module list can drop what they built from an incomplete one. Bootstrap can\n * register modules more than once \u2014 an i18n-only registration is reconciled\n * with the full module list by `mergeI18nModules()` \u2014 and a consumer that\n * memoized its resolution in between would otherwise serve the pre-merge view\n * for the lifetime of the process (issue #5103).\n *\n * Listeners live on `globalThis` for the same module-duplication reason the\n * registry itself does, and they are notified only when the registered set\n * actually changed, so repeated identical bootstraps and HMR re-registrations\n * do not needlessly drop warm caches. Returns an unsubscribe function.\n *\n * Contract (`.ai/specs/2026-08-12-module-registry-registration-listeners.md`):\n * listeners run synchronously at the end of `registerModules()`, after the new\n * module list is readable through `getModules()`; a returned promise is never\n * awaited; neither a throw nor a rejection can break bootstrap; \"changed\" is\n * decided by the registration snapshot below, which sees module ids, their\n * top-level contract keys and the elements of array-valued ones \u2014 a mutation\n * deeper than that is invisible and its owner must invalidate its own cache.\n */\nexport function onModulesRegistered(listener: ModulesRegisteredListener): () => void {\n const listeners = getListeners()\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\n/**\n * Freeze what the registered list declares, so a later in-place mutation of a\n * module object cannot retroactively rewrite what the previous registration\n * looked like. Comparing live `Module` references (the obvious cheap check)\n * cannot see an HMR bootstrap that reassigns `dashboardWidgets` on an existing\n * object and re-registers the same array \u2014 both sides are then the same\n * reference and the stale widget catalog survives until restart.\n */\nfunction snapshotModules(modules: Module[]): RegistrationSnapshot {\n return modules.map((entry) => {\n const contract = Object.keys(entry)\n .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))\n .map((key) => {\n const descriptor = Object.getOwnPropertyDescriptor(entry, key)\n // An accessor is never invoked here: a module may declare a contract as a\n // lazy getter that is expensive, side-effectful, or throws until the rest\n // of bootstrap has run. It is recorded as unreadable instead, which never\n // compares equal \u2014 erring toward dropping a warm cache rather than\n // serving a stale one.\n if (!descriptor || !('value' in descriptor)) return [key, UNREADABLE_CONTRACT_ENTRY] as const\n const value = descriptor.value\n return [key, Array.isArray(value) ? [...value] : value] as const\n })\n return [entry.id, contract] as const\n })\n}\n\nfunction contractValuesMatch(previous: unknown, next: unknown): boolean {\n if (previous === UNREADABLE_CONTRACT_ENTRY || next === UNREADABLE_CONTRACT_ENTRY) return false\n if (Array.isArray(previous) && Array.isArray(next)) {\n return previous.length === next.length && previous.every((item, index) => item === next[index])\n }\n return previous === next\n}\n\nfunction snapshotsMatch(previous: RegistrationSnapshot | null, next: RegistrationSnapshot): boolean {\n if (previous === null || previous.length !== next.length) return false\n return previous.every(([id, contract], index) => {\n const [nextId, nextContract] = next[index]\n if (id !== nextId || contract.length !== nextContract.length) return false\n return contract.every(([key, value], contractIndex) => {\n const [nextKey, nextValue] = nextContract[contractIndex]\n return key === nextKey && contractValuesMatch(value, nextValue)\n })\n })\n}\n\nfunction isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n return typeof (value as PromiseLike<unknown> | null | undefined)?.then === 'function'\n}\n\nfunction notifyModulesRegistered(modules: Module[]): void {\n for (const listener of [...getListeners()]) {\n try {\n const result = listener(modules)\n if (isPromiseLike(result)) {\n // The listener contract is fire-and-forget: registerModules() keeps its\n // synchronous signature, so an async subscriber's rejection would land\n // as an unhandled rejection (fatal under Node's default policy) unless\n // it is observed here.\n Promise.resolve(result).catch((err) => {\n logger.error('Module registration listener rejected', { err })\n })\n }\n } catch (err) {\n logger.error('Module registration listener failed', { err })\n }\n }\n}\n\nfunction hasRuntimeContracts(entry: Module): boolean {\n return Object.keys(entry).some((key) => key !== 'id' && key !== 'translations')\n}\n\nfunction isI18nOnlyRegistration(modules: Module[]): boolean {\n return modules.length > 0 && modules.every((entry) => !hasRuntimeContracts(entry))\n}\n\nfunction mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {\n const incomingById = new Map(incoming.map((entry) => [entry.id, entry]))\n const existingIds = new Set(existing.map((entry) => entry.id))\n const merged = existing.map((entry) => {\n const i18nModule = incomingById.get(entry.id)\n if (!i18nModule?.translations) return entry\n return {\n ...entry,\n translations: {\n ...(entry.translations ?? {}),\n ...i18nModule.translations,\n },\n }\n })\n\n for (const entry of incoming) {\n if (!existingIds.has(entry.id)) merged.push(entry)\n }\n\n return merged\n}\n\nfunction getTranslationLocales(modules: Module[]): Locale[] {\n const locales = new Set<Locale>()\n for (const entry of modules) {\n for (const locale of Object.keys(entry.translations ?? {})) {\n locales.add(locale as Locale)\n }\n }\n return [...locales]\n}\n\nfunction preserveExistingTranslations(existing: Module[], incoming: Module[]): Module[] {\n const existingById = new Map(existing.map((entry) => [entry.id, entry]))\n return incoming.map((entry) => {\n if (entry.translations) return entry\n const translations = existingById.get(entry.id)?.translations\n return translations ? { ...entry, translations } : entry\n })\n}\n\nexport function registerModules(modules: Module[]) {\n const existing = getGlobalModules()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Modules re-registered (this may occur during HMR)')\n }\n const nextModules = applyModuleOverridesToModules(modules)\n const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules)\n const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration\n const registeredModules = shouldMergeI18nOnly\n ? mergeI18nModules(existing, nextModules)\n : preserveExistingTranslations(existing ?? [], nextModules)\n setGlobalModules(registeredModules)\n if (i18nOnlyRegistration) {\n invalidateDictionaryCacheLocales(getTranslationLocales(nextModules))\n } else {\n invalidateDictionaryCache()\n }\n const globalScope = registryGlobals()\n const previousSnapshot = globalScope[SNAPSHOT_GLOBAL_KEY] ?? null\n const nextSnapshot = snapshotModules(registeredModules)\n globalScope[SNAPSHOT_GLOBAL_KEY] = nextSnapshot\n if (!snapshotsMatch(previousSnapshot, nextSnapshot)) {\n notifyModulesRegistered(registeredModules)\n }\n}\n\nexport function getModules(): Module[] {\n const modules = getGlobalModules()\n if (!modules) {\n throw new Error('[Bootstrap] Modules not registered. Call registerModules() at bootstrap.')\n }\n return modules\n}\n\n/**\n * Non-throwing counterpart of `getModules()` for call sites that have a\n * meaningful degraded behavior when bootstrap has not run \u2014 route unit tests\n * exercise a single handler without `registerModules()`, and a hard throw there\n * turns an unrelated assertion into a bootstrap error.\n */\nexport function tryGetModules(): Module[] | null {\n return getGlobalModules()\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { Module } from '@open-mercato/shared/modules/registry'\nimport { applyModuleOverridesToModules } from '@open-mercato/shared/modules/overrides'\nimport type { Locale } from '../i18n/config'\nimport { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from '../i18n/dictionary-cache'\nimport { createLogger } from '../logger'\n\nconst logger = createLogger('shared').child({ component: 'modules-registry' })\n\n// Registration pattern for publishable packages.\n// Use globalThis to survive tsx/esbuild module duplication where the same\n// registry.ts file can be loaded as multiple module instances when mixing\n// dynamic and static imports \u2014 for example a standalone integration test\n// bootstraps via the source path while a worker handler resolves it through\n// node_modules/@open-mercato/shared/dist/. Mirrors the same workaround used\n// by `getDiRegistrars()` in `../di/container.ts`.\nconst GLOBAL_KEY = '__openMercatoModulesRegistry__'\nconst LISTENERS_GLOBAL_KEY = '__openMercatoModulesRegistryListeners__'\nconst SNAPSHOT_GLOBAL_KEY = '__openMercatoModulesRegistrySnapshot__'\n\n/**\n * A listener may return a promise; `registerModules()` stays synchronous and\n * never awaits it, so the contract is fire-and-forget on both paths \u2014 a\n * synchronous throw and an asynchronous rejection are both observed and logged\n * rather than escaping into the bootstrap frame as an unhandled rejection.\n */\nexport type ModulesRegisteredListener = (modules: Module[]) => void | PromiseLike<void>\n\nconst UNREADABLE_CONTRACT_ENTRY = Symbol('open-mercato.modules.unreadable-contract-entry')\n\ntype RegistrationSnapshotEntry = readonly [id: string, contract: ReadonlyArray<readonly [string, unknown]>]\ntype RegistrationSnapshot = ReadonlyArray<RegistrationSnapshotEntry>\n\ntype ModulesRegistryGlobalScope = typeof globalThis & {\n [GLOBAL_KEY]?: Module[] | null\n [LISTENERS_GLOBAL_KEY]?: Set<ModulesRegisteredListener>\n [SNAPSHOT_GLOBAL_KEY]?: RegistrationSnapshot | null\n}\n\nfunction registryGlobals(): ModulesRegistryGlobalScope {\n return globalThis as ModulesRegistryGlobalScope\n}\n\nfunction getGlobalModules(): Module[] | null {\n return registryGlobals()[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalModules(modules: Module[]): void {\n registryGlobals()[GLOBAL_KEY] = modules\n}\n\nfunction getListeners(): Set<ModulesRegisteredListener> {\n const globalScope = registryGlobals()\n const listeners = globalScope[LISTENERS_GLOBAL_KEY] ?? new Set<ModulesRegisteredListener>()\n globalScope[LISTENERS_GLOBAL_KEY] = listeners\n return listeners\n}\n\n/**\n * Subscribe to module-registry (re-)registrations so caches derived from the\n * module list can drop what they built from an incomplete one. Bootstrap can\n * register modules more than once \u2014 an i18n-only registration is reconciled\n * with the full module list by `mergeI18nModules()` \u2014 and a consumer that\n * memoized its resolution in between would otherwise serve the pre-merge view\n * for the lifetime of the process (issue #5103).\n *\n * Listeners live on `globalThis` for the same module-duplication reason the\n * registry itself does, and they are notified only when the registered set\n * actually changed, so repeated identical bootstraps and HMR re-registrations\n * do not needlessly drop warm caches. Returns an unsubscribe function.\n *\n * Contract (`.ai/specs/2026-08-12-module-registry-registration-listeners.md`):\n * listeners run synchronously at the end of `registerModules()`, after the new\n * module list is readable through `getModules()`; a returned promise is never\n * awaited; neither a throw nor a rejection can break bootstrap; \"changed\" is\n * decided by the registration snapshot below, which sees module ids, their\n * top-level contract keys and the elements of array-valued ones \u2014 a mutation\n * deeper than that is invisible and its owner must invalidate its own cache.\n */\nexport function onModulesRegistered(listener: ModulesRegisteredListener): () => void {\n const listeners = getListeners()\n listeners.add(listener)\n return () => {\n listeners.delete(listener)\n }\n}\n\n/**\n * Freeze what the registered list declares, so a later in-place mutation of a\n * module object cannot retroactively rewrite what the previous registration\n * looked like. Comparing live `Module` references (the obvious cheap check)\n * cannot see an HMR bootstrap that reassigns `dashboardWidgets` on an existing\n * object and re-registers the same array \u2014 both sides are then the same\n * reference and the stale widget catalog survives until restart.\n */\nfunction snapshotModules(modules: Module[]): RegistrationSnapshot {\n return modules.map((entry) => {\n const contract = Object.keys(entry)\n .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))\n .map((key) => {\n const descriptor = Object.getOwnPropertyDescriptor(entry, key)\n // An accessor is never invoked here: a module may declare a contract as a\n // lazy getter that is expensive, side-effectful, or throws until the rest\n // of bootstrap has run. It is recorded as unreadable instead, which never\n // compares equal \u2014 erring toward dropping a warm cache rather than\n // serving a stale one.\n if (!descriptor || !('value' in descriptor)) return [key, UNREADABLE_CONTRACT_ENTRY] as const\n const value = descriptor.value\n return [key, Array.isArray(value) ? [...value] : value] as const\n })\n return [entry.id, contract] as const\n })\n}\n\nfunction contractValuesMatch(previous: unknown, next: unknown): boolean {\n if (previous === UNREADABLE_CONTRACT_ENTRY || next === UNREADABLE_CONTRACT_ENTRY) return false\n if (Array.isArray(previous) && Array.isArray(next)) {\n return previous.length === next.length && previous.every((item, index) => item === next[index])\n }\n return previous === next\n}\n\nfunction snapshotsMatch(previous: RegistrationSnapshot | null, next: RegistrationSnapshot): boolean {\n if (previous === null || previous.length !== next.length) return false\n return previous.every(([id, contract], index) => {\n const [nextId, nextContract] = next[index]\n if (id !== nextId || contract.length !== nextContract.length) return false\n return contract.every(([key, value], contractIndex) => {\n const [nextKey, nextValue] = nextContract[contractIndex]\n return key === nextKey && contractValuesMatch(value, nextValue)\n })\n })\n}\n\nfunction isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n return typeof (value as PromiseLike<unknown> | null | undefined)?.then === 'function'\n}\n\nfunction notifyModulesRegistered(modules: Module[]): void {\n for (const listener of [...getListeners()]) {\n try {\n const result = listener(modules)\n if (isPromiseLike(result)) {\n // The listener contract is fire-and-forget: registerModules() keeps its\n // synchronous signature, so an async subscriber's rejection would land\n // as an unhandled rejection (fatal under Node's default policy) unless\n // it is observed here.\n Promise.resolve(result).catch((err) => {\n logger.error('Module registration listener rejected', { err })\n })\n }\n } catch (err) {\n logger.error('Module registration listener failed', { err })\n }\n }\n}\n\nfunction hasRuntimeContracts(entry: Module): boolean {\n return Object.keys(entry).some((key) => key !== 'id' && key !== 'translations')\n}\n\nfunction isI18nOnlyRegistration(modules: Module[]): boolean {\n return modules.length > 0 && modules.every((entry) => !hasRuntimeContracts(entry))\n}\n\nfunction mergeI18nModules(existing: Module[], incoming: Module[]): Module[] {\n const incomingById = new Map(incoming.map((entry) => [entry.id, entry]))\n const existingIds = new Set(existing.map((entry) => entry.id))\n const merged = existing.map((entry) => {\n const i18nModule = incomingById.get(entry.id)\n if (!i18nModule?.translations) return entry\n return {\n ...entry,\n translations: {\n ...(entry.translations ?? {}),\n ...i18nModule.translations,\n },\n }\n })\n\n for (const entry of incoming) {\n if (!existingIds.has(entry.id)) merged.push(entry)\n }\n\n return merged\n}\n\nfunction getTranslationLocales(modules: Module[]): Locale[] {\n const locales = new Set<Locale>()\n for (const entry of modules) {\n for (const locale of Object.keys(entry.translations ?? {})) {\n locales.add(locale as Locale)\n }\n }\n return [...locales]\n}\n\nfunction preserveExistingTranslations(existing: Module[], incoming: Module[]): Module[] {\n const existingById = new Map(existing.map((entry) => [entry.id, entry]))\n return incoming.map((entry) => {\n if (entry.translations) return entry\n const translations = existingById.get(entry.id)?.translations\n return translations ? { ...entry, translations } : entry\n })\n}\n\nexport function registerModules(modules: Module[]) {\n const existing = getGlobalModules()\n if (existing !== null && process.env.NODE_ENV === 'development') {\n logger.debug('Modules re-registered (this may occur during HMR)')\n }\n const i18nOnlyRegistration = isI18nOnlyRegistration(modules)\n const nextModules = i18nOnlyRegistration ? modules : applyModuleOverridesToModules(modules)\n const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration\n const registeredModules = shouldMergeI18nOnly\n ? mergeI18nModules(existing, nextModules)\n : preserveExistingTranslations(existing ?? [], nextModules)\n setGlobalModules(registeredModules)\n if (i18nOnlyRegistration) {\n invalidateDictionaryCacheLocales(getTranslationLocales(nextModules))\n } else {\n invalidateDictionaryCache()\n }\n const globalScope = registryGlobals()\n const previousSnapshot = globalScope[SNAPSHOT_GLOBAL_KEY] ?? null\n const nextSnapshot = snapshotModules(registeredModules)\n globalScope[SNAPSHOT_GLOBAL_KEY] = nextSnapshot\n if (!snapshotsMatch(previousSnapshot, nextSnapshot)) {\n notifyModulesRegistered(registeredModules)\n }\n}\n\nexport function getModules(): Module[] {\n const modules = getGlobalModules()\n if (!modules) {\n throw new Error('[Bootstrap] Modules not registered. Call registerModules() at bootstrap.')\n }\n return modules\n}\n\n/**\n * Non-throwing counterpart of `getModules()` for call sites that have a\n * meaningful degraded behavior when bootstrap has not run \u2014 route unit tests\n * exercise a single handler without `registerModules()`, and a hard throw there\n * turns an unrelated assertion into a bootstrap error.\n */\nexport function tryGetModules(): Module[] | null {\n return getGlobalModules()\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.10.1-develop.7709.1.046124599c";
1
+ const APP_VERSION = "0.10.1-develop.7715.1.9483283afa";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.10.1-develop.7709.1.046124599c';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.10.1-develop.7715.1.9483283afa';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.10.1-develop.7709.1.046124599c",
3
+ "version": "0.10.1-develop.7715.1.9483283afa",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -126,7 +126,7 @@
126
126
  "@mikro-orm/core": "^7.1.14",
127
127
  "@mikro-orm/decorators": "^7.1.14",
128
128
  "@mikro-orm/postgresql": "^7.1.14",
129
- "@open-mercato/cache": "0.10.1-develop.7709.1.046124599c",
129
+ "@open-mercato/cache": "0.10.1-develop.7715.1.9483283afa",
130
130
  "@types/html-to-text": "^9.0.4",
131
131
  "@types/sanitize-html": "^2.16.1",
132
132
  "dotenv": "^17.4.2",
@@ -1,5 +1,12 @@
1
1
  import type { Module } from '@open-mercato/shared/modules/registry'
2
2
  import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import {
4
+ applyModuleOverridesFromEnabledModules,
5
+ resetModuleContractOverridesForTests,
6
+ resetModuleOverrideAppliersForTests,
7
+ } from '@open-mercato/shared/modules/overrides'
8
+ import { getEnabledModuleIds } from '@open-mercato/shared/security/enabledModulesRegistry'
9
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
10
 
4
11
  jest.mock('@open-mercato/shared/lib/logger', () => {
5
12
  const mocked = {
@@ -151,6 +158,56 @@ describe('shared modules registry', () => {
151
158
  ])
152
159
  })
153
160
 
161
+ describe('i18n-only registration with a setup override (#7243)', () => {
162
+ beforeEach(() => {
163
+ resetModuleContractOverridesForTests()
164
+ resetModuleOverrideAppliersForTests()
165
+ })
166
+
167
+ afterEach(() => {
168
+ resetModuleContractOverridesForTests()
169
+ resetModuleOverrideAppliersForTests()
170
+ })
171
+
172
+ it('merges translation stubs instead of replacing the runtime registry', () => {
173
+ applyModuleOverridesFromEnabledModules([
174
+ { id: 'auth', from: '@open-mercato/core', overrides: { setup: { defaultRoleFeatures: {} } } },
175
+ ])
176
+ const registry = loadRegistry()
177
+ registry.registerModules([
178
+ {
179
+ id: 'auth',
180
+ features: [{ id: 'auth.users.view', title: 'View users', module: 'auth' }],
181
+ setup: { defaultRoleFeatures: { admin: ['auth.users.view'] } },
182
+ translations: { en: { title: 'Auth' } },
183
+ } as Module,
184
+ {
185
+ id: 'notary',
186
+ features: [{ id: 'notary.cases.view', title: 'View cases', module: 'notary' }],
187
+ } as Module,
188
+ ])
189
+
190
+ registry.registerModules([
191
+ { id: 'auth', translations: { en: { title: 'Authentication' } } } as Module,
192
+ ])
193
+
194
+ expect(registry.getModules()).toEqual([
195
+ expect.objectContaining({
196
+ id: 'auth',
197
+ features: [expect.objectContaining({ id: 'auth.users.view' })],
198
+ setup: expect.objectContaining({ defaultRoleFeatures: {} }),
199
+ translations: { en: { title: 'Authentication' } },
200
+ }),
201
+ expect.objectContaining({
202
+ id: 'notary',
203
+ features: [expect.objectContaining({ id: 'notary.cases.view' })],
204
+ }),
205
+ ])
206
+ expect(getEnabledModuleIds()).toEqual(['auth', 'notary'])
207
+ expect(authorizeFeatures(['notary.cases.view'], { grantedFeatures: [], unrestricted: true })).toBe(true)
208
+ })
209
+ })
210
+
154
211
  it('preserves translations when runtime modules register after locale shards', () => {
155
212
  const registry = loadRegistry()
156
213
  const handler = jest.fn()
@@ -208,8 +208,8 @@ export function registerModules(modules: Module[]) {
208
208
  if (existing !== null && process.env.NODE_ENV === 'development') {
209
209
  logger.debug('Modules re-registered (this may occur during HMR)')
210
210
  }
211
- const nextModules = applyModuleOverridesToModules(modules)
212
- const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules)
211
+ const i18nOnlyRegistration = isI18nOnlyRegistration(modules)
212
+ const nextModules = i18nOnlyRegistration ? modules : applyModuleOverridesToModules(modules)
213
213
  const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration
214
214
  const registeredModules = shouldMergeI18nOnly
215
215
  ? mergeI18nModules(existing, nextModules)