@open-mercato/shared 0.4.2-canary-c02407ff85

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 (324) hide show
  1. package/build.mjs +101 -0
  2. package/dist/index.js +1 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/lib/api/crud.js +47 -0
  5. package/dist/lib/api/crud.js.map +7 -0
  6. package/dist/lib/api/scoped.js +140 -0
  7. package/dist/lib/api/scoped.js.map +7 -0
  8. package/dist/lib/auth/jwt.js +34 -0
  9. package/dist/lib/auth/jwt.js.map +7 -0
  10. package/dist/lib/auth/server.js +157 -0
  11. package/dist/lib/auth/server.js.map +7 -0
  12. package/dist/lib/boolean.js +22 -0
  13. package/dist/lib/boolean.js.map +7 -0
  14. package/dist/lib/bootstrap/appResolver.js +43 -0
  15. package/dist/lib/bootstrap/appResolver.js.map +7 -0
  16. package/dist/lib/bootstrap/dynamicLoader.js +108 -0
  17. package/dist/lib/bootstrap/dynamicLoader.js.map +7 -0
  18. package/dist/lib/bootstrap/factory.js +59 -0
  19. package/dist/lib/bootstrap/factory.js.map +7 -0
  20. package/dist/lib/bootstrap/index.js +11 -0
  21. package/dist/lib/bootstrap/index.js.map +7 -0
  22. package/dist/lib/bootstrap/types.js +1 -0
  23. package/dist/lib/bootstrap/types.js.map +7 -0
  24. package/dist/lib/cache/segments.js +36 -0
  25. package/dist/lib/cache/segments.js.map +7 -0
  26. package/dist/lib/cli/progress.js +46 -0
  27. package/dist/lib/cli/progress.js.map +7 -0
  28. package/dist/lib/commands/command-bus.js +285 -0
  29. package/dist/lib/commands/command-bus.js.map +7 -0
  30. package/dist/lib/commands/customFieldSnapshots.js +66 -0
  31. package/dist/lib/commands/customFieldSnapshots.js.map +7 -0
  32. package/dist/lib/commands/helpers.js +98 -0
  33. package/dist/lib/commands/helpers.js.map +7 -0
  34. package/dist/lib/commands/index.js +8 -0
  35. package/dist/lib/commands/index.js.map +7 -0
  36. package/dist/lib/commands/operationMetadata.js +32 -0
  37. package/dist/lib/commands/operationMetadata.js.map +7 -0
  38. package/dist/lib/commands/registry.js +43 -0
  39. package/dist/lib/commands/registry.js.map +7 -0
  40. package/dist/lib/commands/scope.js +44 -0
  41. package/dist/lib/commands/scope.js.map +7 -0
  42. package/dist/lib/commands/types.js +8 -0
  43. package/dist/lib/commands/types.js.map +7 -0
  44. package/dist/lib/crud/cache-stats.js +98 -0
  45. package/dist/lib/crud/cache-stats.js.map +7 -0
  46. package/dist/lib/crud/cache.js +175 -0
  47. package/dist/lib/crud/cache.js.map +7 -0
  48. package/dist/lib/crud/custom-fields-client.js +52 -0
  49. package/dist/lib/crud/custom-fields-client.js.map +7 -0
  50. package/dist/lib/crud/custom-fields.js +467 -0
  51. package/dist/lib/crud/custom-fields.js.map +7 -0
  52. package/dist/lib/crud/errors.js +24 -0
  53. package/dist/lib/crud/errors.js.map +7 -0
  54. package/dist/lib/crud/exporters.js +154 -0
  55. package/dist/lib/crud/exporters.js.map +7 -0
  56. package/dist/lib/crud/factory.js +1311 -0
  57. package/dist/lib/crud/factory.js.map +7 -0
  58. package/dist/lib/crud/types.js +1 -0
  59. package/dist/lib/crud/types.js.map +7 -0
  60. package/dist/lib/custom-fields/normalize.js +36 -0
  61. package/dist/lib/custom-fields/normalize.js.map +7 -0
  62. package/dist/lib/data/engine.js +396 -0
  63. package/dist/lib/data/engine.js.map +7 -0
  64. package/dist/lib/db/escapeLikePattern.js +5 -0
  65. package/dist/lib/db/escapeLikePattern.js.map +7 -0
  66. package/dist/lib/db/mikro.js +82 -0
  67. package/dist/lib/db/mikro.js.map +7 -0
  68. package/dist/lib/di/container.js +94 -0
  69. package/dist/lib/di/container.js.map +7 -0
  70. package/dist/lib/email/send.js +12 -0
  71. package/dist/lib/email/send.js.map +7 -0
  72. package/dist/lib/encryption/aes.js +58 -0
  73. package/dist/lib/encryption/aes.js.map +7 -0
  74. package/dist/lib/encryption/customFieldValues.js +49 -0
  75. package/dist/lib/encryption/customFieldValues.js.map +7 -0
  76. package/dist/lib/encryption/entityFields.js +26 -0
  77. package/dist/lib/encryption/entityFields.js.map +7 -0
  78. package/dist/lib/encryption/entityIds.js +80 -0
  79. package/dist/lib/encryption/entityIds.js.map +7 -0
  80. package/dist/lib/encryption/find.js +45 -0
  81. package/dist/lib/encryption/find.js.map +7 -0
  82. package/dist/lib/encryption/indexDoc.js +69 -0
  83. package/dist/lib/encryption/indexDoc.js.map +7 -0
  84. package/dist/lib/encryption/kms.js +282 -0
  85. package/dist/lib/encryption/kms.js.map +7 -0
  86. package/dist/lib/encryption/subscriber.js +330 -0
  87. package/dist/lib/encryption/subscriber.js.map +7 -0
  88. package/dist/lib/encryption/tenantDataEncryptionService.js +252 -0
  89. package/dist/lib/encryption/tenantDataEncryptionService.js.map +7 -0
  90. package/dist/lib/encryption/toggles.js +18 -0
  91. package/dist/lib/encryption/toggles.js.map +7 -0
  92. package/dist/lib/entities/naming.js +9 -0
  93. package/dist/lib/entities/naming.js.map +7 -0
  94. package/dist/lib/entities/system-entities.js +43 -0
  95. package/dist/lib/entities/system-entities.js.map +7 -0
  96. package/dist/lib/frontend/organizationEvents.js +41 -0
  97. package/dist/lib/frontend/organizationEvents.js.map +7 -0
  98. package/dist/lib/frontend/useOrganizationScope.js +32 -0
  99. package/dist/lib/frontend/useOrganizationScope.js.map +7 -0
  100. package/dist/lib/hotkeys/index.js +128 -0
  101. package/dist/lib/hotkeys/index.js.map +7 -0
  102. package/dist/lib/i18n/app-dictionaries.js +17 -0
  103. package/dist/lib/i18n/app-dictionaries.js.map +7 -0
  104. package/dist/lib/i18n/config.js +7 -0
  105. package/dist/lib/i18n/config.js.map +7 -0
  106. package/dist/lib/i18n/context.js +50 -0
  107. package/dist/lib/i18n/context.js.map +7 -0
  108. package/dist/lib/i18n/server.js +68 -0
  109. package/dist/lib/i18n/server.js.map +7 -0
  110. package/dist/lib/i18n/translate.js +45 -0
  111. package/dist/lib/i18n/translate.js.map +7 -0
  112. package/dist/lib/indexers/error-log.js +82 -0
  113. package/dist/lib/indexers/error-log.js.map +7 -0
  114. package/dist/lib/indexers/status-log.js +80 -0
  115. package/dist/lib/indexers/status-log.js.map +7 -0
  116. package/dist/lib/lib/auth/jwt.js +34 -0
  117. package/dist/lib/lib/auth/jwt.js.map +7 -0
  118. package/dist/lib/lib/auth/server.js +77 -0
  119. package/dist/lib/lib/auth/server.js.map +7 -0
  120. package/dist/lib/lib/email/send.js +12 -0
  121. package/dist/lib/lib/email/send.js.map +7 -0
  122. package/dist/lib/lib/i18n/config.js +7 -0
  123. package/dist/lib/lib/i18n/config.js.map +7 -0
  124. package/dist/lib/lib/i18n/context.js +31 -0
  125. package/dist/lib/lib/i18n/context.js.map +7 -0
  126. package/dist/lib/lib/utils.js +9 -0
  127. package/dist/lib/lib/utils.js.map +7 -0
  128. package/dist/lib/location/countries.js +68 -0
  129. package/dist/lib/location/countries.js.map +7 -0
  130. package/dist/lib/modules/index.js +6 -0
  131. package/dist/lib/modules/index.js.map +7 -0
  132. package/dist/lib/modules/registry.js +18 -0
  133. package/dist/lib/modules/registry.js.map +7 -0
  134. package/dist/lib/openapi/crud.js +137 -0
  135. package/dist/lib/openapi/crud.js.map +7 -0
  136. package/dist/lib/openapi/generator.js +1131 -0
  137. package/dist/lib/openapi/generator.js.map +7 -0
  138. package/dist/lib/openapi/index.js +10 -0
  139. package/dist/lib/openapi/index.js.map +7 -0
  140. package/dist/lib/openapi/sanitize.js +110 -0
  141. package/dist/lib/openapi/sanitize.js.map +7 -0
  142. package/dist/lib/openapi/types.js +1 -0
  143. package/dist/lib/openapi/types.js.map +7 -0
  144. package/dist/lib/profiler/index.js +258 -0
  145. package/dist/lib/profiler/index.js.map +7 -0
  146. package/dist/lib/query/engine.js +729 -0
  147. package/dist/lib/query/engine.js.map +7 -0
  148. package/dist/lib/query/join-utils.js +195 -0
  149. package/dist/lib/query/join-utils.js.map +7 -0
  150. package/dist/lib/query/types.js +9 -0
  151. package/dist/lib/query/types.js.map +7 -0
  152. package/dist/lib/search/config.js +32 -0
  153. package/dist/lib/search/config.js.map +7 -0
  154. package/dist/lib/search/tokenize.js +34 -0
  155. package/dist/lib/search/tokenize.js.map +7 -0
  156. package/dist/lib/slugify.js +24 -0
  157. package/dist/lib/slugify.js.map +7 -0
  158. package/dist/lib/testing/bootstrap.js +51 -0
  159. package/dist/lib/testing/bootstrap.js.map +7 -0
  160. package/dist/lib/testing/index.js +17 -0
  161. package/dist/lib/testing/index.js.map +7 -0
  162. package/dist/lib/testing/renderWithProviders.js +15 -0
  163. package/dist/lib/testing/renderWithProviders.js.map +7 -0
  164. package/dist/lib/url.js +12 -0
  165. package/dist/lib/url.js.map +7 -0
  166. package/dist/lib/utils.js +13 -0
  167. package/dist/lib/utils.js.map +7 -0
  168. package/dist/lib/version.js +7 -0
  169. package/dist/lib/version.js.map +7 -0
  170. package/dist/modules/dashboard/widgets.js +1 -0
  171. package/dist/modules/dashboard/widgets.js.map +7 -0
  172. package/dist/modules/dsl.js +30 -0
  173. package/dist/modules/dsl.js.map +7 -0
  174. package/dist/modules/entities/kinds.js +22 -0
  175. package/dist/modules/entities/kinds.js.map +7 -0
  176. package/dist/modules/entities/options.js +26 -0
  177. package/dist/modules/entities/options.js.map +7 -0
  178. package/dist/modules/entities/validation.js +102 -0
  179. package/dist/modules/entities/validation.js.map +7 -0
  180. package/dist/modules/entities/validators.js +88 -0
  181. package/dist/modules/entities/validators.js.map +7 -0
  182. package/dist/modules/entities.js +1 -0
  183. package/dist/modules/entities.js.map +7 -0
  184. package/dist/modules/navigation/sidebarPreferences.js +50 -0
  185. package/dist/modules/navigation/sidebarPreferences.js.map +7 -0
  186. package/dist/modules/perspectives/types.js +1 -0
  187. package/dist/modules/perspectives/types.js.map +7 -0
  188. package/dist/modules/registry.js +96 -0
  189. package/dist/modules/registry.js.map +7 -0
  190. package/dist/modules/search.js +15 -0
  191. package/dist/modules/search.js.map +7 -0
  192. package/dist/modules/vector.js +1 -0
  193. package/dist/modules/vector.js.map +7 -0
  194. package/dist/modules/widgets/injection-loader.js +180 -0
  195. package/dist/modules/widgets/injection-loader.js.map +7 -0
  196. package/dist/modules/widgets/injection.js +1 -0
  197. package/dist/modules/widgets/injection.js.map +7 -0
  198. package/dist/security/features.js +23 -0
  199. package/dist/security/features.js.map +7 -0
  200. package/dist/types/pg.d.js +1 -0
  201. package/dist/types/pg.d.js.map +7 -0
  202. package/dist/types/react-email.d.js +1 -0
  203. package/dist/types/react-email.d.js.map +7 -0
  204. package/dist/types/resend.d.js +1 -0
  205. package/dist/types/resend.d.js.map +7 -0
  206. package/jest.config.cjs +22 -0
  207. package/package.json +88 -0
  208. package/src/index.ts +0 -0
  209. package/src/lib/api/__tests__/scoped.test.ts +38 -0
  210. package/src/lib/api/crud.ts +59 -0
  211. package/src/lib/api/scoped.ts +239 -0
  212. package/src/lib/auth/jwt.ts +39 -0
  213. package/src/lib/auth/server.ts +199 -0
  214. package/src/lib/boolean.ts +17 -0
  215. package/src/lib/bootstrap/appResolver.ts +85 -0
  216. package/src/lib/bootstrap/dynamicLoader.ts +177 -0
  217. package/src/lib/bootstrap/factory.ts +108 -0
  218. package/src/lib/bootstrap/index.ts +23 -0
  219. package/src/lib/bootstrap/types.ts +31 -0
  220. package/src/lib/cache/segments.ts +56 -0
  221. package/src/lib/cli/progress.ts +55 -0
  222. package/src/lib/commands/__tests__/command-bus.test.ts +84 -0
  223. package/src/lib/commands/__tests__/helpers.test.ts +42 -0
  224. package/src/lib/commands/command-bus.ts +349 -0
  225. package/src/lib/commands/customFieldSnapshots.ts +86 -0
  226. package/src/lib/commands/helpers.ts +143 -0
  227. package/src/lib/commands/index.ts +4 -0
  228. package/src/lib/commands/operationMetadata.ts +40 -0
  229. package/src/lib/commands/registry.ts +46 -0
  230. package/src/lib/commands/scope.ts +59 -0
  231. package/src/lib/commands/types.ts +63 -0
  232. package/src/lib/crud/__tests__/crud-factory.test.ts +333 -0
  233. package/src/lib/crud/__tests__/custom-fields.test.ts +150 -0
  234. package/src/lib/crud/cache-stats.ts +127 -0
  235. package/src/lib/crud/cache.ts +205 -0
  236. package/src/lib/crud/custom-fields-client.ts +54 -0
  237. package/src/lib/crud/custom-fields.ts +607 -0
  238. package/src/lib/crud/errors.ts +23 -0
  239. package/src/lib/crud/exporters.ts +188 -0
  240. package/src/lib/crud/factory.ts +1622 -0
  241. package/src/lib/crud/types.ts +29 -0
  242. package/src/lib/custom-fields/normalize.ts +45 -0
  243. package/src/lib/data/engine.ts +562 -0
  244. package/src/lib/db/escapeLikePattern.ts +2 -0
  245. package/src/lib/db/mikro.ts +100 -0
  246. package/src/lib/di/container.ts +105 -0
  247. package/src/lib/email/send.ts +18 -0
  248. package/src/lib/encryption/__tests__/customFieldValues.test.ts +63 -0
  249. package/src/lib/encryption/__tests__/indexDoc.test.ts +115 -0
  250. package/src/lib/encryption/aes.ts +64 -0
  251. package/src/lib/encryption/customFieldValues.ts +67 -0
  252. package/src/lib/encryption/entityFields.ts +39 -0
  253. package/src/lib/encryption/entityIds.ts +107 -0
  254. package/src/lib/encryption/find.ts +81 -0
  255. package/src/lib/encryption/indexDoc.ts +104 -0
  256. package/src/lib/encryption/kms.ts +337 -0
  257. package/src/lib/encryption/subscriber.ts +416 -0
  258. package/src/lib/encryption/tenantDataEncryptionService.ts +313 -0
  259. package/src/lib/encryption/toggles.ts +15 -0
  260. package/src/lib/entities/naming.ts +6 -0
  261. package/src/lib/entities/system-entities.ts +43 -0
  262. package/src/lib/frontend/organizationEvents.ts +55 -0
  263. package/src/lib/frontend/useOrganizationScope.ts +30 -0
  264. package/src/lib/hotkeys/index.ts +168 -0
  265. package/src/lib/i18n/app-dictionaries.ts +18 -0
  266. package/src/lib/i18n/config.ts +4 -0
  267. package/src/lib/i18n/context.tsx +66 -0
  268. package/src/lib/i18n/server.ts +74 -0
  269. package/src/lib/i18n/translate.ts +54 -0
  270. package/src/lib/indexers/error-log.ts +106 -0
  271. package/src/lib/indexers/status-log.ts +119 -0
  272. package/src/lib/lib/auth/jwt.ts +39 -0
  273. package/src/lib/lib/auth/server.ts +94 -0
  274. package/src/lib/lib/email/send.ts +18 -0
  275. package/src/lib/lib/i18n/config.ts +4 -0
  276. package/src/lib/lib/i18n/context.tsx +38 -0
  277. package/src/lib/lib/utils.ts +6 -0
  278. package/src/lib/location/countries.ts +97 -0
  279. package/src/lib/modules/index.ts +1 -0
  280. package/src/lib/modules/registry.ts +18 -0
  281. package/src/lib/openapi/crud.ts +218 -0
  282. package/src/lib/openapi/generator.ts +1311 -0
  283. package/src/lib/openapi/index.ts +4 -0
  284. package/src/lib/openapi/sanitize.ts +137 -0
  285. package/src/lib/openapi/types.ts +79 -0
  286. package/src/lib/profiler/index.ts +371 -0
  287. package/src/lib/query/__tests__/engine.test.ts +274 -0
  288. package/src/lib/query/engine.ts +837 -0
  289. package/src/lib/query/join-utils.ts +238 -0
  290. package/src/lib/query/types.ts +121 -0
  291. package/src/lib/search/config.ts +49 -0
  292. package/src/lib/search/tokenize.ts +45 -0
  293. package/src/lib/slugify.ts +28 -0
  294. package/src/lib/testing/bootstrap.ts +124 -0
  295. package/src/lib/testing/index.ts +15 -0
  296. package/src/lib/testing/renderWithProviders.tsx +31 -0
  297. package/src/lib/url.ts +12 -0
  298. package/src/lib/utils.ts +17 -0
  299. package/src/lib/version.ts +5 -0
  300. package/src/modules/__tests__/dsl.test.ts +35 -0
  301. package/src/modules/__tests__/registry.test.ts +300 -0
  302. package/src/modules/dashboard/widgets.ts +57 -0
  303. package/src/modules/dsl.ts +32 -0
  304. package/src/modules/entities/__tests__/validation.test.ts +52 -0
  305. package/src/modules/entities/kinds.ts +20 -0
  306. package/src/modules/entities/options.ts +36 -0
  307. package/src/modules/entities/validation.ts +118 -0
  308. package/src/modules/entities/validators.ts +93 -0
  309. package/src/modules/entities.ts +102 -0
  310. package/src/modules/navigation/sidebarPreferences.ts +62 -0
  311. package/src/modules/perspectives/types.ts +40 -0
  312. package/src/modules/registry.ts +249 -0
  313. package/src/modules/search.ts +325 -0
  314. package/src/modules/vector.ts +122 -0
  315. package/src/modules/widgets/__tests__/injection.test.ts +48 -0
  316. package/src/modules/widgets/injection-loader.ts +235 -0
  317. package/src/modules/widgets/injection.ts +120 -0
  318. package/src/security/features.ts +22 -0
  319. package/src/types/pg.d.ts +2 -0
  320. package/src/types/react-email.d.ts +2 -0
  321. package/src/types/resend.d.ts +2 -0
  322. package/tsconfig.build.json +11 -0
  323. package/tsconfig.json +9 -0
  324. package/watch.mjs +6 -0
