@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +2 -1
  2. package/build.mjs +4 -1
  3. package/dist/lib/crud/errors.js +6 -1
  4. package/dist/lib/crud/errors.js.map +2 -2
  5. package/dist/lib/crud/factory.js +22 -13
  6. package/dist/lib/crud/factory.js.map +2 -2
  7. package/dist/lib/custom-fields/kinds.js +113 -0
  8. package/dist/lib/custom-fields/kinds.js.map +7 -0
  9. package/dist/lib/encryption/indexDoc.js +12 -9
  10. package/dist/lib/encryption/indexDoc.js.map +2 -2
  11. package/dist/lib/location/countries.generated.js +265 -0
  12. package/dist/lib/location/countries.generated.js.map +7 -0
  13. package/dist/lib/location/countries.js +2 -13
  14. package/dist/lib/location/countries.js.map +2 -2
  15. package/dist/lib/navigation/pageReload.js +11 -0
  16. package/dist/lib/navigation/pageReload.js.map +7 -0
  17. package/dist/lib/schedule/interval.js +33 -0
  18. package/dist/lib/schedule/interval.js.map +7 -0
  19. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  20. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  21. package/dist/lib/version.js +1 -1
  22. package/dist/lib/version.js.map +1 -1
  23. package/package.json +3 -2
  24. package/scripts/generate-countries.mjs +75 -0
  25. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  26. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  27. package/src/lib/crud/errors.ts +16 -0
  28. package/src/lib/crud/factory.ts +37 -15
  29. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  30. package/src/lib/custom-fields/kinds.ts +205 -0
  31. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  32. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  33. package/src/lib/encryption/indexDoc.ts +16 -7
  34. package/src/lib/location/__tests__/countries.test.ts +25 -0
  35. package/src/lib/location/countries.generated.ts +264 -0
  36. package/src/lib/location/countries.ts +2 -23
  37. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  38. package/src/lib/navigation/pageReload.ts +43 -0
  39. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  40. package/src/lib/schedule/interval.ts +45 -0
  41. 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
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.8.1-develop.7275.1.f772b944d4";
1
+ const APP_VERSION = "0.8.1-develop.7294.1.0ef99fc518";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7275.1.f772b944d4';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.8.1-develop.7294.1.0ef99fc518';\nexport const appVersion = APP_VERSION;\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.8.1-develop.7275.1.f772b944d4",
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.7275.1.f772b944d4",
116
+ "@open-mercato/cache": "0.8.1-develop.7294.1.0ef99fc518",
117
117
  "@types/html-to-text": "^9.0.4",
118
118
  "@types/sanitize-html": "^2.16.1",
119
119
  "dotenv": "^17.4.2",
