@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4

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 (224) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +62 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +190 -70
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/bootstrap/factory.js +18 -13
  15. package/dist/lib/bootstrap/factory.js.map +2 -2
  16. package/dist/lib/commands/command-bus.js +9 -3
  17. package/dist/lib/commands/command-bus.js.map +2 -2
  18. package/dist/lib/commands/command-interceptor-runner.js +9 -4
  19. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  20. package/dist/lib/commands/errors.js +25 -4
  21. package/dist/lib/commands/errors.js.map +2 -2
  22. package/dist/lib/commands/index.js +7 -1
  23. package/dist/lib/commands/index.js.map +2 -2
  24. package/dist/lib/crud/enricher-runner.js +2 -11
  25. package/dist/lib/crud/enricher-runner.js.map +2 -2
  26. package/dist/lib/crud/factory.js +104 -23
  27. package/dist/lib/crud/factory.js.map +2 -2
  28. package/dist/lib/crud/interceptor-runner.js +2 -2
  29. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  30. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  31. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  32. package/dist/lib/crud/types.js +4 -0
  33. package/dist/lib/crud/types.js.map +3 -3
  34. package/dist/lib/data/consistency.js +19 -0
  35. package/dist/lib/data/consistency.js.map +7 -0
  36. package/dist/lib/data/engine.js +37 -11
  37. package/dist/lib/data/engine.js.map +2 -2
  38. package/dist/lib/db/entityMetadata.js +26 -0
  39. package/dist/lib/db/entityMetadata.js.map +7 -0
  40. package/dist/lib/db/pg-errors.js +42 -0
  41. package/dist/lib/db/pg-errors.js.map +2 -2
  42. package/dist/lib/di/container.js +48 -6
  43. package/dist/lib/di/container.js.map +2 -2
  44. package/dist/lib/email/send.js +56 -0
  45. package/dist/lib/email/send.js.map +2 -2
  46. package/dist/lib/encryption/find.js +22 -0
  47. package/dist/lib/encryption/find.js.map +2 -2
  48. package/dist/lib/encryption/likeFilterWarning.js +66 -0
  49. package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
  50. package/dist/lib/encryption/subscriber.js +2 -10
  51. package/dist/lib/encryption/subscriber.js.map +2 -2
  52. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  53. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  54. package/dist/lib/i18n/config.js +1 -1
  55. package/dist/lib/i18n/config.js.map +2 -2
  56. package/dist/lib/i18n/dictionary-cache.js +5 -0
  57. package/dist/lib/i18n/dictionary-cache.js.map +2 -2
  58. package/dist/lib/i18n/server.js +4 -4
  59. package/dist/lib/i18n/server.js.map +3 -3
  60. package/dist/lib/logger/extension.js +29 -0
  61. package/dist/lib/logger/extension.js.map +7 -0
  62. package/dist/lib/logger/index.js +47 -3
  63. package/dist/lib/logger/index.js.map +3 -3
  64. package/dist/lib/modules/registry.js +36 -6
  65. package/dist/lib/modules/registry.js.map +2 -2
  66. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  67. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  68. package/dist/lib/query/ciphertext-search-warning.js +51 -0
  69. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  70. package/dist/lib/query/engine.js +60 -43
  71. package/dist/lib/query/engine.js.map +2 -2
  72. package/dist/lib/query/types.js.map +1 -1
  73. package/dist/lib/redis/connection.js +7 -2
  74. package/dist/lib/redis/connection.js.map +2 -2
  75. package/dist/lib/search/auto-indexing.js +14 -0
  76. package/dist/lib/search/auto-indexing.js.map +7 -0
  77. package/dist/lib/search/availability.js +107 -0
  78. package/dist/lib/search/availability.js.map +7 -0
  79. package/dist/lib/search/config.js +61 -2
  80. package/dist/lib/search/config.js.map +2 -2
  81. package/dist/lib/search/tokenLookup.js +46 -0
  82. package/dist/lib/search/tokenLookup.js.map +7 -0
  83. package/dist/lib/search/tokenize.js +23 -11
  84. package/dist/lib/search/tokenize.js.map +2 -2
  85. package/dist/lib/telemetry/runtime.js +35 -0
  86. package/dist/lib/telemetry/runtime.js.map +7 -0
  87. package/dist/lib/version.js +1 -1
  88. package/dist/lib/version.js.map +1 -1
  89. package/dist/lib/webhooks/body.js +57 -0
  90. package/dist/lib/webhooks/body.js.map +7 -0
  91. package/dist/lib/webhooks/inbound-types.js +1 -0
  92. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  93. package/dist/lib/webhooks/index.js +13 -1
  94. package/dist/lib/webhooks/index.js.map +2 -2
  95. package/dist/lib/webhooks/verify.js +12 -7
  96. package/dist/lib/webhooks/verify.js.map +2 -2
  97. package/dist/modules/analytics.js.map +2 -2
  98. package/dist/modules/overrides.js +50 -1
  99. package/dist/modules/overrides.js.map +2 -2
  100. package/dist/modules/payment_gateways/types.js +8 -1
  101. package/dist/modules/payment_gateways/types.js.map +2 -2
  102. package/dist/modules/registry.js +1 -0
  103. package/dist/modules/registry.js.map +2 -2
  104. package/dist/modules/widgets/extension-points.js +96 -0
  105. package/dist/modules/widgets/extension-points.js.map +7 -0
  106. package/dist/security/enabledModulesRegistry.js +56 -11
  107. package/dist/security/enabledModulesRegistry.js.map +2 -2
  108. package/dist/security/featurePolicy.js +62 -0
  109. package/dist/security/featurePolicy.js.map +7 -0
  110. package/jest.config.cjs +20 -10
  111. package/package.json +19 -9
  112. package/scripts/versionSource.cjs +65 -0
  113. package/src/lib/__tests__/versionSource.test.ts +64 -0
  114. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  115. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  116. package/src/lib/ai/llm-provider.ts +33 -0
  117. package/src/lib/ai/safety-identifier.ts +73 -0
  118. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  119. package/src/lib/auth/__tests__/server.test.ts +23 -0
  120. package/src/lib/auth/organizationScope.ts +65 -0
  121. package/src/lib/auth/server.ts +23 -2
  122. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  123. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  124. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  125. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  126. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  127. package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
  128. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  129. package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
  130. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  131. package/src/lib/bootstrap/dynamicLoader.ts +346 -96
  132. package/src/lib/bootstrap/factory.ts +21 -15
  133. package/src/lib/bootstrap/types.ts +6 -0
  134. package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
  135. package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
  136. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
  137. package/src/lib/commands/command-bus.ts +9 -3
  138. package/src/lib/commands/command-interceptor-runner.ts +27 -6
  139. package/src/lib/commands/command-interceptor.ts +8 -0
  140. package/src/lib/commands/errors.ts +67 -2
  141. package/src/lib/commands/index.ts +7 -1
  142. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  143. package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
  144. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  145. package/src/lib/crud/enricher-runner.ts +2 -11
  146. package/src/lib/crud/factory.ts +157 -22
  147. package/src/lib/crud/interceptor-runner.ts +2 -2
  148. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  149. package/src/lib/crud/types.ts +3 -0
  150. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  151. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  152. package/src/lib/data/consistency.ts +17 -0
  153. package/src/lib/data/engine.ts +50 -16
  154. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  155. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  156. package/src/lib/db/entityMetadata.ts +43 -0
  157. package/src/lib/db/pg-errors.ts +58 -0
  158. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  159. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  160. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  161. package/src/lib/di/container.ts +72 -8
  162. package/src/lib/email/__tests__/send.test.ts +37 -1
  163. package/src/lib/email/send.ts +79 -0
  164. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
  165. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  166. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  167. package/src/lib/encryption/find.ts +22 -0
  168. package/src/lib/encryption/likeFilterWarning.ts +102 -0
  169. package/src/lib/encryption/subscriber.ts +2 -14
  170. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  171. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  172. package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
  173. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  174. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  175. package/src/lib/i18n/config.ts +2 -2
  176. package/src/lib/i18n/dictionary-cache.ts +5 -0
  177. package/src/lib/i18n/server.ts +6 -2
  178. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  179. package/src/lib/logger/extension.ts +59 -0
  180. package/src/lib/logger/index.ts +59 -1
  181. package/src/lib/modules/__tests__/registry.test.ts +26 -0
  182. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  183. package/src/lib/modules/registry.ts +46 -7
  184. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  185. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  186. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
  187. package/src/lib/query/__tests__/engine.test.ts +213 -13
  188. package/src/lib/query/ciphertext-search-warning.ts +106 -0
  189. package/src/lib/query/engine.ts +102 -61
  190. package/src/lib/query/types.ts +4 -5
  191. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  192. package/src/lib/redis/connection.ts +10 -7
  193. package/src/lib/search/__tests__/availability.test.ts +211 -0
  194. package/src/lib/search/__tests__/config.test.ts +177 -0
  195. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  196. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  197. package/src/lib/search/auto-indexing.ts +22 -0
  198. package/src/lib/search/availability.ts +232 -0
  199. package/src/lib/search/config.ts +106 -8
  200. package/src/lib/search/tokenLookup.ts +133 -0
  201. package/src/lib/search/tokenize.ts +33 -11
  202. package/src/lib/telemetry/runtime.ts +72 -0
  203. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  204. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  205. package/src/lib/webhooks/body.ts +70 -0
  206. package/src/lib/webhooks/inbound-types.ts +125 -0
  207. package/src/lib/webhooks/index.ts +19 -1
  208. package/src/lib/webhooks/verify.ts +17 -9
  209. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  210. package/src/modules/__tests__/registry.test.ts +10 -2
  211. package/src/modules/analytics.ts +7 -0
  212. package/src/modules/customer-auth.ts +1 -0
  213. package/src/modules/encryption.ts +3 -0
  214. package/src/modules/entities.ts +15 -0
  215. package/src/modules/navigation/backendChrome.ts +15 -0
  216. package/src/modules/overrides.ts +103 -0
  217. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  218. package/src/modules/payment_gateways/types.ts +41 -0
  219. package/src/modules/registry.ts +3 -0
  220. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  221. package/src/modules/widgets/extension-points.ts +524 -0
  222. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  223. package/src/security/enabledModulesRegistry.ts +64 -12
  224. package/src/security/featurePolicy.ts +89 -0
