@open-mercato/shared 0.6.8-develop.6977.1.5041da373e → 0.6.8-develop.6981.1.5c7cff2f3b
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.
- package/AGENTS.md +1 -1
- package/dist/lib/modules/registry.js +75 -3
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/modules/__tests__/registry.test.ts +130 -0
- package/src/lib/modules/registry.ts +140 -2
package/AGENTS.md
CHANGED
|
@@ -50,7 +50,7 @@ yarn workspace @open-mercato/shared build
|
|
|
50
50
|
| `i18n/` | When translating strings — `useT()` client-side, `resolveTranslations()` server-side | `@open-mercato/shared/lib/i18n/context` or `/server` |
|
|
51
51
|
| `indexers/` | When building query index helpers | `@open-mercato/shared/lib/indexers` |
|
|
52
52
|
| `logger/` | When emitting diagnostics — `createLogger(namespace)` instead of raw `console.*` (migrate incrementally, Boy Scout rule). Message-first with structured fields (`logger.warn('Payload too large', { event, maxBytes })`), errors under `err`, `child(bindings)` for context, `getLogLevel()`/`isLevelEnabled()` to gate expensive fields; level via `OM_LOG_LEVEL`. Never log credentials, PII, or payload bodies | `@open-mercato/shared/lib/logger` |
|
|
53
|
-
| `modules/` | When registering or listing modules; `surfaceFingerprint` gives a deploy-time hash of the enabled modules, their declared ACL features, and the backend route manifest — mix it into any cache key whose payload is derived from those (no DB write exists to tag-invalidate on, so an omitted fingerprint serves the pre-deploy payload forever). It cannot see React-element fields such as a route `icon`, so callers MUST still pass a `ttl` | `@open-mercato/shared/lib/modules/registry`, `@open-mercato/shared/lib/modules/surfaceFingerprint` |
|
|
53
|
+
| `modules/` | When registering or listing modules; `onModulesRegistered(listener)` subscribes to (re-)registrations so a cache derived from the module list can drop what it built from an incomplete one — bootstrap may register an i18n-only set before the full module list merges in, and listeners fire only when the registered set actually changed, so nothing is added to the request path. Its governing contract — notification timing, fail-soft handling of a throwing or rejecting listener, snapshot-based change detection, listener lifetime under HMR, and the globals a test MUST clear — is [`.ai/specs/2026-08-12-module-registry-registration-listeners.md`](../../.ai/specs/2026-08-12-module-registry-registration-listeners.md); `surfaceFingerprint` gives a deploy-time hash of the enabled modules, their declared ACL features, and the backend route manifest — mix it into any cache key whose payload is derived from those (no DB write exists to tag-invalidate on, so an omitted fingerprint serves the pre-deploy payload forever). It cannot see React-element fields such as a route `icon`, so callers MUST still pass a `ttl` | `@open-mercato/shared/lib/modules/registry`, `@open-mercato/shared/lib/modules/surfaceFingerprint` |
|
|
54
54
|
| `number.ts` | When parsing numeric strings from env/query params with a fallback and optional min/integer constraint | `@open-mercato/shared/lib/number` |
|
|
55
55
|
| `openapi/` | When generating CRUD OpenAPI specs | `@open-mercato/shared/lib/openapi/crud` |
|
|
56
56
|
| `profiler/` | When profiling with `OM_PROFILE` env flag | `@open-mercato/shared/lib/profiler` |
|
|
@@ -3,12 +3,76 @@ import { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from "../
|
|
|
3
3
|
import { createLogger } from "../logger/index.js";
|
|
4
4
|
const logger = createLogger("shared").child({ component: "modules-registry" });
|
|
5
5
|
const GLOBAL_KEY = "__openMercatoModulesRegistry__";
|
|
6
|
+
const LISTENERS_GLOBAL_KEY = "__openMercatoModulesRegistryListeners__";
|
|
7
|
+
const SNAPSHOT_GLOBAL_KEY = "__openMercatoModulesRegistrySnapshot__";
|
|
8
|
+
const UNREADABLE_CONTRACT_ENTRY = /* @__PURE__ */ Symbol("open-mercato.modules.unreadable-contract-entry");
|
|
9
|
+
function registryGlobals() {
|
|
10
|
+
return globalThis;
|
|
11
|
+
}
|
|
6
12
|
function getGlobalModules() {
|
|
7
|
-
return
|
|
13
|
+
return registryGlobals()[GLOBAL_KEY] ?? null;
|
|
8
14
|
}
|
|
9
15
|
function setGlobalModules(modules) {
|
|
10
|
-
;
|
|
11
|
-
|
|
16
|
+
registryGlobals()[GLOBAL_KEY] = modules;
|
|
17
|
+
}
|
|
18
|
+
function getListeners() {
|
|
19
|
+
const globalScope = registryGlobals();
|
|
20
|
+
const listeners = globalScope[LISTENERS_GLOBAL_KEY] ?? /* @__PURE__ */ new Set();
|
|
21
|
+
globalScope[LISTENERS_GLOBAL_KEY] = listeners;
|
|
22
|
+
return listeners;
|
|
23
|
+
}
|
|
24
|
+
function onModulesRegistered(listener) {
|
|
25
|
+
const listeners = getListeners();
|
|
26
|
+
listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function snapshotModules(modules) {
|
|
32
|
+
return modules.map((entry) => {
|
|
33
|
+
const contract = Object.keys(entry).sort((a, b) => a < b ? -1 : a > b ? 1 : 0).map((key) => {
|
|
34
|
+
const descriptor = Object.getOwnPropertyDescriptor(entry, key);
|
|
35
|
+
if (!descriptor || !("value" in descriptor)) return [key, UNREADABLE_CONTRACT_ENTRY];
|
|
36
|
+
const value = descriptor.value;
|
|
37
|
+
return [key, Array.isArray(value) ? [...value] : value];
|
|
38
|
+
});
|
|
39
|
+
return [entry.id, contract];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function contractValuesMatch(previous, next) {
|
|
43
|
+
if (previous === UNREADABLE_CONTRACT_ENTRY || next === UNREADABLE_CONTRACT_ENTRY) return false;
|
|
44
|
+
if (Array.isArray(previous) && Array.isArray(next)) {
|
|
45
|
+
return previous.length === next.length && previous.every((item, index) => item === next[index]);
|
|
46
|
+
}
|
|
47
|
+
return previous === next;
|
|
48
|
+
}
|
|
49
|
+
function snapshotsMatch(previous, next) {
|
|
50
|
+
if (previous === null || previous.length !== next.length) return false;
|
|
51
|
+
return previous.every(([id, contract], index) => {
|
|
52
|
+
const [nextId, nextContract] = next[index];
|
|
53
|
+
if (id !== nextId || contract.length !== nextContract.length) return false;
|
|
54
|
+
return contract.every(([key, value], contractIndex) => {
|
|
55
|
+
const [nextKey, nextValue] = nextContract[contractIndex];
|
|
56
|
+
return key === nextKey && contractValuesMatch(value, nextValue);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function isPromiseLike(value) {
|
|
61
|
+
return typeof value?.then === "function";
|
|
62
|
+
}
|
|
63
|
+
function notifyModulesRegistered(modules) {
|
|
64
|
+
for (const listener of [...getListeners()]) {
|
|
65
|
+
try {
|
|
66
|
+
const result = listener(modules);
|
|
67
|
+
if (isPromiseLike(result)) {
|
|
68
|
+
Promise.resolve(result).catch((err) => {
|
|
69
|
+
logger.error("Module registration listener rejected", { err });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
logger.error("Module registration listener failed", { err });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
12
76
|
}
|
|
13
77
|
function hasRuntimeContracts(entry) {
|
|
14
78
|
return Object.keys(entry).some((key) => key !== "id" && key !== "translations");
|
|
@@ -67,6 +131,13 @@ function registerModules(modules) {
|
|
|
67
131
|
} else {
|
|
68
132
|
invalidateDictionaryCache();
|
|
69
133
|
}
|
|
134
|
+
const globalScope = registryGlobals();
|
|
135
|
+
const previousSnapshot = globalScope[SNAPSHOT_GLOBAL_KEY] ?? null;
|
|
136
|
+
const nextSnapshot = snapshotModules(registeredModules);
|
|
137
|
+
globalScope[SNAPSHOT_GLOBAL_KEY] = nextSnapshot;
|
|
138
|
+
if (!snapshotsMatch(previousSnapshot, nextSnapshot)) {
|
|
139
|
+
notifyModulesRegistered(registeredModules);
|
|
140
|
+
}
|
|
70
141
|
}
|
|
71
142
|
function getModules() {
|
|
72
143
|
const modules = getGlobalModules();
|
|
@@ -80,6 +151,7 @@ function tryGetModules() {
|
|
|
80
151
|
}
|
|
81
152
|
export {
|
|
82
153
|
getModules,
|
|
154
|
+
onModulesRegistered,
|
|
83
155
|
registerModules,
|
|
84
156
|
tryGetModules
|
|
85
157
|
};
|
|
@@ -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__'\n\nfunction getGlobalModules(): Module[] | null {\n return (globalThis as any)[GLOBAL_KEY] ?? null\n}\n\nfunction setGlobalModules(modules: Module[]): void {\n ;(globalThis as any)[GLOBAL_KEY] = modules\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}\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": "AACA,SAAS,qCAAqC;AAE9C,SAAS,2BAA2B,wCAAwC;AAC5E,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,mBAAmB,CAAC;AAS7E,MAAM,aAAa;
|
|
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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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.6.8-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.8-develop.6981.1.5c7cff2f3b';\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.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6981.1.5c7cff2f3b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"@mikro-orm/core": "^7.1.8",
|
|
106
106
|
"@mikro-orm/decorators": "^7.1.8",
|
|
107
107
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
108
|
-
"@open-mercato/cache": "0.6.8-develop.
|
|
108
|
+
"@open-mercato/cache": "0.6.8-develop.6981.1.5c7cff2f3b",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
@@ -13,12 +13,17 @@ jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
|
13
13
|
return { createLogger: jest.fn(() => mocked) }
|
|
14
14
|
})
|
|
15
15
|
const loggerDebug = createLogger('shared').debug as jest.Mock
|
|
16
|
+
const loggerError = createLogger('shared').error as jest.Mock
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
const GLOBAL_KEY = '__openMercatoModulesRegistry__'
|
|
20
|
+
const LISTENERS_GLOBAL_KEY = '__openMercatoModulesRegistryListeners__'
|
|
21
|
+
const SNAPSHOT_GLOBAL_KEY = '__openMercatoModulesRegistrySnapshot__'
|
|
19
22
|
|
|
20
23
|
function clearGlobalRegistry(): void {
|
|
21
24
|
delete (globalThis as any)[GLOBAL_KEY]
|
|
25
|
+
delete (globalThis as any)[LISTENERS_GLOBAL_KEY]
|
|
26
|
+
delete (globalThis as any)[SNAPSHOT_GLOBAL_KEY]
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
function clearRegistryModuleCache(): void {
|
|
@@ -171,4 +176,129 @@ describe('shared modules registry', () => {
|
|
|
171
176
|
}),
|
|
172
177
|
])
|
|
173
178
|
})
|
|
179
|
+
|
|
180
|
+
describe('onModulesRegistered (#5103)', () => {
|
|
181
|
+
it('notifies subscribers with the reconciled module list on first registration', () => {
|
|
182
|
+
const registry = loadRegistry()
|
|
183
|
+
const listener = jest.fn()
|
|
184
|
+
registry.onModulesRegistered(listener)
|
|
185
|
+
|
|
186
|
+
registry.registerModules(sampleModules)
|
|
187
|
+
|
|
188
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
189
|
+
expect((listener.mock.calls[0][0] as Module[]).map((m) => m.id)).toEqual(['auth', 'customers'])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('notifies subscribers when an i18n-only registration is merged with the full module list', () => {
|
|
193
|
+
const registry = loadRegistry()
|
|
194
|
+
const listener = jest.fn()
|
|
195
|
+
registry.registerModules([{ id: 'checkout', translations: { pl: { title: 'Kasa' } } } as Module])
|
|
196
|
+
registry.onModulesRegistered(listener)
|
|
197
|
+
|
|
198
|
+
registry.registerModules([
|
|
199
|
+
{ id: 'checkout', dashboardWidgets: [] } as unknown as Module,
|
|
200
|
+
{ id: 'reports', dashboardWidgets: [] } as unknown as Module,
|
|
201
|
+
])
|
|
202
|
+
|
|
203
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
204
|
+
expect((listener.mock.calls[0][0] as Module[]).map((m) => m.id)).toEqual(['checkout', 'reports'])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('does not notify subscribers when the identical module set is re-registered', () => {
|
|
208
|
+
const registry = loadRegistry()
|
|
209
|
+
const listener = jest.fn()
|
|
210
|
+
registry.registerModules(sampleModules)
|
|
211
|
+
registry.onModulesRegistered(listener)
|
|
212
|
+
|
|
213
|
+
registry.registerModules(sampleModules)
|
|
214
|
+
|
|
215
|
+
expect(listener).not.toHaveBeenCalled()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('stops notifying after the returned unsubscribe is called', () => {
|
|
219
|
+
const registry = loadRegistry()
|
|
220
|
+
const listener = jest.fn()
|
|
221
|
+
const unsubscribe = registry.onModulesRegistered(listener)
|
|
222
|
+
|
|
223
|
+
registry.registerModules(sampleModules)
|
|
224
|
+
unsubscribe()
|
|
225
|
+
registry.registerModules([{ id: 'auth', dashboardWidgets: [] } as unknown as Module])
|
|
226
|
+
|
|
227
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('notifies subscribers when a re-registered module object was mutated in place', () => {
|
|
231
|
+
const registry = loadRegistry()
|
|
232
|
+
const mutated = { id: 'dashboards', dashboardWidgets: [] } as unknown as Module
|
|
233
|
+
const modules = [mutated]
|
|
234
|
+
registry.registerModules(modules)
|
|
235
|
+
const listener = jest.fn()
|
|
236
|
+
registry.onModulesRegistered(listener)
|
|
237
|
+
|
|
238
|
+
;(mutated as unknown as { dashboardWidgets: unknown[] }).dashboardWidgets = [{ key: 'sales-kpi' }]
|
|
239
|
+
registry.registerModules(modules)
|
|
240
|
+
|
|
241
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('notifies subscribers when an array-valued contract is mutated in place', () => {
|
|
245
|
+
const registry = loadRegistry()
|
|
246
|
+
const widgets: unknown[] = []
|
|
247
|
+
const mutated = { id: 'dashboards', dashboardWidgets: widgets } as unknown as Module
|
|
248
|
+
const modules = [mutated]
|
|
249
|
+
registry.registerModules(modules)
|
|
250
|
+
const listener = jest.fn()
|
|
251
|
+
registry.onModulesRegistered(listener)
|
|
252
|
+
|
|
253
|
+
widgets.push({ key: 'sales-kpi' })
|
|
254
|
+
registry.registerModules(modules)
|
|
255
|
+
|
|
256
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('does not notify subscribers when a semantically identical fresh module list is registered', () => {
|
|
260
|
+
const registry = loadRegistry()
|
|
261
|
+
const widget = { key: 'sales-kpi' }
|
|
262
|
+
registry.registerModules([{ id: 'dashboards', dashboardWidgets: [widget] } as unknown as Module])
|
|
263
|
+
const listener = jest.fn()
|
|
264
|
+
registry.onModulesRegistered(listener)
|
|
265
|
+
|
|
266
|
+
registry.registerModules([{ id: 'dashboards', dashboardWidgets: [widget] } as unknown as Module])
|
|
267
|
+
|
|
268
|
+
expect(listener).not.toHaveBeenCalled()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('observes and logs a rejected async subscriber instead of leaking an unhandled rejection', async () => {
|
|
272
|
+
const registry = loadRegistry()
|
|
273
|
+
const rejection = new Error('async listener boom')
|
|
274
|
+
const failing = jest.fn(async () => {
|
|
275
|
+
await Promise.resolve()
|
|
276
|
+
throw rejection
|
|
277
|
+
})
|
|
278
|
+
const healthy = jest.fn()
|
|
279
|
+
registry.onModulesRegistered(failing)
|
|
280
|
+
registry.onModulesRegistered(healthy)
|
|
281
|
+
loggerError.mockClear()
|
|
282
|
+
|
|
283
|
+
expect(() => registry.registerModules(sampleModules)).not.toThrow()
|
|
284
|
+
expect(healthy).toHaveBeenCalledTimes(1)
|
|
285
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
286
|
+
|
|
287
|
+
expect(loggerError).toHaveBeenCalledWith('Module registration listener rejected', { err: rejection })
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('keeps registering modules when a subscriber throws', () => {
|
|
291
|
+
const registry = loadRegistry()
|
|
292
|
+
const failing = jest.fn(() => {
|
|
293
|
+
throw new Error('listener boom')
|
|
294
|
+
})
|
|
295
|
+
const healthy = jest.fn()
|
|
296
|
+
registry.onModulesRegistered(failing)
|
|
297
|
+
registry.onModulesRegistered(healthy)
|
|
298
|
+
|
|
299
|
+
expect(() => registry.registerModules(sampleModules)).not.toThrow()
|
|
300
|
+
expect(healthy).toHaveBeenCalledTimes(1)
|
|
301
|
+
expect(registry.getModules().map((m) => m.id)).toEqual(['auth', 'customers'])
|
|
302
|
+
})
|
|
303
|
+
})
|
|
174
304
|
})
|
|
@@ -14,13 +14,144 @@ const logger = createLogger('shared').child({ component: 'modules-registry' })
|
|
|
14
14
|
// node_modules/@open-mercato/shared/dist/. Mirrors the same workaround used
|
|
15
15
|
// by `getDiRegistrars()` in `../di/container.ts`.
|
|
16
16
|
const GLOBAL_KEY = '__openMercatoModulesRegistry__'
|
|
17
|
+
const LISTENERS_GLOBAL_KEY = '__openMercatoModulesRegistryListeners__'
|
|
18
|
+
const SNAPSHOT_GLOBAL_KEY = '__openMercatoModulesRegistrySnapshot__'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A listener may return a promise; `registerModules()` stays synchronous and
|
|
22
|
+
* never awaits it, so the contract is fire-and-forget on both paths — a
|
|
23
|
+
* synchronous throw and an asynchronous rejection are both observed and logged
|
|
24
|
+
* rather than escaping into the bootstrap frame as an unhandled rejection.
|
|
25
|
+
*/
|
|
26
|
+
export type ModulesRegisteredListener = (modules: Module[]) => void | PromiseLike<void>
|
|
27
|
+
|
|
28
|
+
const UNREADABLE_CONTRACT_ENTRY = Symbol('open-mercato.modules.unreadable-contract-entry')
|
|
29
|
+
|
|
30
|
+
type RegistrationSnapshotEntry = readonly [id: string, contract: ReadonlyArray<readonly [string, unknown]>]
|
|
31
|
+
type RegistrationSnapshot = ReadonlyArray<RegistrationSnapshotEntry>
|
|
32
|
+
|
|
33
|
+
type ModulesRegistryGlobalScope = typeof globalThis & {
|
|
34
|
+
[GLOBAL_KEY]?: Module[] | null
|
|
35
|
+
[LISTENERS_GLOBAL_KEY]?: Set<ModulesRegisteredListener>
|
|
36
|
+
[SNAPSHOT_GLOBAL_KEY]?: RegistrationSnapshot | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registryGlobals(): ModulesRegistryGlobalScope {
|
|
40
|
+
return globalThis as ModulesRegistryGlobalScope
|
|
41
|
+
}
|
|
17
42
|
|
|
18
43
|
function getGlobalModules(): Module[] | null {
|
|
19
|
-
return (
|
|
44
|
+
return registryGlobals()[GLOBAL_KEY] ?? null
|
|
20
45
|
}
|
|
21
46
|
|
|
22
47
|
function setGlobalModules(modules: Module[]): void {
|
|
23
|
-
|
|
48
|
+
registryGlobals()[GLOBAL_KEY] = modules
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getListeners(): Set<ModulesRegisteredListener> {
|
|
52
|
+
const globalScope = registryGlobals()
|
|
53
|
+
const listeners = globalScope[LISTENERS_GLOBAL_KEY] ?? new Set<ModulesRegisteredListener>()
|
|
54
|
+
globalScope[LISTENERS_GLOBAL_KEY] = listeners
|
|
55
|
+
return listeners
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Subscribe to module-registry (re-)registrations so caches derived from the
|
|
60
|
+
* module list can drop what they built from an incomplete one. Bootstrap can
|
|
61
|
+
* register modules more than once — an i18n-only registration is reconciled
|
|
62
|
+
* with the full module list by `mergeI18nModules()` — and a consumer that
|
|
63
|
+
* memoized its resolution in between would otherwise serve the pre-merge view
|
|
64
|
+
* for the lifetime of the process (issue #5103).
|
|
65
|
+
*
|
|
66
|
+
* Listeners live on `globalThis` for the same module-duplication reason the
|
|
67
|
+
* registry itself does, and they are notified only when the registered set
|
|
68
|
+
* actually changed, so repeated identical bootstraps and HMR re-registrations
|
|
69
|
+
* do not needlessly drop warm caches. Returns an unsubscribe function.
|
|
70
|
+
*
|
|
71
|
+
* Contract (`.ai/specs/2026-08-12-module-registry-registration-listeners.md`):
|
|
72
|
+
* listeners run synchronously at the end of `registerModules()`, after the new
|
|
73
|
+
* module list is readable through `getModules()`; a returned promise is never
|
|
74
|
+
* awaited; neither a throw nor a rejection can break bootstrap; "changed" is
|
|
75
|
+
* decided by the registration snapshot below, which sees module ids, their
|
|
76
|
+
* top-level contract keys and the elements of array-valued ones — a mutation
|
|
77
|
+
* deeper than that is invisible and its owner must invalidate its own cache.
|
|
78
|
+
*/
|
|
79
|
+
export function onModulesRegistered(listener: ModulesRegisteredListener): () => void {
|
|
80
|
+
const listeners = getListeners()
|
|
81
|
+
listeners.add(listener)
|
|
82
|
+
return () => {
|
|
83
|
+
listeners.delete(listener)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Freeze what the registered list declares, so a later in-place mutation of a
|
|
89
|
+
* module object cannot retroactively rewrite what the previous registration
|
|
90
|
+
* looked like. Comparing live `Module` references (the obvious cheap check)
|
|
91
|
+
* cannot see an HMR bootstrap that reassigns `dashboardWidgets` on an existing
|
|
92
|
+
* object and re-registers the same array — both sides are then the same
|
|
93
|
+
* reference and the stale widget catalog survives until restart.
|
|
94
|
+
*/
|
|
95
|
+
function snapshotModules(modules: Module[]): RegistrationSnapshot {
|
|
96
|
+
return modules.map((entry) => {
|
|
97
|
+
const contract = Object.keys(entry)
|
|
98
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
99
|
+
.map((key) => {
|
|
100
|
+
const descriptor = Object.getOwnPropertyDescriptor(entry, key)
|
|
101
|
+
// An accessor is never invoked here: a module may declare a contract as a
|
|
102
|
+
// lazy getter that is expensive, side-effectful, or throws until the rest
|
|
103
|
+
// of bootstrap has run. It is recorded as unreadable instead, which never
|
|
104
|
+
// compares equal — erring toward dropping a warm cache rather than
|
|
105
|
+
// serving a stale one.
|
|
106
|
+
if (!descriptor || !('value' in descriptor)) return [key, UNREADABLE_CONTRACT_ENTRY] as const
|
|
107
|
+
const value = descriptor.value
|
|
108
|
+
return [key, Array.isArray(value) ? [...value] : value] as const
|
|
109
|
+
})
|
|
110
|
+
return [entry.id, contract] as const
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function contractValuesMatch(previous: unknown, next: unknown): boolean {
|
|
115
|
+
if (previous === UNREADABLE_CONTRACT_ENTRY || next === UNREADABLE_CONTRACT_ENTRY) return false
|
|
116
|
+
if (Array.isArray(previous) && Array.isArray(next)) {
|
|
117
|
+
return previous.length === next.length && previous.every((item, index) => item === next[index])
|
|
118
|
+
}
|
|
119
|
+
return previous === next
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function snapshotsMatch(previous: RegistrationSnapshot | null, next: RegistrationSnapshot): boolean {
|
|
123
|
+
if (previous === null || previous.length !== next.length) return false
|
|
124
|
+
return previous.every(([id, contract], index) => {
|
|
125
|
+
const [nextId, nextContract] = next[index]
|
|
126
|
+
if (id !== nextId || contract.length !== nextContract.length) return false
|
|
127
|
+
return contract.every(([key, value], contractIndex) => {
|
|
128
|
+
const [nextKey, nextValue] = nextContract[contractIndex]
|
|
129
|
+
return key === nextKey && contractValuesMatch(value, nextValue)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
135
|
+
return typeof (value as PromiseLike<unknown> | null | undefined)?.then === 'function'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function notifyModulesRegistered(modules: Module[]): void {
|
|
139
|
+
for (const listener of [...getListeners()]) {
|
|
140
|
+
try {
|
|
141
|
+
const result = listener(modules)
|
|
142
|
+
if (isPromiseLike(result)) {
|
|
143
|
+
// The listener contract is fire-and-forget: registerModules() keeps its
|
|
144
|
+
// synchronous signature, so an async subscriber's rejection would land
|
|
145
|
+
// as an unhandled rejection (fatal under Node's default policy) unless
|
|
146
|
+
// it is observed here.
|
|
147
|
+
Promise.resolve(result).catch((err) => {
|
|
148
|
+
logger.error('Module registration listener rejected', { err })
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logger.error('Module registration listener failed', { err })
|
|
153
|
+
}
|
|
154
|
+
}
|
|
24
155
|
}
|
|
25
156
|
|
|
26
157
|
function hasRuntimeContracts(entry: Module): boolean {
|
|
@@ -89,6 +220,13 @@ export function registerModules(modules: Module[]) {
|
|
|
89
220
|
} else {
|
|
90
221
|
invalidateDictionaryCache()
|
|
91
222
|
}
|
|
223
|
+
const globalScope = registryGlobals()
|
|
224
|
+
const previousSnapshot = globalScope[SNAPSHOT_GLOBAL_KEY] ?? null
|
|
225
|
+
const nextSnapshot = snapshotModules(registeredModules)
|
|
226
|
+
globalScope[SNAPSHOT_GLOBAL_KEY] = nextSnapshot
|
|
227
|
+
if (!snapshotsMatch(previousSnapshot, nextSnapshot)) {
|
|
228
|
+
notifyModulesRegistered(registeredModules)
|
|
229
|
+
}
|
|
92
230
|
}
|
|
93
231
|
|
|
94
232
|
export function getModules(): Module[] {
|