@@ -0,0 +1,1622 @@
1
+ import { z } from 'zod'
2
+ import type { AwilixContainer } from 'awilix'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { buildScopedWhere } from '@open-mercato/shared/lib/api/crud'
5
+ import { getAuthFromCookies, getAuthFromRequest, type AuthContext } from '@open-mercato/shared/lib/auth/server'
6
+ import type { QueryEngine, Where, Sort, Page, QueryCustomFieldSource, QueryJoinEdge } from '@open-mercato/shared/lib/query/types'
7
+ import { SortDir } from '@open-mercato/shared/lib/query/types'
8
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
9
+ import { resolveOrganizationScopeForRequest, type OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
10
+ import { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
11
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
12
+ import type {
13
+ CrudEventAction,
14
+ CrudEventsConfig,
15
+ CrudIndexerConfig,
16
+ CrudIdentifierResolver,
17
+ } from './types'
18
+ import {
19
+ extractCustomFieldValuesFromPayload,
20
+ extractAllCustomFieldEntries,
21
+ decorateRecordWithCustomFields,
22
+ loadCustomFieldDefinitionIndex,
23
+ } from './custom-fields'
24
+ import { serializeExport, normalizeExportFormat, defaultExportFilename, ensureColumns, type CrudExportFormat, type PreparedExport } from './exporters'
25
+ import { CrudHttpError } from './errors'
26
+ import type { CommandBus, CommandLogMetadata } from '@open-mercato/shared/lib/commands'
27
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
28
+ import type { EntityManager } from '@mikro-orm/postgresql'
29
+ import {
30
+ buildCollectionTags,
31
+ buildRecordTag,
32
+ canonicalizeResourceTag,
33
+ debugCrudCache,
34
+ deriveResourceFromCommandId,
35
+ expandResourceAliases,
36
+ invalidateCrudCache,
37
+ isCrudCacheDebugEnabled,
38
+ isCrudCacheEnabled,
39
+ normalizeIdentifierValue,
40
+ normalizeTagSegment,
41
+ resolveCrudCache,
42
+ } from './cache'
43
+ import { deriveCrudSegmentTag } from './cache-stats'
44
+ import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
45
+
46
+ export type CrudHooks<TCreate, TUpdate, TList> = {
47
+ beforeList?: (q: TList, ctx: CrudCtx) => Promise<void> | void
48
+ afterList?: (res: any, ctx: CrudCtx & { query: TList }) => Promise<void> | void
49
+ beforeCreate?: (input: TCreate, ctx: CrudCtx) => Promise<TCreate | void> | TCreate | void
50
+ afterCreate?: (entity: any, ctx: CrudCtx & { input: TCreate }) => Promise<void> | void
51
+ beforeUpdate?: (input: TUpdate, ctx: CrudCtx) => Promise<TUpdate | void> | TUpdate | void
52
+ afterUpdate?: (entity: any, ctx: CrudCtx & { input: TUpdate }) => Promise<void> | void
53
+ beforeDelete?: (id: string, ctx: CrudCtx) => Promise<void> | void
54
+ afterDelete?: (id: string, ctx: CrudCtx) => Promise<void> | void
55
+ }
56
+
57
+ export type CrudMetadata = {
58
+ GET?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
59
+ POST?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
60
+ PUT?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
61
+ DELETE?: { requireAuth?: boolean; requireRoles?: string[]; requireFeatures?: string[] }
62
+ }
63
+
64
+ export type OrmEntityConfig = {
65
+ entity: any // MikroORM entity class
66
+ idField?: string // default: 'id'
67
+ orgField?: string | null // default: 'organizationId'; pass null to disable automatic org scoping
68
+ tenantField?: string | null // default: 'tenantId'; pass null to disable automatic tenant scoping
69
+ softDeleteField?: string | null // default: 'deletedAt'; pass null to disable implicit soft delete filter
70
+ }
71
+
72
+ export type CustomFieldsConfig =
73
+ | false
74
+ | {
75
+ enabled: true
76
+ entityId: any // datamodel entity id, e.g. E.example.todo
77
+ // If true, picks body keys starting with `cf_` and maps `cf_<name>` -> `<name>`
78
+ pickPrefixed?: boolean
79
+ // Optional custom mapper; if provided, used instead of pickPrefixed
80
+ map?: (data: Record<string, any>) => Record<string, any>
81
+ }
82
+
83
+ export type CrudListCustomFieldDecorator = {
84
+ entityIds: EntityId | EntityId[]
85
+ resolveContext?: (item: any, ctx: CrudCtx) => { organizationId?: string | null; tenantId?: string | null }
86
+ }
87
+
88
+ export type ListConfig<TList> = {
89
+ schema: z.ZodType<TList>
90
+ // Optional: use the QueryEngine when entityId + fields are provided
91
+ entityId?: any
92
+ fields?: any[]
93
+ sortFieldMap?: Record<string, any>
94
+ buildFilters?: (query: TList, ctx: CrudCtx) => Where<any> | Promise<Where<any>>
95
+ transformItem?: (item: any) => any
96
+ allowCsv?: boolean
97
+ csv?: {
98
+ headers: string[]
99
+ row: (item: any) => (string | number | boolean | null | undefined)[]
100
+ filename?: string
101
+ }
102
+ export?: CrudExportOptions
103
+ customFieldSources?: QueryCustomFieldSource[]
104
+ joins?: QueryJoinEdge[]
105
+ decorateCustomFields?: CrudListCustomFieldDecorator
106
+ }
107
+
108
+ export type CrudExportColumnConfig = {
109
+ field: string
110
+ header?: string
111
+ resolve?: (item: any) => unknown
112
+ }
113
+
114
+ export type CrudExportOptions = {
115
+ enabled?: boolean
116
+ formats?: CrudExportFormat[]
117
+ filename?: string | ((format: CrudExportFormat) => string)
118
+ columns?: CrudExportColumnConfig[]
119
+ batchSize?: number
120
+ }
121
+
122
+ const DEFAULT_EXPORT_FORMATS: CrudExportFormat[] = ['csv', 'json', 'xml', 'markdown']
123
+ const DEFAULT_EXPORT_BATCH_SIZE = 1000
124
+ const MIN_EXPORT_BATCH_SIZE = 100
125
+ const MAX_EXPORT_BATCH_SIZE = 10000
126
+
127
+ type ColumnResolver = {
128
+ field: string
129
+ header: string
130
+ resolve: (item: any) => unknown
131
+ }
132
+
133
+ function resolveAvailableExportFormats(list?: ListConfig<any>): CrudExportFormat[] {
134
+ if (!list) return []
135
+ if (list.export?.enabled === false) return []
136
+ const formats = list.export?.formats && list.export.formats.length > 0
137
+ ? [...list.export.formats]
138
+ : [...DEFAULT_EXPORT_FORMATS]
139
+ if (!list.export?.formats && list.allowCsv && !formats.includes('csv')) formats.push('csv')
140
+ return Array.from(new Set(formats))
141
+ }
142
+
143
+ function resolveExportBatchSize(list: ListConfig<any> | undefined, requestedPageSize: number): number {
144
+ const fallback = Math.max(requestedPageSize, DEFAULT_EXPORT_BATCH_SIZE)
145
+ const raw = list?.export?.batchSize ?? fallback
146
+ return Math.min(Math.max(raw, MIN_EXPORT_BATCH_SIZE), MAX_EXPORT_BATCH_SIZE)
147
+ }
148
+
149
+ function sanitizeFieldName(base: string, used: Set<string>, fallbackIndex: number): string {
150
+ const trimmed = base.trim()
151
+ const sanitized = trimmed.replace(/[^a-zA-Z0-9_\-]/g, '_') || `field_${fallbackIndex}`
152
+ const normalized = /^[A-Za-z_]/.test(sanitized) ? sanitized : `f_${sanitized}`
153
+ let candidate = normalized
154
+ let counter = 1
155
+ while (used.has(candidate)) {
156
+ candidate = `${normalized}_${counter++}`
157
+ }
158
+ used.add(candidate)
159
+ return candidate
160
+ }
161
+
162
+ function buildExportFromColumns(items: any[], columnsConfig: CrudExportColumnConfig[]): PreparedExport {
163
+ const used = new Set<string>()
164
+ const columns: ColumnResolver[] = columnsConfig.map((col, idx) => {
165
+ const fieldName = sanitizeFieldName(col.field || `field_${idx}`, used, idx)
166
+ const header = col.header?.trim().length ? col.header!.trim() : col.field || `Field ${idx + 1}`
167
+ const resolver = col.resolve
168
+ ? col.resolve
169
+ : ((item: any) => (item != null ? (item as any)[col.field] : undefined))
170
+ return { field: fieldName, header, resolve: resolver }
171
+ })
172
+ const rows = items.map((item) => {
173
+ const row: Record<string, unknown> = {}
174
+ columns.forEach((column) => {
175
+ try {
176
+ row[column.field] = column.resolve(item)
177
+ } catch {
178
+ row[column.field] = undefined
179
+ }
180
+ })
181
+ return row
182
+ })
183
+ return {
184
+ columns: columns.map(({ field, header }) => ({ field, header })),
185
+ rows,
186
+ }
187
+ }
188
+
189
+ function buildExportFromCsv(items: any[], csv: NonNullable<ListConfig<any>['csv']>): PreparedExport {
190
+ const used = new Set<string>()
191
+ const columns = csv.headers.map((header, idx) => ({
192
+ field: sanitizeFieldName(header || `column_${idx + 1}`, used, idx),
193
+ header: header || `Column ${idx + 1}`,
194
+ }))
195
+ const rows = items.map((item) => {
196
+ const values = csv.row(item) || []
197
+ const row: Record<string, unknown> = {}
198
+ columns.forEach((column, idx) => {
199
+ row[column.field] = values[idx]
200
+ })
201
+ return row
202
+ })
203
+ return { columns, rows }
204
+ }
205
+
206
+ function buildDefaultExport(items: any[]): PreparedExport {
207
+ const rows = items.map((item) => {
208
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
209
+ return { ...(item as Record<string, unknown>) }
210
+ }
211
+ return { value: item }
212
+ })
213
+ return {
214
+ columns: ensureColumns(rows),
215
+ rows,
216
+ }
217
+ }
218
+
219
+ function prepareExportData(items: any[], list: ListConfig<any>): PreparedExport {
220
+ if (list.export?.columns && list.export.columns.length > 0) {
221
+ return buildExportFromColumns(items, list.export.columns)
222
+ }
223
+ if (list.csv) {
224
+ return buildExportFromCsv(items, list.csv)
225
+ }
226
+ const prepared = buildDefaultExport(items)
227
+ return {
228
+ columns: ensureColumns(prepared.rows, prepared.columns),
229
+ rows: prepared.rows,
230
+ }
231
+ }
232
+
233
+ function finalizeExportFilename(list: ListConfig<any>, format: CrudExportFormat, fallbackBase: string): string {
234
+ const extension = format === 'markdown' ? 'md' : format
235
+ const fromExport = list.export?.filename
236
+ const apply = (value: string | null | undefined): string | null => {
237
+ if (!value) return null
238
+ const trimmed = value.trim()
239
+ if (!trimmed) return null
240
+ const sanitized = trimmed.replace(/[^a-z0-9_\-\.]/gi, '_')
241
+ const lower = sanitized.toLowerCase()
242
+ if (lower.endsWith(`.${extension}`)) return sanitized
243
+ const withoutExtension = sanitized.includes('.') ? sanitized.replace(/\.[^.]+$/, '') : sanitized
244
+ const base = withoutExtension.trim().length > 0 ? withoutExtension : sanitized
245
+ return `${base}.${extension}`
246
+ }
247
+ if (typeof fromExport === 'function') {
248
+ const computed = apply(fromExport(format))
249
+ if (computed) return computed
250
+ } else {
251
+ const computed = apply(fromExport)
252
+ if (computed) return computed
253
+ }
254
+ if (format === 'csv' && list.csv?.filename) {
255
+ const csvName = apply(list.csv.filename)
256
+ if (csvName) return csvName
257
+ }
258
+ return defaultExportFilename(fallbackBase, format)
259
+ }
260
+
261
+ function normalizeFullRecordForExport(input: any): any {
262
+ if (!input || typeof input !== 'object') return input
263
+ if (Array.isArray(input)) return input.map((item) => normalizeFullRecordForExport(item))
264
+ const record: Record<string, unknown> = {}
265
+
266
+ for (const [key, value] of Object.entries(input)) {
267
+ if (key.startsWith('cf_') || key.startsWith('cf:')) continue
268
+ record[key] = value
269
+ }
270
+ const custom = extractAllCustomFieldEntries(input)
271
+ for (const [rawKey, value] of Object.entries(custom)) {
272
+ const sanitizedKey = rawKey.replace(/^cf_/, '')
273
+ record[sanitizedKey] = value
274
+ }
275
+ return record
276
+ }
277
+ export type CreateConfig<TCreate> = {
278
+ schema: z.ZodType<TCreate>
279
+ mapToEntity: (input: TCreate, ctx: CrudCtx) => Record<string, any>
280
+ customFields?: CustomFieldsConfig
281
+ response?: (entity: any) => any
282
+ }
283
+
284
+ export type UpdateConfig<TUpdate> = {
285
+ schema: z.ZodType<TUpdate>
286
+ // Must contain a string uuid `id` field
287
+ getId?: (input: TUpdate) => string
288
+ applyToEntity: (entity: any, input: TUpdate, ctx: CrudCtx) => void | Promise<void>
289
+ customFields?: CustomFieldsConfig
290
+ response?: (entity: any) => any
291
+ }
292
+
293
+ export type DeleteConfig = {
294
+ // Where to take id from; default: query param `id`
295
+ idFrom?: 'query' | 'body'
296
+ softDelete?: boolean // default true
297
+ response?: (id: string) => any
298
+ }
299
+
300
+ export type CrudCommandActionConfig = {
301
+ commandId: string
302
+ schema?: z.ZodTypeAny
303
+ mapInput?: (args: { parsed: any; raw: any; ctx: CrudCtx }) => Promise<any> | any
304
+ metadata?: (args: { input: any; parsed: any; raw: any; ctx: CrudCtx }) => Promise<CommandLogMetadata | null> | CommandLogMetadata | null
305
+ response?: (args: { result: any; logEntry: any | null; ctx: CrudCtx }) => any
306
+ status?: number
307
+ }
308
+
309
+ export type CrudCtx = {
310
+ container: AwilixContainer
311
+ auth: AuthContext | null
312
+ organizationScope: OrganizationScope | null
313
+ selectedOrganizationId: string | null
314
+ organizationIds: string[] | null
315
+ request?: Request
316
+ }
317
+
318
+ export type CrudFactoryOptions<TCreate, TUpdate, TList> = {
319
+ metadata?: CrudMetadata
320
+ orm: OrmEntityConfig
321
+ list?: ListConfig<TList>
322
+ create?: CreateConfig<TCreate>
323
+ update?: UpdateConfig<TUpdate>
324
+ del?: DeleteConfig
325
+ events?: CrudEventsConfig
326
+ indexer?: CrudIndexerConfig
327
+ resolveIdentifiers?: CrudIdentifierResolver
328
+ hooks?: CrudHooks<TCreate, TUpdate, TList>
329
+ actions?: {
330
+ create?: CrudCommandActionConfig
331
+ update?: CrudCommandActionConfig
332
+ delete?: CrudCommandActionConfig
333
+ }
334
+ }
335
+
336
+ function deriveResourceFromActions(actions: CrudFactoryOptions<any, any, any>['actions']): string | null {
337
+ if (!actions) return null
338
+ const ids: Array<string | null | undefined> = [actions.create?.commandId, actions.update?.commandId, actions.delete?.commandId]
339
+ for (const id of ids) {
340
+ const resolved = deriveResourceFromCommandId(id)
341
+ if (resolved) return resolved
342
+ }
343
+ return null
344
+ }
345
+
346
+ function resolveResourceAliasesList(
347
+ opts: CrudFactoryOptions<any, any, any>,
348
+ ormEntityName: string | undefined
349
+ ): { primary: string; aliases: string[] } {
350
+ const eventsResource =
351
+ opts.events?.module && opts.events?.entity ? `${opts.events.module}.${opts.events.entity}` : null
352
+ const commandResource = deriveResourceFromActions(opts.actions)
353
+ const rawCandidate = eventsResource ?? commandResource ?? ormEntityName ?? 'resource'
354
+ const primary = canonicalizeResourceTag(rawCandidate) ?? 'resource'
355
+ return { primary, aliases: [] }
356
+ }
357
+
358
+ function mergeCommandMetadata(base: CommandLogMetadata, override: CommandLogMetadata | null | undefined): CommandLogMetadata {
359
+ if (!override) return base
360
+ const mergedContext = {
361
+ ...(base.context ?? {}),
362
+ ...(override.context ?? {}),
363
+ }
364
+ const merged: CommandLogMetadata = {
365
+ ...base,
366
+ ...override,
367
+ }
368
+ if (Object.keys(mergedContext).length > 0) merged.context = mergedContext
369
+ else if ('context' in merged) delete merged.context
370
+ return merged
371
+ }
372
+
373
+ function json(data: any, init?: ResponseInit) {
374
+ return new Response(JSON.stringify(data), {
375
+ ...(init || {}),
376
+ headers: { 'content-type': 'application/json', ...(init?.headers || {}) },
377
+ })
378
+ }
379
+
380
+ function attachOperationHeader(res: Response, logEntry: any) {
381
+ if (!res || !(res instanceof Response)) return res
382
+ if (!logEntry || typeof logEntry !== 'object') return res
383
+ const undoToken = typeof logEntry.undoToken === 'string' ? logEntry.undoToken : null
384
+ const id = typeof logEntry.id === 'string' ? logEntry.id : null
385
+ const commandId = typeof logEntry.commandId === 'string' ? logEntry.commandId : null
386
+ if (!undoToken || !id || !commandId) return res
387
+ const actionLabel = typeof logEntry.actionLabel === 'string' ? logEntry.actionLabel : null
388
+ const resourceKind = typeof logEntry.resourceKind === 'string' ? logEntry.resourceKind : null
389
+ const resourceId = typeof logEntry.resourceId === 'string' ? logEntry.resourceId : null
390
+ const createdAt = logEntry.createdAt instanceof Date
391
+ ? logEntry.createdAt.toISOString()
392
+ : (typeof logEntry.createdAt === 'string' ? logEntry.createdAt : new Date().toISOString())
393
+ const headerValue = serializeOperationMetadata({
394
+ id,
395
+ undoToken,
396
+ commandId,
397
+ actionLabel,
398
+ resourceKind,
399
+ resourceId,
400
+ executedAt: createdAt,
401
+ })
402
+ try {
403
+ res.headers.set('x-om-operation', headerValue)
404
+ } catch {
405
+ // no-op if headers already sent
406
+ }
407
+ return res
408
+ }
409
+
410
+ function handleError(err: unknown): Response {
411
+ if (err instanceof Response) return err
412
+ if (err instanceof CrudHttpError) return json(err.body, { status: err.status })
413
+ if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
414
+
415
+ const message = err instanceof Error ? err.message : undefined
416
+ const stack = err instanceof Error ? err.stack : undefined
417
+ // eslint-disable-next-line no-console
418
+ console.error('[crud] unexpected error', { message, stack, err })
419
+ const body: Record<string, unknown> = {
420
+ error: 'Internal server error',
421
+ message: 'Something went wrong. Please try again later.',
422
+ }
423
+ return json(body, { status: 500 })
424
+ }
425
+
426
+ function isUuid(v: any): v is string {
427
+ return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
428
+ }
429
+
430
+ type AccessLogServiceLike = { log: (input: any) => Promise<unknown> | unknown }
431
+
432
+ function resolveAccessLogService(container: AwilixContainer): AccessLogServiceLike | null {
433
+ try {
434
+ const service = container.resolve?.('accessLogService') as AccessLogServiceLike | undefined
435
+ if (service && typeof service.log === 'function') return service
436
+ } catch (err) {
437
+ try {
438
+ console.warn('[crud] accessLogService not available in container', err)
439
+ } catch {}
440
+ }
441
+ return null
442
+ }
443
+
444
+ function logForbidden(details: Record<string, unknown>) {
445
+ try {
446
+ // eslint-disable-next-line no-console
447
+ console.warn('[crud] Forbidden request', details)
448
+ } catch {}
449
+ }
450
+
451
+ function collectFieldNames(items: any[]): string[] {
452
+ const set = new Set<string>()
453
+ for (const item of items) {
454
+ if (!item || typeof item !== 'object') continue
455
+ for (const key of Object.keys(item)) {
456
+ if (typeof key === 'string' && key.length > 0) set.add(key)
457
+ }
458
+ }
459
+ return Array.from(set)
460
+ }
461
+
462
+ function determineAccessType(query: unknown, total: number, idField: string): string {
463
+ if (query && typeof query === 'object' && query !== null && idField in (query as Record<string, unknown>)) {
464
+ const value = (query as Record<string, unknown>)[idField]
465
+ if (value !== undefined && value !== null && String(value).length > 0) return 'read:item'
466
+ }
467
+ return total > 1 ? 'read:list' : 'read'
468
+ }
469
+
470
+ function createCrudProfiler(resource: string, operation: string): Profiler {
471
+ const enabled = shouldEnableProfiler(resource)
472
+ return createProfiler({
473
+ scope: `crud:${operation}`,
474
+ target: resource,
475
+ label: `${resource}:${operation}`,
476
+ loggerLabel: '[crud:profile]',
477
+ enabled,
478
+ })
479
+ }
480
+
481
+ export type LogCrudAccessOptions = {
482
+ container: AwilixContainer
483
+ auth: AuthContext | null
484
+ request?: Request
485
+ items: any[]
486
+ idField?: string
487
+ resourceKind: string
488
+ organizationId?: string | null
489
+ tenantId?: string | null
490
+ query?: unknown
491
+ accessType?: string
492
+ fields?: string[]
493
+ }
494
+
495
+ export async function logCrudAccess(options: LogCrudAccessOptions) {
496
+ const { container, auth, request, items, resourceKind } = options
497
+ if (!auth) return
498
+ if (!Array.isArray(items) || items.length === 0) return
499
+ const service = resolveAccessLogService(container)
500
+ if (!service) return
501
+
502
+ const idField = options.idField || 'id'
503
+ const tenantId = options.tenantId ?? auth.tenantId ?? null
504
+ const organizationId = options.organizationId ?? auth.orgId ?? null
505
+ const actorUserId = (auth.keyId ?? auth.sub) ?? null
506
+ const fields = options.fields && options.fields.length ? options.fields : collectFieldNames(items)
507
+ const accessType = options.accessType ?? determineAccessType(options.query, items.length, idField)
508
+
509
+ const context: Record<string, unknown> = {
510
+ resultCount: items.length,
511
+ accessType,
512
+ }
513
+ if (options.query && typeof options.query === 'object' && options.query !== null) {
514
+ context.queryKeys = Object.keys(options.query as Record<string, unknown>)
515
+ }
516
+ try {
517
+ if (request) {
518
+ const url = new URL(request.url)
519
+ context.path = url.pathname
520
+ }
521
+ } catch {
522
+ // ignore url parsing issues
523
+ }
524
+
525
+ const uniqueIds = new Set<string>()
526
+ const tasks: Promise<unknown>[] = []
527
+ for (const item of items) {
528
+ if (!item || typeof item !== 'object') continue
529
+ const rawId = (item as any)[idField]
530
+ const resourceId = normalizeIdentifierValue(rawId)
531
+ if (!resourceId || uniqueIds.has(resourceId)) continue
532
+ uniqueIds.add(resourceId)
533
+ const payload: Record<string, unknown> = {
534
+ tenantId,
535
+ organizationId,
536
+ actorUserId,
537
+ resourceKind,
538
+ resourceId,
539
+ accessType,
540
+ }
541
+ if (fields.length > 0) payload.fields = fields
542
+ if (Object.keys(context).length > 0) payload.context = context
543
+ tasks.push(
544
+ Promise.resolve(service.log(payload)).catch((err) => {
545
+ try {
546
+ console.error('[crud] failed to record access log', { err, payload })
547
+ } catch {}
548
+ return undefined
549
+ })
550
+ )
551
+ }
552
+ if (tasks.length > 0) await Promise.all(tasks)
553
+ }
554
+
555
+ type CrudCacheStoredValue = {
556
+ payload: any
557
+ generatedAt: number
558
+ }
559
+
560
+ function safeClone<T>(value: T): T {
561
+ try {
562
+ const structuredCloneFn = (globalThis as any).structuredClone
563
+ if (typeof structuredCloneFn === 'function') {
564
+ return structuredCloneFn(value)
565
+ }
566
+ } catch {}
567
+ try {
568
+ return JSON.parse(JSON.stringify(value)) as T
569
+ } catch {
570
+ return value
571
+ }
572
+ }
573
+
574
+ function collectScopeOrganizationIds(ctx: CrudCtx): Array<string | null> {
575
+ if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
576
+ return Array.from(new Set(ctx.organizationIds))
577
+ }
578
+ const fallback = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null
579
+ return [fallback]
580
+ }
581
+
582
+ function serializeSearchParams(params: URLSearchParams): string {
583
+ if (!params || params.keys().next().done) return ''
584
+ const grouped = new Map<string, string[]>()
585
+ params.forEach((value, key) => {
586
+ const existing = grouped.get(key) ?? []
587
+ existing.push(value)
588
+ grouped.set(key, existing)
589
+ })
590
+ const normalized: Array<[string, string[]]> = Array.from(grouped.entries()).map(([key, values]) => [key, values.sort()])
591
+ normalized.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
592
+ return JSON.stringify(normalized)
593
+ }
594
+
595
+ function buildCrudCacheKey(resource: string, request: Request, ctx: CrudCtx): string {
596
+ const url = new URL(request.url)
597
+ const scopeIds = collectScopeOrganizationIds(ctx)
598
+ const scopeSegment = scopeIds.length
599
+ ? scopeIds.map((id) => normalizeTagSegment(id)).sort().join(',')
600
+ : 'none'
601
+ return [
602
+ 'crud',
603
+ normalizeTagSegment(resource),
604
+ 'GET',
605
+ url.pathname,
606
+ `tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
607
+ `selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
608
+ `scope:${scopeSegment}`,
609
+ `query:${serializeSearchParams(url.searchParams)}`,
610
+ ].join('|')
611
+ }
612
+
613
+ function extractRecordIds(items: any[], idField: string): string[] {
614
+ if (!Array.isArray(items) || !items.length) return []
615
+ const ids = new Set<string>()
616
+ for (const item of items) {
617
+ if (!item || typeof item !== 'object') continue
618
+ const rawId = (item as any)[idField]
619
+ const id = normalizeIdentifierValue(rawId)
620
+ if (id) ids.add(id)
621
+ }
622
+ return Array.from(ids)
623
+ }
624
+
625
+ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: CrudFactoryOptions<TCreate, TUpdate, TList>) {
626
+ const metadata = opts.metadata || {}
627
+ const ormCfg = {
628
+ entity: opts.orm.entity,
629
+ idField: opts.orm.idField ?? 'id',
630
+ orgField: opts.orm.orgField === null ? null : opts.orm.orgField ?? 'organizationId',
631
+ tenantField: opts.orm.tenantField === null ? null : opts.orm.tenantField ?? 'tenantId',
632
+ softDeleteField: opts.orm.softDeleteField === null ? null : opts.orm.softDeleteField ?? 'deletedAt',
633
+ }
634
+ const entityName = typeof ormCfg.entity?.name === 'string' && ormCfg.entity.name.length > 0 ? ormCfg.entity.name : undefined
635
+ const resourceInfo = resolveResourceAliasesList(opts, entityName)
636
+ const resourceKind = resourceInfo.primary
637
+ const resourceAliases = resourceInfo.aliases
638
+ const resourceTargets = expandResourceAliases(resourceKind, resourceAliases)
639
+ const defaultIdentifierResolver: CrudIdentifierResolver = (entity, _action) => {
640
+ const id = normalizeIdentifierValue((entity as any)[ormCfg.idField!])
641
+ const orgId = ormCfg.orgField ? normalizeIdentifierValue((entity as any)[ormCfg.orgField]) : null
642
+ const tenantId = ormCfg.tenantField ? normalizeIdentifierValue((entity as any)[ormCfg.tenantField]) : null
643
+ return {
644
+ id: id ?? '',
645
+ organizationId: orgId ?? null,
646
+ tenantId: tenantId ?? null,
647
+ }
648
+ }
649
+ const identifierResolver: CrudIdentifierResolver = opts.resolveIdentifiers
650
+ ? (entity, action) => {
651
+ const raw = opts.resolveIdentifiers!(entity, action)
652
+ const id = normalizeIdentifierValue(raw?.id)
653
+ const organizationId = normalizeIdentifierValue(raw?.organizationId)
654
+ const tenantId = normalizeIdentifierValue(raw?.tenantId)
655
+ return {
656
+ id: id ?? '',
657
+ organizationId: organizationId ?? null,
658
+ tenantId: tenantId ?? null,
659
+ }
660
+ }
661
+ : defaultIdentifierResolver
662
+
663
+ const listCustomFieldDecorator = opts.list?.decorateCustomFields
664
+ const indexerConfig = opts.indexer as CrudIndexerConfig | undefined
665
+ const eventsConfig = opts.events as CrudEventsConfig | undefined
666
+
667
+ const pickNormalizedIdentifier = (...values: Array<string | number | null | undefined>): string | null => {
668
+ for (const value of values) {
669
+ const normalized = normalizeIdentifierValue(value)
670
+ if (normalized) return normalized
671
+ }
672
+ return null
673
+ }
674
+
675
+ const extractIdentifierFrom = (...payloads: Array<any>): string | null => {
676
+ const candidates: Array<string | number | null | undefined> = []
677
+ for (const payload of payloads) {
678
+ if (!payload || typeof payload !== 'object') {
679
+ candidates.push(payload as any)
680
+ continue
681
+ }
682
+ candidates.push(
683
+ (payload as any)?.id,
684
+ (payload as any)?.shipmentId,
685
+ (payload as any)?.paymentId,
686
+ (payload as any)?.lineId,
687
+ (payload as any)?.adjustmentId,
688
+ (payload as any)?.itemId,
689
+ (payload as any)?.orderAdjustmentId,
690
+ (payload as any)?.orderId,
691
+ (payload as any)?.quoteId,
692
+ )
693
+ }
694
+ return pickNormalizedIdentifier(...candidates)
695
+ }
696
+
697
+ const markCommandResultForIndexing = async (
698
+ id: string | null,
699
+ action: CrudEventAction,
700
+ ctx: CrudCtx,
701
+ ) => {
702
+ if (!id || (!indexerConfig && !eventsConfig)) return
703
+ try {
704
+ const em = ctx.container.resolve('em') as EntityManager
705
+ const entity = await em.findOne(ormCfg.entity, { [ormCfg.idField!]: id } as any)
706
+ const de = ctx.container.resolve('dataEngine') as DataEngine
707
+ const identifiers = identifierResolver(
708
+ entity ?? ({ [ormCfg.idField!]: id } as any),
709
+ action,
710
+ )
711
+ const scopedIdentifiers = {
712
+ ...identifiers,
713
+ organizationId:
714
+ identifiers.organizationId ??
715
+ ctx.selectedOrganizationId ??
716
+ ctx.auth?.orgId ??
717
+ null,
718
+ tenantId: identifiers.tenantId ?? ctx.auth?.tenantId ?? null,
719
+ }
720
+ de.markOrmEntityChange({
721
+ action,
722
+ entity: entity ?? ({ [ormCfg.idField!]: id } as any),
723
+ identifiers: scopedIdentifiers,
724
+ events: eventsConfig,
725
+ indexer: indexerConfig,
726
+ })
727
+ await de.flushOrmEntityChanges()
728
+ } catch (err) {
729
+ if (process.env.NODE_ENV !== 'production') {
730
+ console.warn('[crud] failed to mark command result for indexing', { err, id, action, resourceKind })
731
+ }
732
+ }
733
+ }
734
+
735
+ const inferFieldValue = (item: Record<string, unknown>, keys: string[]): string | null => {
736
+ for (const key of keys) {
737
+ const value = item[key]
738
+ if (typeof value === 'string') {
739
+ const trimmed = value.trim()
740
+ if (trimmed.length) return trimmed
741
+ }
742
+ }
743
+ return null
744
+ }
745
+
746
+ const decorateItemsWithCustomFields = async (items: any[], ctx: CrudCtx): Promise<any[]> => {
747
+ if (!listCustomFieldDecorator || !Array.isArray(items) || items.length === 0) return items
748
+ const entityIds = Array.isArray(listCustomFieldDecorator.entityIds)
749
+ ? listCustomFieldDecorator.entityIds
750
+ : [listCustomFieldDecorator.entityIds]
751
+ if (!entityIds.length) return items
752
+ const cfProfiler = createCrudProfiler(resourceKind, 'custom_fields')
753
+ cfProfiler.mark('prepare')
754
+ let profileClosed = false
755
+ const endProfile = (extra?: Record<string, unknown>) => {
756
+ if (!cfProfiler.enabled || profileClosed) return
757
+ profileClosed = true
758
+ cfProfiler.end(extra)
759
+ }
760
+ try {
761
+ const em = (ctx.container.resolve('em') as EntityManager)
762
+ const organizationIds =
763
+ Array.isArray(ctx.organizationIds) && ctx.organizationIds.length
764
+ ? ctx.organizationIds
765
+ : [ctx.selectedOrganizationId ?? null]
766
+ const definitionIndex = await loadCustomFieldDefinitionIndex({
767
+ em,
768
+ entityIds,
769
+ tenantId: ctx.auth?.tenantId ?? null,
770
+ organizationIds,
771
+ })
772
+ cfProfiler.mark('definitions_loaded', { definitionCount: definitionIndex.size })
773
+ const decoratedItems = items.map((raw) => {
774
+ if (!raw || typeof raw !== 'object') return raw
775
+ const item = raw as Record<string, unknown>
776
+ const context = listCustomFieldDecorator.resolveContext
777
+ ? listCustomFieldDecorator.resolveContext(raw, ctx) ?? {}
778
+ : {}
779
+ const organizationId =
780
+ context.organizationId ??
781
+ inferFieldValue(item, ['organization_id', 'organizationId'])
782
+ const tenantId =
783
+ context.tenantId ??
784
+ inferFieldValue(item, ['tenant_id', 'tenantId']) ??
785
+ ctx.auth?.tenantId ??
786
+ null
787
+ const decorated = decorateRecordWithCustomFields(item, definitionIndex, {
788
+ organizationId: organizationId ?? null,
789
+ tenantId: tenantId ?? null,
790
+ })
791
+ const output = {
792
+ ...item,
793
+ customValues: decorated.customValues,
794
+ customFields: decorated.customFields,
795
+ }
796
+ return output
797
+ })
798
+ cfProfiler.mark('decorate_complete', { itemCount: decoratedItems.length })
799
+ endProfile({
800
+ entityIds: entityIds.length,
801
+ itemCount: decoratedItems.length,
802
+ })
803
+ return decoratedItems
804
+ } catch (err) {
805
+ console.warn('[crud] failed to decorate custom fields', err)
806
+ endProfile({
807
+ result: 'error',
808
+ entityIds: entityIds.length,
809
+ itemCount: items.length,
810
+ })
811
+ return items
812
+ }
813
+ }
814
+
815
+ async function ensureAuth(request?: Request | null) {
816
+ const auth = request ? await getAuthFromRequest(request) : await getAuthFromCookies()
817
+ if (!auth) return null
818
+ if (auth.tenantId && !isUuid(auth.tenantId)) return null
819
+ return auth
820
+ }
821
+
822
+ async function withCtx(request: Request): Promise<CrudCtx> {
823
+ const container = await createRequestContainer()
824
+ const rawAuth = await ensureAuth(request)
825
+ let scope: OrganizationScope | null = null
826
+ let selectedOrganizationId: string | null = null
827
+ let organizationIds: string[] | null = null
828
+ if (rawAuth) {
829
+ try {
830
+ scope = await resolveOrganizationScopeForRequest({ container, auth: rawAuth, request })
831
+ } catch {
832
+ scope = null
833
+ }
834
+ }
835
+ const scopedTenantId = scope?.tenantId ?? rawAuth?.tenantId ?? null
836
+ const scopedOrgId = scope ? (scope.selectedId ?? null) : (rawAuth?.orgId ?? null)
837
+ selectedOrganizationId = scopedOrgId
838
+ const scopedAuth = rawAuth
839
+ ? {
840
+ ...rawAuth,
841
+ tenantId: scopedTenantId ?? null,
842
+ orgId: scopedOrgId ?? null,
843
+ }
844
+ : null
845
+ const fallbackOrgId = scopedOrgId ?? rawAuth?.orgId ?? null
846
+ const rawScopeIds = scope?.filterIds
847
+ const scopedIds = Array.isArray(rawScopeIds) ? rawScopeIds.filter((id): id is string => typeof id === 'string' && id.length > 0) : null
848
+ if (!scope) {
849
+ organizationIds = fallbackOrgId ? [fallbackOrgId] : null
850
+ } else if (scopedIds === null) {
851
+ organizationIds = scope.allowedIds === null ? null : (fallbackOrgId ? [fallbackOrgId] : null)
852
+ } else if (scopedIds.length > 0) {
853
+ organizationIds = Array.from(new Set(scopedIds))
854
+ } else if (fallbackOrgId) {
855
+ const allowedIds = Array.isArray(scope?.allowedIds) ? scope.allowedIds : null
856
+ let canUseFallback = false
857
+ if (allowedIds === null) {
858
+ canUseFallback = true
859
+ } else if (allowedIds.includes(fallbackOrgId) || allowedIds.length === 0) {
860
+ canUseFallback = true
861
+ }
862
+ if (canUseFallback) {
863
+ organizationIds = [fallbackOrgId]
864
+ } else {
865
+ organizationIds = []
866
+ }
867
+ } else {
868
+ organizationIds = []
869
+ }
870
+ return { container, auth: scopedAuth, organizationScope: scope, selectedOrganizationId, organizationIds, request }
871
+ }
872
+
873
+ async function GET(request: Request) {
874
+ const profiler = createCrudProfiler(resourceKind, 'list')
875
+ const requestMeta: Record<string, unknown> = { method: request.method }
876
+ try {
877
+ const urlObj = new URL(request.url)
878
+ requestMeta.path = urlObj.pathname
879
+ requestMeta.url = request.url
880
+ if (urlObj.search) requestMeta.query = urlObj.search
881
+ } catch {
882
+ requestMeta.url = request.url
883
+ }
884
+ profiler.mark('request_received', requestMeta)
885
+ let profileClosed = false
886
+ const finishProfile = (extra?: Record<string, unknown>) => {
887
+ if (!profiler.enabled || profileClosed) return
888
+ profileClosed = true
889
+ const meta = extra ? { ...requestMeta, ...extra } : { ...requestMeta }
890
+ profiler.end(meta)
891
+ }
892
+ try {
893
+ profiler.mark('resolve_context')
894
+ const ctx = await withCtx(request)
895
+ profiler.mark('context_ready')
896
+ if (!ctx.auth) {
897
+ finishProfile({ reason: 'unauthorized' })
898
+ return json({ error: 'Unauthorized' }, { status: 401 })
899
+ }
900
+ if (!opts.list) {
901
+ finishProfile({ reason: 'list_not_configured' })
902
+ return json({ error: 'Not implemented' }, { status: 501 })
903
+ }
904
+ const url = new URL(request.url)
905
+ const queryParams = Object.fromEntries(url.searchParams.entries())
906
+ profiler.mark('query_parsed')
907
+ const validated = opts.list.schema.parse(queryParams)
908
+ profiler.mark('query_validated')
909
+
910
+ await opts.hooks?.beforeList?.(validated as any, ctx)
911
+ profiler.mark('before_list_hook')
912
+
913
+ const availableFormats = resolveAvailableExportFormats(opts.list)
914
+ const requestedExport = normalizeExportFormat((queryParams as any).format)
915
+ const exportRequested = requestedExport != null && availableFormats.includes(requestedExport)
916
+ const requestedPage = Number((queryParams as any).page ?? 1) || 1
917
+ const requestedPageSize = Math.min(Math.max(Number((queryParams as any).pageSize ?? 50) || 50, 1), 100)
918
+ const exportPageSize = exportRequested ? resolveExportBatchSize(opts.list, requestedPageSize) : requestedPageSize
919
+ const exportScopeParam = (queryParams as any).exportScope ?? (queryParams as any).export_scope
920
+ const exportScope = typeof exportScopeParam === 'string' ? exportScopeParam.toLowerCase() : null
921
+ const exportFullRequested = exportRequested && (exportScope === 'full' || parseBooleanToken((queryParams as any).full) === true)
922
+ profiler.mark('export_configured', { exportRequested, exportFullRequested })
923
+
924
+ const cacheEnabled = isCrudCacheEnabled() && !exportRequested
925
+ const cacheTimerStart = cacheEnabled && isCrudCacheDebugEnabled()
926
+ ? process.hrtime.bigint()
927
+ : null
928
+ const cache = cacheEnabled ? resolveCrudCache(ctx.container) : null
929
+ const cacheKey = cacheEnabled ? buildCrudCacheKey(resourceKind, request, ctx) : null
930
+ let cacheStatus: 'hit' | 'miss' = 'miss'
931
+ let cachedValue: CrudCacheStoredValue | null = null
932
+
933
+ if (cacheEnabled && cache && cacheKey) {
934
+ const rawCached = await cache.get(cacheKey)
935
+ if (rawCached !== null && rawCached !== undefined) {
936
+ if (typeof rawCached === 'object' && 'payload' in (rawCached as any)) {
937
+ cachedValue = rawCached as CrudCacheStoredValue
938
+ } else {
939
+ cachedValue = { payload: rawCached, generatedAt: Date.now() }
940
+ }
941
+ }
942
+ }
943
+ profiler.mark('cache_checked', { cached: cachedValue !== null })
944
+
945
+ const tenantForScope = ctx.auth?.tenantId ?? null
946
+ const maybeStoreCrudCache = async (payload: any) => {
947
+ if (!cacheEnabled || !cache || !cacheKey) return
948
+ if (!payload || typeof payload !== 'object') return
949
+ const items = Array.isArray((payload as any).items) ? (payload as any).items : []
950
+ const tags = new Set<string>()
951
+ const scopeOrgIds = collectScopeOrganizationIds(ctx)
952
+ const crudSegment = deriveCrudSegmentTag(resourceKind, request)
953
+ for (const target of resourceTargets) {
954
+ for (const tag of buildCollectionTags(target, tenantForScope, scopeOrgIds)) {
955
+ tags.add(tag)
956
+ }
957
+ }
958
+ const recordIds = extractRecordIds(items, ormCfg.idField!)
959
+ for (const recordId of recordIds) {
960
+ for (const target of resourceTargets) {
961
+ tags.add(buildRecordTag(target, tenantForScope, recordId))
962
+ }
963
+ }
964
+ if (crudSegment) {
965
+ tags.add(`crud:segment:${crudSegment}`)
966
+ }
967
+ if (!tags.size) return
968
+ try {
969
+ await cache.set(cacheKey, { payload: safeClone(payload), generatedAt: Date.now() }, { tags: Array.from(tags) })
970
+ debugCrudCache('store', {
971
+ resource: resourceKind,
972
+ key: cacheKey,
973
+ tags: Array.from(tags),
974
+ itemCount: items.length,
975
+ })
976
+ } catch (err) {
977
+ debugCrudCache('store', {
978
+ resource: resourceKind,
979
+ key: cacheKey,
980
+ error: err instanceof Error ? err.message : String(err),
981
+ })
982
+ }
983
+ }
984
+
985
+ const logCacheOutcome = (event: 'hit' | 'miss', itemCount: number) => {
986
+ if (!cacheTimerStart) return
987
+ const elapsedMs = Number(process.hrtime.bigint() - cacheTimerStart) / 1_000_000
988
+ debugCrudCache(event, {
989
+ resource: resourceKind,
990
+ key: cacheKey,
991
+ durationMs: Math.round(elapsedMs * 1000) / 1000,
992
+ itemCount,
993
+ })
994
+ }
995
+
996
+ const respondWithPayload = (payload: any, extraHeaders?: Record<string, string>) => {
997
+ const headers: Record<string, string> = extraHeaders ? { ...extraHeaders } : {}
998
+ const warning = payload && typeof payload === 'object' && payload.meta?.partialIndexWarning
999
+ if (warning) {
1000
+ headers['x-om-partial-index'] = JSON.stringify({
1001
+ type: 'partial_index',
1002
+ entity: warning.entity,
1003
+ entityLabel: warning.entityLabel ?? warning.entity,
1004
+ baseCount: warning.baseCount ?? null,
1005
+ indexedCount: warning.indexedCount ?? null,
1006
+ scope: warning.scope ?? 'scoped',
1007
+ })
1008
+ }
1009
+ if (cacheEnabled) {
1010
+ headers['x-om-cache'] = cacheStatus
1011
+ }
1012
+ return json(payload, Object.keys(headers).length ? { headers } : undefined)
1013
+ }
1014
+
1015
+ if (cachedValue) {
1016
+ cacheStatus = 'hit'
1017
+ profiler.mark('cache_hit', { generatedAt: cachedValue.generatedAt ?? null })
1018
+ const payload = safeClone(cachedValue.payload)
1019
+ const items = Array.isArray((payload as any)?.items) ? (payload as any).items : []
1020
+ profiler.mark('cache_payload_ready', { itemCount: items.length })
1021
+ await logCrudAccess({
1022
+ container: ctx.container,
1023
+ auth: ctx.auth,
1024
+ request,
1025
+ items,
1026
+ idField: ormCfg.idField!,
1027
+ resourceKind,
1028
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
1029
+ tenantId: ctx.auth.tenantId ?? null,
1030
+ query: validated,
1031
+ })
1032
+ await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1033
+ logCacheOutcome('hit', items.length)
1034
+ const response = respondWithPayload(payload)
1035
+ finishProfile({ result: 'cache_hit', cacheStatus })
1036
+ return response
1037
+ }
1038
+
1039
+ // Prefer query engine when configured
1040
+ if (opts.list.entityId && opts.list.fields) {
1041
+ profiler.mark('query_engine_prepare')
1042
+ const qe = (ctx.container.resolve('queryEngine') as QueryEngine)
1043
+ profiler.mark('query_engine_resolved')
1044
+ const sortFieldRaw = (queryParams as any).sortField || 'id'
1045
+ const sortDirRaw = ((queryParams as any).sortDir || 'asc').toLowerCase() === 'desc' ? SortDir.Desc : SortDir.Asc
1046
+ const sortField = (opts.list.sortFieldMap && opts.list.sortFieldMap[sortFieldRaw]) || sortFieldRaw
1047
+ const sort: Sort[] = [{ field: sortField as any, dir: sortDirRaw } as any]
1048
+ const page: Page = exportRequested
1049
+ ? { page: 1, pageSize: exportPageSize }
1050
+ : { page: requestedPage, pageSize: requestedPageSize }
1051
+ const filters = exportFullRequested
1052
+ ? ({} as Where<any>)
1053
+ : (opts.list.buildFilters ? await opts.list.buildFilters(validated as any, ctx) : ({} as Where<any>))
1054
+ const withDeleted = parseBooleanToken((queryParams as any).withDeleted) === true
1055
+ profiler.mark('filters_ready', { withDeleted })
1056
+ if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
1057
+ profiler.mark('scope_blocked')
1058
+ logForbidden({
1059
+ resourceKind,
1060
+ action: 'list',
1061
+ reason: 'organization_scope_empty',
1062
+ userId: ctx.auth?.sub ?? null,
1063
+ tenantId: ctx.auth?.tenantId ?? null,
1064
+ organizationIds: ctx.organizationIds,
1065
+ })
1066
+ const emptyPayload = { items: [], total: 0, page: page.page, pageSize: page.pageSize, totalPages: 0 }
1067
+ await opts.hooks?.afterList?.(emptyPayload, { ...ctx, query: validated as any })
1068
+ await maybeStoreCrudCache(emptyPayload)
1069
+ logCacheOutcome(cacheStatus, emptyPayload.items.length)
1070
+ const response = respondWithPayload(emptyPayload)
1071
+ finishProfile({ result: 'empty_scope', cacheStatus, itemCount: 0, total: 0 })
1072
+ return response
1073
+ }
1074
+ const queryOpts: any = {
1075
+ fields: opts.list.fields!,
1076
+ includeCustomFields: true,
1077
+ sort,
1078
+ page,
1079
+ filters,
1080
+ withDeleted,
1081
+ }
1082
+ if (opts.list.customFieldSources) {
1083
+ queryOpts.customFieldSources = opts.list.customFieldSources
1084
+ }
1085
+ if (opts.list.joins) {
1086
+ queryOpts.joins = opts.list.joins
1087
+ }
1088
+ if (ormCfg.tenantField) queryOpts.tenantId = ctx.auth.tenantId!
1089
+ if (ormCfg.orgField) {
1090
+ queryOpts.organizationId = ctx.selectedOrganizationId ?? undefined
1091
+ queryOpts.organizationIds = ctx.organizationIds ?? undefined
1092
+ }
1093
+ const queryEntity = String(opts.list.entityId)
1094
+ profiler.mark('query_options_ready')
1095
+ const queryProfiler = profiler.child('query_engine', { entity: queryEntity })
1096
+ const res = await qe.query(opts.list.entityId as any, { ...queryOpts, profiler: queryProfiler })
1097
+ const rawItems = res.items || []
1098
+ let transformedItems = rawItems.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
1099
+ profiler.mark('transform_complete', { itemCount: transformedItems.length })
1100
+ transformedItems = await decorateItemsWithCustomFields(transformedItems, ctx)
1101
+ profiler.mark('custom_fields_complete', { itemCount: transformedItems.length })
1102
+
1103
+ await logCrudAccess({
1104
+ container: ctx.container,
1105
+ auth: ctx.auth,
1106
+ request,
1107
+ items: transformedItems,
1108
+ idField: ormCfg.idField!,
1109
+ resourceKind,
1110
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
1111
+ tenantId: ctx.auth.tenantId ?? null,
1112
+ query: validated,
1113
+ })
1114
+ profiler.mark('access_logged')
1115
+
1116
+ if (exportRequested && requestedExport) {
1117
+ const total = typeof res.total === 'number' ? res.total : rawItems.length
1118
+ const initialExportItems = exportFullRequested
1119
+ ? rawItems.map(normalizeFullRecordForExport)
1120
+ : transformedItems
1121
+ let exportItems = [...initialExportItems]
1122
+ if (total > exportItems.length) {
1123
+ const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
1124
+ const queryBase: any = { ...queryOpts }
1125
+ delete queryBase.page
1126
+ let nextPage = 2
1127
+ while (exportItems.length < total) {
1128
+ profiler.mark('export_next_page_request', { page: nextPage })
1129
+ const nextRes = await qe.query(opts.list.entityId as any, {
1130
+ ...queryBase,
1131
+ page: { page: nextPage, pageSize: exportPageSizeNumber },
1132
+ profiler: profiler.child('query_engine', { entity: queryEntity, page: nextPage, mode: 'export' }),
1133
+ })
1134
+ const nextItemsRaw = nextRes.items || []
1135
+ if (!nextItemsRaw.length) break
1136
+ let nextTransformed = nextItemsRaw.map(i => (opts.list!.transformItem ? opts.list!.transformItem(i) : i))
1137
+ nextTransformed = await decorateItemsWithCustomFields(nextTransformed, ctx)
1138
+ const nextExportItems = exportFullRequested
1139
+ ? nextItemsRaw.map(normalizeFullRecordForExport)
1140
+ : nextTransformed
1141
+ exportItems.push(...nextExportItems)
1142
+ if (nextExportItems.length < exportPageSizeNumber) break
1143
+ nextPage += 1
1144
+ }
1145
+ }
1146
+ const prepared = exportFullRequested
1147
+ ? { columns: ensureColumns(exportItems), rows: exportItems }
1148
+ : prepareExportData(exportItems, opts.list)
1149
+ const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
1150
+ const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
1151
+ const serialized = serializeExport(prepared, requestedExport)
1152
+ const exportPayload = { items: exportItems, total, page: 1, pageSize: exportItems.length, totalPages: 1, ...(res.meta ? { meta: res.meta } : {}) }
1153
+ await opts.hooks?.afterList?.(exportPayload, { ...ctx, query: validated as any })
1154
+ profiler.mark('after_list_hook')
1155
+ const response = new Response(serialized.body, {
1156
+ headers: {
1157
+ 'content-type': serialized.contentType,
1158
+ 'content-disposition': `attachment; filename="${filename}"`,
1159
+ },
1160
+ })
1161
+ if (res.meta?.partialIndexWarning) {
1162
+ response.headers.set(
1163
+ 'x-om-partial-index',
1164
+ JSON.stringify({
1165
+ type: 'partial_index',
1166
+ entity: res.meta.partialIndexWarning.entity,
1167
+ entityLabel: res.meta.partialIndexWarning.entityLabel ?? res.meta.partialIndexWarning.entity,
1168
+ baseCount: res.meta.partialIndexWarning.baseCount ?? null,
1169
+ indexedCount: res.meta.partialIndexWarning.indexedCount ?? null,
1170
+ scope: res.meta.partialIndexWarning.scope ?? 'scoped',
1171
+ }),
1172
+ )
1173
+ }
1174
+ finishProfile({
1175
+ result: 'export',
1176
+ cacheStatus,
1177
+ itemCount: exportItems.length,
1178
+ total,
1179
+ })
1180
+ return response
1181
+ }
1182
+
1183
+ const payload = {
1184
+ items: transformedItems,
1185
+ total: res.total,
1186
+ page: page.page || requestedPage,
1187
+ pageSize: page.pageSize || requestedPageSize,
1188
+ totalPages: Math.ceil(res.total / (Number(page.pageSize) || 1)),
1189
+ ...(res.meta ? { meta: res.meta } : {}),
1190
+ }
1191
+ await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1192
+ profiler.mark('after_list_hook')
1193
+ await maybeStoreCrudCache(payload)
1194
+ profiler.mark('cache_store_attempt', { cacheEnabled })
1195
+ logCacheOutcome(cacheStatus, payload.items.length)
1196
+ const response = respondWithPayload(payload)
1197
+ finishProfile({
1198
+ result: 'ok',
1199
+ cacheStatus,
1200
+ itemCount: payload.items.length,
1201
+ total: payload.total ?? payload.items.length,
1202
+ })
1203
+ return response
1204
+ }
1205
+
1206
+ // Fallback: plain ORM list
1207
+ profiler.mark('orm_fallback_prepare')
1208
+ const em = (ctx.container.resolve('em') as any)
1209
+ const repo = em.getRepository(ormCfg.entity)
1210
+ profiler.mark('orm_repo_ready')
1211
+ if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
1212
+ profiler.mark('fallback_scope_blocked')
1213
+ logForbidden({
1214
+ resourceKind,
1215
+ action: 'list',
1216
+ reason: 'organization_scope_empty',
1217
+ userId: ctx.auth?.sub ?? null,
1218
+ tenantId: ctx.auth?.tenantId ?? null,
1219
+ organizationIds: ctx.organizationIds,
1220
+ })
1221
+ const emptyPayload = { items: [], total: 0 }
1222
+ await opts.hooks?.afterList?.(emptyPayload, { ...ctx, query: validated as any })
1223
+ await maybeStoreCrudCache(emptyPayload)
1224
+ logCacheOutcome(cacheStatus, emptyPayload.items.length)
1225
+ const response = respondWithPayload(emptyPayload)
1226
+ finishProfile({
1227
+ result: 'empty_scope',
1228
+ cacheStatus,
1229
+ itemCount: 0,
1230
+ total: 0,
1231
+ branch: 'fallback',
1232
+ })
1233
+ return response
1234
+ }
1235
+ const where: any = buildScopedWhere(
1236
+ {},
1237
+ {
1238
+ organizationId: ormCfg.orgField ? (ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null) : undefined,
1239
+ organizationIds: ormCfg.orgField ? ctx.organizationIds ?? undefined : undefined,
1240
+ tenantId: ormCfg.tenantField ? ctx.auth.tenantId : undefined,
1241
+ orgField: ormCfg.orgField,
1242
+ tenantField: ormCfg.tenantField,
1243
+ softDeleteField: ormCfg.softDeleteField,
1244
+ }
1245
+ )
1246
+ let list = await repo.find(where)
1247
+ profiler.mark('orm_query_complete', { itemCount: Array.isArray(list) ? list.length : 0 })
1248
+ list = await decorateItemsWithCustomFields(list, ctx)
1249
+ profiler.mark('fallback_custom_fields_complete', { itemCount: Array.isArray(list) ? list.length : 0 })
1250
+ await logCrudAccess({
1251
+ container: ctx.container,
1252
+ auth: ctx.auth,
1253
+ request,
1254
+ items: list,
1255
+ idField: ormCfg.idField!,
1256
+ resourceKind,
1257
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
1258
+ tenantId: ctx.auth.tenantId ?? null,
1259
+ query: validated,
1260
+ })
1261
+ profiler.mark('access_logged')
1262
+ if (exportRequested && requestedExport) {
1263
+ const exportItems = exportFullRequested ? list.map(normalizeFullRecordForExport) : list
1264
+ const prepared = exportFullRequested
1265
+ ? { columns: ensureColumns(exportItems), rows: exportItems }
1266
+ : prepareExportData(exportItems, opts.list)
1267
+ const fallbackBase = `${opts.events?.entity || resourceKind || 'list'}${exportFullRequested ? '_full' : ''}`
1268
+ const filename = finalizeExportFilename(opts.list, requestedExport, fallbackBase)
1269
+ const serialized = serializeExport(prepared, requestedExport)
1270
+ await opts.hooks?.afterList?.({ items: exportItems, total: exportItems.length, page: 1, pageSize: exportItems.length, totalPages: 1 }, { ...ctx, query: validated as any })
1271
+ profiler.mark('after_list_hook')
1272
+ const response = new Response(serialized.body, {
1273
+ headers: {
1274
+ 'content-type': serialized.contentType,
1275
+ 'content-disposition': `attachment; filename="${filename}"`,
1276
+ },
1277
+ })
1278
+ finishProfile({
1279
+ result: 'export',
1280
+ cacheStatus,
1281
+ itemCount: exportItems.length,
1282
+ total: exportItems.length,
1283
+ branch: 'fallback',
1284
+ })
1285
+ return response
1286
+ }
1287
+ const payload = { items: list, total: list.length }
1288
+ await opts.hooks?.afterList?.(payload, { ...ctx, query: validated as any })
1289
+ profiler.mark('after_list_hook')
1290
+ await maybeStoreCrudCache(payload)
1291
+ profiler.mark('cache_store_attempt', { cacheEnabled })
1292
+ logCacheOutcome(cacheStatus, payload.items.length)
1293
+ const response = respondWithPayload(payload)
1294
+ finishProfile({
1295
+ result: 'ok',
1296
+ cacheStatus,
1297
+ itemCount: payload.items.length,
1298
+ total: payload.total,
1299
+ branch: 'fallback',
1300
+ })
1301
+ return response
1302
+ } catch (e) {
1303
+ finishProfile({ result: 'error' })
1304
+ return handleError(e)
1305
+ }
1306
+ }
1307
+
1308
+ async function POST(request: Request) {
1309
+ try {
1310
+ const useCommand = !!opts.actions?.create
1311
+ if (!opts.create && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
1312
+ const ctx = await withCtx(request)
1313
+ if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
1314
+ if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
1315
+ logForbidden({
1316
+ resourceKind,
1317
+ action: 'create',
1318
+ reason: 'organization_scope_empty',
1319
+ userId: ctx.auth?.sub ?? null,
1320
+ tenantId: ctx.auth?.tenantId ?? null,
1321
+ organizationIds: ctx.organizationIds,
1322
+ })
1323
+ return json({ error: 'Forbidden' }, { status: 403 })
1324
+ }
1325
+ const body = await request.json().catch(() => ({}))
1326
+
1327
+ if (useCommand) {
1328
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
1329
+ const action = opts.actions!.create!
1330
+ const parsed = action.schema ? action.schema.parse(body) : body
1331
+ const input = action.mapInput ? await action.mapInput({ parsed, raw: body, ctx }) : parsed
1332
+ const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw: body, ctx }) : null
1333
+ const baseMetadata: CommandLogMetadata = {
1334
+ tenantId: ctx.auth?.tenantId ?? null,
1335
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
1336
+ resourceKind,
1337
+ context: { cacheAliases: resourceTargets },
1338
+ }
1339
+ const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
1340
+ const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
1341
+ const payload = action.response ? action.response({ result, logEntry, ctx }) : result
1342
+ const resolvedPayload = await Promise.resolve(payload)
1343
+ const status = action.status ?? 201
1344
+ const response = json(resolvedPayload, { status })
1345
+ attachOperationHeader(response, logEntry)
1346
+ const indexedId = extractIdentifierFrom(resolvedPayload, result, parsed)
1347
+ await markCommandResultForIndexing(indexedId, 'created', ctx)
1348
+ return response
1349
+ }
1350
+
1351
+ const createConfig = opts.create
1352
+ if (!createConfig) throw new Error('Create configuration missing')
1353
+
1354
+ let input = createConfig.schema.parse(body)
1355
+ const modified = await opts.hooks?.beforeCreate?.(input as any, ctx)
1356
+ if (modified) input = modified
1357
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
1358
+ const entityData = createConfig.mapToEntity(input as any, ctx)
1359
+ // Inject org/tenant
1360
+ const targetOrgId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
1361
+ if (ormCfg.orgField) {
1362
+ if (!targetOrgId) return json({ error: 'Organization context is required' }, { status: 400 })
1363
+ entityData[ormCfg.orgField] = targetOrgId
1364
+ }
1365
+ if (ormCfg.tenantField) {
1366
+ if (!ctx.auth.tenantId) return json({ error: 'Tenant context is required' }, { status: 400 })
1367
+ entityData[ormCfg.tenantField] = ctx.auth.tenantId
1368
+ }
1369
+ const entity = await de.createOrmEntity({ entity: ormCfg.entity, data: entityData })
1370
+
1371
+ // Custom fields
1372
+ if (createConfig.customFields && (createConfig.customFields as any).enabled) {
1373
+ const cfc = createConfig.customFields as Exclude<CustomFieldsConfig, false>
1374
+ const values = cfc.map
1375
+ ? cfc.map(body)
1376
+ : (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
1377
+ if (values && Object.keys(values).length > 0) {
1378
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
1379
+ await de.setCustomFields({
1380
+ entityId: cfc.entityId as any,
1381
+ recordId: String((entity as any)[ormCfg.idField!]),
1382
+ organizationId: targetOrgId,
1383
+ tenantId: ctx.auth.tenantId!,
1384
+ values,
1385
+ })
1386
+ }
1387
+ }
1388
+
1389
+ await opts.hooks?.afterCreate?.(entity, { ...ctx, input: input as any })
1390
+
1391
+ const identifiers = identifierResolver(entity, 'created')
1392
+ de.markOrmEntityChange({
1393
+ action: 'created',
1394
+ entity,
1395
+ identifiers,
1396
+ events: opts.events as CrudEventsConfig | undefined,
1397
+ indexer: opts.indexer as CrudIndexerConfig | undefined,
1398
+ })
1399
+ await de.flushOrmEntityChanges()
1400
+ await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'created', resourceTargets)
1401
+
1402
+ const payload = createConfig.response ? createConfig.response(entity) : { id: String((entity as any)[ormCfg.idField!]) }
1403
+ return json(payload, { status: 201 })
1404
+ } catch (e) {
1405
+ return handleError(e)
1406
+ }
1407
+ }
1408
+
1409
+ async function PUT(request: Request) {
1410
+ try {
1411
+ const useCommand = !!opts.actions?.update
1412
+ if (!opts.update && !useCommand) return json({ error: 'Not implemented' }, { status: 501 })
1413
+ const ctx = await withCtx(request)
1414
+ if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
1415
+ if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
1416
+ logForbidden({
1417
+ resourceKind,
1418
+ action: 'update',
1419
+ reason: 'organization_scope_empty',
1420
+ userId: ctx.auth?.sub ?? null,
1421
+ tenantId: ctx.auth?.tenantId ?? null,
1422
+ organizationIds: ctx.organizationIds,
1423
+ })
1424
+ return json({ error: 'Forbidden' }, { status: 403 })
1425
+ }
1426
+ const body = await request.json().catch(() => ({}))
1427
+
1428
+ if (useCommand) {
1429
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
1430
+ const action = opts.actions!.update!
1431
+ const parsed = action.schema ? action.schema.parse(body) : body
1432
+ const input = action.mapInput ? await action.mapInput({ parsed, raw: body, ctx }) : parsed
1433
+ const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw: body, ctx }) : null
1434
+ const candidateId = normalizeIdentifierValue((input as Record<string, unknown> | null | undefined)?.id)
1435
+ const baseMetadata: CommandLogMetadata = {
1436
+ tenantId: ctx.auth?.tenantId ?? null,
1437
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
1438
+ resourceKind,
1439
+ context: { cacheAliases: resourceTargets },
1440
+ }
1441
+ if (candidateId) baseMetadata.resourceId = candidateId
1442
+ const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
1443
+ const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
1444
+ const payload = action.response ? action.response({ result, logEntry, ctx }) : result
1445
+ const resolvedPayload = await Promise.resolve(payload)
1446
+ const status = action.status ?? 200
1447
+ const response = json(resolvedPayload, { status })
1448
+ attachOperationHeader(response, logEntry)
1449
+ const indexedId = extractIdentifierFrom(resolvedPayload, result, parsed)
1450
+ await markCommandResultForIndexing(indexedId, 'updated', ctx)
1451
+ return response
1452
+ }
1453
+
1454
+ const updateConfig = opts.update
1455
+ if (!updateConfig) throw new Error('Update configuration missing')
1456
+
1457
+ let input = updateConfig.schema.parse(body)
1458
+ const modified = await opts.hooks?.beforeUpdate?.(input as any, ctx)
1459
+ if (modified) input = modified
1460
+
1461
+ const id = updateConfig.getId ? updateConfig.getId(input as any) : (input as any).id
1462
+ if (!isUuid(id)) return json({ error: 'Invalid id' }, { status: 400 })
1463
+
1464
+ const targetOrgId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
1465
+ if (ormCfg.orgField && !targetOrgId) return json({ error: 'Organization context is required' }, { status: 400 })
1466
+
1467
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
1468
+ const where: any = buildScopedWhere(
1469
+ { [ormCfg.idField!]: id },
1470
+ {
1471
+ organizationId: ormCfg.orgField ? targetOrgId : undefined,
1472
+ organizationIds: ormCfg.orgField ? ctx.organizationIds ?? undefined : undefined,
1473
+ tenantId: ormCfg.tenantField ? ctx.auth.tenantId : undefined,
1474
+ orgField: ormCfg.orgField,
1475
+ tenantField: ormCfg.tenantField,
1476
+ softDeleteField: ormCfg.softDeleteField,
1477
+ }
1478
+ )
1479
+ const entity = await de.updateOrmEntity({
1480
+ entity: ormCfg.entity,
1481
+ where,
1482
+ apply: (e: any) => updateConfig.applyToEntity(e, input as any, ctx),
1483
+ })
1484
+ if (!entity) return json({ error: 'Not found' }, { status: 404 })
1485
+
1486
+ // Custom fields
1487
+ if (updateConfig.customFields && (updateConfig.customFields as any).enabled) {
1488
+ const cfc = updateConfig.customFields as Exclude<CustomFieldsConfig, false>
1489
+ const values = cfc.map
1490
+ ? cfc.map(body)
1491
+ : (cfc.pickPrefixed ? extractCustomFieldValuesFromPayload(body as Record<string, unknown>) : {})
1492
+ if (values && Object.keys(values).length > 0) {
1493
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
1494
+ await de.setCustomFields({
1495
+ entityId: cfc.entityId as any,
1496
+ recordId: String((entity as any)[ormCfg.idField!]),
1497
+ organizationId: targetOrgId,
1498
+ tenantId: ctx.auth.tenantId!,
1499
+ values,
1500
+ })
1501
+ }
1502
+ }
1503
+
1504
+ await opts.hooks?.afterUpdate?.(entity, { ...ctx, input: input as any })
1505
+ const identifiers = identifierResolver(entity, 'updated')
1506
+ de.markOrmEntityChange({
1507
+ action: 'updated',
1508
+ entity,
1509
+ identifiers,
1510
+ events: opts.events as CrudEventsConfig | undefined,
1511
+ indexer: opts.indexer as CrudIndexerConfig | undefined,
1512
+ })
1513
+ await de.flushOrmEntityChanges()
1514
+ await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'updated', resourceTargets)
1515
+ const payload = updateConfig.response ? updateConfig.response(entity) : { success: true }
1516
+ return json(payload)
1517
+ } catch (e) {
1518
+ return handleError(e)
1519
+ }
1520
+ }
1521
+
1522
+ async function DELETE(request: Request) {
1523
+ try {
1524
+ const ctx = await withCtx(request)
1525
+ if (!ctx.auth) return json({ error: 'Unauthorized' }, { status: 401 })
1526
+ if (ormCfg.orgField && ctx.organizationIds && ctx.organizationIds.length === 0) {
1527
+ logForbidden({
1528
+ resourceKind,
1529
+ action: 'delete',
1530
+ reason: 'organization_scope_empty',
1531
+ userId: ctx.auth?.sub ?? null,
1532
+ tenantId: ctx.auth?.tenantId ?? null,
1533
+ organizationIds: ctx.organizationIds,
1534
+ })
1535
+ return json({ error: 'Forbidden' }, { status: 403 })
1536
+ }
1537
+ const useCommand = !!opts.actions?.delete
1538
+ const url = new URL(request.url)
1539
+
1540
+ if (useCommand) {
1541
+ const action = opts.actions!.delete!
1542
+ const body = await request.json().catch(() => ({}))
1543
+ const raw = { body, query: Object.fromEntries(url.searchParams.entries()) }
1544
+ const parsed = action.schema ? action.schema.parse(raw) : raw
1545
+ const input = action.mapInput ? await action.mapInput({ parsed, raw, ctx }) : parsed
1546
+ const userMetadata = action.metadata ? await action.metadata({ input, parsed, raw, ctx }) : null
1547
+ const commandBus = (ctx.container.resolve('commandBus') as CommandBus)
1548
+ const candidateId = normalizeIdentifierValue(
1549
+ (input as Record<string, unknown> | null | undefined)?.id
1550
+ ?? (raw.query as Record<string, unknown> | null | undefined)?.id
1551
+ ?? (raw.body as Record<string, unknown> | null | undefined)?.id
1552
+ )
1553
+ const baseMetadata: CommandLogMetadata = {
1554
+ tenantId: ctx.auth?.tenantId ?? null,
1555
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
1556
+ resourceKind,
1557
+ context: { cacheAliases: resourceTargets },
1558
+ }
1559
+ if (candidateId) baseMetadata.resourceId = candidateId
1560
+ const metadataToSend = mergeCommandMetadata(baseMetadata, userMetadata)
1561
+ const { result, logEntry } = await commandBus.execute(action.commandId, { input, ctx, metadata: metadataToSend })
1562
+ const payload = action.response ? action.response({ result, logEntry, ctx }) : result
1563
+ const resolvedPayload = await Promise.resolve(payload)
1564
+ const status = action.status ?? 200
1565
+ const response = json(resolvedPayload, { status })
1566
+ attachOperationHeader(response, logEntry)
1567
+ const indexedId = extractIdentifierFrom(resolvedPayload, result, (parsed as any)?.body, parsed)
1568
+ await markCommandResultForIndexing(indexedId, 'deleted', ctx)
1569
+ return response
1570
+ }
1571
+
1572
+ const idFrom = opts.del?.idFrom || 'query'
1573
+ const id = idFrom === 'query'
1574
+ ? url.searchParams.get('id')
1575
+ : (await request.json().catch(() => ({}))).id
1576
+ if (!isUuid(id)) return json({ error: 'ID is required' }, { status: 400 })
1577
+
1578
+ const targetOrgId = ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null
1579
+ if (ormCfg.orgField && !targetOrgId) return json({ error: 'Organization context is required' }, { status: 400 })
1580
+
1581
+ const de = (ctx.container.resolve('dataEngine') as DataEngine)
1582
+ const where: any = buildScopedWhere(
1583
+ { [ormCfg.idField!]: id },
1584
+ {
1585
+ organizationId: ormCfg.orgField ? targetOrgId : undefined,
1586
+ organizationIds: ormCfg.orgField ? ctx.organizationIds ?? undefined : undefined,
1587
+ tenantId: ormCfg.tenantField ? ctx.auth.tenantId : undefined,
1588
+ orgField: ormCfg.orgField,
1589
+ tenantField: ormCfg.tenantField,
1590
+ softDeleteField: ormCfg.softDeleteField,
1591
+ }
1592
+ )
1593
+ await opts.hooks?.beforeDelete?.(id!, ctx)
1594
+ const entity = await de.deleteOrmEntity({
1595
+ entity: ormCfg.entity,
1596
+ where,
1597
+ soft: opts.del?.softDelete !== false,
1598
+ softDeleteField: ormCfg.softDeleteField ?? undefined,
1599
+ })
1600
+ if (!entity) return json({ error: 'Not found' }, { status: 404 })
1601
+ await opts.hooks?.afterDelete?.(id!, ctx)
1602
+ if (entity) {
1603
+ const identifiers = identifierResolver(entity, 'deleted')
1604
+ de.markOrmEntityChange({
1605
+ action: 'deleted',
1606
+ entity,
1607
+ identifiers,
1608
+ events: opts.events as CrudEventsConfig | undefined,
1609
+ indexer: opts.indexer as CrudIndexerConfig | undefined,
1610
+ })
1611
+ await de.flushOrmEntityChanges()
1612
+ await invalidateCrudCache(ctx.container, resourceKind, identifiers, ctx.auth.tenantId ?? null, 'deleted', resourceTargets)
1613
+ }
1614
+ const payload = opts.del?.response ? opts.del.response(id) : { success: true }
1615
+ return json(payload)
1616
+ } catch (e) {
1617
+ return handleError(e)
1618
+ }
1619
+ }
1620
+
1621
+ return { metadata, GET, POST, PUT, DELETE }
1622
+ }