@open-mercato/shared 0.8.1-develop.7274.1.21a0083828 → 0.8.1-develop.7294.1.0ef99fc518

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) 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/i18n/server.js +1 -1
  12. package/dist/lib/i18n/server.js.map +2 -2
  13. package/dist/lib/location/countries.generated.js +265 -0
  14. package/dist/lib/location/countries.generated.js.map +7 -0
  15. package/dist/lib/location/countries.js +2 -13
  16. package/dist/lib/location/countries.js.map +2 -2
  17. package/dist/lib/navigation/pageReload.js +11 -0
  18. package/dist/lib/navigation/pageReload.js.map +7 -0
  19. package/dist/lib/schedule/interval.js +33 -0
  20. package/dist/lib/schedule/interval.js.map +7 -0
  21. package/dist/lib/schedule/invalidScheduleValue.js +19 -0
  22. package/dist/lib/schedule/invalidScheduleValue.js.map +7 -0
  23. package/dist/lib/version.js +1 -1
  24. package/dist/lib/version.js.map +1 -1
  25. package/package.json +3 -2
  26. package/scripts/generate-countries.mjs +75 -0
  27. package/src/lib/crud/__tests__/crud-factory.test.ts +177 -0
  28. package/src/lib/crud/__tests__/errors.test.ts +31 -1
  29. package/src/lib/crud/errors.ts +16 -0
  30. package/src/lib/crud/factory.ts +37 -15
  31. package/src/lib/custom-fields/__tests__/kinds.test.ts +208 -0
  32. package/src/lib/custom-fields/kinds.ts +205 -0
  33. package/src/lib/encryption/__tests__/indexDoc.custom-field-kinds.test.ts +127 -0
  34. package/src/lib/encryption/__tests__/indexDoc.test.ts +39 -0
  35. package/src/lib/encryption/indexDoc.ts +16 -7
  36. package/src/lib/i18n/__tests__/dictionary-locale-fallback.test.ts +49 -0
  37. package/src/lib/i18n/server.ts +13 -5
  38. package/src/lib/location/__tests__/countries.test.ts +25 -0
  39. package/src/lib/location/countries.generated.ts +264 -0
  40. package/src/lib/location/countries.ts +2 -23
  41. package/src/lib/navigation/__tests__/pageReload.test.ts +58 -0
  42. package/src/lib/navigation/pageReload.ts +43 -0
  43. package/src/lib/schedule/__tests__/interval.test.ts +37 -0
  44. package/src/lib/schedule/interval.ts +45 -0
  45. package/src/lib/schedule/invalidScheduleValue.ts +33 -0
@@ -1,2 +1,3 @@
1
- [build:shared] found 293 entry points
1
+ [generate-countries] up to date (259 countries)
2
+ [build:shared] found 298 entry points
2
3
  [build:shared] built successfully
package/build.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
4
  import { buildPackage } from '../../scripts/build-package.mjs'
5
5
  import { buildVersionSource } from './scripts/versionSource.cjs'
6
6
 
