@open-mercato/shared 0.8.1-develop.7274.1.21a0083828 → 0.8.1-develop.7294.1.0ef99fc518

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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/build.mjs +4 -1
  3. package/dist/lib/crud/errors.js +6 -1
  4. package/dist/lib/crud/errors.js.map +2 -2
  5. package/dist/lib/crud/factory.js +22 -13
  6. package/dist/lib/crud/factory.js.map +2 -2
  7. package/dist/lib/custom-fields/kinds.js +113 -0
  8. package/dist/lib/custom-fields/kinds.js.map +7 -0
  9. package/dist/lib/encryption/indexDoc.js +12 -9
  10. package/dist/lib/encryption/indexDoc.js.map +2 -2
  11. package/dist/lib/i18n/server.js +1 -1
  12. package/dist/lib/i18n/server.js.map +2 -2
  13. package/dist/lib/location/countries.generated.js +265 -0
  14. package/dist/lib/location/countries.generated.js.map +7 -0
  15. package/dist/lib/location/countries.js +2 -13
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/navigation/pageReload.js +11 -0
  18. package/dist/lib/navigation/pageReload.js.map +7 -0
  19. package/dist/lib/schedule/interval.js +33 -0
  20. package/dist/lib/schedule/interval.js.map +7 -0
  21. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  22. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  23. package/dist/lib/version.js +1 -1
  24. package/dist/lib/version.js.map +1 -1
  25. package/package.json +3 -2
  26. package/scripts/generate-countries.mjs +75 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  28. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  29. package/src/lib/crud/errors.ts +16 -0
  30. package/src/lib/crud/factory.ts +37 -15
  31. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  32. package/src/lib/custom-fields/kinds.ts +205 -0
  33. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  34. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  35. package/src/lib/encryption/indexDoc.ts +16 -7
  36. package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
  37. package/src/lib/i18n/server.ts +13 -5
  38. package/src/lib/location/__tests__/countries.test.ts +25 -0
  39. package/src/lib/location/countries.generated.ts +264 -0
  40. package/src/lib/location/countries.ts +2 -23
  41. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  42. package/src/lib/navigation/pageReload.ts +43 -0
  43. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  44. package/src/lib/schedule/interval.ts +45 -0
  45. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
@@ -1,16 +1,5 @@
1
- import registry from "language-subtag-registry/data/json/registry.json";
2
- function isIsoAlpha2(entry) {
3
- if (entry.Type !== "region") return false;
4
- if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false;
5
- if (entry.Deprecated) return false;
6
- if (!entry.Description || !entry.Description.length) return false;
7
- if (entry.Description[0] === "Private use") return false;
8
- return true;
9
- }
10
- const RAW_COUNTRIES = registry.filter(isIsoAlpha2).map((entry) => ({
11
- code: entry.Subtag,
12
- name: entry.Description.join(", ")
13
- }));
1
+ import { REGISTRY_COUNTRIES } from "./countries.generated.js";
2
+ const RAW_COUNTRIES = REGISTRY_COUNTRIES;
14
3
  const SUPPLEMENTAL_COUNTRIES = [
15
4
  { code: "XK", name: "Kosovo" }
16
5
  ];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/location/countries.ts"],
