@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.
@@ -6,6 +6,7 @@ const STRING_TYPED_CUSTOM_FIELD_KINDS = /* @__PURE__ */ new Set([
6
6
  "select",
7
7
  "currency",
8
8
  "dictionary",
9
+ "phone",
9
10
  "email",
10
11
  "url",
11
12
  "string"
@@ -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
- window.dispatchEvent(new CustomEvent(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }));
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 // Emit event\n window.dispatchEvent(new CustomEvent<OrganizationScopeChangedDetail>(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }))\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;AAEd,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,iBAAe,EAAE,GAAG,OAAO;AAG3B,MAAI,YAAY;AACd;AAAA,EACF;AAGA,SAAO,cAAc,IAAI,YAA4C,kCAAkC,EAAE,OAAO,CAAC,CAAC;AACpH;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;",
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
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.7-develop.6661.1.0043ed1d03";
1
+ const APP_VERSION = "0.6.7-develop.6670.1.4efa7961c6";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6661.1.0043ed1d03'\nexport const appVersion = APP_VERSION\n"],
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
  }
@@ -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 })
@@ -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
  }
@@ -9,6 +9,7 @@ const CUSTOM_FIELD_KINDS = [
9
9
  "relation",
10
10
  "attachment",
11
11
  "dictionary",
12
+ "phone",
12
13
  "date",
13
14
  "datetime"
14
15
  ];
@@ -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.6661.1.0043ed1d03",
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.6661.1.0043ed1d03",
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,
@@ -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 = isCrudCacheEnabled() && !exportRequested
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 () => {
@@ -20,6 +20,7 @@ const STRING_TYPED_CUSTOM_FIELD_KINDS = new Set([
20
20
  'select',
21
21
  'currency',
22
22
  'dictionary',
23
+ 'phone',
23
24
  'email',
24
25
  'url',
25
26
  'string',
@@ -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
- // Emit event
40
- window.dispatchEvent(new CustomEvent<OrganizationScopeChangedDetail>(ORGANIZATION_SCOPE_CHANGED_EVENT, { detail }))
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.
@@ -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 }),
@@ -9,6 +9,7 @@ export const CUSTOM_FIELD_KINDS = [
9
9
  'relation',
10
10
  'attachment',
11
11
  'dictionary',
12
+ 'phone',
12
13
  'date',
13
14
  'datetime',
14
15
  ] as const
@@ -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