@@ -22,4 +22,7 @@ const injectVersion = {
22
22
  await buildPackage(packageDir, {
23
23
  name: 'shared',
24
24
  extraPlugins: [injectVersion],
25
+ async beforeBuild() {
26
+ await import(pathToFileURL(join(packageDir, 'scripts/generate-countries.mjs')).href)
27
+ },
25
28
  })
@@ -45,6 +45,10 @@ function assertFound(value, message) {
45
45
  if (!value) throw notFound(message);
46
46
  return value;
47
47
  }
48
+ function translateCrudErrorBody(body, translate) {
49
+ if (typeof body?.error !== "string") return body;
50
+ return { ...body, error: translate(body.error, body.error) };
51
+ }
48
52
  export {
49
53
  CrudHttpError,
50
54
  assertFound,
@@ -53,6 +57,7 @@ export {
53
57
  forbidden,
54
58
  isCrudHttpError,
55
59
  isUniqueViolation,
56
- notFound
60
+ notFound,
61
+ translateCrudErrorBody
57
62
  };
58
63
  //# sourceMappingURL=errors.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/crud/errors.ts"],
4
- "sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle boundaries\n// (same behaviour as globalThis-based registries used for DI registrars)\nconst CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')\n\nexport class CrudHttpError extends Error {\n readonly [CRUD_HTTP_ERROR_MARKER] = true\n status: number\n body: Record<string, any>\n\n constructor(\n status: number,\n body?: Record<string, any> | string,\n options?: { cause?: unknown },\n ) {\n const normalizedBody = typeof body === 'string' ? { error: body } : body ?? {}\n super(typeof body === 'string' ? body : normalizedBody.error ?? 'Request failed', options)\n this.status = status\n this.body = normalizedBody\n }\n}\n\n/**\n * Type-safe check for CrudHttpError that works across module/bundle boundaries.\n * Prefer this over `instanceof CrudHttpError` whenever the error may originate\n * from a different module bundle (e.g. enterprise packages, dynamic imports).\n */\nexport function isCrudHttpError(err: unknown): err is CrudHttpError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true\n}\n\nexport function badRequest(message: string): CrudHttpError {\n return new CrudHttpError(400, { error: message })\n}\n\nexport function forbidden(message = 'Forbidden'): CrudHttpError {\n return new CrudHttpError(403, { error: message })\n}\n\n/**\n * Builds the standardized 404 (`{ error: message }`) \u2014 use it instead of hand-rolling\n * `new CrudHttpError(404, { error: message })`. Reach for this in guard statements, where\n * the lookup spans several lines or the condition is compound\n * (`if (!entity || entity.tenantId !== auth.tenantId) throw notFound(msg)`); TypeScript\n * still narrows the value after the throw. For a plain inline lookup, `assertFound` is terser.\n *\n * Pass an already-translated message; this helper never derives one, so 404 copy\n * stays routed through i18n.\n */\nexport function notFound(message = 'Not found'): CrudHttpError {\n return new CrudHttpError(404, { error: message })\n}\n\nexport function conflict(message: string): CrudHttpError {\n return new CrudHttpError(409, { error: message })\n}\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n/**\n * Detects a Postgres unique-constraint violation (SQLSTATE 23505) on a thrown\n * error, looking through MikroORM's driver-error wrapping. Use this to map a DB\n * uniqueness race (e.g. a soft-deleted row the in-app check missed, or a\n * timestamp-precision mismatch) onto a clean 409 instead of a generic 500.\n */\nexport function isUniqueViolation(err: unknown, constraintName?: string): boolean {\n if (!err || typeof err !== 'object') return false\n const candidates: unknown[] = [err, (err as { cause?: unknown }).cause, (err as { previous?: unknown }).previous]\n for (const candidate of candidates) {\n if (!candidate || typeof candidate !== 'object') continue\n const record = candidate as Record<string, unknown>\n if (record.code === POSTGRES_UNIQUE_VIOLATION) {\n if (!constraintName) return true\n const constraint = typeof record.constraint === 'string' ? record.constraint : ''\n const detail = typeof record.detail === 'string' ? record.detail : ''\n const message = typeof record.message === 'string' ? record.message : ''\n return constraint === constraintName || detail.includes(constraintName) || message.includes(constraintName)\n }\n }\n return false\n}\n\n/**\n * The canonical \"entity must exist\" guard for API routes and commands: throws the\n * standardized 404 when the lookup came back empty, and returns the value narrowed\n * to `T` so callers drop the `| null` without a cast.\n *\n * const deal = assertFound(await em.findOne(Deal, { id }), translate('customers.errors.deal_not_found', 'Deal not found'))\n *\n * Use this instead of hand-rolling `if (!entity) throw new CrudHttpError(404, ...)`.\n * `message` is required and passed through verbatim, so it must already be translated.\n *\n * Treats every falsy value as missing, so it suits entity/object lookups \u2014 do not use\n * it to guard numbers or strings, where `0` and `''` are legitimate results.\n */\nexport function assertFound<T>(value: T | null | undefined, message: string): T {\n if (!value) throw notFound(message)\n return value\n}\n"],
5
- "mappings": "AAAA;AAEA,MAAM,yBAAyB,uBAAO,IAAI,6BAA6B;AAEhE,MAAM,uBAAsB,YACvB,6BADuB,IAAM;AAAA,EAKvC,YACE,QACA,MACA,SACA;AACA,UAAM,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC;AAC7E,UAAM,OAAO,SAAS,WAAW,OAAO,eAAe,SAAS,kBAAkB,OAAO;AAV3F,SAAU,MAA0B;AAWlC,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,gBAAgB,KAAoC;AAClE,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,sBAAsB,MAAM;AAC1G;AAEO,SAAS,WAAW,SAAgC;AACzD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,UAAU,UAAU,aAA4B;AAC9D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAYO,SAAS,SAAS,UAAU,aAA4B;AAC7D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,SAAS,SAAgC;AACvD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEA,MAAM,4BAA4B;AAQ3B,SAAS,kBAAkB,KAAc,gBAAkC;AAChF,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,aAAwB,CAAC,KAAM,IAA4B,OAAQ,IAA+B,QAAQ;AAChH,aAAW,aAAa,YAAY;AAClC,QAAI,CAAC,aAAa,OAAO,cAAc,SAAU;AACjD,UAAM,SAAS;AACf,QAAI,OAAO,SAAS,2BAA2B;AAC7C,UAAI,CAAC,eAAgB,QAAO;AAC5B,YAAM,aAAa,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;AAC/E,YAAM,SAAS,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AACnE,YAAM,UAAU,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;AACtE,aAAO,eAAe,kBAAkB,OAAO,SAAS,cAAc,KAAK,QAAQ,SAAS,cAAc;AAAA,IAC5G;AAAA,EACF;AACA,SAAO;AACT;AAeO,SAAS,YAAe,OAA6B,SAAoB;AAC9E,MAAI,CAAC,MAAO,OAAM,SAAS,OAAO;AAClC,SAAO;AACT;",
4
+ "sourcesContent": ["// Use Symbol.for so the marker survives module duplication across bundle boundaries\n// (same behaviour as globalThis-based registries used for DI registrars)\nconst CRUD_HTTP_ERROR_MARKER = Symbol.for('@open-mercato/CrudHttpError')\n\nexport class CrudHttpError extends Error {\n readonly [CRUD_HTTP_ERROR_MARKER] = true\n status: number\n body: Record<string, any>\n\n constructor(\n status: number,\n body?: Record<string, any> | string,\n options?: { cause?: unknown },\n ) {\n const normalizedBody = typeof body === 'string' ? { error: body } : body ?? {}\n super(typeof body === 'string' ? body : normalizedBody.error ?? 'Request failed', options)\n this.status = status\n this.body = normalizedBody\n }\n}\n\n/**\n * Type-safe check for CrudHttpError that works across module/bundle boundaries.\n * Prefer this over `instanceof CrudHttpError` whenever the error may originate\n * from a different module bundle (e.g. enterprise packages, dynamic imports).\n */\nexport function isCrudHttpError(err: unknown): err is CrudHttpError {\n return !!err && typeof err === 'object' && (err as Record<symbol, unknown>)[CRUD_HTTP_ERROR_MARKER] === true\n}\n\nexport function badRequest(message: string): CrudHttpError {\n return new CrudHttpError(400, { error: message })\n}\n\nexport function forbidden(message = 'Forbidden'): CrudHttpError {\n return new CrudHttpError(403, { error: message })\n}\n\n/**\n * Builds the standardized 404 (`{ error: message }`) \u2014 use it instead of hand-rolling\n * `new CrudHttpError(404, { error: message })`. Reach for this in guard statements, where\n * the lookup spans several lines or the condition is compound\n * (`if (!entity || entity.tenantId !== auth.tenantId) throw notFound(msg)`); TypeScript\n * still narrows the value after the throw. For a plain inline lookup, `assertFound` is terser.\n *\n * Pass an already-translated message; this helper never derives one, so 404 copy\n * stays routed through i18n.\n */\nexport function notFound(message = 'Not found'): CrudHttpError {\n return new CrudHttpError(404, { error: message })\n}\n\nexport function conflict(message: string): CrudHttpError {\n return new CrudHttpError(409, { error: message })\n}\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n/**\n * Detects a Postgres unique-constraint violation (SQLSTATE 23505) on a thrown\n * error, looking through MikroORM's driver-error wrapping. Use this to map a DB\n * uniqueness race (e.g. a soft-deleted row the in-app check missed, or a\n * timestamp-precision mismatch) onto a clean 409 instead of a generic 500.\n */\nexport function isUniqueViolation(err: unknown, constraintName?: string): boolean {\n if (!err || typeof err !== 'object') return false\n const candidates: unknown[] = [err, (err as { cause?: unknown }).cause, (err as { previous?: unknown }).previous]\n for (const candidate of candidates) {\n if (!candidate || typeof candidate !== 'object') continue\n const record = candidate as Record<string, unknown>\n if (record.code === POSTGRES_UNIQUE_VIOLATION) {\n if (!constraintName) return true\n const constraint = typeof record.constraint === 'string' ? record.constraint : ''\n const detail = typeof record.detail === 'string' ? record.detail : ''\n const message = typeof record.message === 'string' ? record.message : ''\n return constraint === constraintName || detail.includes(constraintName) || message.includes(constraintName)\n }\n }\n return false\n}\n\n/**\n * The canonical \"entity must exist\" guard for API routes and commands: throws the\n * standardized 404 when the lookup came back empty, and returns the value narrowed\n * to `T` so callers drop the `| null` without a cast.\n *\n * const deal = assertFound(await em.findOne(Deal, { id }), translate('customers.errors.deal_not_found', 'Deal not found'))\n *\n * Use this instead of hand-rolling `if (!entity) throw new CrudHttpError(404, ...)`.\n * `message` is required and passed through verbatim, so it must already be translated.\n *\n * Treats every falsy value as missing, so it suits entity/object lookups \u2014 do not use\n * it to guard numbers or strings, where `0` and `''` are legitimate results.\n */\nexport function assertFound<T>(value: T | null | undefined, message: string): T {\n if (!value) throw notFound(message)\n return value\n}\n\n/**\n * Translates a `CrudHttpError` body's `error` field before it reaches the client.\n * Some callers (command handlers, lib helpers reused by subscribers/CLI/workers) raise\n * `CrudHttpError` with a raw i18n key because they run without a request locale \u2014 a route\n * handler forwarding `err.body` verbatim would leak that key to the user. Call this at the\n * route boundary, passing the `translate` the route already resolved, instead of forwarding\n * `err.body` directly.\n */\nexport function translateCrudErrorBody<T extends Record<string, unknown>>(\n body: T,\n translate: (key: string, fallback?: string) => string,\n): T {\n if (typeof body?.error !== 'string') return body\n return { ...body, error: translate(body.error, body.error) }\n}\n"],
5
+ "mappings": "AAAA;AAEA,MAAM,yBAAyB,uBAAO,IAAI,6BAA6B;AAEhE,MAAM,uBAAsB,YACvB,6BADuB,IAAM;AAAA,EAKvC,YACE,QACA,MACA,SACA;AACA,UAAM,iBAAiB,OAAO,SAAS,WAAW,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC;AAC7E,UAAM,OAAO,SAAS,WAAW,OAAO,eAAe,SAAS,kBAAkB,OAAO;AAV3F,SAAU,MAA0B;AAWlC,SAAK,SAAS;AACd,SAAK,OAAO;AAAA,EACd;AACF;AAOO,SAAS,gBAAgB,KAAoC;AAClE,SAAO,CAAC,CAAC,OAAO,OAAO,QAAQ,YAAa,IAAgC,sBAAsB,MAAM;AAC1G;AAEO,SAAS,WAAW,SAAgC;AACzD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,UAAU,UAAU,aAA4B;AAC9D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAYO,SAAS,SAAS,UAAU,aAA4B;AAC7D,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEO,SAAS,SAAS,SAAgC;AACvD,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,QAAQ,CAAC;AAClD;AAEA,MAAM,4BAA4B;AAQ3B,SAAS,kBAAkB,KAAc,gBAAkC;AAChF,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,aAAwB,CAAC,KAAM,IAA4B,OAAQ,IAA+B,QAAQ;AAChH,aAAW,aAAa,YAAY;AAClC,QAAI,CAAC,aAAa,OAAO,cAAc,SAAU;AACjD,UAAM,SAAS;AACf,QAAI,OAAO,SAAS,2BAA2B;AAC7C,UAAI,CAAC,eAAgB,QAAO;AAC5B,YAAM,aAAa,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;AAC/E,YAAM,SAAS,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AACnE,YAAM,UAAU,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;AACtE,aAAO,eAAe,kBAAkB,OAAO,SAAS,cAAc,KAAK,QAAQ,SAAS,cAAc;AAAA,IAC5G;AAAA,EACF;AACA,SAAO;AACT;AAeO,SAAS,YAAe,OAA6B,SAAoB;AAC9E,MAAI,CAAC,MAAO,OAAM,SAAS,OAAO;AAClC,SAAO;AACT;AAUO,SAAS,uBACd,MACA,WACG;AACH,MAAI,OAAO,MAAM,UAAU,SAAU,QAAO;AAC5C,SAAO,EAAE,GAAG,MAAM,OAAO,UAAU,KAAK,OAAO,KAAK,KAAK,EAAE;AAC7D;",
6
6
  "names": []
7
7
  }