4
- "sourcesContent": ["import registry from 'language-subtag-registry/data/json/registry.json'\n\ntype RegistryEntry = {\n Type: string\n Subtag?: string\n Description?: string[]\n Deprecated?: string\n}\n\nexport type IsoCountry = {\n code: string\n name: string\n}\n\nfunction isIsoAlpha2(entry: RegistryEntry): entry is RegistryEntry & { Subtag: string; Description: string[] } {\n if (entry.Type !== 'region') return false\n if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false\n if (entry.Deprecated) return false\n if (!entry.Description || !entry.Description.length) return false\n if (entry.Description[0] === 'Private use') return false\n return true\n}\n\nconst RAW_COUNTRIES: IsoCountry[] = (registry as RegistryEntry[])\n .filter(isIsoAlpha2)\n .map((entry) => ({\n code: entry.Subtag,\n name: entry.Description.join(', '),\n }))\n\n// Codes in common use that the language-subtag registry does not list as region subtags.\n// XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.\nconst SUPPLEMENTAL_COUNTRIES: IsoCountry[] = [\n { code: 'XK', name: 'Kosovo' },\n]\n\nconst RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code))\n\nexport const ISO_COUNTRIES: IsoCountry[] = [\n ...RAW_COUNTRIES,\n ...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code)),\n].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))\n\nexport const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']\n\nconst displayNameCache = new Map<string, Intl.DisplayNames>()\n\nfunction getDisplayNames(locale: string): Intl.DisplayNames | null {\n if (typeof Intl === 'undefined' || typeof Intl.DisplayNames === 'undefined') return null\n const key = locale || 'en'\n let instance = displayNameCache.get(key)\n if (!instance) {\n try {\n instance = new Intl.DisplayNames([key], { type: 'region' })\n displayNameCache.set(key, instance)\n } catch {\n return null\n }\n }\n return instance\n}\n\nexport function resolveCountryName(code: string, options: { locale?: string } = {}): string {\n const normalized = code.toUpperCase()\n const fallback = ISO_COUNTRIES.find((entry) => entry.code === normalized)?.name ?? normalized\n const displayNames = getDisplayNames(options.locale ?? 'en')\n if (!displayNames) return fallback\n try {\n const label = displayNames.of(normalized as any)\n // Intl echoes the raw code back when its ICU data cannot resolve the region.\n if (typeof label !== 'string' || label === normalized) return fallback\n return label\n } catch {\n return fallback\n }\n}\n\nexport function buildCountryOptions(options: {\n locale?: string\n priority?: string[]\n transformLabel?: (code: string, defaultLabel: string) => string\n} = {}): Array<{ code: string; label: string }> {\n const { locale, transformLabel } = options\n const priority = (options.priority ?? COUNTRY_PRIORITY).map((code) => code.toUpperCase())\n const prioritySet = new Set(priority)\n const labelFor = (code: string) => {\n const base = resolveCountryName(code, { locale })\n return transformLabel ? transformLabel(code, base) : base\n }\n\n const byCode = new Map(ISO_COUNTRIES.map((entry) => [entry.code, entry]))\n\n const prioritized = priority\n .map((code) => {\n const base = byCode.get(code)\n if (!base) return null\n return { code, label: labelFor(code) }\n })\n .filter((entry): entry is { code: string; label: string } => !!entry)\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n const remaining = ISO_COUNTRIES\n .filter((entry) => !prioritySet.has(entry.code))\n .map((entry) => ({ code: entry.code, label: labelFor(entry.code) }))\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n return [...prioritized, ...remaining]\n}\n\nexport function matchCountryCodes(\n term: string,\n options: { locales?: string[] } = {},\n): string[] {\n const needle = term.trim().toLowerCase()\n if (!needle) return []\n const searchLocales = options.locales?.length ? options.locales : ['en']\n return ISO_COUNTRIES\n .filter((entry) => {\n if (entry.name.toLowerCase().includes(needle)) return true\n return searchLocales.some((locale) =>\n resolveCountryName(entry.code, { locale }).toLowerCase().includes(needle),\n )\n })\n .map((entry) => entry.code)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { REGISTRY_COUNTRIES } from './countries.generated'\n\nexport type IsoCountry = {\n code: string\n name: string\n}\n\nconst RAW_COUNTRIES: IsoCountry[] = REGISTRY_COUNTRIES\n\n// Codes in common use that the language-subtag registry does not list as region subtags.\n// XK is user-assigned rather than ISO 3166-1 assigned, so it never appears in the registry.\nconst SUPPLEMENTAL_COUNTRIES: IsoCountry[] = [\n { code: 'XK', name: 'Kosovo' },\n]\n\nconst RAW_COUNTRY_CODES = new Set(RAW_COUNTRIES.map((entry) => entry.code))\n\nexport const ISO_COUNTRIES: IsoCountry[] = [\n ...RAW_COUNTRIES,\n ...SUPPLEMENTAL_COUNTRIES.filter((entry) => !RAW_COUNTRY_CODES.has(entry.code)),\n].sort((a, b) => a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }))\n\nexport const COUNTRY_PRIORITY: string[] = ['PL', 'DE', 'ES', 'FR', 'IT', 'US', 'GB', 'CA']\n\nconst displayNameCache = new Map<string, Intl.DisplayNames>()\n\nfunction getDisplayNames(locale: string): Intl.DisplayNames | null {\n if (typeof Intl === 'undefined' || typeof Intl.DisplayNames === 'undefined') return null\n const key = locale || 'en'\n let instance = displayNameCache.get(key)\n if (!instance) {\n try {\n instance = new Intl.DisplayNames([key], { type: 'region' })\n displayNameCache.set(key, instance)\n } catch {\n return null\n }\n }\n return instance\n}\n\nexport function resolveCountryName(code: string, options: { locale?: string } = {}): string {\n const normalized = code.toUpperCase()\n const fallback = ISO_COUNTRIES.find((entry) => entry.code === normalized)?.name ?? normalized\n const displayNames = getDisplayNames(options.locale ?? 'en')\n if (!displayNames) return fallback\n try {\n const label = displayNames.of(normalized as any)\n // Intl echoes the raw code back when its ICU data cannot resolve the region.\n if (typeof label !== 'string' || label === normalized) return fallback\n return label\n } catch {\n return fallback\n }\n}\n\nexport function buildCountryOptions(options: {\n locale?: string\n priority?: string[]\n transformLabel?: (code: string, defaultLabel: string) => string\n} = {}): Array<{ code: string; label: string }> {\n const { locale, transformLabel } = options\n const priority = (options.priority ?? COUNTRY_PRIORITY).map((code) => code.toUpperCase())\n const prioritySet = new Set(priority)\n const labelFor = (code: string) => {\n const base = resolveCountryName(code, { locale })\n return transformLabel ? transformLabel(code, base) : base\n }\n\n const byCode = new Map(ISO_COUNTRIES.map((entry) => [entry.code, entry]))\n\n const prioritized = priority\n .map((code) => {\n const base = byCode.get(code)\n if (!base) return null\n return { code, label: labelFor(code) }\n })\n .filter((entry): entry is { code: string; label: string } => !!entry)\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n const remaining = ISO_COUNTRIES\n .filter((entry) => !prioritySet.has(entry.code))\n .map((entry) => ({ code: entry.code, label: labelFor(entry.code) }))\n .sort((a, b) => a.label.localeCompare(b.label, locale ?? 'en', { sensitivity: 'base' }))\n\n return [...prioritized, ...remaining]\n}\n\nexport function matchCountryCodes(\n term: string,\n options: { locales?: string[] } = {},\n): string[] {\n const needle = term.trim().toLowerCase()\n if (!needle) return []\n const searchLocales = options.locales?.length ? options.locales : ['en']\n return ISO_COUNTRIES\n .filter((entry) => {\n if (entry.name.toLowerCase().includes(needle)) return true\n return searchLocales.some((locale) =>\n resolveCountryName(entry.code, { locale }).toLowerCase().includes(needle),\n )\n })\n .map((entry) => entry.code)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,11 @@
1
+ function navigateWithPageReload(path) {
2
+ window.location.assign(path);
3
+ }
4
+ function replaceWithPageReload(path) {
5
+ window.location.replace(path);
6
+ }
7
+ export {
8
+ navigateWithPageReload,
9
+ replaceWithPageReload
10
+ };
11
+ //# sourceMappingURL=pageReload.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/navigation/pageReload.ts"],
4
+ "sourcesContent": ["/**\n * Boundary-crossing navigation helpers: they reload the document instead of handing the route to\n * the client router.\n *\n * The portal's `(frontend)` layout \u2014 the server component that decides the portal chrome and\n * resolves the customer session \u2014 sits *above* the `[...slug]` segment so portal navigation does\n * not remount the client subtree. A client-side `router.push`/`router.replace` therefore never\n * re-runs that layout, and any navigation that crosses the public/authenticated boundary would\n * keep rendering chrome computed for the page it came from: authenticated chrome over the login\n * page after a logout, or logged-out chrome over the dashboard after a login.\n *\n * Use these helpers for every boundary-crossing navigation (login, logout, signup completion,\n * invite acceptance, auth guards redirecting to login) so the layout re-runs and recomputes both\n * the chrome and the session. Same-side navigation should keep using the client router.\n *\n * Pick the one that matches the client-router call it stands in for, so session history behaves\n * the same way: `navigateWithPageReload` for `router.push`, `replaceWithPageReload` for\n * `router.replace`.\n */\n\n/** Full page load that adds a session-history entry \u2014 the document-loading counterpart of `router.push`. */\nexport function navigateWithPageReload(path: string): void {\n window.location.assign(path)\n}\n\n/**\n * Full page load that replaces the current session-history entry \u2014 the document-loading\n * counterpart of `router.replace`.\n *\n * Use this where the client-router call being replaced was a `router.replace`: an auth guard\n * sending an unauthenticated visitor to the login page, or the portal landing page forwarding a\n * signed-in customer to the dashboard.\n *\n * Note that for those call sites specifically, `navigateWithPageReload` was measured to behave\n * identically: they redirect from an effect during page load, and a browser treats a navigation\n * started then as a client redirect, replacing the entry rather than pushing it whichever method\n * is used. This helper exists so the call site states which semantics it means instead of relying\n * on that heuristic \u2014 and so a redirect that later moves behind a user gesture, where the\n * distinction does bite, keeps the behaviour it was written with.\n */\nexport function replaceWithPageReload(path: string): void {\n window.location.replace(path)\n}\n"],
5
+ "mappings": "AAqBO,SAAS,uBAAuB,MAAoB;AACzD,SAAO,SAAS,OAAO,IAAI;AAC7B;AAiBO,SAAS,sBAAsB,MAAoB;AACxD,SAAO,SAAS,QAAQ,IAAI;AAC9B;",
6
+ "names": []
7
+ }
@@ -0,0 +1,33 @@
1
+ const MIN_SCHEDULE_INTERVAL_MS = 60 * 1e3;
2
+ const SCHEDULE_INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
3
+ const UNIT_MULTIPLIERS = {
4
+ s: 1e3,
5
+ m: 60 * 1e3,
6
+ h: 60 * 60 * 1e3,
7
+ d: 24 * 60 * 60 * 1e3
8
+ };
9
+ function matchScheduleInterval(interval) {
10
+ const match = SCHEDULE_INTERVAL_PATTERN.exec(interval);
11
+ if (!match) return null;
12
+ return { amount: Number.parseInt(match[1], 10), unit: match[2] };
13
+ }
14
+ function parseScheduleInterval(interval) {
15
+ const parts = matchScheduleInterval(interval);
16
+ if (!parts) {
17
+ throw new Error(`Invalid interval format: ${interval}. Expected format: <number><unit> (e.g., 15m, 2h, 1d)`);
18
+ }
19
+ return parts.amount * UNIT_MULTIPLIERS[parts.unit];
20
+ }
21
+ function isValidScheduleInterval(interval) {
22
+ const parts = matchScheduleInterval(interval);
23
+ if (!parts) return false;
24
+ return parts.amount * UNIT_MULTIPLIERS[parts.unit] >= MIN_SCHEDULE_INTERVAL_MS;
25
+ }
26
+ export {
27
+ MIN_SCHEDULE_INTERVAL_MS,
28
+ SCHEDULE_INTERVAL_PATTERN,
29
+ isValidScheduleInterval,
30
+ matchScheduleInterval,
31
+ parseScheduleInterval
32
+ };
33
+ //# sourceMappingURL=interval.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/schedule/interval.ts"],
4
+ "sourcesContent": ["/**\n * Canonical interval-format rules for recurring schedules.\n *\n * Both the scheduler runtime and the API validators that reject a schedule\n * before it is persisted read the format from here, so the documented format\n * (`<number><unit>`, e.g. `15m`, `1h`, `24h`) cannot drift between the layer\n * that accepts a value and the layer that has to run it.\n */\nexport const MIN_SCHEDULE_INTERVAL_MS = 60 * 1000\n\nexport const SCHEDULE_INTERVAL_PATTERN = /^(\\d+)(s|m|h|d)$/\n\nconst UNIT_MULTIPLIERS: Record<string, number> = {\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n}\n\nexport type ScheduleIntervalUnit = 's' | 'm' | 'h' | 'd'\n\nexport type ScheduleIntervalParts = {\n amount: number\n unit: ScheduleIntervalUnit\n}\n\nexport function matchScheduleInterval(interval: string): ScheduleIntervalParts | null {\n const match = SCHEDULE_INTERVAL_PATTERN.exec(interval)\n if (!match) return null\n return { amount: Number.parseInt(match[1], 10), unit: match[2] as ScheduleIntervalUnit }\n}\n\nexport function parseScheduleInterval(interval: string): number {\n const parts = matchScheduleInterval(interval)\n if (!parts) {\n throw new Error(`Invalid interval format: ${interval}. Expected format: <number><unit> (e.g., 15m, 2h, 1d)`)\n }\n return parts.amount * UNIT_MULTIPLIERS[parts.unit]\n}\n\nexport function isValidScheduleInterval(interval: string): boolean {\n const parts = matchScheduleInterval(interval)\n if (!parts) return false\n return parts.amount * UNIT_MULTIPLIERS[parts.unit] >= MIN_SCHEDULE_INTERVAL_MS\n}\n"],
5
+ "mappings": "AAQO,MAAM,2BAA2B,KAAK;AAEtC,MAAM,4BAA4B;AAEzC,MAAM,mBAA2C;AAAA,EAC/C,GAAG;AAAA,EACH,GAAG,KAAK;AAAA,EACR,GAAG,KAAK,KAAK;AAAA,EACb,GAAG,KAAK,KAAK,KAAK;AACpB;AASO,SAAS,sBAAsB,UAAgD;AACpF,QAAM,QAAQ,0BAA0B,KAAK,QAAQ;AACrD,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,EAAE,QAAQ,OAAO,SAAS,MAAM,CAAC,GAAG,EAAE,GAAG,MAAM,MAAM,CAAC,EAA0B;AACzF;AAEO,SAAS,sBAAsB,UAA0B;AAC9D,QAAM,QAAQ,sBAAsB,QAAQ;AAC5C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI,MAAM,4BAA4B,QAAQ,uDAAuD;AAAA,EAC7G;AACA,SAAO,MAAM,SAAS,iBAAiB,MAAM,IAAI;AACnD;AAEO,SAAS,wBAAwB,UAA2B;AACjE,QAAM,QAAQ,sBAAsB,QAAQ;AAC5C,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,SAAS,iBAAiB,MAAM,IAAI,KAAK;AACxD;",
6
+ "names": []
7
+ }
@@ -0,0 +1,19 @@
1
+ const INVALID_SCHEDULE_VALUE_CODE = "INVALID_SCHEDULE_VALUE";
2
+ function createInvalidScheduleValueError(scheduleType, scheduleValue, message) {
3
+ return Object.assign(new Error(message), {
4
+ code: INVALID_SCHEDULE_VALUE_CODE,
5
+ scheduleType,
6
+ scheduleValue
7
+ });
8
+ }
9
+ function isInvalidScheduleValueError(error) {
10
+ if (typeof error !== "object" || error === null) return false;
11
+ const candidate = error;
12
+ return candidate.code === INVALID_SCHEDULE_VALUE_CODE && (candidate.scheduleType === "cron" || candidate.scheduleType === "interval");
13
+ }
14
+ export {
15
+ INVALID_SCHEDULE_VALUE_CODE,
16
+ createInvalidScheduleValueError,
17
+ isInvalidScheduleValueError
18
+ };
19
+ //# sourceMappingURL=invalidScheduleValue.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/schedule/invalidScheduleValue.ts"],
4
+ "sourcesContent": ["export const INVALID_SCHEDULE_VALUE_CODE = 'INVALID_SCHEDULE_VALUE'\n\nexport type ScheduleValueKind = 'cron' | 'interval'\n\nexport type InvalidScheduleValueError = Error & {\n code: typeof INVALID_SCHEDULE_VALUE_CODE\n scheduleType: ScheduleValueKind\n scheduleValue: string\n}\n\nexport function createInvalidScheduleValueError(\n scheduleType: ScheduleValueKind,\n scheduleValue: string,\n message: string,\n): InvalidScheduleValueError {\n return Object.assign(new Error(message), {\n code: INVALID_SCHEDULE_VALUE_CODE,\n scheduleType,\n scheduleValue,\n } as const)\n}\n\n/**\n * Structural check rather than `instanceof`: the error crosses a package\n * boundary (and a production bundle), where a duplicated class identity would\n * make `instanceof` silently false.\n */\nexport function isInvalidScheduleValueError(error: unknown): error is InvalidScheduleValueError {\n if (typeof error !== 'object' || error === null) return false\n const candidate = error as { code?: unknown; scheduleType?: unknown }\n return candidate.code === INVALID_SCHEDULE_VALUE_CODE\n && (candidate.scheduleType === 'cron' || candidate.scheduleType === 'interval')\n}\n"],
5
+ "mappings": "AAAO,MAAM,8BAA8B;AAUpC,SAAS,gCACd,cACA,eACA,SAC2B;AAC3B,SAAO,OAAO,OAAO,IAAI,MAAM,OAAO,GAAG;AAAA,IACvC,MAAM;AAAA,IACN;AAAA,IACA;AAAA,EACF,CAAU;AACZ;AAOO,SAAS,4BAA4B,OAAoD;AAC9F,MAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;AACxD,QAAM,YAAY;AAClB,SAAO,UAAU,SAAS,gCACpB,UAAU,iBAAiB,UAAU,UAAU,iBAAiB;AACxE;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.8.1-develop.7274.1.21a0083828";
1
+ const APP_VERSION = "0.8.1-develop.7294.1.0ef99fc518";
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.8.1-develop.7274.1.21a0083828';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7294.1.0ef99fc518';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.8.1-develop.7274.1.21a0083828",
3
+ "version": "0.8.1-develop.7294.1.0ef99fc518",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -113,7 +113,7 @@
113
113
  "@mikro-orm/core": "^7.1.14",
