@open-mercato/shared 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7295.1.d0e0e33014

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 (56) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/AGENTS.md +6 -3
  3. package/build.mjs +4 -1
  4. package/dist/lib/crud/errors.js +6 -1
  5. package/dist/lib/crud/errors.js.map +2 -2
  6. package/dist/lib/crud/factory.js +22 -13
  7. package/dist/lib/crud/factory.js.map +2 -2
  8. package/dist/lib/custom-fields/kinds.js +113 -0
  9. package/dist/lib/custom-fields/kinds.js.map +7 -0
  10. package/dist/lib/encryption/indexDoc.js +12 -9
  11. package/dist/lib/encryption/indexDoc.js.map +2 -2
  12. package/dist/lib/location/countries.generated.js +265 -0
  13. package/dist/lib/location/countries.generated.js.map +7 -0
  14. package/dist/lib/location/countries.js +2 -13
  15. package/dist/lib/location/countries.js.map +2 -2
  16. package/dist/lib/navigation/pageReload.js +11 -0
  17. package/dist/lib/navigation/pageReload.js.map +7 -0
  18. package/dist/lib/query/encrypted-sort.js +4 -1
  19. package/dist/lib/query/encrypted-sort.js.map +2 -2
  20. package/dist/lib/query/engine.js +97 -10
  21. package/dist/lib/query/engine.js.map +3 -3
  22. package/dist/lib/schedule/interval.js +33 -0
  23. package/dist/lib/schedule/interval.js.map +7 -0
  24. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  25. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  26. package/dist/lib/search/config.js.map +2 -2
  27. package/dist/lib/search/containment.js +32 -0
  28. package/dist/lib/search/containment.js.map +7 -0
  29. package/dist/lib/version.js +1 -1
  30. package/dist/lib/version.js.map +1 -1
  31. package/package.json +3 -2
  32. package/scripts/generate-countries.mjs +75 -0
  33. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  34. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  35. package/src/lib/crud/errors.ts +16 -0
  36. package/src/lib/crud/factory.ts +37 -15
  37. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  38. package/src/lib/custom-fields/kinds.ts +205 -0
  39. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  40. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  41. package/src/lib/encryption/indexDoc.ts +16 -7
  42. package/src/lib/location/__tests__/countries.test.ts +25 -0
  43. package/src/lib/location/countries.generated.ts +264 -0
  44. package/src/lib/location/countries.ts +2 -23
  45. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  46. package/src/lib/navigation/pageReload.ts +43 -0
  47. package/src/lib/query/__tests__/engine.test.ts +208 -16
  48. package/src/lib/query/encrypted-sort.ts +8 -1
  49. package/src/lib/query/engine.ts +172 -19
  50. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  51. package/src/lib/schedule/interval.ts +45 -0
  52. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
  53. package/src/lib/search/__tests__/config.test.ts +27 -0
  54. package/src/lib/search/__tests__/containment.test.ts +61 -0
  55. package/src/lib/search/config.ts +28 -6
  56. package/src/lib/search/containment.ts +66 -0
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/search/config.ts"],
4
- "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'\n\nexport type SearchConfig = {\n enabled: boolean\n minTokenLength: number\n enablePartials: boolean\n hashAlgorithm: 'sha256' | 'sha1' | 'md5'\n storeRawTokens: boolean\n /**\n * When true, a like/ilike on a PLAINTEXT base column runs as exact SQL ILIKE instead of being\n * rewritten into an approximate search-token match; encrypted columns always keep the token\n * path (ILIKE against ciphertext cannot match). Off by default: token matching can be faster\n * than an unanchored ILIKE, which may need a full scan without a trigram index \u2014 but it is\n * approximate (fragments under minTokenLength vanish, so `ZK 1/2026` degrades to its year and\n * an all-short term drops the predicate). Flip it on when list search must be exact.\n */\n useIlikeForNonEncryptedFields?: boolean\n blocklistedFields: string[]\n entityBlocklistedFields?: Record<string, string[]>\n maxFieldChars?: number\n maxTokensPerField?: number\n /**\n * Ceiling on token rows across all fields of one record; `0` disables it. The budget is spent in\n * the order the document's own keys iterate in, so on an over-budget record *which* fields stay\n * searchable depends on that key order \u2014 see `buildSearchTokenRows` in\n * `@open-mercato/core/modules/query_index/lib/search-tokens` before recomputing expected tokens\n * from a document that did not come straight from the indexer.\n */\n maxTokensPerRecord?: number\n}\n\nexport const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3\nexport const DEFAULT_SEARCH_MAX_FIELD_CHARS = 20_000\nexport const DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD = 5_000\nexport const DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD = 20_000\n\nexport type SearchTokenLimits = {\n maxFieldChars: number\n maxTokensPerField: number\n maxTokensPerRecord: number\n}\n\nconst DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']\n\nconst ENTITY_BLOCKLIST_SEPARATOR = '@'\n\nfunction parseBoolean(raw: string | undefined, fallback: boolean): boolean {\n return parseBooleanWithDefault(raw, fallback)\n}\n\nfunction parseNumber(raw: string | undefined, fallback: number, min = 1): number {\n return parseNumberWithDefault(raw, fallback, { integer: true, min })\n}\n\nexport function resolveSearchTokenLimits(config: SearchConfig): SearchTokenLimits {\n const resolveLimit = (value: number | undefined, fallback: number): number => {\n if (value === undefined) return fallback\n if (!Number.isFinite(value) || value < 0) return fallback\n return Math.trunc(value)\n }\n return {\n maxFieldChars: resolveLimit(config.maxFieldChars, DEFAULT_SEARCH_MAX_FIELD_CHARS),\n maxTokensPerField: resolveLimit(config.maxTokensPerField, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD),\n maxTokensPerRecord: resolveLimit(config.maxTokensPerRecord, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD),\n }\n}\n\nfunction parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {\n const value = (raw ?? '').trim().toLowerCase()\n if (value === 'sha1') return 'sha1'\n if (value === 'md5') return 'md5'\n return 'sha256'\n}\n\n/**\n * Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.\n *\n * Why: a deployment often needs to keep one large free-text column out of the token\n * index (e-mail bodies on `customers:customer_interaction`) while still indexing the\n * same-named column elsewhere. A flat global list cannot express that.\n *\n * How to apply: entries are comma-separated; an entry may carry an optional\n * `entityType@` prefix \u2014 `body` blocks the field everywhere, while\n * `customers:customer_interaction@body` blocks it only for that entity type. Entries\n * whose field part is empty are ignored so malformed env input cannot break indexing.\n */\nfunction parseFieldBlocklist(raw: string | undefined): {\n global: string[]\n byEntity: Record<string, string[]>\n} {\n const global: string[] = []\n const byEntity = new Map<string, string[]>()\n\n for (const rawEntry of parseCommaSeparatedList(raw)) {\n const entry = rawEntry.toLowerCase()\n const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)\n const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''\n const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry\n if (!field.length) continue\n\n if (!entityType.length) {\n if (!global.includes(field)) global.push(field)\n continue\n }\n\n const scoped = byEntity.get(entityType) ?? []\n if (!scoped.includes(field)) scoped.push(field)\n byEntity.set(entityType, scoped)\n }\n\n for (const fallback of DEFAULT_BLOCKLIST) {\n if (!global.includes(fallback)) global.push(fallback)\n }\n\n const scopedBlocklist = Object.create(null) as Record<string, string[]>\n for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields\n\n return { global, byEntity: scopedBlocklist }\n}\n\nexport function resolveSearchConfig(): SearchConfig {\n const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)\n return {\n enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),\n minTokenLength: resolveSearchMinTokenLength(),\n enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),\n hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),\n storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),\n useIlikeForNonEncryptedFields: parseBoolean(process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS, false),\n blocklistedFields: blocklist.global,\n entityBlocklistedFields: blocklist.byEntity,\n maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),\n maxTokensPerField: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_FIELD, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD, 0),\n maxTokensPerRecord: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_RECORD, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD, 0),\n }\n}\n\n/**\n * Single matcher for \"should this field be kept out of the search index?\".\n *\n * Why: the per-field token path and the `search_text` aggregate previously each\n * decided this on their own, and the aggregate simply never consulted the config \u2014\n * so a blocklisted column's text came back into the index under the aggregate's\n * field name (#4624). Both paths now share this function so they cannot drift.\n *\n * How to apply: pass the document's field name and the entity type being indexed;\n * `entityType` may be omitted when unknown, in which case only global entries apply.\n * Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).\n */\nexport function isSearchFieldBlocklisted(\n field: string,\n entityType: string | null | undefined,\n config: SearchConfig,\n): boolean {\n const lower = field.toLowerCase()\n if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true\n if (!entityType) return false\n const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]\n if (!Array.isArray(scoped) || !scoped.length) return false\n return scoped.some((blocked) => lower.includes(blocked))\n}\n\n/**\n * Browser-safe accessor for the minimum search token length.\n *\n * Why: client components (e.g. global search dialog) must mirror the server-side\n * tokenizer's `minTokenLength` so the UI gates the request before hitting an\n * empty result set. Pulling the value through this single helper keeps the env\n * contract (`OM_SEARCH_MIN_LEN`) authoritative on both sides.\n *\n * How to apply: call from anywhere \u2014 server, client (when the host app exposes\n * `OM_SEARCH_MIN_LEN` through `next.config.ts`'s `env` block), or tests.\n */\nexport function resolveSearchMinTokenLength(): number {\n return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { parseNumberWithDefault } from '@open-mercato/shared/lib/number'\nimport { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'\n\nexport type SearchConfig = {\n enabled: boolean\n minTokenLength: number\n enablePartials: boolean\n hashAlgorithm: 'sha256' | 'sha1' | 'md5'\n storeRawTokens: boolean\n /**\n * When true, a like/ilike on a PLAINTEXT base column runs as SQL ILIKE \u2014 one containment\n * predicate per word of the term, ANDed \u2014 instead of being rewritten into an approximate\n * search-token match; encrypted columns always keep the token path (ILIKE against ciphertext\n * cannot match).\n *\n * Off by default, per #5383: the token store is expected to become faster than ILIKE once\n * tokenization is made semantically equivalent to it, so the plan there is to keep this switch\n * off until that follow-up lands rather than trade performance for correctness by default. #5803\n * documents the correctness gap this switch closes when enabled: the token rewrite is lossy in a\n * way that silently returns the WRONG record rather than merely extra ones (tokenization splits\n * on non-alphanumerics and drops fragments under minTokenLength, so `2026-08` and `2026-01` both\n * reduce to {202, 2026} and a picker offers the neighbouring period; a term that tokenizes to\n * nothing (`08`) drops the predicate entirely and matches every row) \u2014 a deployment that hits\n * that gap before #5383 lands can opt in here.\n *\n * Per-word ANDing (see lib/search/containment) is a trade-off, not a strict improvement, over\n * the single-literal ILIKE #4622 originally introduced: the token subquery matched a value\n * carrying every token in any order with anything between them, so `?search=Warehouse 1757`\n * must keep matching `Warehouse A 1757` \u2014 a single verbatim `ILIKE '%Warehouse 1757%'` would\n * not, and TC-RESO-009 pins that as required behavior. The same word-order independence also\n * widens multi-word document-number searches: `?search=ZK 1/2026` now also matches\n * `ZK 11/2026` and `1/2026 ZK`, where the old single-literal ILIKE matched neither.\n *\n * Set `OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS=true` to opt into declared-column ILIKE\n * ahead of #5383 \u2014 worth doing when the #5803 wrong-record symptom is hit in practice. Leaving it\n * unset keeps the legacy rewrite-everything behavior, including the token index's prefix matching\n * (`?search=ware` matching `Warehouse` when `enablePartials` is on, which literal containment\n * gives only where the fragment really is a substring).\n */\n useIlikeForNonEncryptedFields?: boolean\n blocklistedFields: string[]\n entityBlocklistedFields?: Record<string, string[]>\n maxFieldChars?: number\n maxTokensPerField?: number\n /**\n * Ceiling on token rows across all fields of one record; `0` disables it. The budget is spent in\n * the order the document's own keys iterate in, so on an over-budget record *which* fields stay\n * searchable depends on that key order \u2014 see `buildSearchTokenRows` in\n * `@open-mercato/core/modules/query_index/lib/search-tokens` before recomputing expected tokens\n * from a document that did not come straight from the indexer.\n */\n maxTokensPerRecord?: number\n}\n\nexport const DEFAULT_SEARCH_MIN_TOKEN_LENGTH = 3\nexport const DEFAULT_SEARCH_MAX_FIELD_CHARS = 20_000\nexport const DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD = 5_000\nexport const DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD = 20_000\n\nexport type SearchTokenLimits = {\n maxFieldChars: number\n maxTokensPerField: number\n maxTokensPerRecord: number\n}\n\nconst DEFAULT_BLOCKLIST = ['password', 'token', 'secret', 'hash']\n\nconst ENTITY_BLOCKLIST_SEPARATOR = '@'\n\nfunction parseBoolean(raw: string | undefined, fallback: boolean): boolean {\n return parseBooleanWithDefault(raw, fallback)\n}\n\nfunction parseNumber(raw: string | undefined, fallback: number, min = 1): number {\n return parseNumberWithDefault(raw, fallback, { integer: true, min })\n}\n\nexport function resolveSearchTokenLimits(config: SearchConfig): SearchTokenLimits {\n const resolveLimit = (value: number | undefined, fallback: number): number => {\n if (value === undefined) return fallback\n if (!Number.isFinite(value) || value < 0) return fallback\n return Math.trunc(value)\n }\n return {\n maxFieldChars: resolveLimit(config.maxFieldChars, DEFAULT_SEARCH_MAX_FIELD_CHARS),\n maxTokensPerField: resolveLimit(config.maxTokensPerField, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD),\n maxTokensPerRecord: resolveLimit(config.maxTokensPerRecord, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD),\n }\n}\n\nfunction parseHashAlgorithm(raw: string | undefined): 'sha256' | 'sha1' | 'md5' {\n const value = (raw ?? '').trim().toLowerCase()\n if (value === 'sha1') return 'sha1'\n if (value === 'md5') return 'md5'\n return 'sha256'\n}\n\n/**\n * Parses `OM_SEARCH_FIELD_BLOCKLIST` into a global list plus per-entity-type lists.\n *\n * Why: a deployment often needs to keep one large free-text column out of the token\n * index (e-mail bodies on `customers:customer_interaction`) while still indexing the\n * same-named column elsewhere. A flat global list cannot express that.\n *\n * How to apply: entries are comma-separated; an entry may carry an optional\n * `entityType@` prefix \u2014 `body` blocks the field everywhere, while\n * `customers:customer_interaction@body` blocks it only for that entity type. Entries\n * whose field part is empty are ignored so malformed env input cannot break indexing.\n */\nfunction parseFieldBlocklist(raw: string | undefined): {\n global: string[]\n byEntity: Record<string, string[]>\n} {\n const global: string[] = []\n const byEntity = new Map<string, string[]>()\n\n for (const rawEntry of parseCommaSeparatedList(raw)) {\n const entry = rawEntry.toLowerCase()\n const separatorIndex = entry.indexOf(ENTITY_BLOCKLIST_SEPARATOR)\n const entityType = separatorIndex >= 0 ? entry.slice(0, separatorIndex).trim() : ''\n const field = separatorIndex >= 0 ? entry.slice(separatorIndex + 1).trim() : entry\n if (!field.length) continue\n\n if (!entityType.length) {\n if (!global.includes(field)) global.push(field)\n continue\n }\n\n const scoped = byEntity.get(entityType) ?? []\n if (!scoped.includes(field)) scoped.push(field)\n byEntity.set(entityType, scoped)\n }\n\n for (const fallback of DEFAULT_BLOCKLIST) {\n if (!global.includes(fallback)) global.push(fallback)\n }\n\n const scopedBlocklist = Object.create(null) as Record<string, string[]>\n for (const [entityType, fields] of byEntity) scopedBlocklist[entityType] = fields\n\n return { global, byEntity: scopedBlocklist }\n}\n\nexport function resolveSearchConfig(): SearchConfig {\n const blocklist = parseFieldBlocklist(process.env.OM_SEARCH_FIELD_BLOCKLIST)\n return {\n enabled: parseBoolean(process.env.OM_SEARCH_ENABLED, true),\n minTokenLength: resolveSearchMinTokenLength(),\n enablePartials: parseBoolean(process.env.OM_SEARCH_ENABLE_PARTIAL, true),\n hashAlgorithm: parseHashAlgorithm(process.env.OM_SEARCH_HASH_ALGO),\n storeRawTokens: parseBoolean(process.env.OM_SEARCH_STORE_RAW_TOKENS, false),\n useIlikeForNonEncryptedFields: parseBoolean(process.env.OM_SEARCH_USE_ILIKE_FOR_NON_ENCRYPTED_FIELDS, false),\n blocklistedFields: blocklist.global,\n entityBlocklistedFields: blocklist.byEntity,\n maxFieldChars: parseNumber(process.env.OM_SEARCH_MAX_FIELD_CHARS, DEFAULT_SEARCH_MAX_FIELD_CHARS, 0),\n maxTokensPerField: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_FIELD, DEFAULT_SEARCH_MAX_TOKENS_PER_FIELD, 0),\n maxTokensPerRecord: parseNumber(process.env.OM_SEARCH_MAX_TOKENS_PER_RECORD, DEFAULT_SEARCH_MAX_TOKENS_PER_RECORD, 0),\n }\n}\n\n/**\n * Single matcher for \"should this field be kept out of the search index?\".\n *\n * Why: the per-field token path and the `search_text` aggregate previously each\n * decided this on their own, and the aggregate simply never consulted the config \u2014\n * so a blocklisted column's text came back into the index under the aggregate's\n * field name (#4624). Both paths now share this function so they cannot drift.\n *\n * How to apply: pass the document's field name and the entity type being indexed;\n * `entityType` may be omitted when unknown, in which case only global entries apply.\n * Matching keeps the historical substring semantics (`fieldName.includes(pattern)`).\n */\nexport function isSearchFieldBlocklisted(\n field: string,\n entityType: string | null | undefined,\n config: SearchConfig,\n): boolean {\n const lower = field.toLowerCase()\n if (config.blocklistedFields.some((blocked) => lower.includes(blocked))) return true\n if (!entityType) return false\n const scoped = config.entityBlocklistedFields?.[entityType.trim().toLowerCase()]\n if (!Array.isArray(scoped) || !scoped.length) return false\n return scoped.some((blocked) => lower.includes(blocked))\n}\n\n/**\n * Browser-safe accessor for the minimum search token length.\n *\n * Why: client components (e.g. global search dialog) must mirror the server-side\n * tokenizer's `minTokenLength` so the UI gates the request before hitting an\n * empty result set. Pulling the value through this single helper keeps the env\n * contract (`OM_SEARCH_MIN_LEN`) authoritative on both sides.\n *\n * How to apply: call from anywhere \u2014 server, client (when the host app exposes\n * `OM_SEARCH_MIN_LEN` through `next.config.ts`'s `env` block), or tests.\n */\nexport function resolveSearchMinTokenLength(): number {\n return parseNumber(process.env.OM_SEARCH_MIN_LEN, DEFAULT_SEARCH_MIN_TOKEN_LENGTH, 1)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,32 @@
1
+ const MAX_CONTAINMENT_WORDS = 10;
2
+ function buildContainmentPatterns(pattern) {
3
+ if (!isWrappedContainsPattern(pattern)) return [pattern];
4
+ const term = pattern.slice(1, -1);
5
+ if (hasUnescapedWildcard(term)) return [pattern];
6
+ const words = term.split(/\s+/).filter((word) => word.length > 0);
7
+ if (words.length < 2 || words.length > MAX_CONTAINMENT_WORDS) return [pattern];
8
+ return words.map((word) => `%${word}%`);
9
+ }
10
+ function isWrappedContainsPattern(pattern) {
11
+ if (pattern.length < 2) return false;
12
+ if (!pattern.startsWith("%") || !pattern.endsWith("%")) return false;
13
+ return countTrailingBackslashes(pattern, pattern.length - 2) % 2 === 0;
14
+ }
15
+ function hasUnescapedWildcard(term) {
16
+ for (let index = 0; index < term.length; index += 1) {
17
+ const char = term[index];
18
+ if (char !== "%" && char !== "_") continue;
19
+ if (countTrailingBackslashes(term, index - 1) % 2 === 0) return true;
20
+ }
21
+ return false;
22
+ }
23
+ function countTrailingBackslashes(value, fromIndex) {
24
+ let count = 0;
25
+ for (let index = fromIndex; index >= 0 && value[index] === "\\"; index -= 1) count += 1;
26
+ return count;
27
+ }
28
+ export {
29
+ MAX_CONTAINMENT_WORDS,
30
+ buildContainmentPatterns
31
+ };
32
+ //# sourceMappingURL=containment.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/search/containment.ts"],
4
+ "sourcesContent": ["/**\n * Splits a `contains` like/ilike pattern into one pattern per whitespace-separated word, so a\n * plaintext column taken off the hashed-token path keeps the word-order-independent matching the\n * token index provided.\n *\n * The token path matches a value when it carries EVERY token of the term, in any order and with\n * anything in between: `?search=Warehouse 1757` matches `Warehouse A 1757`. A single verbatim\n * `name ILIKE '%Warehouse 1757%'` is literal substring containment, so the `A ` sitting between the\n * two words defeats it \u2014 that is a capability every list grid has today, and #5803's fix must not\n * take it away (`TC-RESO-009` pins it).\n *\n * ANDing one containment predicate per word reproduces the token semantics exactly on a column the\n * engine can read, without the token path's two lossy steps: nothing is dropped for being shorter\n * than `minTokenLength` (`?search=08` filters instead of matching every row) and nothing is split on\n * non-alphanumerics (`?search=2026-08` no longer collapses onto `2026-01`).\n *\n * Splitting is deliberately narrow \u2014 a pattern is only split when it is unambiguously the\n * `%term%` shape `buildIlikeTerm(value, 'contains')` produces:\n *\n * - it opens and closes with a wildcard `%` (a trailing `\\%` is an escaped literal, not a wildcard);\n * - the term between them carries no unescaped `%` or `_`, so a hand-built structured pattern such\n * as `%a% b%` is left exactly as the caller wrote it;\n * - the term holds at least two words.\n *\n * Anything else returns the input unchanged as a single pattern, which is the caller's existing\n * behavior.\n *\n * The split is also capped at {@link MAX_CONTAINMENT_WORDS} words. Most list routes declare\n * `search` as an unbounded string, this repository ships no trigram index for the resulting\n * `ILIKE`, and the hybrid engine's `$or` groups multiply the split across every leaf \u2014 so an\n * attacker-controlled term with thousands of words would otherwise compile into thousands of\n * sequential-scan predicates from one request. A term at or under the cap keeps the per-word AND\n * semantics; over the cap it falls back to the single verbatim pattern, which is bounded and was\n * this helper's own behavior before the split existed.\n */\nexport const MAX_CONTAINMENT_WORDS = 10\n\nexport function buildContainmentPatterns(pattern: string): string[] {\n if (!isWrappedContainsPattern(pattern)) return [pattern]\n const term = pattern.slice(1, -1)\n if (hasUnescapedWildcard(term)) return [pattern]\n const words = term.split(/\\s+/).filter((word) => word.length > 0)\n if (words.length < 2 || words.length > MAX_CONTAINMENT_WORDS) return [pattern]\n return words.map((word) => `%${word}%`)\n}\n\nfunction isWrappedContainsPattern(pattern: string): boolean {\n if (pattern.length < 2) return false\n if (!pattern.startsWith('%') || !pattern.endsWith('%')) return false\n return countTrailingBackslashes(pattern, pattern.length - 2) % 2 === 0\n}\n\nfunction hasUnescapedWildcard(term: string): boolean {\n for (let index = 0; index < term.length; index += 1) {\n const char = term[index]\n if (char !== '%' && char !== '_') continue\n if (countTrailingBackslashes(term, index - 1) % 2 === 0) return true\n }\n return false\n}\n\nfunction countTrailingBackslashes(value: string, fromIndex: number): number {\n let count = 0\n for (let index = fromIndex; index >= 0 && value[index] === '\\\\'; index -= 1) count += 1\n return count\n}\n"],
5
+ "mappings": "AAmCO,MAAM,wBAAwB;AAE9B,SAAS,yBAAyB,SAA2B;AAClE,MAAI,CAAC,yBAAyB,OAAO,EAAG,QAAO,CAAC,OAAO;AACvD,QAAM,OAAO,QAAQ,MAAM,GAAG,EAAE;AAChC,MAAI,qBAAqB,IAAI,EAAG,QAAO,CAAC,OAAO;AAC/C,QAAM,QAAQ,KAAK,MAAM,KAAK,EAAE,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AAChE,MAAI,MAAM,SAAS,KAAK,MAAM,SAAS,sBAAuB,QAAO,CAAC,OAAO;AAC7E,SAAO,MAAM,IAAI,CAAC,SAAS,IAAI,IAAI,GAAG;AACxC;AAEA,SAAS,yBAAyB,SAA0B;AAC1D,MAAI,QAAQ,SAAS,EAAG,QAAO;AAC/B,MAAI,CAAC,QAAQ,WAAW,GAAG,KAAK,CAAC,QAAQ,SAAS,GAAG,EAAG,QAAO;AAC/D,SAAO,yBAAyB,SAAS,QAAQ,SAAS,CAAC,IAAI,MAAM;AACvE;AAEA,SAAS,qBAAqB,MAAuB;AACnD,WAAS,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS,GAAG;AACnD,UAAM,OAAO,KAAK,KAAK;AACvB,QAAI,SAAS,OAAO,SAAS,IAAK;AAClC,QAAI,yBAAyB,MAAM,QAAQ,CAAC,IAAI,MAAM,EAAG,QAAO;AAAA,EAClE;AACA,SAAO;AACT;AAEA,SAAS,yBAAyB,OAAe,WAA2B;AAC1E,MAAI,QAAQ;AACZ,WAAS,QAAQ,WAAW,SAAS,KAAK,MAAM,KAAK,MAAM,MAAM,SAAS,EAAG,UAAS;AACtF,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.8.1-develop.7275.1.f772b944d4";
1
+ const APP_VERSION = "0.8.1-develop.7295.1.d0e0e33014";
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.7275.1.f772b944d4';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7295.1.d0e0e33014';\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.7275.1.f772b944d4",
3
+ "version": "0.8.1-develop.7295.1.d0e0e33014",
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.7275.1.f772b944d4",
116
+ "@open-mercato/cache": "0.8.1-develop.7295.1.d0e0e33014",
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
+ }