@@ -129,6 +129,7 @@
129
129
  "devDependencies": {
130
130
  "@types/jest": "^30.0.0",
131
131
  "jest": "^30.4.2",
132
+ "language-subtag-registry": "^0.3.20",
132
133
  "ts-jest": "^29.4.12",
133
134
  "ts-morph": "^28.0.0",
134
135
  "typescript": "7.0.2"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build-time generator: extracts ISO alpha-2 region subtags from
4
+ * language-subtag-registry into a small TypeScript module so the published
5
+ * package never imports the registry JSON at runtime (Node ESM requires
6
+ * `with { type: 'json' }`, which esbuild strips when bundle:false).
7
+ *
8
+ * Usage:
9
+ * node packages/shared/scripts/generate-countries.mjs # write when drifted
10
+ * node packages/shared/scripts/generate-countries.mjs --check # exit 1 on drift
11
+ */
12
+ import { createRequire } from 'node:module'
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import process from 'node:process'
17
+
18
+ const checkMode = process.argv.includes('--check')
19
+
20
+ const packageDir = fileURLToPath(new URL('..', import.meta.url))
21
+ const require = createRequire(join(packageDir, 'package.json'))
22
+ const registry = require('language-subtag-registry/data/json/registry.json')
23
+
24
+ /**
25
+ * @typedef {{ Type: string, Subtag?: string, Description?: string[], Deprecated?: string }} RegistryEntry
26
+ */
27
+
28
+ /** @param {RegistryEntry} entry */
29
+ function isIsoAlpha2(entry) {
30
+ if (entry.Type !== 'region') return false
31
+ if (!entry.Subtag || !/^[A-Z]{2}$/.test(entry.Subtag)) return false
32
+ if (entry.Deprecated) return false
33
+ if (!entry.Description || !entry.Description.length) return false
34
+ if (entry.Description[0] === 'Private use') return false
35
+ return true
36
+ }
37
+
38
+ const countries = /** @type {RegistryEntry[]} */ (registry)
39
+ .filter(isIsoAlpha2)
40
+ .map((entry) => ({
41
+ code: /** @type {string} */ (entry.Subtag),
42
+ name: /** @type {string[]} */ (entry.Description).join(', '),
43
+ }))
44
+ .sort((a, b) => a.code.localeCompare(b.code))
45
+
46
+ const lines = [
47
+ '// AUTO-GENERATED by scripts/generate-countries.mjs — do not edit by hand.',
48
+ '// Regenerate via `node packages/shared/scripts/generate-countries.mjs` or the shared package build.',
49
+ '',
50
+ 'export const REGISTRY_COUNTRIES: Array<{ code: string; name: string }> = [',
51
+ ...countries.map((c) => ` { code: ${JSON.stringify(c.code)}, name: ${JSON.stringify(c.name)} },`),
52
+ ']',
53
+ '',
54
+ ]
55
+
56
+ const outPath = join(packageDir, 'src/lib/location/countries.generated.ts')
57
+ const content = lines.join('\n')
58
+
59
+ if (checkMode) {
60
+ if (!existsSync(outPath)) {
61
+ console.error(`[generate-countries] missing ${outPath}`)
62
+ process.exit(1)
63
+ }
64
+ const existing = readFileSync(outPath, 'utf8')
65
+ if (existing !== content) {
66
+ console.error(`[generate-countries] drift detected in ${outPath}`)
67
+ process.exit(1)
68
+ }
69
+ console.log(`[generate-countries] check passed (${countries.length} countries)`)
70
+ } else if (existsSync(outPath) && readFileSync(outPath, 'utf8') === content) {
71
+ console.log(`[generate-countries] up to date (${countries.length} countries)`)
72
+ } else {
73
+ writeFileSync(outPath, content, 'utf8')
74
+ console.log(`[generate-countries] wrote ${countries.length} countries → ${outPath}`)
75
+ }
@@ -2,7 +2,18 @@ jest.mock('@open-mercato/cache', () => ({
2
2
  runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
3
3
  }), { virtual: true })
4
4
 
5
+ // Default behavior matches production's fallback-translator shape for a key with no
6
+ // dictionary entry (`dict[key] ?? fallback ?? key`) — shared has no domain dictionary to
7
+ // consult, so every existing test observes the same pass-through it always has. Individual
8
+ // tests override `mockTranslate` to prove `handleError` actually routes a CrudHttpError body
9
+ // through the resolved `translate()` instead of forwarding it verbatim (#5727).
10
+ const mockTranslate = jest.fn((key: string, fallback?: string) => fallback ?? key)
11
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
12
+ resolveTranslations: async () => ({ t: mockTranslate, translate: mockTranslate }),
13
+ }))
14
+
5
15
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
16
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
17
  import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
7
18
  import {
8
19
  clearOptimisticLockReadersForTests,
@@ -803,6 +814,139 @@ describe('CRUD Factory', () => {
803
814
  })
804
815
  })
805
816
 