114
114
  "@mikro-orm/decorators": "^7.1.14",
115
115
  "@mikro-orm/postgresql": "^7.1.14",
116
- "@open-mercato/cache": "0.8.1-develop.7274.1.21a0083828",
116
+ "@open-mercato/cache": "0.8.1-develop.7294.1.0ef99fc518",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
@@ -129,6 +129,7 @@
129
129
  "devDependencies": {
130
130
  "@types/jest": "^30.0.0",
131
131
  "jest": "^30.4.2",
132
+ "language-subtag-registry": "^0.3.20",
132
133
  "ts-jest": "^29.4.12",
133
134
  "ts-morph": "^28.0.0",
134
135
  "typescript": "7.0.2"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build-time generator: extracts ISO alpha-2 region subtags from
4
+ * language-subtag-registry into a small TypeScript module so the published
5
+ * package never imports the registry JSON at runtime (Node ESM requires
6
+ * `with { type: 'json' }`, which esbuild strips when bundle:false).
7
+ *
8
+ * Usage:
9
+ * node packages/shared/scripts/generate-countries.mjs # write when drifted
10
+ * node packages/shared/scripts/generate-countries.mjs --check # exit 1 on drift
11
+ */
12
+ import { createRequire } from 'node:module'
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import process from 'node:process'
17
+
18
+ const checkMode = process.argv.includes('--check')
19
+
20
+ const packageDir = fileURLToPath(new URL('..', import.meta.url))
21
+ const require = createRequire(join(packageDir, 'package.json'))
22
+ const registry = require('language-subtag-registry/data/json/registry.json')
23
+
24
+ /**
25
+ * @typedef {{ Type: string, Subtag?: string, Description?: string[], Deprecated?: string }} RegistryEntry
26
+ */
27
+
28
+ /** @param {RegistryEntry} entry */
29
+ function isIsoAlpha2(entry) {
30
+ if (entry.Type !== 'region') return false
31
+ if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false
32
+ if (entry.Deprecated) return false
33
+ if (!entry.Description || !entry.Description.length) return false
34
+ if (entry.Description[0] === 'Private use') return false
35
+ return true
36
+ }
37
+
38
+ const countries = /** @type {RegistryEntry[]} */ (registry)
39
+ .filter(isIsoAlpha2)
40
+ .map((entry) => ({
41
+ code: /** @type {string} */ (entry.Subtag),
42
+ name: /** @type {string[]} */ (entry.Description).join(', '),
43
+ }))
44
+ .sort((a, b) => a.code.localeCompare(b.code))
45
+
46
+ const lines = [
47
+ '// AUTO-GENERATED by scripts/generate-countries.mjs — do not edit by hand.',
48
+ '// Regenerate via `node packages/shared/scripts/generate-countries.mjs` or the shared package build.',
49
+ '',
50
+ 'export const REGISTRY_COUNTRIES: Array<{ code: string; name: string }> = [',
51
+ ...countries.map((c) => ` { code: ${JSON.stringify(c.code)}, name: ${JSON.stringify(c.name)} },`),
52
+ ']',
53
+ '',
54
+ ]
55
+
56
+ const outPath = join(packageDir, 'src/lib/location/countries.generated.ts')
57
+ const content = lines.join('\n')
58
+
59
+ if (checkMode) {
60
+ if (!existsSync(outPath)) {
61
+ console.error(`[generate-countries] missing ${outPath}`)
62
+ process.exit(1)
63
+ }
64
+ const existing = readFileSync(outPath, 'utf8')
65
+ if (existing !== content) {
66
+ console.error(`[generate-countries] drift detected in ${outPath}`)
67
+ process.exit(1)
68
+ }
69
+ console.log(`[generate-countries] check passed (${countries.length} countries)`)
70
+ } else if (existsSync(outPath) && readFileSync(outPath, 'utf8') === content) {
71
+ console.log(`[generate-countries] up to date (${countries.length} countries)`)
72
+ } else {
73
+ writeFileSync(outPath, content, 'utf8')
74
+ console.log(`[generate-countries] wrote ${countries.length} countries → ${outPath}`)
75
+ }
@@ -2,7 +2,18 @@ jest.mock('@open-mercato/cache', () => ({
2
2
  runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
3
3
  }), { virtual: true })
4
4
 
5
+ // Default behavior matches production's fallback-translator shape for a key with no
6
+ // dictionary entry (`dict[key] ?? fallback ?? key`) — shared has no domain dictionary to
7
+ // consult, so every existing test observes the same pass-through it always has. Individual
8
+ // tests override `mockTranslate` to prove `handleError` actually routes a CrudHttpError body
9
+ // through the resolved `translate()` instead of forwarding it verbatim (#5727).
10
+ const mockTranslate = jest.fn((key: string, fallback?: string) => fallback ?? key)
11
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
12
+ resolveTranslations: async () => ({ t: mockTranslate, translate: mockTranslate }),
13
+ }))
14
+
5
15
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
16
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
17
  import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
