@open-mercato/shared 0.6.7-develop.6661.1.0043ed1d03 → 0.6.7-develop.6670.1.4efa7961c6
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/dist/lib/bootstrap/dynamicLoader.js +1 -1
- package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
- package/dist/lib/commands/command-bus.js +21 -0
- package/dist/lib/commands/command-bus.js.map +2 -2
- package/dist/lib/crud/factory.js +1 -1
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/encryption/customFieldValues.js +1 -0
- package/dist/lib/encryption/customFieldValues.js.map +2 -2
- package/dist/lib/frontend/organizationEvents.js +6 -1
- package/dist/lib/frontend/organizationEvents.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/dsl.js +1 -0
- package/dist/modules/dsl.js.map +2 -2
- package/dist/modules/entities/kinds.js +1 -0
- package/dist/modules/entities/kinds.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +150 -0
- package/src/lib/bootstrap/dynamicLoader.ts +1 -1
- package/src/lib/commands/__tests__/command-bus.cache.test.ts +46 -0
- package/src/lib/commands/command-bus.ts +23 -0
- package/src/lib/crud/factory.ts +4 -1
- package/src/lib/encryption/__tests__/customFieldValues.test.ts +17 -0
- package/src/lib/encryption/customFieldValues.ts +1 -0
- package/src/lib/frontend/organizationEvents.ts +11 -2
- package/src/modules/__tests__/dsl.test.ts +12 -0
- package/src/modules/dsl.ts +1 -0
- package/src/modules/entities/kinds.ts +1 -0
- package/src/modules/widgets/injection.ts +7 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/encryption/customFieldValues.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { encryptWithAesGcm, decryptWithAesGcm } from './aes'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\n\n/**\n * Custom field kinds that ALWAYS round-trip as a string. The encrypt path\n * stores raw strings unwrapped, so blindly running `JSON.parse` on the\n * decrypted payload coerces text values like `\"123\"` or `\"true\"` back into\n * numbers/booleans (issue #1734). For these kinds, callers MUST pass the\n * `kind` option so we keep the decrypted value as a string.\n *\n * Numeric (`integer`/`float`) and `boolean` kinds rely on JSON round-trip\n * because the encrypt path JSON-stringifies the typed value before storage.\n * Omitting the kind preserves legacy round-trip behavior for backward\n * compatibility.\n */\nconst STRING_TYPED_CUSTOM_FIELD_KINDS = new Set([\n 'text',\n 'multiline',\n 'select',\n 'currency',\n 'dictionary',\n 'email',\n 'url',\n 'string',\n])\n\nexport type DecryptCustomFieldOptions = {\n /** Field kind, e.g. from `CustomFieldDef.kind`. When string-typed, the helper preserves the decrypted string verbatim. */\n kind?: string | null\n}\n\nfunction shouldPreserveAsString(kind: string | null | undefined): boolean {\n if (!kind) return false\n return STRING_TYPED_CUSTOM_FIELD_KINDS.has(kind)\n}\n\nconst serviceCache = new WeakMap<EntityManager, TenantDataEncryptionService>()\n\nexport function resolveTenantEncryptionService(\n em: EntityManager,\n provided?: TenantDataEncryptionService | null,\n): TenantDataEncryptionService | null {\n if (provided) return provided\n const cached = serviceCache.get(em)\n if (cached) return cached\n const service = new TenantDataEncryptionService(em as any)\n serviceCache.set(em, service)\n return service\n}\n\nasync function resolveDekKey(\n service: TenantDataEncryptionService | null,\n tenantId: string | null | undefined,\n cache?: Map<string | null, string | null>,\n opts?: { createIfMissing?: boolean },\n): Promise<string | null> {\n const scopedTenantId = tenantId ?? null\n if (!service || !service.isEnabled() || !scopedTenantId) return null\n if (cache?.has(scopedTenantId)) return cache.get(scopedTenantId) ?? null\n const dek = await service.getDek(scopedTenantId)\n let key = dek?.key ?? null\n if (!key && opts?.createIfMissing && typeof service.createDek === 'function') {\n const created = await service.createDek(scopedTenantId)\n key = created?.key ?? null\n }\n cache?.set(scopedTenantId, key)\n return key\n}\n\nexport async function encryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n): Promise<unknown> {\n if (value === undefined || value === null) return value\n const key = await resolveDekKey(service, tenantId, cache, { createIfMissing: true })\n if (!key) return value\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n return encryptWithAesGcm(serialized, key).value\n}\n\nexport async function decryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n options?: DecryptCustomFieldOptions,\n): Promise<unknown> {\n if (value === undefined || value === null || typeof value !== 'string') return value\n const key = await resolveDekKey(service, tenantId, cache)\n if (!key) return value\n const decrypted = decryptWithAesGcm(value, key)\n if (decrypted === null) return value\n if (shouldPreserveAsString(options?.kind ?? null)) return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,mBAAmB,yBAAyB;AACrD,SAAS,mCAAmC;AAc5C,MAAM,kCAAkC,oBAAI,IAAI;AAAA,EAC9C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAOD,SAAS,uBAAuB,MAA0C;AACxE,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,gCAAgC,IAAI,IAAI;AACjD;AAEA,MAAM,eAAe,oBAAI,QAAoD;AAEtE,SAAS,+BACd,IACA,UACoC;AACpC,MAAI,SAAU,QAAO;AACrB,QAAM,SAAS,aAAa,IAAI,EAAE;AAClC,MAAI,OAAQ,QAAO;AACnB,QAAM,UAAU,IAAI,4BAA4B,EAAS;AACzD,eAAa,IAAI,IAAI,OAAO;AAC5B,SAAO;AACT;AAEA,eAAe,cACb,SACA,UACA,OACA,MACwB;AACxB,QAAM,iBAAiB,YAAY;AACnC,MAAI,CAAC,WAAW,CAAC,QAAQ,UAAU,KAAK,CAAC,eAAgB,QAAO;AAChE,MAAI,OAAO,IAAI,cAAc,EAAG,QAAO,MAAM,IAAI,cAAc,KAAK;AACpE,QAAM,MAAM,MAAM,QAAQ,OAAO,cAAc;AAC/C,MAAI,MAAM,KAAK,OAAO;AACtB,MAAI,CAAC,OAAO,MAAM,mBAAmB,OAAO,QAAQ,cAAc,YAAY;AAC5E,UAAM,UAAU,MAAM,QAAQ,UAAU,cAAc;AACtD,UAAM,SAAS,OAAO;AAAA,EACxB;AACA,SAAO,IAAI,gBAAgB,GAAG;AAC9B,SAAO;AACT;AAEA,eAAsB,wBACpB,OACA,UACA,SACA,OACkB;AAClB,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,QAAM,MAAM,MAAM,cAAc,SAAS,UAAU,OAAO,EAAE,iBAAiB,KAAK,CAAC;AACnF,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,aAAa,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AAC3E,SAAO,kBAAkB,YAAY,GAAG,EAAE;AAC5C;AAEA,eAAsB,wBACpB,OACA,UACA,SACA,OACA,SACkB;AAClB,MAAI,UAAU,UAAa,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO;AAC/E,QAAM,MAAM,MAAM,cAAc,SAAS,UAAU,KAAK;AACxD,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,YAAY,kBAAkB,OAAO,GAAG;AAC9C,MAAI,cAAc,KAAM,QAAO;AAC/B,MAAI,uBAAuB,SAAS,QAAQ,IAAI,EAAG,QAAO;AAC1D,MAAI;AACF,WAAO,KAAK,MAAM,SAAS;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport { encryptWithAesGcm, decryptWithAesGcm } from './aes'\nimport { TenantDataEncryptionService } from './tenantDataEncryptionService'\n\n/**\n * Custom field kinds that ALWAYS round-trip as a string. The encrypt path\n * stores raw strings unwrapped, so blindly running `JSON.parse` on the\n * decrypted payload coerces text values like `\"123\"` or `\"true\"` back into\n * numbers/booleans (issue #1734). For these kinds, callers MUST pass the\n * `kind` option so we keep the decrypted value as a string.\n *\n * Numeric (`integer`/`float`) and `boolean` kinds rely on JSON round-trip\n * because the encrypt path JSON-stringifies the typed value before storage.\n * Omitting the kind preserves legacy round-trip behavior for backward\n * compatibility.\n */\nconst STRING_TYPED_CUSTOM_FIELD_KINDS = new Set([\n 'text',\n 'multiline',\n 'select',\n 'currency',\n 'dictionary',\n 'phone',\n 'email',\n 'url',\n 'string',\n])\n\nexport type DecryptCustomFieldOptions = {\n /** Field kind, e.g. from `CustomFieldDef.kind`. When string-typed, the helper preserves the decrypted string verbatim. */\n kind?: string | null\n}\n\nfunction shouldPreserveAsString(kind: string | null | undefined): boolean {\n if (!kind) return false\n return STRING_TYPED_CUSTOM_FIELD_KINDS.has(kind)\n}\n\nconst serviceCache = new WeakMap<EntityManager, TenantDataEncryptionService>()\n\nexport function resolveTenantEncryptionService(\n em: EntityManager,\n provided?: TenantDataEncryptionService | null,\n): TenantDataEncryptionService | null {\n if (provided) return provided\n const cached = serviceCache.get(em)\n if (cached) return cached\n const service = new TenantDataEncryptionService(em as any)\n serviceCache.set(em, service)\n return service\n}\n\nasync function resolveDekKey(\n service: TenantDataEncryptionService | null,\n tenantId: string | null | undefined,\n cache?: Map<string | null, string | null>,\n opts?: { createIfMissing?: boolean },\n): Promise<string | null> {\n const scopedTenantId = tenantId ?? null\n if (!service || !service.isEnabled() || !scopedTenantId) return null\n if (cache?.has(scopedTenantId)) return cache.get(scopedTenantId) ?? null\n const dek = await service.getDek(scopedTenantId)\n let key = dek?.key ?? null\n if (!key && opts?.createIfMissing && typeof service.createDek === 'function') {\n const created = await service.createDek(scopedTenantId)\n key = created?.key ?? null\n }\n cache?.set(scopedTenantId, key)\n return key\n}\n\nexport async function encryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n): Promise<unknown> {\n if (value === undefined || value === null) return value\n const key = await resolveDekKey(service, tenantId, cache, { createIfMissing: true })\n if (!key) return value\n const serialized = typeof value === 'string' ? value : JSON.stringify(value)\n return encryptWithAesGcm(serialized, key).value\n}\n\nexport async function decryptCustomFieldValue(\n value: unknown,\n tenantId: string | null | undefined,\n service: TenantDataEncryptionService | null,\n cache?: Map<string | null, string | null>,\n options?: DecryptCustomFieldOptions,\n): Promise<unknown> {\n if (value === undefined || value === null || typeof value !== 'string') return value\n const key = await resolveDekKey(service, tenantId, cache)\n if (!key) return value\n const decrypted = decryptWithAesGcm(value, key)\n if (decrypted === null) return value\n if (shouldPreserveAsString(options?.kind ?? null)) return decrypted\n try {\n return JSON.parse(decrypted)\n } catch {\n return decrypted\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,mBAAmB,yBAAyB;AACrD,SAAS,mCAAmC;AAc5C,MAAM,kCAAkC,oBAAI,IAAI;AAAA,EAC9C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAOD,SAAS,uBAAuB,MAA0C;AACxE,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,gCAAgC,IAAI,IAAI;AACjD;AAEA,MAAM,eAAe,oBAAI,QAAoD;AAEtE,SAAS,+BACd,IACA,UACoC;AACpC,MAAI,SAAU,QAAO;AACrB,QAAM,SAAS,aAAa,IAAI,EAAE;AAClC,MAAI,OAAQ,QAAO;AACnB,QAAM,UAAU,IAAI,4BAA4B,EAAS;AACzD,eAAa,IAAI,IAAI,OAAO;AAC5B,SAAO;AACT;AAEA,eAAe,cACb,SACA,UACA,OACA,MACwB;AACxB,QAAM,iBAAiB,YAAY;AACnC,MAAI,CAAC,WAAW,CAAC,QAAQ,UAAU,KAAK,CAAC,eAAgB,QAAO;AAChE,MAAI,OAAO,IAAI,cAAc,EAAG,QAAO,MAAM,IAAI,cAAc,KAAK;AACpE,QAAM,MAAM,MAAM,QAAQ,OAAO,cAAc;AAC/C,MAAI,MAAM,KAAK,OAAO;AACtB,MAAI,CAAC,OAAO,MAAM,mBAAmB,OAAO,QAAQ,cAAc,YAAY;AAC5E,UAAM,UAAU,MAAM,QAAQ,UAAU,cAAc;AACtD,UAAM,SAAS,OAAO;AAAA,EACxB;AACA,SAAO,IAAI,gBAAgB,GAAG;AAC9B,SAAO;AACT;AAEA,eAAsB,wBACpB,OACA,UACA,SACA,OACkB;AAClB,MAAI,UAAU,UAAa,UAAU,KAAM,QAAO;AAClD,QAAM,MAAM,MAAM,cAAc,SAAS,UAAU,OAAO,EAAE,iBAAiB,KAAK,CAAC;AACnF,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,aAAa,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AAC3E,SAAO,kBAAkB,YAAY,GAAG,EAAE;AAC5C;AAEA,eAAsB,wBACpB,OACA,UACA,SACA,OACA,SACkB;AAClB,MAAI,UAAU,UAAa,UAAU,QAAQ,OAAO,UAAU,SAAU,QAAO;AAC/E,QAAM,MAAM,MAAM,cAAc,SAAS,UAAU,KAAK;AACxD,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,YAAY,kBAAkB,OAAO,GAAG;AAC9C,MAAI,cAAc,KAAM,QAAO;AAC/B,MAAI,uBAAuB,SAAS,QAAQ,IAAI,EAAG,QAAO;AAC1D,MAAI;AACF,WAAO,KAAK,MAAM,SAAS;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,6 +4,7 @@ let currentScope = {
|
|
|
4
4
|
tenantId: null
|
|
5
5
|
};
|
|
6
6
|
let currentVersion = 0;
|
|
7
|
+
let hasEmitted = false;
|
|
7
8
|
function getCurrentOrganizationScope() {
|
|
8
9
|
return { ...currentScope };
|
|
9
10
|
}
|
|
@@ -13,11 +14,15 @@ function getCurrentOrganizationScopeVersion() {
|
|
|
13
14
|
function emitOrganizationScopeChanged(detail) {
|
|
14
15
|
if (typeof window === "undefined" || typeof CustomEvent === "undefined") return;
|
|
15
16
|
const hasChanged = currentScope.organizationId !== detail.organizationId || currentScope.tenantId !== detail.tenantId;
|
|
17
|
+
const isFirstEmit = !hasEmitted;
|
|
16
18
|
currentScope = { ...detail };
|
|
19
|
+
hasEmitted = true;
|
|
17
20
|
if (hasChanged) {
|
|
18
21
|
currentVersion++;
|
|
19
22
|
}
|
|
20
|
-
|
|
23
|
+
if (hasChanged || isFirstEmit) {
|
|
24
|
+
window.dispatchEvent(new CustomEvent(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }));
|
|
25
|
+
}
|
|
21
26
|
}
|
|
22
27
|
function subscribeOrganizationScopeChanged(handler) {
|
|
23
28
|
if (typeof window === "undefined") return () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/frontend/organizationEvents.ts"],
|
|
4
|
-
"sourcesContent": ["export const ORGANIZATION_SCOPE_CHANGED_EVENT = 'om:organization-scope-changed'\n\nexport type OrganizationScopeChangedDetail = {\n organizationId: string | null\n tenantId: string | null\n}\n\n// Module-level state to track current scope and version\nlet currentScope: OrganizationScopeChangedDetail = { \n organizationId: null, \n tenantId: null \n}\nlet currentVersion = 0\n\nexport function getCurrentOrganizationScope(): OrganizationScopeChangedDetail {\n return { ...currentScope }\n}\n\nexport function getCurrentOrganizationScopeVersion(): number {\n return currentVersion\n}\n\nexport function emitOrganizationScopeChanged(detail: OrganizationScopeChangedDetail): void {\n if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return\n \n // Detect actual changes\n const hasChanged = \n currentScope.organizationId !== detail.organizationId ||\n currentScope.tenantId !== detail.tenantId\n \n // Update module-level state\n currentScope = { ...detail }\n \n // Increment version only if actual change detected\n if (hasChanged) {\n currentVersion++\n }\n \n //
|
|
5
|
-
"mappings": "AAAO,MAAM,mCAAmC;AAQhD,IAAI,eAA+C;AAAA,EACjD,gBAAgB;AAAA,EAChB,UAAU;AACZ;AACA,IAAI,iBAAiB;
|
|
4
|
+
"sourcesContent": ["export const ORGANIZATION_SCOPE_CHANGED_EVENT = 'om:organization-scope-changed'\n\nexport type OrganizationScopeChangedDetail = {\n organizationId: string | null\n tenantId: string | null\n}\n\n// Module-level state to track current scope and version\nlet currentScope: OrganizationScopeChangedDetail = { \n organizationId: null, \n tenantId: null \n}\nlet currentVersion = 0\nlet hasEmitted = false\n\nexport function getCurrentOrganizationScope(): OrganizationScopeChangedDetail {\n return { ...currentScope }\n}\n\nexport function getCurrentOrganizationScopeVersion(): number {\n return currentVersion\n}\n\nexport function emitOrganizationScopeChanged(detail: OrganizationScopeChangedDetail): void {\n if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return\n \n // Detect actual changes\n const hasChanged = \n currentScope.organizationId !== detail.organizationId ||\n currentScope.tenantId !== detail.tenantId\n // Guard so the very first emission always syncs subscribers, even when the\n // initial scope matches the default all-orgs `{ null, null }` state.\n const isFirstEmit = !hasEmitted\n \n // Update module-level state\n currentScope = { ...detail }\n hasEmitted = true\n \n // Increment version only if actual change detected\n if (hasChanged) {\n currentVersion++\n }\n \n // Only dispatch on real scope changes (or the first emission). Suppressing\n // no-op re-emits avoids spurious DataTable refreshes, chrome refetches, and\n // scope-hook churn when navigating between pages with the same scope.\n if (hasChanged || isFirstEmit) {\n window.dispatchEvent(new CustomEvent<OrganizationScopeChangedDetail>(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }))\n }\n}\n\nexport function subscribeOrganizationScopeChanged(\n handler: (detail: OrganizationScopeChangedDetail) => void\n): () => void {\n if (typeof window === 'undefined') return () => {}\n const listener = (event: Event) => {\n const detail = (event as CustomEvent<OrganizationScopeChangedDetail>).detail ?? { organizationId: null, tenantId: null }\n handler(detail)\n }\n window.addEventListener(ORGANIZATION_SCOPE_CHANGED_EVENT, listener as EventListener)\n return () => {\n window.removeEventListener(ORGANIZATION_SCOPE_CHANGED_EVENT, listener as EventListener)\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,mCAAmC;AAQhD,IAAI,eAA+C;AAAA,EACjD,gBAAgB;AAAA,EAChB,UAAU;AACZ;AACA,IAAI,iBAAiB;AACrB,IAAI,aAAa;AAEV,SAAS,8BAA8D;AAC5E,SAAO,EAAE,GAAG,aAAa;AAC3B;AAEO,SAAS,qCAA6C;AAC3D,SAAO;AACT;AAEO,SAAS,6BAA6B,QAA8C;AACzF,MAAI,OAAO,WAAW,eAAe,OAAO,gBAAgB,YAAa;AAGzE,QAAM,aACJ,aAAa,mBAAmB,OAAO,kBACvC,aAAa,aAAa,OAAO;AAGnC,QAAM,cAAc,CAAC;AAGrB,iBAAe,EAAE,GAAG,OAAO;AAC3B,eAAa;AAGb,MAAI,YAAY;AACd;AAAA,EACF;AAKA,MAAI,cAAc,aAAa;AAC7B,WAAO,cAAc,IAAI,YAA4C,kCAAkC,EAAE,OAAO,CAAC,CAAC;AAAA,EACpH;AACF;AAEO,SAAS,kCACd,SACY;AACZ,MAAI,OAAO,WAAW,YAAa,QAAO,MAAM;AAAA,EAAC;AACjD,QAAM,WAAW,CAAC,UAAiB;AACjC,UAAM,SAAU,MAAsD,UAAU,EAAE,gBAAgB,MAAM,UAAU,KAAK;AACvH,YAAQ,MAAM;AAAA,EAChB;AACA,SAAO,iBAAiB,kCAAkC,QAAyB;AACnF,SAAO,MAAM;AACX,WAAO,oBAAoB,kCAAkC,QAAyB;AAAA,EACxF;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.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6670.1.4efa7961c6'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/modules/dsl.js
CHANGED
|
@@ -15,6 +15,7 @@ const cf = {
|
|
|
15
15
|
boolean: (key, opts = {}) => ({ key, kind: "boolean", ...opts }),
|
|
16
16
|
select: (key, options, opts = {}) => ({ key, kind: "select", options, ...opts }),
|
|
17
17
|
currency: (key, opts = {}) => ({ key, kind: "currency", ...opts }),
|
|
18
|
+
phone: (key, opts = {}) => ({ key, kind: "phone", ...opts }),
|
|
18
19
|
date: (key, opts = {}) => ({ key, kind: "date", ...opts }),
|
|
19
20
|
datetime: (key, opts = {}) => ({ key, kind: "datetime", ...opts }),
|
|
20
21
|
dictionary: (key, dictionaryId, opts = {}) => ({ key, kind: "dictionary", dictionaryId, ...opts })
|
package/dist/modules/dsl.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/modules/dsl.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CustomFieldDefinition, CustomFieldSet, EntityExtension, EntityId } from '@open-mercato/shared/modules/entities'\n\nexport function entityId(moduleId: string, entity: string): EntityId {\n return `${moduleId}:${entity}`\n}\n\nexport function linkable(moduleId: string, entities: string[]): Record<string, EntityId> {\n return Object.fromEntries(entities.map((e) => [e, entityId(moduleId, e)]))\n}\n\nexport function defineLink(\n base: EntityId,\n extension: EntityId,\n opts: Pick<EntityExtension, 'join' | 'cardinality' | 'required' | 'description'>\n): EntityExtension {\n return { base, extension, ...opts }\n}\n\nexport const cf = {\n text: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'text', ...opts }),\n multiline: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'multiline', ...opts }),\n integer: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'integer', ...opts }),\n float: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'float', ...opts }),\n boolean: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'boolean', ...opts }),\n select: (key: string, options: string[], opts: Omit<CustomFieldDefinition, 'key' | 'kind' | 'options'> = {}): CustomFieldDefinition => ({ key, kind: 'select', options, ...opts }),\n currency: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'currency', ...opts }),\n date: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'date', ...opts }),\n datetime: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'datetime', ...opts }),\n dictionary: (key: string, dictionaryId: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind' | 'dictionaryId'> = {}): CustomFieldDefinition => ({ key, kind: 'dictionary', dictionaryId, ...opts }),\n}\n\nexport function defineFields(entity: EntityId, fields: CustomFieldDefinition[], source?: string): CustomFieldSet {\n return { entity, fields, source }\n}\n"],
|
|
5
|
-
"mappings": "AAEO,SAAS,SAAS,UAAkB,QAA0B;AACnE,SAAO,GAAG,QAAQ,IAAI,MAAM;AAC9B;AAEO,SAAS,SAAS,UAAkB,UAA8C;AACvF,SAAO,OAAO,YAAY,SAAS,IAAI,CAAC,MAAM,CAAC,GAAG,SAAS,UAAU,CAAC,CAAC,CAAC,CAAC;AAC3E;AAEO,SAAS,WACd,MACA,WACA,MACiB;AACjB,SAAO,EAAE,MAAM,WAAW,GAAG,KAAK;AACpC;AAEO,MAAM,KAAK;AAAA,EAChB,MAAM,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,QAAQ,GAAG,KAAK;AAAA,EACpI,WAAW,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,aAAa,GAAG,KAAK;AAAA,EAC9I,SAAS,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,WAAW,GAAG,KAAK;AAAA,EAC1I,OAAO,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,SAAS,GAAG,KAAK;AAAA,EACtI,SAAS,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,WAAW,GAAG,KAAK;AAAA,EAC1I,QAAQ,CAAC,KAAa,SAAmB,OAAgE,CAAC,OAA8B,EAAE,KAAK,MAAM,UAAU,SAAS,GAAG,KAAK;AAAA,EAChL,UAAU,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,YAAY,GAAG,KAAK;AAAA,EAC5I,MAAM,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,QAAQ,GAAG,KAAK;AAAA,EACpI,UAAU,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,YAAY,GAAG,KAAK;AAAA,EAC5I,YAAY,CAAC,KAAa,cAAsB,OAAqE,CAAC,OAA8B,EAAE,KAAK,MAAM,cAAc,cAAc,GAAG,KAAK;AACvM;AAEO,SAAS,aAAa,QAAkB,QAAiC,QAAiC;AAC/G,SAAO,EAAE,QAAQ,QAAQ,OAAO;AAClC;",
|
|
4
|
+
"sourcesContent": ["import type { CustomFieldDefinition, CustomFieldSet, EntityExtension, EntityId } from '@open-mercato/shared/modules/entities'\n\nexport function entityId(moduleId: string, entity: string): EntityId {\n return `${moduleId}:${entity}`\n}\n\nexport function linkable(moduleId: string, entities: string[]): Record<string, EntityId> {\n return Object.fromEntries(entities.map((e) => [e, entityId(moduleId, e)]))\n}\n\nexport function defineLink(\n base: EntityId,\n extension: EntityId,\n opts: Pick<EntityExtension, 'join' | 'cardinality' | 'required' | 'description'>\n): EntityExtension {\n return { base, extension, ...opts }\n}\n\nexport const cf = {\n text: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'text', ...opts }),\n multiline: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'multiline', ...opts }),\n integer: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'integer', ...opts }),\n float: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'float', ...opts }),\n boolean: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'boolean', ...opts }),\n select: (key: string, options: string[], opts: Omit<CustomFieldDefinition, 'key' | 'kind' | 'options'> = {}): CustomFieldDefinition => ({ key, kind: 'select', options, ...opts }),\n currency: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'currency', ...opts }),\n phone: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'phone', ...opts }),\n date: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'date', ...opts }),\n datetime: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'datetime', ...opts }),\n dictionary: (key: string, dictionaryId: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind' | 'dictionaryId'> = {}): CustomFieldDefinition => ({ key, kind: 'dictionary', dictionaryId, ...opts }),\n}\n\nexport function defineFields(entity: EntityId, fields: CustomFieldDefinition[], source?: string): CustomFieldSet {\n return { entity, fields, source }\n}\n"],
|
|
5
|
+
"mappings": "AAEO,SAAS,SAAS,UAAkB,QAA0B;AACnE,SAAO,GAAG,QAAQ,IAAI,MAAM;AAC9B;AAEO,SAAS,SAAS,UAAkB,UAA8C;AACvF,SAAO,OAAO,YAAY,SAAS,IAAI,CAAC,MAAM,CAAC,GAAG,SAAS,UAAU,CAAC,CAAC,CAAC,CAAC;AAC3E;AAEO,SAAS,WACd,MACA,WACA,MACiB;AACjB,SAAO,EAAE,MAAM,WAAW,GAAG,KAAK;AACpC;AAEO,MAAM,KAAK;AAAA,EAChB,MAAM,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,QAAQ,GAAG,KAAK;AAAA,EACpI,WAAW,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,aAAa,GAAG,KAAK;AAAA,EAC9I,SAAS,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,WAAW,GAAG,KAAK;AAAA,EAC1I,OAAO,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,SAAS,GAAG,KAAK;AAAA,EACtI,SAAS,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,WAAW,GAAG,KAAK;AAAA,EAC1I,QAAQ,CAAC,KAAa,SAAmB,OAAgE,CAAC,OAA8B,EAAE,KAAK,MAAM,UAAU,SAAS,GAAG,KAAK;AAAA,EAChL,UAAU,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,YAAY,GAAG,KAAK;AAAA,EAC5I,OAAO,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,SAAS,GAAG,KAAK;AAAA,EACtI,MAAM,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,QAAQ,GAAG,KAAK;AAAA,EACpI,UAAU,CAAC,KAAa,OAAoD,CAAC,OAA8B,EAAE,KAAK,MAAM,YAAY,GAAG,KAAK;AAAA,EAC5I,YAAY,CAAC,KAAa,cAAsB,OAAqE,CAAC,OAA8B,EAAE,KAAK,MAAM,cAAc,cAAc,GAAG,KAAK;AACvM;AAEO,SAAS,aAAa,QAAkB,QAAiC,QAAiC;AAC/G,SAAO,EAAE,QAAQ,QAAQ,OAAO;AAClC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/entities/kinds.ts"],
|
|
4
|
-
"sourcesContent": ["export const CUSTOM_FIELD_KINDS = [\n 'text',\n 'multiline',\n 'integer',\n 'float',\n 'boolean',\n 'select',\n 'currency',\n 'relation',\n 'attachment',\n 'dictionary',\n 'date',\n 'datetime',\n] as const\n\nexport type CustomFieldKind = typeof CUSTOM_FIELD_KINDS[number]\n\nexport function isCustomFieldKind(x: string): x is CustomFieldKind {\n return (CUSTOM_FIELD_KINDS as readonly string[]).includes(x)\n}\n\nexport const CURRENCY_OPTIONS_URL = '/api/currencies/currencies/options'\n"],
|
|
5
|
-
"mappings": "AAAO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,SAAS,kBAAkB,GAAiC;AACjE,SAAQ,mBAAyC,SAAS,CAAC;AAC7D;AAEO,MAAM,uBAAuB;",
|
|
4
|
+
"sourcesContent": ["export const CUSTOM_FIELD_KINDS = [\n 'text',\n 'multiline',\n 'integer',\n 'float',\n 'boolean',\n 'select',\n 'currency',\n 'relation',\n 'attachment',\n 'dictionary',\n 'phone',\n 'date',\n 'datetime',\n] as const\n\nexport type CustomFieldKind = typeof CUSTOM_FIELD_KINDS[number]\n\nexport function isCustomFieldKind(x: string): x is CustomFieldKind {\n return (CUSTOM_FIELD_KINDS as readonly string[]).includes(x)\n}\n\nexport const CURRENCY_OPTIONS_URL = '/api/currencies/currencies/options'\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIO,SAAS,kBAAkB,GAAiC;AACjE,SAAQ,mBAAyC,SAAS,CAAC;AAC7D;AAEO,MAAM,uBAAuB;",
|
|
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.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6670.1.4efa7961c6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@mikro-orm/core": "^7.1.5",
|
|
98
98
|
"@mikro-orm/decorators": "^7.1.5",
|
|
99
99
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
100
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
100
|
+
"@open-mercato/cache": "0.6.7-develop.6670.1.4efa7961c6",
|
|
101
101
|
"@types/sanitize-html": "^2.16.1",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
103
|
"pino": "^10.3.1",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* Regression guard for #4526: compileAndImport must await its dynamic import so
|
|
5
|
+
* an import-time rejection is caught by the surrounding try/catch and reaches
|
|
6
|
+
* the MikroORM v7 generated-cache recovery. Returning the promise unawaited let
|
|
7
|
+
* it settle after the try block had exited, so the reactive recovery was dead
|
|
8
|
+
* code for exactly the failure it exists to repair (a stale generated cache
|
|
9
|
+
* whose top-level `@mikro-orm/core` decorator import no longer resolves).
|
|
10
|
+
*
|
|
11
|
+
* generatedCacheRecovery is mocked here: it owns cache detection/deletion and is
|
|
12
|
+
* covered by its own tests, while these cases only assert how dynamicLoader
|
|
13
|
+
* reacts to a rejecting import. The recovery double rewrites the compiled
|
|
14
|
+
* sibling the way a real cache wipe plus recompile would, so the retry loads the
|
|
15
|
+
* refreshed module without invoking esbuild.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { loadBootstrapData } from '../dynamicLoader'
|
|
21
|
+
import {
|
|
22
|
+
ensureMikroOrmV7GeneratedCacheCompatibility,
|
|
23
|
+
recoverMikroOrmV7GeneratedCacheFromImportError,
|
|
24
|
+
type GeneratedCacheRecoveryResult,
|
|
25
|
+
} from '../generatedCacheRecovery'
|
|
26
|
+
|
|
27
|
+
jest.mock('../generatedCacheRecovery', () => ({
|
|
28
|
+
ensureMikroOrmV7GeneratedCacheCompatibility: jest.fn(),
|
|
29
|
+
recoverMikroOrmV7GeneratedCacheFromImportError: jest.fn(),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const ensureCompatibilityMock = ensureMikroOrmV7GeneratedCacheCompatibility as jest.MockedFunction<
|
|
33
|
+
typeof ensureMikroOrmV7GeneratedCacheCompatibility
|
|
34
|
+
>
|
|
35
|
+
const recoverFromImportErrorMock = recoverMikroOrmV7GeneratedCacheFromImportError as jest.MockedFunction<
|
|
36
|
+
typeof recoverMikroOrmV7GeneratedCacheFromImportError
|
|
37
|
+
>
|
|
38
|
+
|
|
39
|
+
const NO_RECOVERY: GeneratedCacheRecoveryResult = { applied: false, deletedFiles: [], markerPath: null }
|
|
40
|
+
const STALE_DECORATOR_IMPORT_ERROR =
|
|
41
|
+
"The requested module '@mikro-orm/core' does not provide an export named 'Entity'"
|
|
42
|
+
|
|
43
|
+
const STALE_ENTITY_IDS_CACHE = `throw new SyntaxError(${JSON.stringify(STALE_DECORATOR_IMPORT_ERROR)})`
|
|
44
|
+
const FRESH_ENTITY_IDS_CACHE = "module.exports = { E: { example: { todo: 'example:todo' } } }"
|
|
45
|
+
|
|
46
|
+
const BASE_GENERATED_MODULES: Record<string, { ts: string; compiled: string }> = {
|
|
47
|
+
'modules.cli.generated': { ts: 'export const modules = []', compiled: 'module.exports = { modules: [] }' },
|
|
48
|
+
'entities.generated': { ts: 'export const entities = []', compiled: 'module.exports = { entities: [] }' },
|
|
49
|
+
'di.generated': { ts: 'export const diRegistrars = []', compiled: 'module.exports = { diRegistrars: [] }' },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let compiledCacheGeneration = 0
|
|
53
|
+
|
|
54
|
+
function writeGeneratedModule(generatedDir: string, baseName: string, source: { ts: string; compiled: string }) {
|
|
55
|
+
fs.writeFileSync(path.join(generatedDir, `${baseName}.ts`), source.ts)
|
|
56
|
+
writeCompiledSibling(generatedDir, baseName, source.compiled)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Write the .mjs sibling with an mtime ahead of its .ts source so
|
|
61
|
+
* compileAndImport takes its cache path and never invokes esbuild. Each write
|
|
62
|
+
* advances the timestamp, which also gives the retry a distinct `?mtime=` import
|
|
63
|
+
* URL instead of the rejected module's cached one.
|
|
64
|
+
*/
|
|
65
|
+
function writeCompiledSibling(generatedDir: string, baseName: string, compiled: string) {
|
|
66
|
+
const compiledPath = path.join(generatedDir, `${baseName}.mjs`)
|
|
67
|
+
fs.writeFileSync(compiledPath, compiled)
|
|
68
|
+
compiledCacheGeneration += 1
|
|
69
|
+
const fresh = new Date(Date.now() + compiledCacheGeneration * 60_000)
|
|
70
|
+
fs.utimesSync(compiledPath, fresh, fresh)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createAppRoot(entityIdsCache: string): string {
|
|
74
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-bootstrap-4526-'))
|
|
75
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
76
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
77
|
+
for (const [baseName, source] of Object.entries(BASE_GENERATED_MODULES)) {
|
|
78
|
+
writeGeneratedModule(generatedDir, baseName, source)
|
|
79
|
+
}
|
|
80
|
+
writeGeneratedModule(generatedDir, 'entities.ids.generated', {
|
|
81
|
+
ts: 'export const E = {}',
|
|
82
|
+
compiled: entityIdsCache,
|
|
83
|
+
})
|
|
84
|
+
return appRoot
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recoverByRewritingCache(appRoot: string, compiled: string): GeneratedCacheRecoveryResult {
|
|
88
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
89
|
+
writeCompiledSibling(generatedDir, 'entities.ids.generated', compiled)
|
|
90
|
+
// Node busts its ESM cache through the `?mtime=` query compileAndImport
|
|
91
|
+
// appends; Jest's registry keys on the resolved path alone, so the retry would
|
|
92
|
+
// otherwise replay the rejected evaluation instead of the rewritten file.
|
|
93
|
+
jest.resetModules()
|
|
94
|
+
return {
|
|
95
|
+
applied: true,
|
|
96
|
+
deletedFiles: [path.join(generatedDir, 'entities.ids.generated.mjs')],
|
|
97
|
+
markerPath: path.join(generatedDir, '.mikro-orm-v7-cache-recovery.json'),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('compileAndImport — a rejecting import reaches the cache recovery (#4526)', () => {
|
|
102
|
+
const appRoots: string[] = []
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
ensureCompatibilityMock.mockReset()
|
|
106
|
+
recoverFromImportErrorMock.mockReset()
|
|
107
|
+
ensureCompatibilityMock.mockReturnValue(NO_RECOVERY)
|
|
108
|
+
recoverFromImportErrorMock.mockReturnValue(NO_RECOVERY)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
afterAll(() => {
|
|
112
|
+
for (const appRoot of appRoots) {
|
|
113
|
+
fs.rmSync(appRoot, { recursive: true, force: true })
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
function createTrackedAppRoot(entityIdsCache: string): string {
|
|
118
|
+
const appRoot = createAppRoot(entityIdsCache)
|
|
119
|
+
appRoots.push(appRoot)
|
|
120
|
+
return appRoot
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
it('recovers the stale cache and loads the refreshed module', async () => {
|
|
124
|
+
const appRoot = createTrackedAppRoot(STALE_ENTITY_IDS_CACHE)
|
|
125
|
+
recoverFromImportErrorMock.mockImplementation(() => recoverByRewritingCache(appRoot, FRESH_ENTITY_IDS_CACHE))
|
|
126
|
+
|
|
127
|
+
const data = await loadBootstrapData(appRoot)
|
|
128
|
+
|
|
129
|
+
expect(data.entityIds).toEqual({ example: { todo: 'example:todo' } })
|
|
130
|
+
expect(recoverFromImportErrorMock).toHaveBeenCalledTimes(1)
|
|
131
|
+
const [recoveryAppRoot, recoveryError] = recoverFromImportErrorMock.mock.calls[0]
|
|
132
|
+
expect(recoveryAppRoot).toBe(appRoot)
|
|
133
|
+
expect(String((recoveryError as Error).message)).toContain(STALE_DECORATOR_IMPORT_ERROR)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('propagates the import error when no recovery applies', async () => {
|
|
137
|
+
const appRoot = createTrackedAppRoot(STALE_ENTITY_IDS_CACHE)
|
|
138
|
+
|
|
139
|
+
await expect(loadBootstrapData(appRoot)).rejects.toThrow(STALE_DECORATOR_IMPORT_ERROR)
|
|
140
|
+
expect(recoverFromImportErrorMock).toHaveBeenCalledTimes(1)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('retries at most once when the refreshed cache still fails to import', async () => {
|
|
144
|
+
const appRoot = createTrackedAppRoot(STALE_ENTITY_IDS_CACHE)
|
|
145
|
+
recoverFromImportErrorMock.mockImplementation(() => recoverByRewritingCache(appRoot, STALE_ENTITY_IDS_CACHE))
|
|
146
|
+
|
|
147
|
+
await expect(loadBootstrapData(appRoot)).rejects.toThrow(STALE_DECORATOR_IMPORT_ERROR)
|
|
148
|
+
expect(recoverFromImportErrorMock).toHaveBeenCalledTimes(1)
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -91,7 +91,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
|
|
|
91
91
|
// Import the compiled JavaScript
|
|
92
92
|
try {
|
|
93
93
|
const fileUrl = `${pathToFileURL(jsPath).href}?mtime=${fs.statSync(jsPath).mtimeMs}`
|
|
94
|
-
return import(fileUrl)
|
|
94
|
+
return await import(fileUrl)
|
|
95
95
|
} catch (error) {
|
|
96
96
|
if (!allowRecovery) {
|
|
97
97
|
throw error
|
|
@@ -26,6 +26,7 @@ describe('CommandBus cache invalidation for sales documents', () => {
|
|
|
26
26
|
afterEach(() => {
|
|
27
27
|
unregisterCommand('sales.orders.update')
|
|
28
28
|
unregisterCommand('sales.orders.noop-update')
|
|
29
|
+
unregisterCommand('wms.warehouses.create')
|
|
29
30
|
invalidateMock.mockClear()
|
|
30
31
|
})
|
|
31
32
|
|
|
@@ -194,4 +195,49 @@ describe('CommandBus cache invalidation for sales documents', () => {
|
|
|
194
195
|
expect.any(Array)
|
|
195
196
|
)
|
|
196
197
|
})
|
|
198
|
+
|
|
199
|
+
it('resolves record id from warehouseId-style command results for cache tags', async () => {
|
|
200
|
+
const logMock = jest.fn(async () => ({ id: 'log-entry' }))
|
|
201
|
+
|
|
202
|
+
registerCommand({
|
|
203
|
+
id: 'wms.warehouses.create',
|
|
204
|
+
execute: jest.fn(async () => ({ warehouseId: 'warehouse-uuid-1' })),
|
|
205
|
+
buildLog: jest.fn(() => ({
|
|
206
|
+
actionLabel: 'Create warehouse',
|
|
207
|
+
resourceKind: 'wms.warehouse',
|
|
208
|
+
tenantId: 'tenant-1',
|
|
209
|
+
organizationId: 'org-1',
|
|
210
|
+
})),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
|
|
214
|
+
container.register({
|
|
215
|
+
actionLogService: asValue({
|
|
216
|
+
log: logMock,
|
|
217
|
+
findByUndoToken: jest.fn(async () => null),
|
|
218
|
+
markUndone: jest.fn(async () => {}),
|
|
219
|
+
}),
|
|
220
|
+
dataEngine: asValue({ flushOrmEntityChanges: jest.fn() }),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const bus = new CommandBus()
|
|
224
|
+
const ctx = {
|
|
225
|
+
container,
|
|
226
|
+
auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: 'org-1' },
|
|
227
|
+
organizationScope: null,
|
|
228
|
+
selectedOrganizationId: 'org-1',
|
|
229
|
+
organizationIds: null,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await bus.execute('wms.warehouses.create', { input: {}, ctx })
|
|
233
|
+
|
|
234
|
+
expect(invalidateMock).toHaveBeenCalledWith(
|
|
235
|
+
container,
|
|
236
|
+
'wms.warehouse',
|
|
237
|
+
{ id: 'warehouse-uuid-1', organizationId: 'org-1', tenantId: 'tenant-1' },
|
|
238
|
+
'tenant-1',
|
|
239
|
+
'command:wms.warehouses.create:execute',
|
|
240
|
+
expect.any(Array)
|
|
241
|
+
)
|
|
242
|
+
})
|
|
197
243
|
})
|
|
@@ -48,6 +48,28 @@ function asRecord(input: unknown): Record<string, unknown> | null {
|
|
|
48
48
|
return input as Record<string, unknown>
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Command handlers often return domain keys (e.g. warehouseId) without `id`; cache invalidation must still resolve the record. */
|
|
52
|
+
function extractPrimaryIdFromCommandResult(result: unknown): string | null {
|
|
53
|
+
const r = asRecord(result)
|
|
54
|
+
if (!r) return null
|
|
55
|
+
const direct = pickFirstIdentifier(r.id, r.entityId, r.recordId)
|
|
56
|
+
if (direct) return direct
|
|
57
|
+
for (const key of [
|
|
58
|
+
'warehouseId',
|
|
59
|
+
'zoneId',
|
|
60
|
+
'locationId',
|
|
61
|
+
'lotId',
|
|
62
|
+
'reservationId',
|
|
63
|
+
'profileId',
|
|
64
|
+
'movementId',
|
|
65
|
+
'balanceId',
|
|
66
|
+
]) {
|
|
67
|
+
const v = r[key]
|
|
68
|
+
if (typeof v === 'string' && v.trim().length > 0) return v.trim()
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
function toISOString(value: unknown): string | null {
|
|
52
74
|
if (value instanceof Date) {
|
|
53
75
|
const iso = value.toISOString()
|
|
@@ -584,6 +606,7 @@ export class CommandBus {
|
|
|
584
606
|
|
|
585
607
|
const recordId = pickFirstIdentifier(
|
|
586
608
|
metadata?.resourceId,
|
|
609
|
+
extractPrimaryIdFromCommandResult(result),
|
|
587
610
|
resultRecord?.entityId,
|
|
588
611
|
resultRecord?.id,
|
|
589
612
|
resultRecord?.recordId,
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -228,6 +228,8 @@ export type ListConfig<TList> = {
|
|
|
228
228
|
* filters/sorts, `search_tokens` fulltext filtering, and vector-search branches are bypassed.
|
|
229
229
|
*/
|
|
230
230
|
omitAutomaticTenantOrgScope?: boolean
|
|
231
|
+
/** When true, skip server-side CRUD GET cache for this list (avoids stale empty payloads after mutations). */
|
|
232
|
+
disableListCache?: boolean
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
export type CrudExportColumnConfig = {
|
|
@@ -1461,7 +1463,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
1461
1463
|
const exportFullRequested = exportRequested && (exportScope === 'full' || parseBooleanToken((queryParams as any).full) === true)
|
|
1462
1464
|
profiler.mark('export_configured', { exportRequested, exportFullRequested })
|
|
1463
1465
|
|
|
1464
|
-
const cacheEnabled =
|
|
1466
|
+
const cacheEnabled =
|
|
1467
|
+
isCrudCacheEnabled() && !exportRequested && !opts.list?.disableListCache
|
|
1465
1468
|
const cacheTimerStart = cacheEnabled && isCrudCacheDebugEnabled()
|
|
1466
1469
|
? process.hrtime.bigint()
|
|
1467
1470
|
: null
|
|
@@ -79,6 +79,23 @@ describe('customFieldValues encryption helpers', () => {
|
|
|
79
79
|
expect(await decryptCustomFieldValue(booleanText, 'tenant-1', service, cache, { kind: 'currency' })).toBe('true')
|
|
80
80
|
expect(await decryptCustomFieldValue(booleanText, 'tenant-1', service, cache, { kind: 'dictionary' })).toBe('true')
|
|
81
81
|
expect(await decryptCustomFieldValue(booleanText, 'tenant-1', service, cache, { kind: 'email' })).toBe('true')
|
|
82
|
+
expect(await decryptCustomFieldValue(booleanText, 'tenant-1', service, cache, { kind: 'phone' })).toBe('true')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('keeps an all-digit phone value a string instead of coercing it to a number (#62)', async () => {
|
|
86
|
+
const service = {
|
|
87
|
+
isEnabled: () => true,
|
|
88
|
+
getDek: async () => ({ key: fixedKey }),
|
|
89
|
+
} as any
|
|
90
|
+
const cache = new Map<string | null, string | null>()
|
|
91
|
+
|
|
92
|
+
const digitsOnly = await encryptCustomFieldValue('15551234567', 'tenant-1', service, cache)
|
|
93
|
+
const decrypted = await decryptCustomFieldValue(digitsOnly, 'tenant-1', service, cache, { kind: 'phone' })
|
|
94
|
+
expect(decrypted).toBe('15551234567')
|
|
95
|
+
expect(typeof decrypted).toBe('string')
|
|
96
|
+
|
|
97
|
+
const formatted = await encryptCustomFieldValue('+1 212 555 1234', 'tenant-1', service, cache)
|
|
98
|
+
expect(await decryptCustomFieldValue(formatted, 'tenant-1', service, cache, { kind: 'phone' })).toBe('+1 212 555 1234')
|
|
82
99
|
})
|
|
83
100
|
|
|
84
101
|
it('still parses typed kinds (integer/float/boolean) so legacy round-trip stays correct', async () => {
|
|
@@ -11,6 +11,7 @@ let currentScope: OrganizationScopeChangedDetail = {
|
|
|
11
11
|
tenantId: null
|
|
12
12
|
}
|
|
13
13
|
let currentVersion = 0
|
|
14
|
+
let hasEmitted = false
|
|
14
15
|
|
|
15
16
|
export function getCurrentOrganizationScope(): OrganizationScopeChangedDetail {
|
|
16
17
|
return { ...currentScope }
|
|
@@ -27,17 +28,25 @@ export function emitOrganizationScopeChanged(detail: OrganizationScopeChangedDet
|
|
|
27
28
|
const hasChanged =
|
|
28
29
|
currentScope.organizationId !== detail.organizationId ||
|
|
29
30
|
currentScope.tenantId !== detail.tenantId
|
|
31
|
+
// Guard so the very first emission always syncs subscribers, even when the
|
|
32
|
+
// initial scope matches the default all-orgs `{ null, null }` state.
|
|
33
|
+
const isFirstEmit = !hasEmitted
|
|
30
34
|
|
|
31
35
|
// Update module-level state
|
|
32
36
|
currentScope = { ...detail }
|
|
37
|
+
hasEmitted = true
|
|
33
38
|
|
|
34
39
|
// Increment version only if actual change detected
|
|
35
40
|
if (hasChanged) {
|
|
36
41
|
currentVersion++
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
//
|
|
40
|
-
|
|
44
|
+
// Only dispatch on real scope changes (or the first emission). Suppressing
|
|
45
|
+
// no-op re-emits avoids spurious DataTable refreshes, chrome refetches, and
|
|
46
|
+
// scope-hook churn when navigating between pages with the same scope.
|
|
47
|
+
if (hasChanged || isFirstEmit) {
|
|
48
|
+
window.dispatchEvent(new CustomEvent<OrganizationScopeChangedDetail>(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }))
|
|
49
|
+
}
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
export function subscribeOrganizationScopeChanged(
|
|
@@ -39,6 +39,18 @@ describe('DSL helpers', () => {
|
|
|
39
39
|
expect(cf.datetime('seen_at')).toMatchObject({ key: 'seen_at', kind: 'datetime' })
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
+
test('cf.phone helper produces a phone kind (#62)', () => {
|
|
43
|
+
expect(cf.phone('work_phone', { label: 'Work phone', formEditable: true })).toMatchObject({
|
|
44
|
+
key: 'work_phone',
|
|
45
|
+
kind: 'phone',
|
|
46
|
+
label: 'Work phone',
|
|
47
|
+
formEditable: true,
|
|
48
|
+
})
|
|
49
|
+
expect(CUSTOM_FIELD_KINDS).toContain('phone')
|
|
50
|
+
const phoneField: CustomFieldDefinition = { key: 'work_phone', kind: 'phone' }
|
|
51
|
+
expect(phoneField.kind).toBe('phone')
|
|
52
|
+
})
|
|
53
|
+
|
|
42
54
|
test('CustomFieldKind type stays in sync with the runtime kinds list (#3042)', () => {
|
|
43
55
|
// The CustomFieldKind type is derived from CUSTOM_FIELD_KINDS, so declaring a
|
|
44
56
|
// field with any runtime kind — including date/datetime — must type-check.
|
package/src/modules/dsl.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const cf = {
|
|
|
24
24
|
boolean: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'boolean', ...opts }),
|
|
25
25
|
select: (key: string, options: string[], opts: Omit<CustomFieldDefinition, 'key' | 'kind' | 'options'> = {}): CustomFieldDefinition => ({ key, kind: 'select', options, ...opts }),
|
|
26
26
|
currency: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'currency', ...opts }),
|
|
27
|
+
phone: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'phone', ...opts }),
|
|
27
28
|
date: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'date', ...opts }),
|
|
28
29
|
datetime: (key: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind'> = {}): CustomFieldDefinition => ({ key, kind: 'datetime', ...opts }),
|
|
29
30
|
dictionary: (key: string, dictionaryId: string, opts: Omit<CustomFieldDefinition, 'key' | 'kind' | 'dictionaryId'> = {}): CustomFieldDefinition => ({ key, kind: 'dictionary', dictionaryId, ...opts }),
|
|
@@ -248,6 +248,13 @@ export type InjectionWidgetModule<TContext = unknown, TData = unknown> = {
|
|
|
248
248
|
|
|
249
249
|
export type InjectionColumnDefinition = {
|
|
250
250
|
id: string
|
|
251
|
+
/**
|
|
252
|
+
* i18n translation key for the column header.
|
|
253
|
+
* When present, consumers should call `t(headerKey, header)` instead of treating `header` as a key.
|
|
254
|
+
* Follows the same `label`/`labelKey` convention used by `InjectionFieldDefinition`.
|
|
255
|
+
*/
|
|
256
|
+
headerKey?: string
|
|
257
|
+
/** Display text (and i18n fallback) for the column header. */
|
|
251
258
|
header: string
|
|
252
259
|
accessorKey: string
|
|
253
260
|
cell?: (props: { getValue: () => unknown }) => ReactNode
|