817
+ describe('afterList hook ordering on the export paths', () => {
818
+ // Issue #5969: both export branches used to call serializeExport() before awaiting
819
+ // hooks.afterList, so a hook that patches values the base query cannot compute reached
820
+ // the JSON list response but never the exported file.
821
+ const patchTitles = (res: any) => {
822
+ for (const item of res.items) item.title = `patched:${item.title}`
823
+ }
824
+
825
+ it('GET applies afterList mutations to the query-engine export, matching the JSON list', async () => {
826
+ const hookedRoute = makeCrudRoute({
827
+ metadata: { GET: { requireAuth: true } },
828
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
829
+ indexer: { entityType: 'example.todo' },
830
+ list: {
831
+ schema: querySchema,
832
+ entityId: 'example.todo',
833
+ fields: ['id', 'title', 'is_done'],
834
+ sortFieldMap: { id: 'id' },
835
+ buildFilters: () => ({} as any),
836
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
837
+ allowCsv: true,
838
+ csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
839
+ },
840
+ hooks: { afterList: patchTitles },
841
+ })
842
+
843
+ const jsonRes = await hookedRoute.GET(new Request('http://x/api/example/todos'))
844
+ expect((await jsonRes.json()).items[0].title).toBe('patched:A')
845
+
846
+ const csvRes = await hookedRoute.GET(new Request('http://x/api/example/todos?format=csv'))
847
+ expect((await csvRes.text()).split('\n')[1]).toBe('id-1,patched:A')
848
+ })
849
+
850
+ it('GET honors an afterList hook that replaces the export payload items', async () => {
851
+ const replacingRoute = makeCrudRoute({
852
+ metadata: { GET: { requireAuth: true } },
853
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
854
+ indexer: { entityType: 'example.todo' },
855
+ list: {
856
+ schema: querySchema,
857
+ entityId: 'example.todo',
858
+ fields: ['id', 'title', 'is_done'],
859
+ sortFieldMap: { id: 'id' },
860
+ buildFilters: () => ({} as any),
861
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
862
+ allowCsv: true,
863
+ csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
864
+ },
865
+ hooks: { afterList: (res: any) => { res.items = [{ id: 'replaced', title: 'Z' }] } },
866
+ })
867
+
868
+ const csvRes = await replacingRoute.GET(new Request('http://x/api/example/todos?format=csv'))
869
+ expect((await csvRes.text()).split('\n').slice(1)).toEqual(['replaced,Z'])
870
+ })
871
+
872
+ it('GET applies afterList mutations to the ORM-fallback export', async () => {
873
+ db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
874
+ const fallbackRoute = makeCrudRoute({
875
+ metadata: { GET: { requireAuth: true } },
876
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
877
+ list: {
878
+ schema: querySchema,
879
+ buildFilters: () => ({} as any),
880
+ allowCsv: true,
881
+ csv: { headers: ['id', 'title'], row: (t: any) => [t.id, t.title], filename: 'todos.csv' },
882
+ },
883
+ hooks: { afterList: patchTitles },
884
+ })
885
+
886
+ const jsonRes = await fallbackRoute.GET(new Request('http://x/api/example/todos'))
887
+ expect((await jsonRes.json()).items[0].title).toBe('patched:A')
888
+
889
+ db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
890
+ const csvRes = await fallbackRoute.GET(new Request('http://x/api/example/todos?format=csv'))
891
+ expect((await csvRes.text()).split('\n')[1]).toBe('id-1,patched:A')
892
+ })
893
+
894
+ // #6019 review: on exportScope=full, items are normalized via normalizeFullRecordForExport
895
+ // before the hook runs but the hook's own additions used to skip that normalization,
896
+ // leaking `_`-prefixed metadata and un-flattened `cf_*` keys into the exported file.
897
+ const addAssociationsMetadata = (res: any) => {
898
+ for (const item of res.items) {
899
+ item.title = `patched:${item.title}`
900
+ item._associations = { ok: false, reason: 'lookup failed' }
901
+ item.cf_color = 're-added'
902
+ }
903
+ }
904
+
905
+ it('GET re-normalizes afterList output on the full-export query-engine path (#6019)', async () => {
906
+ const fullExportRoute = makeCrudRoute({
907
+ metadata: { GET: { requireAuth: true } },
908
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
909
+ indexer: { entityType: 'example.todo' },
910
+ list: {
911
+ schema: querySchema,
912
+ entityId: 'example.todo',
913
+ fields: ['id', 'title', 'is_done'],
914
+ sortFieldMap: { id: 'id' },
915
+ buildFilters: () => ({} as any),
916
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
917
+ },
918
+ hooks: { afterList: addAssociationsMetadata },
919
+ })
920
+
921
+ const res = await fullExportRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
922
+ const parsed = JSON.parse(await res.text())
923
+ expect(parsed[0].Title).toBe('patched:A')
924
+ expect(parsed[0].Color).toBe('re-added')
925
+ expect(Object.keys(parsed[0])).not.toContain('_associations')
926
+ expect(JSON.stringify(parsed)).not.toContain('lookup failed')
927
+ })
928
+
929
+ it('GET re-normalizes afterList output on the full-export ORM-fallback path (#6019)', async () => {
930
+ db['id-1'] = { id: 'id-1', title: 'A', organizationId: defaultOrganizationId, tenantId: defaultTenantId }
931
+ const fullFallbackRoute = makeCrudRoute({
932
+ metadata: { GET: { requireAuth: true } },
933
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
934
+ list: {
935
+ schema: querySchema,
936
+ buildFilters: () => ({} as any),
937
+ },
938
+ hooks: { afterList: addAssociationsMetadata },
939
+ })
940
+
941
+ const res = await fullFallbackRoute.GET(new Request('http://x/api/example/todos?format=json&exportScope=full'))
942
+ const parsed = JSON.parse(await res.text())
943
+ expect(parsed[0].Title).toBe('patched:A')
944
+ expect(parsed[0].Color).toBe('re-added')
945
+ expect(Object.keys(parsed[0])).not.toContain('_associations')
946
+ expect(JSON.stringify(parsed)).not.toContain('lookup failed')
947
+ })
948
+ })
949
+
806
950
  describe('export loop termination', () => {
807
951
  const EXPORT_PAGE_SIZE = 1000
808
952
 
@@ -1477,6 +1621,39 @@ describe('CRUD Factory', () => {
1477
1621
  })
1478
1622
  })