@@ -15,6 +15,7 @@ import type {
15
15
  import { getEnrichersForEntity } from './enricher-registry'
16
16
  import { logEnricherTiming } from '../umes/enricher-timing'
17
17
  import { createLogger } from '../logger'
18
+ import { authorizeFeatures } from '../../security/featurePolicy'
18
19
 
19
20
  const logger = createLogger('shared').child({ component: 'umes' })
20
21
 
@@ -35,17 +36,7 @@ function hasRequiredFeatures(
35
36
  ): boolean {
36
37
  if (!enricher.features || enricher.features.length === 0) return true
37
38
  if (!userFeatures) return false
38
- const hasFeature = (required: string): boolean => {
39
- for (const granted of userFeatures) {
40
- if (granted === '*' || granted === required) return true
41
- if (granted.endsWith('.*')) {
42
- const prefix = granted.slice(0, -1)
43
- if (required.startsWith(prefix)) return true
44
- }
45
- }
46
- return false
47
- }
48
- return enricher.features.every((feature) => hasFeature(feature))
39
+ return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })
49
40
  }
50
41
 
51
42
  function filterByACLAndTenant(
@@ -8,6 +8,7 @@ import { SortDir } from '@open-mercato/shared/lib/query/types'
8
8
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
9
9
  import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
10
10
  import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
11
+ import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
11
12
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
12
13
  import {
13
14
  runMutationGuards,
@@ -57,6 +58,7 @@ import {
57
58
  isCrudCacheEnabled,
58
59
  normalizeIdentifierValue,
59
60
  normalizeTagSegment,
61
+ pickFirstIdentifier,
60
62
  resolveCrudCache,
61
63
  } from './cache'
62
64
  import { deriveCrudSegmentTag } from './cache-stats'
@@ -72,6 +74,7 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
72
74
  import { createGenericOptimisticLockReader } from './optimistic-lock'
73
75
  import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
74
76
  import { createLogger } from '../logger'
77
+ import { isTransientDbError } from '../db/pg-errors'
75
78
 
76
79
  type RbacServiceLike = {
77
80
  getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
@@ -79,10 +82,13 @@ type RbacServiceLike = {
79
82
 
80
83
  const logger = createLogger('shared').child({ component: 'crud' })
81
84
 
82
- function resolveSortParams(queryParams: Record<string, unknown>) {
83
- const rawSortField = queryParams.sortField ?? queryParams.sort ?? 'id'
84
- const rawSortDir = queryParams.sortDir ?? queryParams.order ?? 'asc'
85
- const sortField = typeof rawSortField === 'string' && rawSortField.trim().length > 0 ? rawSortField.trim() : 'id'
85
+ function resolveSortParams(queryParams: Record<string, unknown>, defaultSort?: CrudDefaultSort) {
86
+ const rawSortField = queryParams.sortField ?? queryParams.sort
87
+ const requestedSortField =
88
+ typeof rawSortField === 'string' && rawSortField.trim().length > 0 ? rawSortField.trim() : null
89
+ const sortField = requestedSortField ?? defaultSort?.field ?? 'id'
90
+ const rawSortDir =
91
+ queryParams.sortDir ?? queryParams.order ?? (requestedSortField ? 'asc' : defaultSort?.dir ?? 'asc')
86
92
  const normalizedDir = typeof rawSortDir === 'string' ? rawSortDir.trim().toLowerCase() : 'asc'
87
93
  const sortDir = normalizedDir === 'desc' ? SortDir.Desc : SortDir.Asc
88
94
  return { sortField, sortDir }
@@ -190,6 +196,8 @@ export type CrudListCustomFieldDecorator = {
190
196
  stripPrefixedKeys?: boolean
191
197
  }
192
198
 
199
+ export type CrudDefaultSort = { field: string; dir?: 'asc' | 'desc' }
200
+
193
201
  export type ListConfig<TList> = {
194
202
  schema: z.ZodType<TList>
195
203
  // Optional: use the QueryEngine when entityId + fields are provided.
@@ -200,12 +208,41 @@ export type ListConfig<TList> = {
200
208
  entityId?: any
201
209
  fields?: any[] | ((query: TList, ctx: CrudCtx) => any[])
202
210
  sortFieldMap?: Record<string, any>
211
+ /**
212
+ * Sort used when the request carries no `sortField` / `sort` param. The field is
213
+ * resolved through `sortFieldMap`, so a name the map defines is translated to its
214
+ * column and an unmapped name is used as the column directly. `dir` only applies
215
+ * together with the default field — an explicit `sortField` without `sortDir`
216
+ * still defaults to ascending. Defaults to `{ field: 'id', dir: 'asc' }`, which is
217
+ * only a meaningful order for sequential ids, never for random UUIDs.
218
+ *
219
+ * Applies to the Query Engine list path only (the route must set both `entityId`
220
+ * and `fields`). The plain-ORM fallback list issues an unordered `find` and
221
+ * already ignores `sortField` today, so it ignores this too.
222
+ *
223
+ * The list schema must keep `sortField` optional for this to take effect — a zod
224
+ * `.default()` on `sortField` reaches the sort resolver as an explicit request and
225
+ * pins the order itself.
226
+ */
227
+ defaultSort?: CrudDefaultSort
228
+ /**
229
+ * Appended as a secondary ascending sort whenever it differs from the resolved
230
+ * primary sort, so rows sharing a primary value keep a stable order across pages
231
+ * and re-fetches instead of falling back to the database's arbitrary row order.
232
+ * Applies to explicit sorts too, and is resolved through `sortFieldMap` and gated
233
+ * on the Query Engine path exactly like `defaultSort`.
234
+ */
235
+ tiebreakSortField?: string
203
236
  buildFilters?: (query: TList, ctx: CrudCtx) => Where<any> | Promise<Where<any>>
204
237
  transformItem?: (item: any) => any
205
238
  allowCsv?: boolean
239
+ // The function forms mirror `fields` above: a route whose export columns depend
240
+ // on per-request state (for example custom-field definitions discovered in
241
+ // `beforeList`) MUST resolve them from `ctx` rather than from module-level
242
+ // mutable state, which would bleed one tenant's columns into another's export.
206
243
  csv?: {
207
- headers: string[]
208
- row: (item: any) => (string | number | boolean | null | undefined)[]
244
+ headers: string[] | ((query: TList, ctx: CrudCtx) => string[])
245
+ row: (item: any, ctx: CrudCtx) => (string | number | boolean | null | undefined)[]
209
246
  filename?: string
210
247
  }
211
248
  export?: CrudExportOptions
@@ -250,6 +287,7 @@ const DEFAULT_EXPORT_FORMATS: CrudExportFormat[] = ['csv', 'json', 'xml', 'markd
250
287
  const DEFAULT_EXPORT_BATCH_SIZE = 1000
251
288
  const MIN_EXPORT_BATCH_SIZE = 100
252
289
  const MAX_EXPORT_BATCH_SIZE = 10000
290
+ const EXPORT_MAX_PAGES = 1000
253
291
 
254
292
  type ColumnResolver = {
255
293
  field: string
@@ -313,14 +351,20 @@ function buildExportFromColumns(items: any[], columnsConfig: CrudExportColumnCon
313
351
  }
314
352
  }
315
353
 
316
- function buildExportFromCsv(items: any[], csv: NonNullable<ListConfig<any>['csv']>): PreparedExport {
354
+ function buildExportFromCsv(
355
+ items: any[],
356
+ csv: NonNullable<ListConfig<any>['csv']>,
357
+ query: unknown,
358
+ ctx: CrudCtx,
359
+ ): PreparedExport {
317
360
  const used = new Set<string>()
318
- const columns = csv.headers.map((header, idx) => ({
361
+ const resolvedHeaders = typeof csv.headers === 'function' ? csv.headers(query as any, ctx) : csv.headers
362
+ const columns = resolvedHeaders.map((header, idx) => ({
319
363
  field: sanitizeFieldName(header || `column_${idx + 1}`, used, idx),
320
364
  header: header || `Column ${idx + 1}`,
321
365
  }))
322
366
  const rows = items.map((item) => {
323
- const values = csv.row(item) || []
367
+ const values = csv.row(item, ctx) || []
324
368
  const row: Record<string, unknown> = {}
325
369
  columns.forEach((column, idx) => {
326
370
  row[column.field] = values[idx]
@@ -343,12 +387,12 @@ function buildDefaultExport(items: any[]): PreparedExport {
343
387
  }
344
388
  }
345
389
 
346
- function prepareExportData(items: any[], list: ListConfig<any>): PreparedExport {
390
+ function prepareExportData(items: any[], list: ListConfig<any>, query: unknown, ctx: CrudCtx): PreparedExport {
347
391
  if (list.export?.columns && list.export.columns.length > 0) {
348
392
  return buildExportFromColumns(items, list.export.columns)
349
393
  }
350
394
  if (list.csv) {
351
- return buildExportFromCsv(items, list.csv)
395
+ return buildExportFromCsv(items, list.csv, query, ctx)
352
396
  }
353
397
  const prepared = buildDefaultExport(items)
354
398
  return {
@@ -553,7 +597,26 @@ function attachOperationHeader(res: Response, logEntry: any) {
553
597
  function handleError(err: unknown): Response {
554
598
  if (err instanceof Response) return err
555
599
  if (isCrudHttpError(err)) return json(err.body, { status: err.status })
600
+ // A command interceptor that blocked with an explicit status is a deliberate business
601
+ // rejection, not a server fault — surface its status and message instead of a generic 500.
602
+ // Without a usable status the error falls through to the historical handling below (issue #5045).
603
+ const interceptorRejection = getCommandInterceptorHttpRejection(err)
604
+ if (interceptorRejection) {
605
+ return json(interceptorRejection.body, { status: interceptorRejection.status })
606
+ }
556
607
  if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
608
+ if (isTransientDbError(err)) {
609
+ // Transient DB unavailability (pool exhausted, `max_connections` reached, DB
610
+ // restarting) is retryable — surface a 503 with a Retry-After hint instead of
611
+ // a generic 500 so clients back off and retry once the DB recovers.
612
+ logger.warn('Transient DB failure during CRUD handler', {
613
+ message: err instanceof Error ? err.message : undefined,
614
+ })
615
+ return json(
616
+ { error: 'Service temporarily unavailable' },
617
+ { status: 503, headers: { 'Retry-After': '2' } },
618
+ )
619
+ }
557
620
 
558
621
  const message = err instanceof Error ? err.message : undefined
559
622
  const stack = err instanceof Error ? err.stack : undefined
@@ -915,6 +978,12 @@ function buildCrudCacheKey(
915
978
  `tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
916
979
  `selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
917
980
  `scope:${scopeSegment}`,
981
+ // List payloads can vary per caller identity beyond tenant/org scope:
982
+ // buildFilters may narrow by ctx.auth (e.g. ?mine=true), before-interceptor
983
+ // query rewrites are feature-gated per user, and afterList/after-interceptor
984
+ // output is embedded in the stored payload — so entries MUST be partitioned
985
+ // per actor (API key or user), never shared across identities.
986
+ `user:${normalizeTagSegment((ctx.auth?.keyId ?? ctx.auth?.sub) ?? null)}`,
918
987
  `query:${serializeSearchParams(url.searchParams)}`,
919
988
  ]
920
989
  // The cached list payload already embeds enricher output (enrichment runs before
@@ -1655,10 +1724,21 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1655
1724
  profiler.mark('query_engine_prepare')
1656
1725
  const qe = (ctx.container.resolve('queryEngine') as QueryEngine)
1657
1726
  profiler.mark('query_engine_resolved')
1658
- const { sortField: sortFieldRaw, sortDir: sortDirRaw } = resolveSortParams(queryParams as Record<string, unknown>)
1659
- const mappedSortField = (opts.list.sortFieldMap && opts.list.sortFieldMap[sortFieldRaw]) || sortFieldRaw
1660
- const sortField = typeof mappedSortField === 'string' ? normalizeSortFieldSelector(mappedSortField) : mappedSortField
1727
+ const sortFieldMap = opts.list.sortFieldMap
1728
+ const resolveSortSelector = (field: string) => {
1729
+ const mapped = (sortFieldMap && sortFieldMap[field]) || field
1730
+ return typeof mapped === 'string' ? normalizeSortFieldSelector(mapped) : mapped
1731
+ }
1732
+ const { sortField: sortFieldRaw, sortDir: sortDirRaw } = resolveSortParams(
1733
+ queryParams as Record<string, unknown>,
1734
+ opts.list.defaultSort,
1735
+ )
1736
+ const sortField = resolveSortSelector(sortFieldRaw)
1661
1737
  const sort: Sort[] = [{ field: sortField as any, dir: sortDirRaw } as any]
1738
+ if (opts.list.tiebreakSortField) {
1739
+ const tiebreakField = resolveSortSelector(opts.list.tiebreakSortField)
1740
+ if (tiebreakField !== sortField) sort.push({ field: tiebreakField as any, dir: SortDir.Asc } as any)
1741
+ }
1662
1742
  const page: Page = exportRequested
1663
1743
  ? { page: 1, pageSize: exportPageSize }
1664
1744
  : { page: requestedPage, pageSize: requestedPageSize }
@@ -1786,13 +1866,17 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1786
1866
  const initialExportItems = exportFullRequested
1787
1867
  ? rawItems.map(normalizeFullRecordForExport)
1788
1868
  : transformedItems
1789
- let exportItems = [...initialExportItems]
1790
- if (total > exportItems.length) {
1791
- const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
1869
+ const exportItems = [...initialExportItems]
1870
+ const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
1871
+ // Short-page termination: `total` is a display value, not a loop bound — it can
1872
+ // under-report (capped counts) or drift while rows are inserted/deleted mid-export.
1873
+ // Keep fetching while pages come back full; fail closed at the page ceiling rather
1874
+ // than serializing a partial export.
1875
+ if (rawItems.length >= exportPageSizeNumber) {
1792
1876
  const queryBase: any = { ...queryOpts }
1793
1877
  delete queryBase.page
1794
1878
  let nextPage = 2
1795
- while (exportItems.length < total) {
1879
+ for (;;) {
1796
1880
  profiler.mark('export_next_page_request', { page: nextPage })
1797
1881
  const nextRes = await qe.query(opts.list.entityId as any, {
1798
1882
  ...queryBase,
@@ -1807,13 +1891,16 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1807
1891
  ? nextItemsRaw.map(normalizeFullRecordForExport)
1808
1892
  : nextTransformed
1809
1893
  exportItems.push(...nextExportItems)
1810
- if (nextExportItems.length < exportPageSizeNumber) break
1894
+ if (nextItemsRaw.length < exportPageSizeNumber) break
1895
+ if (nextPage >= EXPORT_MAX_PAGES) {
1896
+ throw new Error(`[internal] export exceeded ${EXPORT_MAX_PAGES} pages; refusing to return a partial export`)
1897
+ }
1811
1898
  nextPage += 1
1812
1899
  }
1813
1900
  }
1814
1901
  const prepared = exportFullRequested
1815
1902
  ? { columns: ensureColumns(exportItems), rows: exportItems }
1816
- : prepareExportData(exportItems, opts.list)
1903
+ : prepareExportData(exportItems, opts.list, validated as any, ctx)
1817
1904
  const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
1818
1905
  const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
1819
1906
  const serialized = serializeExport(prepared, requestedExport)
@@ -2015,7 +2102,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2015
2102
  const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
2016
2103
  const prepared = exportFullRequested
2017
2104
  ? { columns: ensureColumns(exportItems), rows: exportItems }
2018
- : prepareExportData(exportItems, opts.list)
2105
+ : prepareExportData(exportItems, opts.list, validated as any, ctx)
2019
2106
  const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
2020
2107
  const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
2021
2108
  const serialized = serializeExport(prepared, requestedExport)
@@ -2140,6 +2227,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2140
2227
  }
2141
2228
  }
2142
2229
 
2230
+ // Mutation guard registry — command path (mirrors the direct create branch)
2231
+ const createCmdUserFeatures = await resolveUserFeatures(ctx)
2232
+ const { allGuards: createCmdAllGuards } = collectAndRunGuards(ctx.container)
2233
+ let createCmdGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
2234
+ if (createCmdAllGuards.length && ctx.auth.tenantId) {
2235
+ const guardResult = await runMutationGuards(createCmdAllGuards, {
2236
+ tenantId: ctx.auth.tenantId,
2237
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
2238
+ userId: ctx.auth.sub,
2239
+ resourceKind,
2240
+ resourceId: null,
2241
+ operation: 'create',
2242
+ requestMethod: request.method,
2243
+ requestHeaders: request.headers,
2244
+ mutationPayload: input && typeof input === 'object' ? (input as Record<string, unknown>) : null,
2245
+ }, { userFeatures: createCmdUserFeatures ?? [] })
2246
+ if (!guardResult.ok) {
2247
+ return json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })
2248
+ }
2249
+ if (guardResult.modifiedPayload && typeof input === 'object' && input) {
2250
+ input = { ...input as Record<string, unknown>, ...guardResult.modifiedPayload }
2251
+ }
2252
+ createCmdGuardAfterCallbacks = guardResult.afterSuccessCallbacks
2253
+ }
2254
+
2143
2255
  const baseMetadata: CommandLogMetadata = {
2144
2256
  tenantId: ctx.auth?.tenantId ?? null,
2145
2257
  organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
@@ -2182,6 +2294,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2182
2294
  const status = action.status ?? 201
2183
2295
  const response = json(resolvedPayload, { status })
2184
2296
  attachOperationHeader(response, logEntry)
2297
+ const commandResultId = pickFirstIdentifier(
2298
+ (result as Record<string, unknown> | null | undefined)?.id,
2299
+ (resolvedPayload as Record<string, unknown> | null | undefined)?.id,
2300
+ )
2301
+ if (createCmdGuardAfterCallbacks.length && ctx.auth.tenantId && commandResultId) {
2302
+ await runGuardAfterSuccessCallbacks(createCmdGuardAfterCallbacks, {
2303
+ tenantId: ctx.auth.tenantId,
2304
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
2305
+ userId: ctx.auth.sub,
2306
+ resourceKind,
2307
+ resourceId: commandResultId,
2308
+ operation: 'create',
2309
+ requestMethod: request.method,
2310
+ requestHeaders: request.headers,
2311
+ })
2312
+ }
2185
2313
  // Note: side effects (events + indexing) are already flushed by CommandBus.execute()
2186
2314
  // via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
2187
2315
  // duplicate event emissions.
@@ -2285,6 +2413,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2285
2413
  organizationId: targetOrgId,
2286
2414
  tenantId: writeTenantId,
2287
2415
  values,
2416
+ notify: false,
2288
2417
  })
2289
2418
  }
2290
2419
  }
@@ -2422,6 +2551,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2422
2551
  const updateUserFeatures = await resolveUserFeatures(ctx)
2423
2552
  const { allGuards: updateAllGuards } = collectAndRunGuards(ctx.container)
2424
2553
  let cmdUpdateGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
2554
+ // Commands whose mapInput wraps the payload (e.g. `{ body }`) intentionally
2555
+ // null candidateId and OPT OUT of row-level guards, leaving the command-level
2556
+ // optimistic-lock check as the sole guard — a documented contract, see
2557
+ // apps/docs/docs/framework/data-integrity/concurrency-locking.mdx.
2425
2558
  if (updateAllGuards.length && ctx.auth.tenantId && candidateId) {
2426
2559
  const guardResult = await runMutationGuards(updateAllGuards, {
2427
2560
  tenantId: ctx.auth.tenantId,
@@ -2618,6 +2751,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2618
2751
  organizationId: targetOrgId,
2619
2752
  tenantId: writeTenantId,
2620
2753
  values,
2754
+ notify: false,
2621
2755
  })
2622
2756
  }
2623
2757
  }
@@ -2721,6 +2855,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2721
2855
  request,
2722
2856
  method: 'DELETE',
2723
2857
  body: interceptorInput,
2858
+ query: raw.query,
2724
2859
  })
2725
2860
  if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2726
2861
  interceptorRequestPayload = beforeInterceptors.requestPayload
@@ -2845,7 +2980,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2845
2980
  request,
2846
2981
  method: 'DELETE',
2847
2982
  body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
2848
- query: idFrom === 'query' ? ({ id } as Record<string, unknown>) : undefined,
2983
+ query: idFrom === 'query' ? Object.fromEntries(url.searchParams.entries()) : undefined,
2849
2984
  })
2850
2985
  if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2851
2986
  interceptorRequestPayload = beforeInterceptors.requestPayload
@@ -6,7 +6,7 @@ import type {
6
6
  InterceptorBeforeResult,
7
7
  } from './api-interceptor'
8
8
  import { getApiInterceptorsForRoute } from './interceptor-registry'
9
- import { hasAllFeatures } from '../../security/features'
9
+ import { authorizeFeatures } from '../../security/featurePolicy'
10
10
  import { logInterceptorActivity } from '../umes/interceptor-activity'
11
11
 
12
12
  const DEFAULT_TIMEOUT_MS = 5000
@@ -39,7 +39,7 @@ function sanitizeObject(input?: Record<string, unknown>): Record<string, unknown
39
39
  }
40
40
 
41
41
  function hasRequiredFeatures(features: string[] | undefined, userFeatures: string[] | undefined): boolean {
42
- return hasAllFeatures(userFeatures, features)
42
+ return authorizeFeatures(features ?? [], { grantedFeatures: userFeatures ?? [] })
43
43
  }
44
44
 
45
45
  function timeoutPromise(ms: number, interceptorId: string): Promise<never> {
@@ -1,5 +1,5 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
- import { hasAllFeatures } from '../../security/features'
2
+ import { authorizeFeatures } from '../../security/featurePolicy'
3
3
  import { resolveCrudMutationGuardService } from './mutation-guard-service'
4
4
 
5
5
  // ---------------------------------------------------------------------------
@@ -101,7 +101,7 @@ export async function runMutationGuards(
101
101
  const matching = guards
102
102
  .filter((g) => matchesEntity(g.targetEntity, input.resourceKind))
103
103
  .filter((g) => g.operations.includes(input.operation))
104
- .filter((g) => hasAllFeatures(context.userFeatures, g.features))
104
+ .filter((g) => authorizeFeatures(g.features ?? [], { grantedFeatures: context.userFeatures }))
105
105
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
106
106
 
107
107
  let payload = input.mutationPayload
@@ -1,5 +1,8 @@
1
1
  export type CrudEventAction = 'created' | 'updated' | 'deleted'
2
2
 
3
+ /** Internal payload marker: the data engine owns this CRUD event's query-index decision. */
4
+ export const CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY = '__omQueryIndexManaged' as const
5
+
3
6
  export type CrudEntityIdentifiers = {
4
7
  id: string
5
8
  organizationId: string | null
@@ -0,0 +1,38 @@
1
+ import {
2
+ __resetAlwaysConsistentCacheForTests,
3
+ isReadProjectionAlwaysConsistent,
4
+ parseAlwaysConsistentEnv,
5
+ } from '../consistency'
6
+
7
+ describe('read projection consistency flag', () => {
8
+ const originalEnv = process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
9
+
10
+ afterEach(() => {
11
+ if (originalEnv === undefined) {
12
+ delete process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT
13
+ } else {
14
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = originalEnv
15
+ }
16
+ __resetAlwaysConsistentCacheForTests()
17
+ })
18
+
19
+ it.each([undefined, null, '', ' ', 'off', 'false', '0', 'no', 'disabled', 'none', 'unexpected'])(
20
+ 'parses %p as OFF',
21
+ (raw) => {
22
+ expect(parseAlwaysConsistentEnv(raw)).toBe(false)
23
+ },
24
+ )
25
+
26
+ it.each(['on', 'true', '1', 'yes', 'enabled'])('parses %p as ON', (raw) => {
27
+ expect(parseAlwaysConsistentEnv(raw)).toBe(true)
28
+ })
29
+
30
+ it('memoizes the env value until reset for tests', () => {
31
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'on'
32
+ expect(isReadProjectionAlwaysConsistent()).toBe(true)
33
+ process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT = 'off'
34
+ expect(isReadProjectionAlwaysConsistent()).toBe(true)
35
+ __resetAlwaysConsistentCacheForTests()
36
+ expect(isReadProjectionAlwaysConsistent()).toBe(false)
37
+ })
38
+ })
@@ -1,7 +1,11 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { DefaultDataEngine } from '../engine'
4
- import type { CrudEventsConfig, CrudIndexerConfig } from '../../crud/types'
4
+ import {
5
+ CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY,
6
+ type CrudEventsConfig,
7
+ type CrudIndexerConfig,
8
+ } from '../../crud/types'
5
9
 
6
10
  // The bulk-import deferral (`suppress`) must gate the two per-record side effects `emitOrmEntityEvent`
7
11
  // fans out — the `<module>.<entity>.<action>` domain event and the inline `query_index.upsert_one`
@@ -25,6 +29,15 @@ function buildEngine() {
25
29
  return { engine, emitEvent, emittedNames }
26
30
  }
27
31
 
32
+ function expectManagedDomainPayload(emitEvent: jest.Mock, eventName: string): void {
33
+ const call = emitEvent.mock.calls.find(([name]) => name === eventName)
34
+ expect(call).toBeDefined()
35
+ const payload = call?.[1] as Record<string, unknown>
36
+ expect(payload[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY]).toBe(true)
37
+ expect(Object.keys(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
38
+ expect(JSON.stringify(payload)).not.toContain(CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY)
39
+ }
40
+
28
41
  describe('DefaultDataEngine bulk-import suppression', () => {
29
42
  // The test events are intentionally not registered in the event registry; silence the
30
43
  // one-time "undeclared event" warning so it doesn't clutter the suite output.
@@ -33,9 +46,10 @@ describe('DefaultDataEngine bulk-import suppression', () => {
33
46
  afterAll(() => { warnSpy.mockRestore() })
34
47
 
35
48
  it('emits both the domain event and the reindex when unsuppressed', async () => {
36
- const { engine, emittedNames } = buildEngine()
49
+ const { engine, emitEvent, emittedNames } = buildEngine()
37
50
  await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS })
38
51
  expect(emittedNames()).toEqual(expect.arrayContaining(['sales.order.created', 'query_index.upsert_one']))
52
+ expectManagedDomainPayload(emitEvent, 'sales.order.created')
39
53
  })
40
54
 
41
55
  it('skips the domain event but keeps the reindex with skipEvents', async () => {
@@ -47,11 +61,12 @@ describe('DefaultDataEngine bulk-import suppression', () => {
47
61
  })
48
62
 
49
63
  it('skips the reindex but keeps the domain event with skipReindex', async () => {
50
- const { engine, emittedNames } = buildEngine()
64
+ const { engine, emitEvent, emittedNames } = buildEngine()
51
65
  await engine.emitOrmEntityEvent({ action: 'created', entity: {}, events: EVENTS, indexer: INDEXER, identifiers: IDENTIFIERS, suppress: { skipReindex: true } })
52
66
  const names = emittedNames()
53
67
  expect(names).toContain('sales.order.created')
54
68
  expect(names).not.toContain('query_index.upsert_one')
69
+ expectManagedDomainPayload(emitEvent, 'sales.order.created')
55
70
  })
56
71
 
57
72
  it('emits nothing when both are suppressed', async () => {
@@ -0,0 +1,17 @@
1
+ import { parseBooleanWithDefault } from '../boolean'
2
+
3
+ let alwaysConsistentFlag: boolean | null = null
4
+
5
+ export function parseAlwaysConsistentEnv(raw: string | undefined | null): boolean {
6
+ return parseBooleanWithDefault(raw, false)
7
+ }
8
+
9
+ export function isReadProjectionAlwaysConsistent(): boolean {
10
+ if (alwaysConsistentFlag !== null) return alwaysConsistentFlag
11
+ alwaysConsistentFlag = parseAlwaysConsistentEnv(process.env.OM_CACHE_SAFETY_ALWAYS_CONSISTENT)
12
+ return alwaysConsistentFlag
13
+ }
14
+
15
+ export function __resetAlwaysConsistentCacheForTests(): void {
16
+ alwaysConsistentFlag = null
17
+ }