@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.
- package/.turbo/turbo-build.log +2 -1
- package/AGENTS.md +6 -3
- 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/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/query/encrypted-sort.js +4 -1
- package/dist/lib/query/encrypted-sort.js.map +2 -2
- package/dist/lib/query/engine.js +97 -10
- package/dist/lib/query/engine.js.map +3 -3
- 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/search/config.js.map +2 -2
- package/dist/lib/search/containment.js +32 -0
- package/dist/lib/search/containment.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/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/query/__tests__/engine.test.ts +208 -16
- package/src/lib/query/encrypted-sort.ts +8 -1
- package/src/lib/query/engine.ts +172 -19
- 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
- package/src/lib/search/__tests__/config.test.ts +27 -0
- package/src/lib/search/__tests__/containment.test.ts +61 -0
- package/src/lib/search/config.ts +28 -6
- 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
|
|
5
|
-
"mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,8BAA8B;AACvC,SAAS,+BAA+B;
|
|
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
|
+
}
|
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.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.
|
|
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.
|
|
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
|
+
})
|
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
|
+
}
|