@open-mercato/shared 0.8.1-develop.7275.1.f772b944d4 → 0.8.1-develop.7294.1.0ef99fc518
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -1
- package/build.mjs +4 -1
- package/dist/lib/crud/errors.js +6 -1
- package/dist/lib/crud/errors.js.map +2 -2
- package/dist/lib/crud/factory.js +22 -13
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/custom-fields/kinds.js +113 -0
- package/dist/lib/custom-fields/kinds.js.map +7 -0
- package/dist/lib/encryption/indexDoc.js +12 -9
- package/dist/lib/encryption/indexDoc.js.map +2 -2
- package/dist/lib/location/countries.generated.js +265 -0
- package/dist/lib/location/countries.generated.js.map +7 -0
- package/dist/lib/location/countries.js +2 -13
- package/dist/lib/location/countries.js.map +2 -2
- package/dist/lib/navigation/pageReload.js +11 -0
- package/dist/lib/navigation/pageReload.js.map +7 -0
- package/dist/lib/schedule/interval.js +33 -0
- package/dist/lib/schedule/interval.js.map +7 -0
- package/dist/lib/schedule/invalidScheduleValue.js +19 -0
- package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +3 -2
- package/scripts/generate-countries.mjs +75 -0
- package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
- package/src/lib/crud/__tests__/errors.test.ts +31 -1
- package/src/lib/crud/errors.ts +16 -0
- package/src/lib/crud/factory.ts +37 -15
- package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
- package/src/lib/custom-fields/kinds.ts +205 -0
- package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
- package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
- package/src/lib/encryption/indexDoc.ts +16 -7
- package/src/lib/location/__tests__/countries.test.ts +25 -0
- package/src/lib/location/countries.generated.ts +264 -0
- package/src/lib/location/countries.ts +2 -23
- package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
- package/src/lib/navigation/pageReload.ts +43 -0
- package/src/lib/schedule/__tests__/interval.test.ts +37 -0
- package/src/lib/schedule/interval.ts +45 -0
- package/src/lib/schedule/invalidScheduleValue.ts +33 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/navigation/pageReload.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Boundary-crossing navigation helpers: they reload the document instead of handing the route to\n * the client router.\n *\n * The portal's `(frontend)` layout \u2014 the server component that decides the portal chrome and\n * resolves the customer session \u2014 sits *above* the `[...slug]` segment so portal navigation does\n * not remount the client subtree. A client-side `router.push`/`router.replace` therefore never\n * re-runs that layout, and any navigation that crosses the public/authenticated boundary would\n * keep rendering chrome computed for the page it came from: authenticated chrome over the login\n * page after a logout, or logged-out chrome over the dashboard after a login.\n *\n * Use these helpers for every boundary-crossing navigation (login, logout, signup completion,\n * invite acceptance, auth guards redirecting to login) so the layout re-runs and recomputes both\n * the chrome and the session. Same-side navigation should keep using the client router.\n *\n * Pick the one that matches the client-router call it stands in for, so session history behaves\n * the same way: `navigateWithPageReload` for `router.push`, `replaceWithPageReload` for\n * `router.replace`.\n */\n\n/** Full page load that adds a session-history entry \u2014 the document-loading counterpart of `router.push`. */\nexport function navigateWithPageReload(path: string): void {\n window.location.assign(path)\n}\n\n/**\n * Full page load that replaces the current session-history entry \u2014 the document-loading\n * counterpart of `router.replace`.\n *\n * Use this where the client-router call being replaced was a `router.replace`: an auth guard\n * sending an unauthenticated visitor to the login page, or the portal landing page forwarding a\n * signed-in customer to the dashboard.\n *\n * Note that for those call sites specifically, `navigateWithPageReload` was measured to behave\n * identically: they redirect from an effect during page load, and a browser treats a navigation\n * started then as a client redirect, replacing the entry rather than pushing it whichever method\n * is used. This helper exists so the call site states which semantics it means instead of relying\n * on that heuristic \u2014 and so a redirect that later moves behind a user gesture, where the\n * distinction does bite, keeps the behaviour it was written with.\n */\nexport function replaceWithPageReload(path: string): void {\n window.location.replace(path)\n}\n"],
|
|
5
|
+
"mappings": "AAqBO,SAAS,uBAAuB,MAAoB;AACzD,SAAO,SAAS,OAAO,IAAI;AAC7B;AAiBO,SAAS,sBAAsB,MAAoB;AACxD,SAAO,SAAS,QAAQ,IAAI;AAC9B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const MIN_SCHEDULE_INTERVAL_MS = 60 * 1e3;
|
|
2
|
+
const SCHEDULE_INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
|
|
3
|
+
const UNIT_MULTIPLIERS = {
|
|
4
|
+
s: 1e3,
|
|
5
|
+
m: 60 * 1e3,
|
|
6
|
+
h: 60 * 60 * 1e3,
|
|
7
|
+
d: 24 * 60 * 60 * 1e3
|
|
8
|
+
};
|
|
9
|
+
function matchScheduleInterval(interval) {
|
|
10
|
+
const match = SCHEDULE_INTERVAL_PATTERN.exec(interval);
|
|
11
|
+
if (!match) return null;
|
|
12
|
+
return { amount: Number.parseInt(match[1], 10), unit: match[2] };
|
|
13
|
+
}
|
|
14
|
+
function parseScheduleInterval(interval) {
|
|
15
|
+
const parts = matchScheduleInterval(interval);
|
|
16
|
+
if (!parts) {
|
|
17
|
+
throw new Error(`Invalid interval format: ${interval}. Expected format: <number><unit> (e.g., 15m, 2h, 1d)`);
|
|
18
|
+
}
|
|
19
|
+
return parts.amount * UNIT_MULTIPLIERS[parts.unit];
|
|
20
|
+
}
|
|
21
|
+
function isValidScheduleInterval(interval) {
|
|
22
|
+
const parts = matchScheduleInterval(interval);
|
|
23
|
+
if (!parts) return false;
|
|
24
|
+
return parts.amount * UNIT_MULTIPLIERS[parts.unit] >= MIN_SCHEDULE_INTERVAL_MS;
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
MIN_SCHEDULE_INTERVAL_MS,
|
|
28
|
+
SCHEDULE_INTERVAL_PATTERN,
|
|
29
|
+
isValidScheduleInterval,
|
|
30
|
+
matchScheduleInterval,
|
|
31
|
+
parseScheduleInterval
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=interval.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/schedule/interval.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Canonical interval-format rules for recurring schedules.\n *\n * Both the scheduler runtime and the API validators that reject a schedule\n * before it is persisted read the format from here, so the documented format\n * (`<number><unit>`, e.g. `15m`, `1h`, `24h`) cannot drift between the layer\n * that accepts a value and the layer that has to run it.\n */\nexport const MIN_SCHEDULE_INTERVAL_MS = 60 * 1000\n\nexport const SCHEDULE_INTERVAL_PATTERN = /^(\\d+)(s|m|h|d)$/\n\nconst UNIT_MULTIPLIERS: Record<string, number> = {\n s: 1000,\n m: 60 * 1000,\n h: 60 * 60 * 1000,\n d: 24 * 60 * 60 * 1000,\n}\n\nexport type ScheduleIntervalUnit = 's' | 'm' | 'h' | 'd'\n\nexport type ScheduleIntervalParts = {\n amount: number\n unit: ScheduleIntervalUnit\n}\n\nexport function matchScheduleInterval(interval: string): ScheduleIntervalParts | null {\n const match = SCHEDULE_INTERVAL_PATTERN.exec(interval)\n if (!match) return null\n return { amount: Number.parseInt(match[1], 10), unit: match[2] as ScheduleIntervalUnit }\n}\n\nexport function parseScheduleInterval(interval: string): number {\n const parts = matchScheduleInterval(interval)\n if (!parts) {\n throw new Error(`Invalid interval format: ${interval}. Expected format: <number><unit> (e.g., 15m, 2h, 1d)`)\n }\n return parts.amount * UNIT_MULTIPLIERS[parts.unit]\n}\n\nexport function isValidScheduleInterval(interval: string): boolean {\n const parts = matchScheduleInterval(interval)\n if (!parts) return false\n return parts.amount * UNIT_MULTIPLIERS[parts.unit] >= MIN_SCHEDULE_INTERVAL_MS\n}\n"],
|
|
5
|
+
"mappings": "AAQO,MAAM,2BAA2B,KAAK;AAEtC,MAAM,4BAA4B;AAEzC,MAAM,mBAA2C;AAAA,EAC/C,GAAG;AAAA,EACH,GAAG,KAAK;AAAA,EACR,GAAG,KAAK,KAAK;AAAA,EACb,GAAG,KAAK,KAAK,KAAK;AACpB;AASO,SAAS,sBAAsB,UAAgD;AACpF,QAAM,QAAQ,0BAA0B,KAAK,QAAQ;AACrD,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,EAAE,QAAQ,OAAO,SAAS,MAAM,CAAC,GAAG,EAAE,GAAG,MAAM,MAAM,CAAC,EAA0B;AACzF;AAEO,SAAS,sBAAsB,UAA0B;AAC9D,QAAM,QAAQ,sBAAsB,QAAQ;AAC5C,MAAI,CAAC,OAAO;AACV,UAAM,IAAI,MAAM,4BAA4B,QAAQ,uDAAuD;AAAA,EAC7G;AACA,SAAO,MAAM,SAAS,iBAAiB,MAAM,IAAI;AACnD;AAEO,SAAS,wBAAwB,UAA2B;AACjE,QAAM,QAAQ,sBAAsB,QAAQ;AAC5C,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,SAAS,iBAAiB,MAAM,IAAI,KAAK;AACxD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const INVALID_SCHEDULE_VALUE_CODE = "INVALID_SCHEDULE_VALUE";
|
|
2
|
+
function createInvalidScheduleValueError(scheduleType, scheduleValue, message) {
|
|
3
|
+
return Object.assign(new Error(message), {
|
|
4
|
+
code: INVALID_SCHEDULE_VALUE_CODE,
|
|
5
|
+
scheduleType,
|
|
6
|
+
scheduleValue
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function isInvalidScheduleValueError(error) {
|
|
10
|
+
if (typeof error !== "object" || error === null) return false;
|
|
11
|
+
const candidate = error;
|
|
12
|
+
return candidate.code === INVALID_SCHEDULE_VALUE_CODE && (candidate.scheduleType === "cron" || candidate.scheduleType === "interval");
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
INVALID_SCHEDULE_VALUE_CODE,
|
|
16
|
+
createInvalidScheduleValueError,
|
|
17
|
+
isInvalidScheduleValueError
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=invalidScheduleValue.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/schedule/invalidScheduleValue.ts"],
|
|
4
|
+
"sourcesContent": ["export const INVALID_SCHEDULE_VALUE_CODE = 'INVALID_SCHEDULE_VALUE'\n\nexport type ScheduleValueKind = 'cron' | 'interval'\n\nexport type InvalidScheduleValueError = Error & {\n code: typeof INVALID_SCHEDULE_VALUE_CODE\n scheduleType: ScheduleValueKind\n scheduleValue: string\n}\n\nexport function createInvalidScheduleValueError(\n scheduleType: ScheduleValueKind,\n scheduleValue: string,\n message: string,\n): InvalidScheduleValueError {\n return Object.assign(new Error(message), {\n code: INVALID_SCHEDULE_VALUE_CODE,\n scheduleType,\n scheduleValue,\n } as const)\n}\n\n/**\n * Structural check rather than `instanceof`: the error crosses a package\n * boundary (and a production bundle), where a duplicated class identity would\n * make `instanceof` silently false.\n */\nexport function isInvalidScheduleValueError(error: unknown): error is InvalidScheduleValueError {\n if (typeof error !== 'object' || error === null) return false\n const candidate = error as { code?: unknown; scheduleType?: unknown }\n return candidate.code === INVALID_SCHEDULE_VALUE_CODE\n && (candidate.scheduleType === 'cron' || candidate.scheduleType === 'interval')\n}\n"],
|
|
5
|
+
"mappings": "AAAO,MAAM,8BAA8B;AAUpC,SAAS,gCACd,cACA,eACA,SAC2B;AAC3B,SAAO,OAAO,OAAO,IAAI,MAAM,OAAO,GAAG;AAAA,IACvC,MAAM;AAAA,IACN;AAAA,IACA;AAAA,EACF,CAAU;AACZ;AAOO,SAAS,4BAA4B,OAAoD;AAC9F,MAAI,OAAO,UAAU,YAAY,UAAU,KAAM,QAAO;AACxD,QAAM,YAAY;AAClB,SAAO,UAAU,SAAS,gCACpB,UAAU,iBAAiB,UAAU,UAAU,iBAAiB;AACxE;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7294.1.0ef99fc518';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7294.1.0ef99fc518",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@mikro-orm/core": "^7.1.14",
|
|
114
114
|
"@mikro-orm/decorators": "^7.1.14",
|
|
115
115
|
"@mikro-orm/postgresql": "^7.1.14",
|
|
116
|
-
"@open-mercato/cache": "0.8.1-develop.
|
|
116
|
+
"@open-mercato/cache": "0.8.1-develop.7294.1.0ef99fc518",
|
|
117
117
|
"@types/html-to-text": "^9.0.4",
|
|
118
118
|
"@types/sanitize-html": "^2.16.1",
|
|
119
119
|
"dotenv": "^17.4.2",
|
|
@@ -129,6 +129,7 @@
|
|
|
129
129
|
"devDependencies": {
|
|
130
130
|
"@types/jest": "^30.0.0",
|
|
131
131
|
"jest": "^30.4.2",
|
|
132
|
+
"language-subtag-registry": "^0.3.20",
|
|
132
133
|
"ts-jest": "^29.4.12",
|
|
133
134
|
"ts-morph": "^28.0.0",
|
|
134
135
|
"typescript": "7.0.2"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Build-time generator: extracts ISO alpha-2 region subtags from
|
|
4
|
+
* language-subtag-registry into a small TypeScript module so the published
|
|
5
|
+
* package never imports the registry JSON at runtime (Node ESM requires
|
|
6
|
+
* `with { type: 'json' }`, which esbuild strips when bundle:false).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node packages/shared/scripts/generate-countries.mjs # write when drifted
|
|
10
|
+
* node packages/shared/scripts/generate-countries.mjs --check # exit 1 on drift
|
|
11
|
+
*/
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import process from 'node:process'
|
|
17
|
+
|
|
18
|
+
const checkMode = process.argv.includes('--check')
|
|
19
|
+
|
|
20
|
+
const packageDir = fileURLToPath(new URL('..', import.meta.url))
|
|
21
|
+
const require = createRequire(join(packageDir, 'package.json'))
|
|
22
|
+
const registry = require('language-subtag-registry/data/json/registry.json')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{ Type: string, Subtag?: string, Description?: string[], Deprecated?: string }} RegistryEntry
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @param {RegistryEntry} entry */
|
|
29
|
+
function isIsoAlpha2(entry) {
|
|
30
|
+
if (entry.Type !== 'region') return false
|
|
31
|
+
if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false
|
|
32
|
+
if (entry.Deprecated) return false
|
|
33
|
+
if (!entry.Description || !entry.Description.length) return false
|
|
34
|
+
if (entry.Description[0] === 'Private use') return false
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const countries = /** @type {RegistryEntry[]} */ (registry)
|
|
39
|
+
.filter(isIsoAlpha2)
|
|
40
|
+
.map((entry) => ({
|
|
41
|
+
code: /** @type {string} */ (entry.Subtag),
|
|
42
|
+
name: /** @type {string[]} */ (entry.Description).join(', '),
|
|
43
|
+
}))
|
|
44
|
+
.sort((a, b) => a.code.localeCompare(b.code))
|
|
45
|
+
|
|
46
|
+
const lines = [
|
|
47
|
+
'// AUTO-GENERATED by scripts/generate-countries.mjs — do not edit by hand.',
|
|
48
|
+
'// Regenerate via `node packages/shared/scripts/generate-countries.mjs` or the shared package build.',
|
|
49
|
+
'',
|
|
50
|
+
'export const REGISTRY_COUNTRIES: Array<{ code: string; name: string }> = [',
|
|
51
|
+
...countries.map((c) => ` { code: ${JSON.stringify(c.code)}, name: ${JSON.stringify(c.name)} },`),
|
|
52
|
+
']',
|
|
53
|
+
'',
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
const outPath = join(packageDir, 'src/lib/location/countries.generated.ts')
|
|
57
|
+
const content = lines.join('\n')
|
|
58
|
+
|
|
59
|
+
if (checkMode) {
|
|
60
|
+
if (!existsSync(outPath)) {
|
|
61
|
+
console.error(`[generate-countries] missing ${outPath}`)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
64
|
+
const existing = readFileSync(outPath, 'utf8')
|
|
65
|
+
if (existing !== content) {
|
|
66
|
+
console.error(`[generate-countries] drift detected in ${outPath}`)
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
console.log(`[generate-countries] check passed (${countries.length} countries)`)
|
|
70
|
+
} else if (existsSync(outPath) && readFileSync(outPath, 'utf8') === content) {
|
|
71
|
+
console.log(`[generate-countries] up to date (${countries.length} countries)`)
|
|
72
|
+
} else {
|
|
73
|
+
writeFileSync(outPath, content, 'utf8')
|
|
74
|
+
console.log(`[generate-countries] wrote ${countries.length} countries → ${outPath}`)
|
|
75
|
+
}
|
|
@@ -2,7 +2,18 @@ jest.mock('@open-mercato/cache', () => ({
|
|
|
2
2
|
runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
|
|
3
3
|
}), { virtual: true })
|
|
4
4
|
|
|
5
|
+
// Default behavior matches production's fallback-translator shape for a key with no
|
|
6
|
+
// dictionary entry (`dict[key] ?? fallback ?? key`) — shared has no domain dictionary to
|
|
7
|
+
// consult, so every existing test observes the same pass-through it always has. Individual
|
|
8
|
+
// tests override `mockTranslate` to prove `handleError` actually routes a CrudHttpError body
|
|
9
|
+
// through the resolved `translate()` instead of forwarding it verbatim (#5727).
|
|
10
|
+
const mockTranslate = jest.fn((key: string, fallback?: string) => fallback ?? key)
|
|
11
|
+
jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
|
|
12
|
+
resolveTranslations: async () => ({ t: mockTranslate, translate: mockTranslate }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
5
15
|
import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
|
|
16
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
17
|
import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
|
|
7
18
|
import {
|
|
8
19
|
clearOptimisticLockReadersForTests,
|
|
@@ -803,6 +814,139 @@ describe('CRUD Factory', () => {
|
|
|
803
814
|
})
|
|
804
815
|
})
|
|
805
816
|
|
|
817
|
+
describe('afterList hook ordering on the export paths', () => {
|
|
818
|
+
// Issue #5969: both export branches used to call serializeExport() before awaiting
|
|
819
|
+
// hooks.afterList, so a hook that patches values the base query cannot compute reached
|
|
820
|
+
// the JSON list response but never the exported file.
|
|
821
|
+
const patchTitles = (res: any) => {
|
|
822
|
+
for (const item of res.items) item.title = `patched:${item.title}`
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
it('GET applies afterList mutations to the query-engine export, matching the JSON list', async () => {
|
|
826
|
+
const hookedRoute = makeCrudRoute({
|
|
827
|
+
metadata: { GET: { requireAuth: true } },
|
|
828
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
829
|
+
indexer: { entityType: 'example.todo' },
|
|
830
|
+
list: {
|
|
831
|
+
schema: querySchema,
|
|
832
|
+
entityId: 'example.todo',
|
|
833
|
+
fields: ['id', 'title', 'is_done'],
|
|
834
|
+
sortFieldMap: { id: 'id' },
|
|
835
|
+
buildFilters: () => ({} as any),
|
|
836
|
+
transformItem: (i: any) => ({ id: i.id, title: i.title }),
|
|
837
|
+
allowCsv: true,
|
|
838
|
+
csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
|
|
839
|
+
},
|
|
840
|
+
hooks: { afterList: patchTitles },
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
const jsonRes = await hookedRoute.GET(new Request('http://x/api/example/todos'))
|
|
844
|
+
expect((await jsonRes.json()).items[0].title).toBe('patched:A')
|
|
845
|
+
|
|
846
|
+
const csvRes = await hookedRoute.GET(new Request('http://x/api/example/todos?format=csv'))
|
|
847
|
+
expect((await csvRes.text()).split('\n')[1]).toBe('id-1,patched:A')
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('GET honors an afterList hook that replaces the export payload items', async () => {
|
|
851
|
+
const replacingRoute = makeCrudRoute({
|
|
852
|
+
metadata: { GET: { requireAuth: true } },
|
|
853
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
854
|
+
indexer: { entityType: 'example.todo' },
|
|
855
|
+
list: {
|
|
856
|
+
schema: querySchema,
|
|
857
|
+
entityId: 'example.todo',
|
|
858
|
+
fields: ['id', 'title', 'is_done'],
|
|
859
|
+
sortFieldMap: { id: 'id' },
|
|
860
|
+
buildFilters: () => ({} as any),
|
|
861
|
+
transformItem: (i: any) => ({ id: i.id, title: i.title }),
|
|
862
|
+
allowCsv: true,
|
|
863
|
+
csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
|
|
864
|
+
},
|
|
865
|
+
hooks: { afterList: (res: any) => { res.items = [{ id: 'replaced', title: 'Z' }] } },
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
const csvRes = await replacingRoute.GET(new Request('http://x/api/example/todos?format=csv'))
|
|
869
|
+
expect((await csvRes.text()).split('\n').slice(1)).toEqual(['replaced,Z'])
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('GET applies afterList mutations to the ORM-fallback export', async () => {
|
|
873
|
+
db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
|
|
874
|
+
const fallbackRoute = makeCrudRoute({
|
|
875
|
+
metadata: { GET: { requireAuth: true } },
|
|
876
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
877
|
+
list: {
|
|
878
|
+
schema: querySchema,
|
|
879
|
+
buildFilters: () => ({} as any),
|
|
880
|
+
allowCsv: true,
|
|
881
|
+
csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
|
|
882
|
+
},
|
|
883
|
+
hooks: { afterList: patchTitles },
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
const jsonRes = await fallbackRoute.GET(new Request('http://x/api/example/todos'))
|
|
887
|
+
expect((await jsonRes.json()).items[0].title).toBe('patched:A')
|
|
888
|
+
|
|
889
|
+
db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
|
|
890
|
+
const csvRes = await fallbackRoute.GET(new Request('http://x/api/example/todos?format=csv'))
|
|
891
|
+
expect((await csvRes.text()).split('\n')[1]).toBe('id-1,patched:A')
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
// #6019 review: on exportScope=full, items are normalized via normalizeFullRecordForExport
|
|
895
|
+
// before the hook runs but the hook's own additions used to skip that normalization,
|
|
896
|
+
// leaking `_`-prefixed metadata and un-flattened `cf_*` keys into the exported file.
|
|
897
|
+
const addAssociationsMetadata = (res: any) => {
|
|
898
|
+
for (const item of res.items) {
|
|
899
|
+
item.title = `patched:${item.title}`
|
|
900
|
+
item._associations = { ok: false, reason: 'lookup failed' }
|
|
901
|
+
item.cf_color = 're-added'
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
it('GET re-normalizes afterList output on the full-export query-engine path (#6019)', async () => {
|
|
906
|
+
const fullExportRoute = makeCrudRoute({
|
|
907
|
+
metadata: { GET: { requireAuth: true } },
|
|
908
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
909
|
+
indexer: { entityType: 'example.todo' },
|
|
910
|
+
list: {
|
|
911
|
+
schema: querySchema,
|
|
912
|
+
entityId: 'example.todo',
|
|
913
|
+
fields: ['id', 'title', 'is_done'],
|
|
914
|
+
sortFieldMap: { id: 'id' },
|
|
915
|
+
buildFilters: () => ({} as any),
|
|
916
|
+
transformItem: (i: any) => ({ id: i.id, title: i.title }),
|
|
917
|
+
},
|
|
918
|
+
hooks: { afterList: addAssociationsMetadata },
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
const res = await fullExportRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
|
|
922
|
+
const parsed = JSON.parse(await res.text())
|
|
923
|
+
expect(parsed[0].Title).toBe('patched:A')
|
|
924
|
+
expect(parsed[0].Color).toBe('re-added')
|
|
925
|
+
expect(Object.keys(parsed[0])).not.toContain('_associations')
|
|
926
|
+
expect(JSON.stringify(parsed)).not.toContain('lookup failed')
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
it('GET re-normalizes afterList output on the full-export ORM-fallback path (#6019)', async () => {
|
|
930
|
+
db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
|
|
931
|
+
const fullFallbackRoute = makeCrudRoute({
|
|
932
|
+
metadata: { GET: { requireAuth: true } },
|
|
933
|
+
orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
|
|
934
|
+
list: {
|
|
935
|
+
schema: querySchema,
|
|
936
|
+
buildFilters: () => ({} as any),
|
|
937
|
+
},
|
|
938
|
+
hooks: { afterList: addAssociationsMetadata },
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
const res = await fullFallbackRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
|
|
942
|
+
const parsed = JSON.parse(await res.text())
|
|
943
|
+
expect(parsed[0].Title).toBe('patched:A')
|
|
944
|
+
expect(parsed[0].Color).toBe('re-added')
|
|
945
|
+
expect(Object.keys(parsed[0])).not.toContain('_associations')
|
|
946
|
+
expect(JSON.stringify(parsed)).not.toContain('lookup failed')
|
|
947
|
+
})
|
|
948
|
+
})
|
|
949
|
+
|
|
806
950
|
describe('export loop termination', () => {
|
|
807
951
|
const EXPORT_PAGE_SIZE = 1000
|
|
808
952
|
|
|
@@ -1477,6 +1621,39 @@ describe('CRUD Factory', () => {
|
|
|
1477
1621
|
})
|
|
1478
1622
|
})
|
|
1479
1623
|
|
|
1624
|
+
// Issue #5727 — a command that raises CrudHttpError with a raw i18n key (rather than an
|
|
1625
|
+
// already-translated message) must not leak that key verbatim; handleError() routes it
|
|
1626
|
+
// through the resolved translate() before responding.
|
|
1627
|
+
it('POST command route translates a raw i18n key on a CrudHttpError body instead of forwarding it verbatim', async () => {
|
|
1628
|
+
mockTranslate.mockImplementationOnce((key: string, fallback?: string) =>
|
|
1629
|
+
key === 'some_module.errors.lineLocked' ? 'This line is locked.' : (fallback ?? key),
|
|
1630
|
+
)
|
|
1631
|
+
commandBus.execute.mockRejectedValue(new CrudHttpError(400, { error: 'some_module.errors.lineLocked' }))
|
|
1632
|
+
|
|
1633
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1634
|
+
|
|
1635
|
+
expect(res.status).toBe(400)
|
|
1636
|
+
await expect(res.json()).resolves.toEqual({ error: 'This line is locked.' })
|
|
1637
|
+
expect(mockTranslate).toHaveBeenCalledWith('some_module.errors.lineLocked', 'some_module.errors.lineLocked')
|
|
1638
|
+
})
|
|
1639
|
+
|
|
1640
|
+
it('POST command route preserves other CrudHttpError body fields alongside the translated error', async () => {
|
|
1641
|
+
mockTranslate.mockImplementationOnce((key: string, fallback?: string) =>
|
|
1642
|
+
key === 'some_module.errors.conflict' ? 'A conflicting record already exists.' : (fallback ?? key),
|
|
1643
|
+
)
|
|
1644
|
+
commandBus.execute.mockRejectedValue(
|
|
1645
|
+
new CrudHttpError(409, { error: 'some_module.errors.conflict', conflictingId: 'todo-9' }),
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
const res = await postInterceptorErrorRequest(interceptorErrorRoute())
|
|
1649
|
+
|
|
1650
|
+
expect(res.status).toBe(409)
|
|
1651
|
+
await expect(res.json()).resolves.toEqual({
|
|
1652
|
+
error: 'A conflicting record already exists.',
|
|
1653
|
+
conflictingId: 'todo-9',
|
|
1654
|
+
})
|
|
1655
|
+
})
|
|
1656
|
+
|
|
1480
1657
|
// Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
|
|
1481
1658
|
// that same id must appear on the server log line so the two can be correlated.
|
|
1482
1659
|
describe('generic 500 requestId correlation', () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CrudHttpError, isCrudHttpError, assertFound, notFound } from '../errors'
|
|
1
|
+
import { CrudHttpError, isCrudHttpError, assertFound, notFound, translateCrudErrorBody } from '../errors'
|
|
2
2
|
|
|
3
3
|
describe('CrudHttpError', () => {
|
|
4
4
|
it('builds from string body', () => {
|
|
@@ -80,3 +80,33 @@ describe('assertFound', () => {
|
|
|
80
80
|
}
|
|
81
81
|
})
|
|
82
82
|
})
|
|
83
|
+
|
|
84
|
+
describe('translateCrudErrorBody', () => {
|
|
85
|
+
it('translates a raw i18n key found in the dictionary', () => {
|
|
86
|
+
const dict: Record<string, string> = {
|
|
87
|
+
'warranty_claims.errors.lineLocked': 'This line is locked in the current claim status.',
|
|
88
|
+
}
|
|
89
|
+
const translate = (key: string, fallback?: string) => dict[key] ?? fallback ?? key
|
|
90
|
+
const body = translateCrudErrorBody({ error: 'warranty_claims.errors.lineLocked' }, translate)
|
|
91
|
+
expect(body).toEqual({ error: 'This line is locked in the current claim status.' })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('falls back to the raw key when the dictionary has no entry, instead of throwing', () => {
|
|
95
|
+
const translate = (key: string, fallback?: string) => fallback ?? key
|
|
96
|
+
const body = translateCrudErrorBody({ error: 'warranty_claims.errors.unknownKey' }, translate)
|
|
97
|
+
expect(body).toEqual({ error: 'warranty_claims.errors.unknownKey' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('preserves other body fields untouched', () => {
|
|
101
|
+
const translate = (key: string) => `translated:${key}`
|
|
102
|
+
const body = translateCrudErrorBody({ error: 'some.key', field: 'x', code: 42 }, translate)
|
|
103
|
+
expect(body).toEqual({ error: 'translated:some.key', field: 'x', code: 42 })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns the body unchanged when error is not a string', () => {
|
|
107
|
+
const translate = jest.fn((key: string) => key)
|
|
108
|
+
const body = translateCrudErrorBody({ field: 'x' }, translate)
|
|
109
|
+
expect(body).toEqual({ field: 'x' })
|
|
110
|
+
expect(translate).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
})
|
package/src/lib/crud/errors.ts
CHANGED
|
@@ -96,3 +96,19 @@ export function assertFound<T>(value: T | null | undefined, message: string): T
|
|
|
96
96
|
if (!value) throw notFound(message)
|
|
97
97
|
return value
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Translates a `CrudHttpError` body's `error` field before it reaches the client.
|
|
102
|
+
* Some callers (command handlers, lib helpers reused by subscribers/CLI/workers) raise
|
|
103
|
+
* `CrudHttpError` with a raw i18n key because they run without a request locale — a route
|
|
104
|
+
* handler forwarding `err.body` verbatim would leak that key to the user. Call this at the
|
|
105
|
+
* route boundary, passing the `translate` the route already resolved, instead of forwarding
|
|
106
|
+
* `err.body` directly.
|
|
107
|
+
*/
|
|
108
|
+
export function translateCrudErrorBody<T extends Record<string, unknown>>(
|
|
109
|
+
body: T,
|
|
110
|
+
translate: (key: string, fallback?: string) => string,
|
|
111
|
+
): T {
|
|
112
|
+
if (typeof body?.error !== 'string') return body
|
|
113
|
+
return { ...body, error: translate(body.error, body.error) }
|
|
114
|
+
}
|
package/src/lib/crud/factory.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@ope
|
|
|
10
10
|
import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
11
11
|
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
12
12
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
13
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
13
14
|
import {
|
|
14
15
|
runMutationGuards,
|
|
15
16
|
bridgeLegacyGuard,
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
type ResolvedCustomFieldDefinitions,
|
|
42
43
|
} from './custom-field-definition-index'
|
|
43
44
|
import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
|
|
44
|
-
import { CrudHttpError, isCrudHttpError } from './errors'
|
|
45
|
+
import { CrudHttpError, isCrudHttpError, translateCrudErrorBody } from './errors'
|
|
45
46
|
import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
|
|
46
47
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
47
48
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
@@ -634,9 +635,12 @@ function classifyCrudError(err: unknown): { code: CrudErrorCode; pgSqlState: str
|
|
|
634
635
|
return { code: 'INTERNAL_ERROR', pgSqlState: null }
|
|
635
636
|
}
|
|
636
637
|
|
|
637
|
-
function handleError(err: unknown, request?: Request): Response {
|
|
638
|
+
async function handleError(err: unknown, request?: Request): Promise<Response> {
|
|
638
639
|
if (err instanceof Response) return err
|
|
639
|
-
if (isCrudHttpError(err))
|
|
640
|
+
if (isCrudHttpError(err)) {
|
|
641
|
+
const { translate } = await resolveTranslations()
|
|
642
|
+
return json(translateCrudErrorBody(err.body, translate), { status: err.status })
|
|
643
|
+
}
|
|
640
644
|
// A command interceptor that blocked with an explicit status is a deliberate business
|
|
641
645
|
// rejection, not a server fault — surface its status and message instead of a generic 500.
|
|
642
646
|
// Without a usable status the error falls through to the historical handling below (issue #5045).
|
|
@@ -2027,15 +2031,25 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2027
2031
|
nextPage += 1
|
|
2028
2032
|
}
|
|
2029
2033
|
}
|
|
2034
|
+
const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...(res.meta ? { meta: res.meta } : {}) }
|
|
2035
|
+
// Serialize only after `afterList` has run: the hook is the documented place to
|
|
2036
|
+
// patch values the base query cannot compute, and an export built before it runs
|
|
2037
|
+
// ships a different shape than the JSON list response for the same request (#5969).
|
|
2038
|
+
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
2039
|
+
profiler.mark('after_list_hook')
|
|
2040
|
+
const hookExportItems = Array.isArray(exportPayload.items) ? exportPayload.items : exportItems
|
|
2041
|
+
// Re-normalize after the hook: `afterList` can add keys the full-export contract
|
|
2042
|
+
// strips (`_`-prefixed metadata, `cf_*`) or fail to flatten (#6019 review).
|
|
2043
|
+
// Idempotent on records already shaped by normalizeFullRecordForExport above.
|
|
2044
|
+
const finalExportItems = exportFullRequested
|
|
2045
|
+
? hookExportItems.map(normalizeFullRecordForExport)
|
|
2046
|
+
: hookExportItems
|
|
2030
2047
|
const prepared = exportFullRequested
|
|
2031
|
-
? { columns: ensureColumns(
|
|
2032
|
-
: prepareExportData(
|
|
2048
|
+
? { columns: ensureColumns(finalExportItems), rows: finalExportItems }
|
|
2049
|
+
: prepareExportData(finalExportItems, opts.list, validated as any, ctx)
|
|
2033
2050
|
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
2034
2051
|
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
2035
2052
|
const serialized = serializeExport(prepared, requestedExport)
|
|
2036
|
-
const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...(res.meta ? { meta: res.meta } : {}) }
|
|
2037
|
-
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
2038
|
-
profiler.mark('after_list_hook')
|
|
2039
2053
|
const response = new Response(serialized.body, {
|
|
2040
2054
|
headers: {
|
|
2041
2055
|
'content-type': serialized.contentType,
|
|
@@ -2058,7 +2072,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2058
2072
|
finishProfile({
|
|
2059
2073
|
result: 'export',
|
|
2060
2074
|
cacheStatus,
|
|
2061
|
-
itemCount:
|
|
2075
|
+
itemCount: finalExportItems.length,
|
|
2062
2076
|
total,
|
|
2063
2077
|
})
|
|
2064
2078
|
return response
|
|
@@ -2230,14 +2244,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2230
2244
|
profiler.mark('access_logged', accessLogResult)
|
|
2231
2245
|
if (exportRequested && requestedExport) {
|
|
2232
2246
|
const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
|
|
2247
|
+
const exportPayload = { items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }
|
|
2248
|
+
// Same ordering contract as the query-engine export path above (#5969).
|
|
2249
|
+
await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
|
|
2250
|
+
profiler.mark('after_list_hook')
|
|
2251
|
+
const hookExportItems = Array.isArray(exportPayload.items) ? exportPayload.items : exportItems
|
|
2252
|
+
// Re-normalize after the hook: same full-export contract as the query-engine path
|
|
2253
|
+
// above (#6019 review).
|
|
2254
|
+
const finalExportItems = exportFullRequested
|
|
2255
|
+
? hookExportItems.map(normalizeFullRecordForExport)
|
|
2256
|
+
: hookExportItems
|
|
2233
2257
|
const prepared = exportFullRequested
|
|
2234
|
-
? { columns: ensureColumns(
|
|
2235
|
-
: prepareExportData(
|
|
2258
|
+
? { columns: ensureColumns(finalExportItems), rows: finalExportItems }
|
|
2259
|
+
: prepareExportData(finalExportItems, opts.list, validated as any, ctx)
|
|
2236
2260
|
const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
|
|
2237
2261
|
const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
|
|
2238
2262
|
const serialized = serializeExport(prepared, requestedExport)
|
|
2239
|
-
await opts.hooks?.afterList?.({ items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }, { ...ctx, query: validated as any })
|
|
2240
|
-
profiler.mark('after_list_hook')
|
|
2241
2263
|
const response = new Response(serialized.body, {
|
|
2242
2264
|
headers: {
|
|
2243
2265
|
'content-type': serialized.contentType,
|
|
@@ -2247,8 +2269,8 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
|
|
|
2247
2269
|
finishProfile({
|
|
2248
2270
|
result: 'export',
|
|
2249
2271
|
cacheStatus,
|
|
2250
|
-
itemCount:
|
|
2251
|
-
total:
|
|
2272
|
+
itemCount: finalExportItems.length,
|
|
2273
|
+
total: finalExportItems.length,
|
|
2252
2274
|
branch: 'fallback',
|
|
2253
2275
|
})
|
|
2254
2276
|
return response
|