@@ -7,6 +7,7 @@ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/d
7
7
  import { serializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
8
8
  import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
9
9
  import { parseBooleanToken } from "@open-mercato/shared/lib/boolean";
10
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
10
11
  import {
11
12
  runMutationGuards,
12
13
  bridgeLegacyGuard
@@ -26,7 +27,7 @@ import {
26
27
  resolveCfDefIndexOrgCandidates
27
28
  } from "./custom-field-definition-index.js";
28
29
  import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns } from "./exporters.js";
29
- import { isCrudHttpError } from "./errors.js";
30
+ import { isCrudHttpError, translateCrudErrorBody } from "./errors.js";
30
31
  import {
31
32
  buildCollectionTags,
32
33
  buildRecordTag,
@@ -316,9 +317,12 @@ function classifyCrudError(err) {
316
317
  }
317
318
  return { code: "INTERNAL_ERROR", pgSqlState: null };
318
319
  }
319
- function handleError(err, request) {
320
+ async function handleError(err, request) {
320
321
  if (err instanceof Response) return err;
321
- if (isCrudHttpError(err)) return json(err.body, { status: err.status });
322
+ if (isCrudHttpError(err)) {
323
+ const { translate } = await resolveTranslations();
324
+ return json(translateCrudErrorBody(err.body, translate), { status: err.status });
325
+ }
322
326
  const interceptorRejection = getCommandInterceptorHttpRejection(err);
323
327
  if (interceptorRejection) {
324
328
  return json(interceptorRejection.body, { status: interceptorRejection.status });
@@ -1400,13 +1404,15 @@ function makeCrudRoute(opts) {
1400
1404
  nextPage += 1;
1401
1405
  }
1402
1406
  }
1403
- const prepared = exportFullRequested ? { columns: ensureColumns(exportItems), rows: exportItems } : prepareExportData(exportItems, opts.list, validated, ctx);
1404
- const fallbackBase = `${opts.events?.entity || resourceKind || "list"}${exportFullRequested ? "_full" : ""}`;
1405
- const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase);
1406
- const serialized = serializeExport(prepared, requestedExport);
1407
1407
  const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...res.meta ? { meta: res.meta } : {} };
1408
1408
  await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated });
