@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.
- package/.turbo/turbo-build.log +2 -1
- package/build.mjs +4 -1
- package/dist/lib/crud/errors.js +6 -1
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -13
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/custom-fields/kinds.js +113 -0
- package/dist/lib/custom-fields/kinds.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +12 -9
- package/dist/lib/encryption/indexDoc.js.map +2 -2
- package/dist/lib/i18n/server.js +1 -1
- package/dist/lib/i18n/server.js.map +2 -2
- package/dist/lib/location/countries.generated.js +265 -0
- package/dist/lib/location/countries.generated.js.map +7 -0
- package/dist/lib/location/countries.js +2 -13
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/navigation/pageReload.js +11 -0
- package/dist/lib/navigation/pageReload.js.map +7 -0
- package/dist/lib/schedule/interval.js +33 -0
- package/dist/lib/schedule/interval.js.map +7 -0
- package/dist/lib/schedule/invalidScheduleValue.js +19 -0
- package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +3 -2
- package/scripts/generate-countries.mjs +75 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
- package/src/lib/crud/__tests__/errors.test.ts +31 -1
- package/src/lib/crud/errors.ts +16 -0
- package/src/lib/crud/factory.ts +37 -15
- package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
- package/src/lib/custom-fields/kinds.ts +205 -0
- package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
- package/src/lib/encryption/indexDoc.ts +16 -7
- package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
- package/src/lib/i18n/server.ts +13 -5
- package/src/lib/location/__tests__/countries.test.ts +25 -0
- package/src/lib/location/countries.generated.ts +264 -0
- package/src/lib/location/countries.ts +2 -23
- package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
- package/src/lib/navigation/pageReload.ts +43 -0
- package/src/lib/schedule/__tests__/interval.test.ts +37 -0
- package/src/lib/schedule/interval.ts +45 -0
- package/src/lib/schedule/invalidScheduleValue.ts +33 -0
|
@@ -1,16 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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
|
|
5
|
-
"mappings": "AAAA,
|
|
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
|
+
}
|
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.8.1-develop.
|
|
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.
|
|
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.
|
|
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
|
+
})
|
package/src/lib/crud/errors.ts
CHANGED
|
@@ -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
|
+
}
|