1479
1623
 
1624
+ // Issue #5727 — a command that raises CrudHttpError with a raw i18n key (rather than an
1625
+ // already-translated message) must not leak that key verbatim; handleError() routes it
1626
+ // through the resolved translate() before responding.
1627
+ it('POST command route translates a raw i18n key on a CrudHttpError body instead of forwarding it verbatim', async () => {
1628
+ mockTranslate.mockImplementationOnce((key: string, fallback?: string) =>
1629
+ key === 'some_module.errors.lineLocked' ? 'This line is locked.' : (fallback ?? key),
1630
+ )
1631
+ commandBus.execute.mockRejectedValue(new CrudHttpError(400, { error: 'some_module.errors.lineLocked' }))
1632
+
1633
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1634
+
1635
+ expect(res.status).toBe(400)
1636
+ await expect(res.json()).resolves.toEqual({ error: 'This line is locked.' })
1637
+ expect(mockTranslate).toHaveBeenCalledWith('some_module.errors.lineLocked', 'some_module.errors.lineLocked')
1638
+ })
1639
+
1640
+ it('POST command route preserves other CrudHttpError body fields alongside the translated error', async () => {
1641
+ mockTranslate.mockImplementationOnce((key: string, fallback?: string) =>
1642
+ key === 'some_module.errors.conflict' ? 'A conflicting record already exists.' : (fallback ?? key),
1643
+ )
1644
+ commandBus.execute.mockRejectedValue(
1645
+ new CrudHttpError(409, { error: 'some_module.errors.conflict', conflictingId: 'todo-9' }),
1646
+ )
1647
+
1648
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1649
+
1650
+ expect(res.status).toBe(409)
1651
+ await expect(res.json()).resolves.toEqual({
1652
+ error: 'A conflicting record already exists.',
1653
+ conflictingId: 'todo-9',
1654
+ })
1655
+ })
1656
+
1480
1657
  // Issue #5608 — a generic 500 must carry a requestId the client/support can cite, and