1409
1409
  profiler.mark("after_list_hook");
1410
+ const hookExportItems = Array.isArray(exportPayload.items) ? exportPayload.items : exportItems;
1411
+ const finalExportItems = exportFullRequested ? hookExportItems.map(normalizeFullRecordForExport) : hookExportItems;
1412
+ const prepared = exportFullRequested ? { columns: ensureColumns(finalExportItems), rows: finalExportItems } : prepareExportData(finalExportItems, opts.list, validated, ctx);
1413
+ const fallbackBase = `${opts.events?.entity || resourceKind || "list"}${exportFullRequested ? "_full" : ""}`;
1414
+ const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase);
1415
+ const serialized = serializeExport(prepared, requestedExport);
1410
1416
  const response3 = new Response(serialized.body, {
1411
1417
  headers: {
1412
1418
  "content-type": serialized.contentType,
@@ -1429,7 +1435,7 @@ function makeCrudRoute(opts) {
1429
1435
  finishProfile({
1430
1436
  result: "export",
1431
1437
  cacheStatus,
1432
- itemCount: exportItems.length,
1438
+ itemCount: finalExportItems.length,
1433
1439
  total
1434
1440
  });
1435
1441
  return response3;
@@ -1585,12 +1591,15 @@ function makeCrudRoute(opts) {
1585
1591
  profiler.mark("access_logged", accessLogResult);
1586
1592
  if (exportRequested && requestedExport) {
1587
1593
  const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list;
1588
- const prepared = exportFullRequested ? { columns: ensureColumns(exportItems), rows: exportItems } : prepareExportData(exportItems, opts.list, validated, ctx);
1594
+ const exportPayload = { items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 };
1595
+ await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated });
1596
+ profiler.mark("after_list_hook");
1597
+ const hookExportItems = Array.isArray(exportPayload.items) ? exportPayload.items : exportItems;
1598
+ const finalExportItems = exportFullRequested ? hookExportItems.map(normalizeFullRecordForExport) : hookExportItems;
1599
+ const prepared = exportFullRequested ? { columns: ensureColumns(finalExportItems), rows: finalExportItems } : prepareExportData(finalExportItems, opts.list, validated, ctx);
1589
1600
  const fallbackBase = `${opts.events?.entity || resourceKind || "list"}${exportFullRequested ? "_full" : ""}`;
1590
1601
  const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase);
1591
1602
  const serialized = serializeExport(prepared, requestedExport);
1592
- await opts.hooks?.afterList?.({ items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }, { ...ctx, query: validated });
1593
- profiler.mark("after_list_hook");
1594
1603
  const response2 = new Response(serialized.body, {
1595
1604
  headers: {
1596
1605
  "content-type": serialized.contentType,
@@ -1600,8 +1609,8 @@ function makeCrudRoute(opts) {
1600
1609
  finishProfile({
1601
1610
  result: "export",
1602
1611
  cacheStatus,
1603
- itemCount: exportItems.length,
1604
- total: exportItems.length,
1612
+ itemCount: finalExportItems.length,
1613
+ total: finalExportItems.length,
1605
1614
  branch: "fallback"
1606
1615
  });
1607
1616
  return response2;