@open-mercato/shared 0.6.8-develop.6889.1.af4501ca97 → 0.6.8-develop.6892.1.f179677788
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +5 -0
- package/dist/lib/bootstrap/dynamicLoader.js +40 -4
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/bootstrap/factory.js +18 -13
- package/dist/lib/bootstrap/factory.js.map +2 -2
- package/dist/lib/encryption/find.js +22 -0
- package/dist/lib/encryption/find.js.map +2 -2
- package/dist/lib/encryption/likeFilterWarning.js +66 -0
- package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
- package/dist/lib/i18n/dictionary-cache.js +5 -0
- package/dist/lib/i18n/dictionary-cache.js.map +2 -2
- package/dist/lib/modules/registry.js +31 -5
- package/dist/lib/modules/registry.js.map +2 -2
- package/dist/lib/query/ciphertext-search-warning.js +7 -1
- package/dist/lib/query/ciphertext-search-warning.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/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
- package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
- package/src/lib/bootstrap/dynamicLoader.ts +52 -4
- package/src/lib/bootstrap/factory.ts +21 -15
- package/src/lib/bootstrap/types.ts +6 -0
- package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
- package/src/lib/encryption/find.ts +22 -0
- package/src/lib/encryption/likeFilterWarning.ts +102 -0
- package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
- package/src/lib/i18n/dictionary-cache.ts +5 -0
- package/src/lib/modules/__tests__/registry.test.ts +26 -0
- package/src/lib/modules/registry.ts +36 -7
- package/src/lib/query/ciphertext-search-warning.ts +17 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { applyModuleOverridesToModules } from "@open-mercato/shared/modules/overrides";
|
|
2
|
-
import { invalidateDictionaryCache } from "../i18n/dictionary-cache.js";
|
|
2
|
+
import { invalidateDictionaryCache, invalidateDictionaryCacheLocales } from "../i18n/dictionary-cache.js";
|
|
3
3
|
import { createLogger } from "../logger/index.js";
|
|
4
4
|
const logger = createLogger("shared").child({ component: "modules-registry" });
|
|
5
5
|
const GLOBAL_KEY = "__openMercatoModulesRegistry__";
|
|
@@ -24,7 +24,10 @@ function mergeI18nModules(existing, incoming) {
|
|
|
24
24
|
if (!i18nModule?.translations) return entry;
|
|
25
25
|
return {
|
|
26
26
|
...entry,
|
|
27
|
-
translations:
|
|
27
|
+
translations: {
|
|
28
|
+
...entry.translations ?? {},
|
|
29
|
+
...i18nModule.translations
|
|
30
|
+
}
|
|
28
31
|
};
|
|
29
32
|
});
|
|
30
33
|
for (const entry of incoming) {
|
|
@@ -32,15 +35,38 @@ function mergeI18nModules(existing, incoming) {
|
|
|
32
35
|
}
|
|
33
36
|
return merged;
|
|
34
37
|
}
|
|
38
|
+
function getTranslationLocales(modules) {
|
|
39
|
+
const locales = /* @__PURE__ */ new Set();
|
|
40
|
+
for (const entry of modules) {
|
|
41
|
+
for (const locale of Object.keys(entry.translations ?? {})) {
|
|
42
|
+
locales.add(locale);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...locales];
|
|
46
|
+
}
|
|
47
|
+
function preserveExistingTranslations(existing, incoming) {
|
|
48
|
+
const existingById = new Map(existing.map((entry) => [entry.id, entry]));
|
|
49
|
+
return incoming.map((entry) => {
|
|
50
|
+
if (entry.translations) return entry;
|
|
51
|
+
const translations = existingById.get(entry.id)?.translations;
|
|
52
|
+
return translations ? { ...entry, translations } : entry;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
35
55
|
function registerModules(modules) {
|
|
36
56
|
const existing = getGlobalModules();
|
|
37
57
|
if (existing !== null && process.env.NODE_ENV === "development") {
|
|
38
58
|
logger.debug("Modules re-registered (this may occur during HMR)");
|
|
39
59
|
}
|
|
40
60
|
const nextModules = applyModuleOverridesToModules(modules);
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
61
|
+
const i18nOnlyRegistration = isI18nOnlyRegistration(nextModules);
|
|
62
|
+
const shouldMergeI18nOnly = existing !== null && i18nOnlyRegistration;
|
|
63
|
+
const registeredModules = shouldMergeI18nOnly ? mergeI18nModules(existing, nextModules) : preserveExistingTranslations(existing ?? [], nextModules);
|
|
64
|
+
setGlobalModules(registeredModules);
|
|
65
|
+
if (i18nOnlyRegistration) {
|
|
66
|
+
invalidateDictionaryCacheLocales(getTranslationLocales(nextModules));
|
|
67
|
+
} else {
|
|
68
|
+
invalidateDictionaryCache();
|
|
69
|
+
}
|
|
44
70
|
}
|
|
45
71
|
function getModules() {
|
|
46
72
|
const modules = getGlobalModules();
|
|
@@ -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 { invalidateDictionaryCache } 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: i18nModule.translations,\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\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 shouldMergeI18nOnly = existing !== null\n
|
|
5
|
-
"mappings": "AACA,SAAS,qCAAqC;
|
|
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;AAEnB,SAAS,mBAAoC;AAC3C,SAAQ,WAAmB,UAAU,KAAK;AAC5C;AAEA,SAAS,iBAAiB,SAAyB;AACjD;AAAC,EAAC,WAAmB,UAAU,IAAI;AACrC;AAEA,SAAS,oBAAoB,OAAwB;AACnD,SAAO,OAAO,KAAK,KAAK,EAAE,KAAK,CAAC,QAAQ,QAAQ,QAAQ,QAAQ,cAAc;AAChF;AAEA,SAAS,uBAAuB,SAA4B;AAC1D,SAAO,QAAQ,SAAS,KAAK,QAAQ,MAAM,CAAC,UAAU,CAAC,oBAAoB,KAAK,CAAC;AACnF;AAEA,SAAS,iBAAiB,UAAoB,UAA8B;AAC1E,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC;AACvE,QAAM,cAAc,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,MAAM,EAAE,CAAC;AAC7D,QAAM,SAAS,SAAS,IAAI,CAAC,UAAU;AACrC,UAAM,aAAa,aAAa,IAAI,MAAM,EAAE;AAC5C,QAAI,CAAC,YAAY,aAAc,QAAO;AACtC,WAAO;AAAA,MACL,GAAG;AAAA,MACH,cAAc;AAAA,QACZ,GAAI,MAAM,gBAAgB,CAAC;AAAA,QAC3B,GAAG,WAAW;AAAA,MAChB;AAAA,IACF;AAAA,EACF,CAAC;AAED,aAAW,SAAS,UAAU;AAC5B,QAAI,CAAC,YAAY,IAAI,MAAM,EAAE,EAAG,QAAO,KAAK,KAAK;AAAA,EACnD;AAEA,SAAO;AACT;AAEA,SAAS,sBAAsB,SAA6B;AAC1D,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,SAAS,SAAS;AAC3B,eAAW,UAAU,OAAO,KAAK,MAAM,gBAAgB,CAAC,CAAC,GAAG;AAC1D,cAAQ,IAAI,MAAgB;AAAA,IAC9B;AAAA,EACF;AACA,SAAO,CAAC,GAAG,OAAO;AACpB;AAEA,SAAS,6BAA6B,UAAoB,UAA8B;AACtF,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,UAAU,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC;AACvE,SAAO,SAAS,IAAI,CAAC,UAAU;AAC7B,QAAI,MAAM,aAAc,QAAO;AAC/B,UAAM,eAAe,aAAa,IAAI,MAAM,EAAE,GAAG;AACjD,WAAO,eAAe,EAAE,GAAG,OAAO,aAAa,IAAI;AAAA,EACrD,CAAC;AACH;AAEO,SAAS,gBAAgB,SAAmB;AACjD,QAAM,WAAW,iBAAiB;AAClC,MAAI,aAAa,QAAQ,QAAQ,IAAI,aAAa,eAAe;AAC/D,WAAO,MAAM,mDAAmD;AAAA,EAClE;AACA,QAAM,cAAc,8BAA8B,OAAO;AACzD,QAAM,uBAAuB,uBAAuB,WAAW;AAC/D,QAAM,sBAAsB,aAAa,QAAQ;AACjD,QAAM,oBAAoB,sBACtB,iBAAiB,UAAU,WAAW,IACtC,6BAA6B,YAAY,CAAC,GAAG,WAAW;AAC5D,mBAAiB,iBAAiB;AAClC,MAAI,sBAAsB;AACxB,qCAAiC,sBAAsB,WAAW,CAAC;AAAA,EACrE,OAAO;AACL,8BAA0B;AAAA,EAC5B;AACF;AAEO,SAAS,aAAuB;AACrC,QAAM,UAAU,iBAAiB;AACjC,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,0EAA0E;AAAA,EAC5F;AACA,SAAO;AACT;AAQO,SAAS,gBAAiC;AAC/C,SAAO,iBAAiB;AAC1B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createLogger } from "../logger/index.js";
|
|
2
2
|
const logger = createLogger("shared").child({ component: "query" });
|
|
3
|
+
const REASON_HINTS = {
|
|
4
|
+
"search-disabled": "OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.",
|
|
5
|
+
"no-indexable-tokens": "The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.",
|
|
6
|
+
"no-search-tokens": "No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.",
|
|
7
|
+
"raw-orm-filter": "This filter bypasses the query engine, so it runs as ILIKE against ciphertext and matches nothing. Resolve ids through findEntityIdsBySearchTokens (@open-mercato/shared/lib/search/tokenLookup), filter on the deterministic hash column, or compare in memory after decryption."
|
|
8
|
+
};
|
|
3
9
|
const WARN_CACHE_CAP = 5e3;
|
|
4
10
|
const warned = /* @__PURE__ */ new Set();
|
|
5
11
|
const warnKey = (entity, tenantId, field) => `${entity}|${tenantId ?? "null"}|${field}`;
|
|
@@ -27,7 +33,7 @@ async function warnOnCiphertextLikeFallback(params) {
|
|
|
27
33
|
entity,
|
|
28
34
|
field,
|
|
29
35
|
reason,
|
|
30
|
-
hint: reason
|
|
36
|
+
hint: REASON_HINTS[reason]
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
} catch (err) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/query/ciphertext-search-warning.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '../logger'\nimport type { EntityId } from '../../modules/entities'\n\nconst logger = createLogger('shared').child({ component: 'query' })\n\n/**\n * Minimal slice of `TenantDataEncryptionService` needed to decide whether a\n * text filter targets an encrypted column.\n */\nexport type CiphertextWarningEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\n} | null | undefined\n\nexport type CiphertextLikeFallbackReason
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAG7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '../logger'\nimport type { EntityId } from '../../modules/entities'\n\nconst logger = createLogger('shared').child({ component: 'query' })\n\n/**\n * Minimal slice of `TenantDataEncryptionService` needed to decide whether a\n * text filter targets an encrypted column.\n */\nexport type CiphertextWarningEncryptionService = {\n getEncryptedFieldNames?: (\n entityId: EntityId,\n tenantId?: string | null,\n organizationId?: string | null,\n ) => Promise<readonly string[]>\n isEnabled?: () => boolean\n} | null | undefined\n\nexport type CiphertextLikeFallbackReason =\n | 'no-indexable-tokens'\n | 'no-search-tokens'\n | 'search-disabled'\n | 'raw-orm-filter'\n\nconst REASON_HINTS: Record<CiphertextLikeFallbackReason, string> = {\n 'search-disabled':\n 'OM_SEARCH_ENABLED is off, so the filter runs as ILIKE against ciphertext and matches nothing.',\n 'no-indexable-tokens':\n 'The filter value produced no search tokens, so it runs as ILIKE against ciphertext and matches nothing. Use at least OM_SEARCH_MIN_LEN indexable characters, or filter on the deterministic hash column.',\n 'no-search-tokens':\n 'No search_tokens exist for this entity and scope, so the filter runs as ILIKE against ciphertext and matches nothing. Index the entity through query_index and reindex, or filter on the deterministic hash column.',\n 'raw-orm-filter':\n 'This filter bypasses the query engine, so it runs as ILIKE against ciphertext and matches nothing. Resolve ids through findEntityIdsBySearchTokens (@open-mercato/shared/lib/search/tokenLookup), filter on the deterministic hash column, or compare in memory after decryption.',\n}\n\n// One warning per entity/tenant/field per process \u2014 the fallback repeats on\n// every request, and an operator only needs to learn about it once.\nconst WARN_CACHE_CAP = 5000\nconst warned = new Set<string>()\n\nconst warnKey = (entity: string, tenantId: string | null, field: string): string =>\n `${entity}|${tenantId ?? 'null'}|${field}`\n\nexport const normalizeColumnName = (value: string): string =>\n value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()\n\n/** Test seam: the warn-once cache is process-global by design. */\nexport function resetCiphertextLikeWarnCache(): void {\n warned.clear()\n}\n\n/**\n * Warn when a `like`/`ilike` filter is about to run against a column that an\n * encryption map covers while the token index is unavailable.\n *\n * Without this the query degrades silently: the predicate compares a plaintext\n * pattern against ciphertext, matches nothing, and the endpoint returns an\n * empty page that is indistinguishable from a genuine no-result. Issue #2990.\n *\n * Never throws and never blocks the query \u2014 a failure here is logged at debug\n * level and the caller proceeds unchanged.\n */\nexport async function warnOnCiphertextLikeFallback(params: {\n entity: string\n fields: readonly string[]\n tenantId: string | null\n reason: CiphertextLikeFallbackReason\n service: CiphertextWarningEncryptionService\n}): Promise<void> {\n const { entity, tenantId, reason, service } = params\n if (!service || service.isEnabled?.() === false) return\n if (typeof service.getEncryptedFieldNames !== 'function') return\n\n const candidates = params.fields\n .filter((field) => typeof field === 'string' && !field.startsWith('cf:'))\n .map((field) => normalizeColumnName(field))\n .filter((field, index, all) => field.length > 0 && all.indexOf(field) === index)\n .filter((field) => !warned.has(warnKey(entity, tenantId, field)))\n if (!candidates.length) return\n\n try {\n // `organizationId: null` resolves the global map plus every per-organization\n // override, so a field encrypted for any organization is reported.\n const encrypted = new Set(\n (await service.getEncryptedFieldNames(entity, tenantId, null)).map((field) =>\n normalizeColumnName(String(field)),\n ),\n )\n for (const field of candidates) {\n if (!encrypted.has(field)) continue\n if (warned.size >= WARN_CACHE_CAP) warned.clear()\n warned.add(warnKey(entity, tenantId, field))\n logger.warn('Text search filter cannot match an encrypted column', {\n entity,\n field,\n reason,\n hint: REASON_HINTS[reason],\n })\n }\n } catch (err) {\n logger.debug('Ciphertext search warning check failed', {\n entity,\n err: err instanceof Error ? err.message : String(err),\n })\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAG7B,MAAM,SAAS,aAAa,QAAQ,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAqBlE,MAAM,eAA6D;AAAA,EACjE,mBACE;AAAA,EACF,uBACE;AAAA,EACF,oBACE;AAAA,EACF,kBACE;AACJ;AAIA,MAAM,iBAAiB;AACvB,MAAM,SAAS,oBAAI,IAAY;AAE/B,MAAM,UAAU,CAAC,QAAgB,UAAyB,UACxD,GAAG,MAAM,IAAI,YAAY,MAAM,IAAI,KAAK;AAEnC,MAAM,sBAAsB,CAAC,UAClC,MAAM,QAAQ,sBAAsB,OAAO,EAAE,YAAY;AAGpD,SAAS,+BAAqC;AACnD,SAAO,MAAM;AACf;AAaA,eAAsB,6BAA6B,QAMjC;AAChB,QAAM,EAAE,QAAQ,UAAU,QAAQ,QAAQ,IAAI;AAC9C,MAAI,CAAC,WAAW,QAAQ,YAAY,MAAM,MAAO;AACjD,MAAI,OAAO,QAAQ,2BAA2B,WAAY;AAE1D,QAAM,aAAa,OAAO,OACvB,OAAO,CAAC,UAAU,OAAO,UAAU,YAAY,CAAC,MAAM,WAAW,KAAK,CAAC,EACvE,IAAI,CAAC,UAAU,oBAAoB,KAAK,CAAC,EACzC,OAAO,CAAC,OAAO,OAAO,QAAQ,MAAM,SAAS,KAAK,IAAI,QAAQ,KAAK,MAAM,KAAK,EAC9E,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,QAAQ,QAAQ,UAAU,KAAK,CAAC,CAAC;AAClE,MAAI,CAAC,WAAW,OAAQ;AAExB,MAAI;AAGF,UAAM,YAAY,IAAI;AAAA,OACnB,MAAM,QAAQ,uBAAuB,QAAQ,UAAU,IAAI,GAAG;AAAA,QAAI,CAAC,UAClE,oBAAoB,OAAO,KAAK,CAAC;AAAA,MACnC;AAAA,IACF;AACA,eAAW,SAAS,YAAY;AAC9B,UAAI,CAAC,UAAU,IAAI,KAAK,EAAG;AAC3B,UAAI,OAAO,QAAQ,eAAgB,QAAO,MAAM;AAChD,aAAO,IAAI,QAAQ,QAAQ,UAAU,KAAK,CAAC;AAC3C,aAAO,KAAK,uDAAuD;AAAA,QACjE;AAAA,QACA;AAAA,QACA;AAAA,QACA,MAAM,aAAa,MAAM;AAAA,MAC3B,CAAC;AAAA,IACH;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,MAAM,0CAA0C;AAAA,MACrD;AAAA,MACA,KAAK,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,IACtD,CAAC;AAAA,EACH;AACF;",
|
|
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.6892.1.f179677788';\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.6892.1.f179677788",
|
|
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.6892.1.f179677788",
|
|
109
109
|
"@types/sanitize-html": "^2.16.1",
|
|
110
110
|
"dotenv": "^17.4.2",
|
|
111
111
|
"pino": "^10.3.1",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const generatedExports: Record<string, string> = {
|
|
6
|
+
// Jest evaluates file-URL imports through its CommonJS runtime in this suite.
|
|
7
|
+
'entities.ids.generated.mjs': 'exports.E = {}\n',
|
|
8
|
+
'modules.cli.generated.mjs': 'exports.modules = []\n',
|
|
9
|
+
'entities.generated.mjs': 'exports.entities = []\n',
|
|
10
|
+
'di.generated.mjs': 'exports.diRegistrars = []\n',
|
|
11
|
+
'search.generated.mjs': 'exports.searchModuleConfigs = []\n',
|
|
12
|
+
'command-loaders.generated.mjs': 'exports.commandLoaderEntries = []\n',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const mockBuild = jest.fn(async ({ outfile }: { outfile: string }) => {
|
|
16
|
+
const output = generatedExports[path.basename(outfile)]
|
|
17
|
+
if (!output) throw new Error(`Unexpected generated output: ${outfile}`)
|
|
18
|
+
fs.writeFileSync(outfile, output)
|
|
19
|
+
// compileAndImport records the bundle's inputs in its cache metadata, so the
|
|
20
|
+
// double has to supply a metafile the way a real esbuild build does.
|
|
21
|
+
return { metafile: { inputs: {} } }
|
|
22
|
+
})
|
|
23
|
+
const mockStop = jest.fn()
|
|
24
|
+
|
|
25
|
+
jest.mock('esbuild', () => ({
|
|
26
|
+
build: mockBuild,
|
|
27
|
+
stop: mockStop,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
import { loadBootstrapData } from '../dynamicLoader'
|
|
31
|
+
|
|
32
|
+
const generatedNames = [
|
|
33
|
+
'entities.ids.generated.ts',
|
|
34
|
+
'modules.cli.generated.ts',
|
|
35
|
+
'entities.generated.ts',
|
|
36
|
+
'di.generated.ts',
|
|
37
|
+
'search.generated.ts',
|
|
38
|
+
'command-loaders.generated.ts',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
function createGeneratedApp(prefix: string): string {
|
|
42
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
|
43
|
+
const generatedDir = path.join(root, '.mercato', 'generated')
|
|
44
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
45
|
+
// compileAndImport resolves the app tsconfig and folds it into the compile
|
|
46
|
+
// cache key, so a generated app without one is not loadable.
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
path.join(root, 'tsconfig.json'),
|
|
49
|
+
JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext' } }),
|
|
50
|
+
)
|
|
51
|
+
for (const name of generatedNames) {
|
|
52
|
+
fs.writeFileSync(path.join(generatedDir, name), '// generated test input\n')
|
|
53
|
+
}
|
|
54
|
+
return root
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('loadBootstrapData esbuild lifecycle', () => {
|
|
58
|
+
let appRoot: string
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
appRoot = createGeneratedApp('open-mercato-dynamic-loader-')
|
|
62
|
+
mockBuild.mockClear()
|
|
63
|
+
mockStop.mockReset()
|
|
64
|
+
mockStop.mockResolvedValue(undefined)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('stops esbuild after compilation and starts it again for a later changed load', async () => {
|
|
72
|
+
await loadBootstrapData(appRoot)
|
|
73
|
+
|
|
74
|
+
expect(mockBuild).toHaveBeenCalledTimes(generatedNames.length)
|
|
75
|
+
expect(mockStop).toHaveBeenCalledTimes(1)
|
|
76
|
+
|
|
77
|
+
// The compile cache keys off source content, not mtime, so the change that
|
|
78
|
+
// forces a recompile has to be a real content change.
|
|
79
|
+
const entityIdsPath = path.join(appRoot, '.mercato', 'generated', 'entities.ids.generated.ts')
|
|
80
|
+
fs.writeFileSync(entityIdsPath, '// generated test input (changed)\n')
|
|
81
|
+
|
|
82
|
+
await loadBootstrapData(appRoot)
|
|
83
|
+
|
|
84
|
+
expect(mockBuild).toHaveBeenCalledTimes(generatedNames.length + 1)
|
|
85
|
+
expect(mockStop).toHaveBeenCalledTimes(2)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('waits for concurrent bootstrap loads before stopping the shared service', async () => {
|
|
89
|
+
const secondAppRoot = createGeneratedApp('open-mercato-dynamic-loader-concurrent-')
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await Promise.all([
|
|
93
|
+
loadBootstrapData(appRoot),
|
|
94
|
+
loadBootstrapData(secondAppRoot),
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
expect(mockBuild).toHaveBeenCalledTimes(generatedNames.length * 2)
|
|
98
|
+
expect(mockStop).toHaveBeenCalledTimes(1)
|
|
99
|
+
} finally {
|
|
100
|
+
fs.rmSync(secondAppRoot, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('waits for an asynchronous service stop before starting a later build', async () => {
|
|
105
|
+
let releaseStop: () => void = () => undefined
|
|
106
|
+
const stopPending = new Promise<void>((resolve) => {
|
|
107
|
+
releaseStop = resolve
|
|
108
|
+
})
|
|
109
|
+
mockStop.mockImplementationOnce(() => stopPending)
|
|
110
|
+
|
|
111
|
+
const firstLoad = loadBootstrapData(appRoot)
|
|
112
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
113
|
+
expect(mockStop).toHaveBeenCalledTimes(1)
|
|
114
|
+
|
|
115
|
+
const entityIdsPath = path.join(appRoot, '.mercato', 'generated', 'entities.ids.generated.ts')
|
|
116
|
+
fs.writeFileSync(entityIdsPath, '// generated test input (changed during stop)\n')
|
|
117
|
+
const secondLoad = loadBootstrapData(appRoot)
|
|
118
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
119
|
+
|
|
120
|
+
expect(mockBuild).toHaveBeenCalledTimes(generatedNames.length)
|
|
121
|
+
|
|
122
|
+
releaseStop()
|
|
123
|
+
await Promise.all([firstLoad, secondLoad])
|
|
124
|
+
expect(mockBuild).toHaveBeenCalledTimes(generatedNames.length + 1)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { BootstrapData } from '../types'
|
|
2
|
+
import { createBootstrap, isBootstrapped, resetBootstrapState, waitForAsyncRegistration } from '../factory'
|
|
3
|
+
|
|
4
|
+
const registerCoreInjectionWidgetsMock = jest.fn()
|
|
5
|
+
const registerCoreInjectionTablesMock = jest.fn()
|
|
6
|
+
const registerEnabledModuleIdsMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/core/modules/widgets/lib/injection', () => ({
|
|
9
|
+
registerCoreInjectionWidgets: registerCoreInjectionWidgetsMock,
|
|
10
|
+
registerCoreInjectionTables: registerCoreInjectionTablesMock,
|
|
11
|
+
registerEnabledModuleIds: registerEnabledModuleIdsMock,
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
const emptyBootstrapData: BootstrapData = {
|
|
15
|
+
modules: [],
|
|
16
|
+
entities: [],
|
|
17
|
+
diRegistrars: [],
|
|
18
|
+
entityIds: {},
|
|
19
|
+
dashboardWidgetEntries: [],
|
|
20
|
+
injectionWidgetEntries: [],
|
|
21
|
+
injectionTables: [],
|
|
22
|
+
searchModuleConfigs: [],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('partitioned bootstrap registration', () => {
|
|
26
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
resetBootstrapState()
|
|
30
|
+
process.env.NODE_ENV = 'production'
|
|
31
|
+
registerCoreInjectionWidgetsMock.mockReset()
|
|
32
|
+
registerCoreInjectionTablesMock.mockReset()
|
|
33
|
+
registerEnabledModuleIdsMock.mockReset()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('runs distinct registration keys once each', async () => {
|
|
41
|
+
const apiComplete = jest.fn()
|
|
42
|
+
const fullComplete = jest.fn()
|
|
43
|
+
const apiBootstrap = createBootstrap(emptyBootstrapData, {
|
|
44
|
+
registrationKey: 'api',
|
|
45
|
+
skipUiRegistries: true,
|
|
46
|
+
onRegistrationComplete: apiComplete,
|
|
47
|
+
})
|
|
48
|
+
const fullBootstrap = createBootstrap(emptyBootstrapData, {
|
|
49
|
+
registrationKey: 'full',
|
|
50
|
+
onRegistrationComplete: fullComplete,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
apiBootstrap()
|
|
54
|
+
apiBootstrap()
|
|
55
|
+
fullBootstrap()
|
|
56
|
+
fullBootstrap()
|
|
57
|
+
await waitForAsyncRegistration()
|
|
58
|
+
|
|
59
|
+
expect(apiComplete).toHaveBeenCalledTimes(1)
|
|
60
|
+
expect(fullComplete).toHaveBeenCalledTimes(1)
|
|
61
|
+
expect(isBootstrapped()).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps API-only bootstrap from replacing core injection widgets', async () => {
|
|
65
|
+
const apiBootstrap = createBootstrap(emptyBootstrapData, {
|
|
66
|
+
registrationKey: 'api-only',
|
|
67
|
+
skipUiRegistries: true,
|
|
68
|
+
skipCoreInjectionWidgets: true,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
apiBootstrap()
|
|
72
|
+
await waitForAsyncRegistration()
|
|
73
|
+
|
|
74
|
+
expect(registerCoreInjectionWidgetsMock).not.toHaveBeenCalled()
|
|
75
|
+
expect(registerCoreInjectionTablesMock).toHaveBeenCalledWith([])
|
|
76
|
+
expect(registerEnabledModuleIdsMock).toHaveBeenCalledTimes(1)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -14,8 +14,47 @@ import crypto from 'node:crypto'
|
|
|
14
14
|
import { createRequire } from 'node:module'
|
|
15
15
|
import { pathToFileURL } from 'node:url'
|
|
16
16
|
|
|
17
|
+
let activeBootstrapLoads = 0
|
|
18
|
+
let esbuildRuntime: typeof import('esbuild') | null = null
|
|
19
|
+
let esbuildStopPromise: Promise<void> | null = null
|
|
20
|
+
|
|
17
21
|
const logger = createLogger('shared').child({ component: 'bootstrap' })
|
|
18
22
|
|
|
23
|
+
async function getEsbuildRuntime(): Promise<typeof import('esbuild')> {
|
|
24
|
+
if (esbuildStopPromise) await esbuildStopPromise
|
|
25
|
+
if (esbuildRuntime) return esbuildRuntime
|
|
26
|
+
|
|
27
|
+
const loadedRuntime = await import('esbuild')
|
|
28
|
+
esbuildRuntime ??= loadedRuntime
|
|
29
|
+
return esbuildRuntime
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function withEsbuildLifecycle<T>(load: () => Promise<T>): Promise<T> {
|
|
33
|
+
activeBootstrapLoads += 1
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return await load()
|
|
37
|
+
} finally {
|
|
38
|
+
activeBootstrapLoads -= 1
|
|
39
|
+
if (activeBootstrapLoads === 0 && esbuildRuntime) {
|
|
40
|
+
// esbuild keeps a helper process alive after build(). Bootstrap compilation
|
|
41
|
+
// is a bounded phase, so release it once every concurrent loader is done.
|
|
42
|
+
// A later build() call transparently starts a fresh helper process.
|
|
43
|
+
const runtimeToStop = esbuildRuntime
|
|
44
|
+
esbuildRuntime = null
|
|
45
|
+
const stopPromise = runtimeToStop.stop().catch((err) => {
|
|
46
|
+
logger.warn('Failed to stop the bootstrap compiler service', { err })
|
|
47
|
+
})
|
|
48
|
+
esbuildStopPromise = stopPromise
|
|
49
|
+
try {
|
|
50
|
+
await stopPromise
|
|
51
|
+
} finally {
|
|
52
|
+
if (esbuildStopPromise === stopPromise) esbuildStopPromise = null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
19
58
|
/**
|
|
20
59
|
* Thrown when an expected generated source file is absent.
|
|
21
60
|
*
|
|
@@ -351,7 +390,7 @@ async function compileAndImport(
|
|
|
351
390
|
if (needsCompile) {
|
|
352
391
|
fs.mkdirSync(path.dirname(jsPath), { recursive: true })
|
|
353
392
|
// Dynamically import esbuild only when needed
|
|
354
|
-
const esbuild = await
|
|
393
|
+
const esbuild = await getEsbuildRuntime()
|
|
355
394
|
|
|
356
395
|
// Use esbuild.build with bundling to handle JSON imports
|
|
357
396
|
const result = await esbuild.build({
|
|
@@ -509,7 +548,7 @@ async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null
|
|
|
509
548
|
* @returns The loaded bootstrap data
|
|
510
549
|
* @throws Error if app root cannot be found or generated files are missing
|
|
511
550
|
*/
|
|
512
|
-
|
|
551
|
+
async function loadBootstrapDataWithActiveEsbuild(appRoot?: string): Promise<BootstrapData> {
|
|
513
552
|
const resolved = resolveAppRootOrThrow(appRoot)
|
|
514
553
|
|
|
515
554
|
const { generatedDir } = resolved
|
|
@@ -569,6 +608,10 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
569
608
|
}
|
|
570
609
|
}
|
|
571
610
|
|
|
611
|
+
export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
|
|
612
|
+
return withEsbuildLifecycle(() => loadBootstrapDataWithActiveEsbuild(appRoot))
|
|
613
|
+
}
|
|
614
|
+
|
|
572
615
|
/**
|
|
573
616
|
* Create and execute bootstrap in CLI context.
|
|
574
617
|
*
|
|
@@ -584,8 +627,13 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
|
|
|
584
627
|
export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
|
|
585
628
|
const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
|
|
586
629
|
const resolved = resolveAppRootOrThrow(appRoot)
|
|
587
|
-
|
|
588
|
-
|
|
630
|
+
// Both loads compile through esbuild, so they share one lifecycle scope: without it
|
|
631
|
+
// `loadBootstrapData` releases the esbuild helper process and `loadAppDiRegistrar`
|
|
632
|
+
// silently starts a second one that nothing ever stops.
|
|
633
|
+
const { data, appDiRegistrar } = await withEsbuildLifecycle(async () => ({
|
|
634
|
+
data: await loadBootstrapData(resolved.appDir),
|
|
635
|
+
appDiRegistrar: await loadAppDiRegistrar(resolved.appDir),
|
|
636
|
+
}))
|
|
589
637
|
const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
|
|
590
638
|
bootstrap()
|
|
591
639
|
// In CLI context, wait for async registrations (UI widgets, search configs, etc.)
|
|
@@ -17,7 +17,7 @@ import { registerNotificationHandlers } from '../notifications/handler-registry'
|
|
|
17
17
|
import { clearRegisteredIntegrations, registerBundles, registerIntegrations } from '../../modules/integrations/types'
|
|
18
18
|
import { applyComponentOverridesToEntries } from '../../modules/overrides'
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const _bootstrappedKeys = new Set<string>()
|
|
21
21
|
|
|
22
22
|
// Store the async registration promise so callers can await it if needed
|
|
23
23
|
let _asyncRegistrationPromise: Promise<void> | null = null
|
|
@@ -34,13 +34,14 @@ let _asyncRegistrationPromise: Promise<void> | null = null
|
|
|
34
34
|
*/
|
|
35
35
|
export function createBootstrap(data: BootstrapData, options: BootstrapOptions = {}) {
|
|
36
36
|
return function bootstrap(): void {
|
|
37
|
+
const registrationKey = options.registrationKey ?? 'default'
|
|
37
38
|
if (options.appDiRegistrar) {
|
|
38
39
|
registerAppDiRegistrar(options.appDiRegistrar)
|
|
39
40
|
}
|
|
40
41
|
// In development, always re-run registrations to handle HMR
|
|
41
42
|
// (Module state may be reset when Turbopack reloads packages)
|
|
42
|
-
if (
|
|
43
|
-
|
|
43
|
+
if (_bootstrappedKeys.has(registrationKey) && process.env.NODE_ENV !== 'development') return
|
|
44
|
+
_bootstrappedKeys.add(registrationKey)
|
|
44
45
|
|
|
45
46
|
// === 1. Foundation: ORM entities and DI registrars ===
|
|
46
47
|
registerOrmEntities(data.entities)
|
|
@@ -138,21 +139,26 @@ export async function waitForAsyncRegistration(): Promise<void> {
|
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
async function registerWidgetsAndOptionalPackages(data: BootstrapData, options: BootstrapOptions): Promise<void> {
|
|
141
|
-
// Register
|
|
142
|
+
// Register widget data required by server-side injection independently from
|
|
143
|
+
// browser-facing UI registries. API-only bootstraps avoid loading @open-mercato/ui.
|
|
142
144
|
try {
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
])
|
|
148
|
-
|
|
149
|
-
dashboardRegistry.registerDashboardWidgets(data.dashboardWidgetEntries)
|
|
150
|
-
injectionRegistry.registerInjectionWidgets(data.injectionWidgetEntries)
|
|
151
|
-
coreInjection.registerCoreInjectionWidgets(data.injectionWidgetEntries)
|
|
145
|
+
const coreInjection = await import('@open-mercato/core/modules/widgets/lib/injection')
|
|
146
|
+
if (!options.skipCoreInjectionWidgets) {
|
|
147
|
+
coreInjection.registerCoreInjectionWidgets(data.injectionWidgetEntries)
|
|
148
|
+
}
|
|
152
149
|
coreInjection.registerCoreInjectionTables(data.injectionTables)
|
|
153
150
|
coreInjection.registerEnabledModuleIds(
|
|
154
151
|
data.modules.map((module) => module.id).filter((id): id is string => typeof id === 'string' && id.length > 0),
|
|
155
152
|
)
|
|
153
|
+
|
|
154
|
+
if (!options.skipUiRegistries) {
|
|
155
|
+
const [dashboardRegistry, injectionRegistry] = await Promise.all([
|
|
156
|
+
import('@open-mercato/ui/backend/dashboard/widgetRegistry'),
|
|
157
|
+
import('@open-mercato/ui/backend/injection/widgetRegistry'),
|
|
158
|
+
])
|
|
159
|
+
dashboardRegistry.registerDashboardWidgets(data.dashboardWidgetEntries)
|
|
160
|
+
injectionRegistry.registerInjectionWidgets(data.injectionWidgetEntries)
|
|
161
|
+
}
|
|
156
162
|
} catch {
|
|
157
163
|
// UI packages may not be available in all contexts
|
|
158
164
|
}
|
|
@@ -170,12 +176,12 @@ async function registerWidgetsAndOptionalPackages(data: BootstrapData, options:
|
|
|
170
176
|
* Check if bootstrap has been called.
|
|
171
177
|
*/
|
|
172
178
|
export function isBootstrapped(): boolean {
|
|
173
|
-
return
|
|
179
|
+
return _bootstrappedKeys.size > 0
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
/**
|
|
177
183
|
* Reset bootstrap state. Useful for testing.
|
|
178
184
|
*/
|
|
179
185
|
export function resetBootstrapState(): void {
|
|
180
|
-
|
|
186
|
+
_bootstrappedKeys.clear()
|
|
181
187
|
}
|
|
@@ -69,6 +69,12 @@ export interface BootstrapData {
|
|
|
69
69
|
|
|
70
70
|
export interface BootstrapOptions {
|
|
71
71
|
skipSearchConfigs?: boolean
|
|
72
|
+
/** Independent registration scope for partitioned runtime bootstraps. */
|
|
73
|
+
registrationKey?: string
|
|
74
|
+
/** Skip browser-facing dashboard/injection registries in API-only runtimes. */
|
|
75
|
+
skipUiRegistries?: boolean
|
|
76
|
+
/** Keep an existing core injection-widget registry intact in partitioned runtimes. */
|
|
77
|
+
skipCoreInjectionWidgets?: boolean
|
|
72
78
|
onRegistrationComplete?: () => void
|
|
73
79
|
appDiRegistrar?: AppDiRegistrar
|
|
74
80
|
}
|