1481
1658
  // that same id must appear on the server log line so the two can be correlated.
1482
1659
  describe('generic 500 requestId correlation', () => {
@@ -1,4 +1,4 @@
1
- import { CrudHttpError, isCrudHttpError, assertFound, notFound } from '../errors'
1
+ import { CrudHttpError, isCrudHttpError, assertFound, notFound, translateCrudErrorBody } from '../errors'
2
2
 
3
3
  describe('CrudHttpError', () => {
4
4
  it('builds from string body', () => {
@@ -80,3 +80,33 @@ describe('assertFound', () => {
80
80
  }
81
81
  })
82
82
  })
83
+
84
+ describe('translateCrudErrorBody', () => {
85
+ it('translates a raw i18n key found in the dictionary', () => {
86
+ const dict: Record<string, string> = {
87
+ 'warranty_claims.errors.lineLocked': 'This line is locked in the current claim status.',
88
+ }
89
+ const translate = (key: string, fallback?: string) => dict[key] ?? fallback ?? key
90
+ const body = translateCrudErrorBody({ error: 'warranty_claims.errors.lineLocked' }, translate)
91
+ expect(body).toEqual({ error: 'This line is locked in the current claim status.' })
92
+ })
93
+
94
+ it('falls back to the raw key when the dictionary has no entry, instead of throwing', () => {
95
+ const translate = (key: string, fallback?: string) => fallback ?? key
96
+ const body = translateCrudErrorBody({ error: 'warranty_claims.errors.unknownKey' }, translate)
97
+ expect(body).toEqual({ error: 'warranty_claims.errors.unknownKey' })
98
+ })
99
+
100
+ it('preserves other body fields untouched', () => {
101
+ const translate = (key: string) => `translated:${key}`
102
+ const body = translateCrudErrorBody({ error: 'some.key', field: 'x', code: 42 }, translate)
103
+ expect(body).toEqual({ error: 'translated:some.key', field: 'x', code: 42 })
104
+ })
105
+
106
+ it('returns the body unchanged when error is not a string', () => {
107
+ const translate = jest.fn((key: string) => key)
108
+ const body = translateCrudErrorBody({ field: 'x' }, translate)
109
+ expect(body).toEqual({ field: 'x' })
110
+ expect(translate).not.toHaveBeenCalled()
111
+ })
112
+ })
@@ -96,3 +96,19 @@ export function assertFound<T>(value: T | null | undefined, message: string): T
96
96
  if (!value) throw notFound(message)
97
97
  return value
98
98
  }
99
+
100
+ /**
101
+ * Translates a `CrudHttpError` body's `error` field before it reaches the client.
102
+ * Some callers (command handlers, lib helpers reused by subscribers/CLI/workers) raise
103
+ * `CrudHttpError` with a raw i18n key because they run without a request locale — a route
104
+ * handler forwarding `err.body` verbatim would leak that key to the user. Call this at the
105
+ * route boundary, passing the `translate` the route already resolved, instead of forwarding
106
+ * `err.body` directly.
107
+ */
108
+ export function translateCrudErrorBody<T extends Record<string, unknown>>(
109
+ body: T,
110
+ translate: (key: string, fallback?: string) => string,
111
+ ): T {
112
+ if (typeof body?.error !== 'string') return body
113
+ return { ...body, error: translate(body.error, body.error) }
114
+ }
@@ -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)) return json(err.body, { status: err.status })
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(exportItems), rows: exportItems }
2032
- : prepareExportData(exportItems, opts.list, validated as any, ctx)
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: exportItems.length,
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(exportItems), rows: exportItems }
2235
- : prepareExportData(exportItems, opts.list, validated as any, ctx)
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: exportItems.length,
2251
- total: exportItems.length,
2272
+ itemCount: finalExportItems.length,
2273
+ total: finalExportItems.length,
2252
2274
  branch: 'fallback',
2253
2275
  })
2254
2276
  return response