7
18
  import {
8
19
  clearOptimisticLockReadersForTests,
@@ -803,6 +814,139 @@ describe('CRUD Factory', () => {
803
814
  })
804
815
  })
805
816
 
817
+ describe('afterList hook ordering on the export paths', () => {
818
+ // Issue #5969: both export branches used to call serializeExport() before awaiting
819
+ // hooks.afterList, so a hook that patches values the base query cannot compute reached
820
+ // the JSON list response but never the exported file.
821
+ const patchTitles = (res: any) => {
822
+ for (const item of res.items) item.title = `patched:${item.title}`
823
+ }
824
+
825
+ it('GET applies afterList mutations to the query-engine export, matching the JSON list', async () => {
826
+ const hookedRoute = makeCrudRoute({
827
+ metadata: { GET: { requireAuth: true } },
828
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
829
+ indexer: { entityType: 'example.todo' },
830
+ list: {
831
+ schema: querySchema,
832
+ entityId: 'example.todo',
833
+ fields: ['id', 'title', 'is_done'],
834
+ sortFieldMap: { id: 'id' },
835
+ buildFilters: () => ({} as any),
836
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
837
+ allowCsv: true,
838
+ csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
839
+ },
840
+ hooks: { afterList: patchTitles },
841
+ })
842
+
843
+ const jsonRes = await hookedRoute.GET(new Request('http://x/api/example/todos'))
844
+ expect((await jsonRes.json()).items[0].title).toBe('patched:A')
845
+
846
+ const csvRes = await hookedRoute.GET(new Request('http://x/api/example/todos?format=csv'))
847
+ expect((await csvRes.text()).split('\n')[1]).toBe('id-1,patched:A')
848
+ })
849
+
850
+ it('GET honors an afterList hook that replaces the export payload items', async () => {
851
+ const replacingRoute = makeCrudRoute({
852
+ metadata: { GET: { requireAuth: true } },
853
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
854
+ indexer: { entityType: 'example.todo' },
855
+ list: {
856
+ schema: querySchema,
857
+ entityId: 'example.todo',
858
+ fields: ['id', 'title', 'is_done'],
859
+ sortFieldMap: { id: 'id' },
860
+ buildFilters: () => ({} as any),
861
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
862
+ allowCsv: true,
863
+ csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
864
+ },
865
+ hooks: { afterList: (res: any) => { res.items = [{ id: 'replaced', title: 'Z' }] } },
866
+ })
867
+
868
+ const csvRes = await replacingRoute.GET(new Request('http://x/api/example/todos?format=csv'))
869
+ expect((await csvRes.text()).split('\n').slice(1)).toEqual(['replaced,Z'])
870
+ })
871
+
872
+ it('GET applies afterList mutations to the ORM-fallback export', async () => {
873
+ db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
874
+ const fallbackRoute = makeCrudRoute({
875
+ metadata: { GET: { requireAuth: true } },
876
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
877
+ list: {
878
+ schema: querySchema,
879
+ buildFilters: () => ({} as any),
880
+ allowCsv: true,
881
+ csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
882
+ },
883
+ hooks: { afterList: patchTitles },
884
+ })
885
+
886
+ const jsonRes = await fallbackRoute.GET(new Request('http://x/api/example/todos'))
887
+ expect((await jsonRes.json()).items[0].title).toBe('patched:A')
888
+
889
+ db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
890
+ const csvRes = await fallbackRoute.GET(new Request('http://x/api/example/todos?format=csv'))
891
+ expect((await csvRes.text()).split('\n')[1]).toBe('id-1,patched:A')
892
+ })
893
+
894
+ // #6019 review: on exportScope=full, items are normalized via normalizeFullRecordForExport
895
+ // before the hook runs but the hook's own additions used to skip that normalization,
896
+ // leaking `_`-prefixed metadata and un-flattened `cf_*` keys into the exported file.
897
+ const addAssociationsMetadata = (res: any) => {
898
+ for (const item of res.items) {
899
+ item.title = `patched:${item.title}`
900
+ item._associations = { ok: false, reason: 'lookup failed' }
901
+ item.cf_color = 're-added'
902
+ }
903
+ }
904
+
905
+ it('GET re-normalizes afterList output on the full-export query-engine path (#6019)', async () => {
906
+ const fullExportRoute = makeCrudRoute({
907
+ metadata: { GET: { requireAuth: true } },
908
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
909
+ indexer: { entityType: 'example.todo' },
910
+ list: {
911
+ schema: querySchema,
912
+ entityId: 'example.todo',
913
+ fields: ['id', 'title', 'is_done'],
914
+ sortFieldMap: { id: 'id' },
915
+ buildFilters: () => ({} as any),
916
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
917
+ },
918
+ hooks: { afterList: addAssociationsMetadata },
919
+ })
920
+
921
+ const res = await fullExportRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
922
+ const parsed = JSON.parse(await res.text())
923
+ expect(parsed[0].Title).toBe('patched:A')
924
+ expect(parsed[0].Color).toBe('re-added')
925
+ expect(Object.keys(parsed[0])).not.toContain('_associations')
926
+ expect(JSON.stringify(parsed)).not.toContain('lookup failed')
927
+ })
928
+
929
+ it('GET re-normalizes afterList output on the full-export ORM-fallback path (#6019)', async () => {
930
+ db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
931
+ const fullFallbackRoute = makeCrudRoute({
932
+ metadata: { GET: { requireAuth: true } },
933
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
934
+ list: {
935
+ schema: querySchema,
936
+ buildFilters: () => ({} as any),
937
+ },
938
+ hooks: { afterList: addAssociationsMetadata },
939
+ })
940
+
941
+ const res = await fullFallbackRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
942
+ const parsed = JSON.parse(await res.text())
943
+ expect(parsed[0].Title).toBe('patched:A')
944
+ expect(parsed[0].Color).toBe('re-added')
945
+ expect(Object.keys(parsed[0])).not.toContain('_associations')
946
+ expect(JSON.stringify(parsed)).not.toContain('lookup failed')
947
+ })
948
+ })
949
+
806
950
  describe('export loop termination', () => {
807
951
  const EXPORT_PAGE_SIZE = 1000
808
952
 
@@ -1477,6 +1621,39 @@ describe('CRUD Factory', () => {
1477
1621
  })
1478
1622
  })
1479
1623
 
1624
+ // Issue #5727 — a command that raises CrudHttpError with a raw i18n key (rather than an
1625
+ // already-translated message) must not leak that key verbatim; handleError() routes it
1626
+ // through the resolved translate() before responding.
1627
+ it('POST command route translates a raw i18n key on a CrudHttpError body instead of forwarding it verbatim', async () => {
1628
+ mockTranslate.mockImplementationOnce((key: string, fallback?: string) =>
1629
+ key === 'some_module.errors.lineLocked' ? 'This line is locked.' : (fallback ?? key),
1630
+ )
1631
+ commandBus.execute.mockRejectedValue(new CrudHttpError(400, { error: 'some_module.errors.lineLocked' }))
1632
+
1633
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1634
+
1635
+ expect(res.status).toBe(400)
1636
+ await expect(res.json()).resolves.toEqual({ error: 'This line is locked.' })
1637
+ expect(mockTranslate).toHaveBeenCalledWith('some_module.errors.lineLocked', 'some_module.errors.lineLocked')
1638
+ })
1639
+
1640
+ it('POST command route preserves other CrudHttpError body fields alongside the translated error', async () => {
1641
+ mockTranslate.mockImplementationOnce((key: string, fallback?: string) =>
1642
+ key === 'some_module.errors.conflict' ? 'A conflicting record already exists.' : (fallback ?? key),
1643
+ )
1644
+ commandBus.execute.mockRejectedValue(
1645
+ new CrudHttpError(409, { error: 'some_module.errors.conflict', conflictingId: 'todo-9' }),
1646
+ )
1647
+
1648
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1649
+
1650
+ expect(res.status).toBe(409)
1651
+ await expect(res.json()).resolves.toEqual({
1652
+ error: 'A conflicting record already exists.',
1653
+ conflictingId: 'todo-9',
1654
+ })
1655
+ })
1656
+
1480
1657
  // Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
1481
1658
  // that same id must appear on the server log line so the two can be correlated.
1482
1659
  describe('generic 500 requestId correlation', () => {
@@ -1,4 +1,4 @@
1
- import { CrudHttpError, isCrudHttpError, assertFound, notFound } from '../errors'
1
+ import { CrudHttpError, isCrudHttpError, assertFound, notFound, translateCrudErrorBody } from '../errors'
2
2
 
3
3
  describe('CrudHttpError', () => {
4
4
  it('builds from string body', () => {
@@ -80,3 +80,33 @@ describe('assertFound', () => {
80
80
  }
81
81
  })
82
82
  })
83
+
84
+ describe('translateCrudErrorBody', () => {
85
+ it('translates a raw i18n key found in the dictionary', () => {
86
+ const dict: Record<string, string> = {
87
+ 'warranty_claims.errors.lineLocked': 'This line is locked in the current claim status.',
88
+ }
89
+ const translate = (key: string, fallback?: string) => dict[key] ?? fallback ?? key
90
+ const body = translateCrudErrorBody({ error: 'warranty_claims.errors.lineLocked' }, translate)
91
+ expect(body).toEqual({ error: 'This line is locked in the current claim status.' })
92
+ })
93
+
94
+ it('falls back to the raw key when the dictionary has no entry, instead of throwing', () => {
95
+ const translate = (key: string, fallback?: string) => fallback ?? key
96
+ const body = translateCrudErrorBody({ error: 'warranty_claims.errors.unknownKey' }, translate)
97
+ expect(body).toEqual({ error: 'warranty_claims.errors.unknownKey' })
98
+ })
99
+
100
+ it('preserves other body fields untouched', () => {
101
+ const translate = (key: string) => `translated:${key}`
102
+ const body = translateCrudErrorBody({ error: 'some.key', field: 'x', code: 42 }, translate)
103
+ expect(body).toEqual({ error: 'translated:some.key', field: 'x', code: 42 })
104
+ })
105
+
106
+ it('returns the body unchanged when error is not a string', () => {
107
+ const translate = jest.fn((key: string) => key)
108
+ const body = translateCrudErrorBody({ field: 'x' }, translate)
109
+ expect(body).toEqual({ field: 'x' })
110
+ expect(translate).not.toHaveBeenCalled()
111
+ })
112
+ })
@@ -96,3 +96,19 @@ export function assertFound<T>(value: T | null | undefined, message: string): T
96
96
  if (!value) throw notFound(message)
97
97
  return value
98
98
  }
99
+
100
+ /**
101
+ * Translates a `CrudHttpError` body's `error` field before it reaches the client.
102
+ * Some callers (command handlers, lib helpers reused by subscribers/CLI/workers) raise
103
+ * `CrudHttpError` with a raw i18n key because they run without a request locale — a route
104
+ * handler forwarding `err.body` verbatim would leak that key to the user. Call this at the
105
+ * route boundary, passing the `translate` the route already resolved, instead of forwarding
106
+ * `err.body` directly.
107
+ */
108
+ export function translateCrudErrorBody<T extends Record<string, unknown>>(
109
+ body: T,
110
+ translate: (key: string, fallback?: string) => string,
111
+ ): T {
112
+ if (typeof body?.error !== 'string') return body
113
+ return { ...body, error: translate(body.error, body.error) }
114
+ }