@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,29 @@
1
+ export type CrudEventAction = 'created' | 'updated' | 'deleted'
2
+
3
+ export type CrudEntityIdentifiers = {
4
+ id: string
5
+ organizationId: string | null
6
+ tenantId: string | null
7
+ }
8
+
9
+ export type CrudEmitContext<TEntity = unknown> = {
10
+ action: CrudEventAction
11
+ entity: TEntity
12
+ identifiers: CrudEntityIdentifiers
13
+ }
14
+
15
+ export type CrudEventsConfig<TEntity = unknown> = {
16
+ module: string
17
+ entity: string
18
+ persistent?: boolean
19
+ buildPayload?: (ctx: CrudEmitContext<TEntity>) => unknown
20
+ }
21
+
22
+ export type CrudIndexerConfig<TEntity = unknown> = {
23
+ entityType: string
24
+ buildUpsertPayload?: (ctx: CrudEmitContext<TEntity>) => unknown
25
+ buildDeletePayload?: (ctx: CrudEmitContext<TEntity>) => unknown
26
+ cacheAliases?: string[]
27
+ }
28
+
29
+ export type CrudIdentifierResolver<TEntity = unknown> = (entity: TEntity, action: CrudEventAction) => CrudEntityIdentifiers
@@ -0,0 +1,45 @@
1
+ import type { DataEngine } from '../data/engine'
2
+
3
+ type CustomFieldValueInput = Parameters<DataEngine['setCustomFields']>[0]['values']
4
+
5
+ export function normalizeCustomFieldValues(values: Record<string, unknown>): CustomFieldValueInput {
6
+ const result: CustomFieldValueInput = {}
7
+ for (const [key, value] of Object.entries(values)) {
8
+ if (Array.isArray(value)) {
9
+ result[key] = value.map((entry) => normalizePrimitive(entry)) as CustomFieldValueInput[string]
10
+ } else {
11
+ result[key] = normalizePrimitive(value)
12
+ }
13
+ }
14
+ return result
15
+ }
16
+
17
+ function normalizePrimitive(value: unknown): CustomFieldValueInput[string] {
18
+ if (
19
+ value === null ||
20
+ value === undefined ||
21
+ typeof value === 'string' ||
22
+ typeof value === 'number' ||
23
+ typeof value === 'boolean'
24
+ ) {
25
+ return value as CustomFieldValueInput[string]
26
+ }
27
+ return String(value) as CustomFieldValueInput[string]
28
+ }
29
+
30
+ export function normalizeCustomFieldResponse(
31
+ values: Record<string, unknown> | null | undefined,
32
+ ): Record<string, unknown> | undefined {
33
+ if (!values) return undefined
34
+ const entries: Record<string, unknown> = {}
35
+ for (const [key, value] of Object.entries(values)) {
36
+ if (value === undefined) continue
37
+ if (key.startsWith('cf_') || key.startsWith('cf:')) {
38
+ const normalized = key.slice(3)
39
+ if (normalized) entries[normalized] = value
40
+ continue
41
+ }
42
+ entries[key] = value
43
+ }
44
+ return Object.keys(entries).length ? entries : undefined
45
+ }
@@ -0,0 +1,562 @@
1
+ import type { EntityData, EntityName, FilterQuery, RequiredEntityData } from '@mikro-orm/core'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { AwilixContainer } from 'awilix'
4
+ import { setRecordCustomFields } from '@open-mercato/core/modules/entities/lib/helpers'
5
+ import { validateCustomFieldValuesServer } from '@open-mercato/core/modules/entities/lib/validation'
6
+ import type { EventBus } from '@open-mercato/events/types'
7
+ import type {
8
+ CrudEventAction,
9
+ CrudEventsConfig,
10
+ CrudIndexerConfig,
11
+ CrudEntityIdentifiers,
12
+ } from '../crud/types'
13
+ import { CrudHttpError } from '../crud/errors'
14
+ import { normalizeCustomFieldValues } from '../custom-fields/normalize'
15
+ import { parseBooleanToken } from '../boolean'
16
+
17
+ const COVERAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000
18
+ const coverageRefreshTracker = new Map<string, number>()
19
+
20
+ function shouldTriggerCoverageRefresh(entityType: string | undefined, tenantId: string | null): boolean {
21
+ if (!entityType) return false
22
+ const key = `${entityType}|${tenantId ?? '__null__'}`
23
+ const now = Date.now()
24
+ const last = coverageRefreshTracker.get(key) ?? 0
25
+ if (now - last < COVERAGE_REFRESH_INTERVAL_MS) return false
26
+ coverageRefreshTracker.set(key, now)
27
+ return true
28
+ }
29
+
30
+ type CustomEntityValues = Record<string, unknown>
31
+
32
+ type QueuedCrudSideEffect = {
33
+ action: CrudEventAction
34
+ entity: unknown
35
+ identifiers: CrudEntityIdentifiers
36
+ events?: CrudEventsConfig<unknown>
37
+ indexer?: CrudIndexerConfig<unknown>
38
+ }
39
+
40
+ export interface DataEngine {
41
+ setCustomFields(opts: {
42
+ entityId: string
43
+ recordId: string
44
+ organizationId?: string | null
45
+ tenantId?: string | null
46
+ values: Record<string, string | number | boolean | null | undefined | Array<string | number | boolean | null | undefined>>
47
+ notify?: boolean // default true -> emit '<module>.<entity>.updated'
48
+ }): Promise<void>
49
+
50
+ // Storage for user-defined entities (doc-based)
51
+ createCustomEntityRecord(opts: {
52
+ entityId: string // '<module>:<entity>'
53
+ recordId?: string // optional; auto-generate if not provided
54
+ organizationId?: string | null
55
+ tenantId?: string | null
56
+ values: CustomEntityValues
57
+ notify?: boolean // keep event emitting as it is via setCustomFields (updated)
58
+ }): Promise<{ id: string }>
59
+
60
+ updateCustomEntityRecord(opts: {
61
+ entityId: string
62
+ recordId: string
63
+ organizationId?: string | null
64
+ tenantId?: string | null
65
+ values: CustomEntityValues
66
+ notify?: boolean // keep event emitting as it is via setCustomFields (updated)
67
+ }): Promise<void>
68
+
69
+ deleteCustomEntityRecord(opts: {
70
+ entityId: string
71
+ recordId: string
72
+ organizationId?: string | null
73
+ tenantId?: string | null
74
+ soft?: boolean // default true: sets deleted_at
75
+ notify?: boolean // keep event emitting as it is (no extra events here)
76
+ }): Promise<void>
77
+
78
+ // Generic ORM-backed entity operations used by CrudFactory
79
+ createOrmEntity<T extends object>(opts: {
80
+ entity: EntityName<T>
81
+ data: EntityData<T>
82
+ }): Promise<T>
83
+
84
+ updateOrmEntity<T extends object>(opts: {
85
+ entity: EntityName<T>
86
+ where: FilterQuery<T>
87
+ apply: (current: T) => Promise<void> | void
88
+ }): Promise<T | null>
89
+
90
+ deleteOrmEntity<T extends object>(opts: {
91
+ entity: EntityName<T>
92
+ where: FilterQuery<T>
93
+ soft?: boolean
94
+ softDeleteField?: keyof T & string
95
+ }): Promise<T | null>
96
+
97
+ emitOrmEntityEvent<T>(opts: {
98
+ action: CrudEventAction
99
+ entity: T
100
+ events?: CrudEventsConfig<T>
101
+ indexer?: CrudIndexerConfig<T>
102
+ identifiers: CrudEntityIdentifiers
103
+ }): Promise<void>
104
+
105
+ markOrmEntityChange<T>(opts: {
106
+ action: CrudEventAction
107
+ entity: T | null | undefined
108
+ events?: CrudEventsConfig<T>
109
+ indexer?: CrudIndexerConfig<T>
110
+ identifiers: CrudEntityIdentifiers
111
+ }): void
112
+
113
+ flushOrmEntityChanges(): Promise<void>
114
+ }
115
+
116
+ export class DefaultDataEngine implements DataEngine {
117
+ private pendingSideEffects = new Map<string, QueuedCrudSideEffect>()
118
+ constructor(private em: EntityManager, private container: AwilixContainer) {}
119
+
120
+ async setCustomFields(opts: Parameters<DataEngine['setCustomFields']>[0]): Promise<void> {
121
+ const { entityId, recordId, organizationId = null, tenantId = null, values } = opts
122
+ await this.validateCustomFieldValues(entityId, organizationId, tenantId, values as Record<string, unknown>)
123
+ let encryptionService: any = null
124
+ try {
125
+ encryptionService = this.container.resolve('tenantEncryptionService') as any
126
+ } catch {
127
+ encryptionService = null
128
+ }
129
+ await setRecordCustomFields(this.em, {
130
+ entityId,
131
+ recordId,
132
+ organizationId,
133
+ tenantId,
134
+ values,
135
+ encryptionService,
136
+ })
137
+ if (opts.notify !== false) {
138
+ let bus: EventBus | null = null
139
+ try {
140
+ bus = (this.container.resolve('eventBus') as EventBus)
141
+ } catch {
142
+ bus = null
143
+ }
144
+ if (bus) {
145
+ const [mod, ent] = (entityId || '').split(':')
146
+ if (mod && ent) {
147
+ try {
148
+ await bus.emitEvent(`${mod}.${ent}.updated`, { id: recordId, organizationId, tenantId }, { persistent: true })
149
+ } catch {
150
+ // non-blocking
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ private normalizeDocValues(values: CustomEntityValues): CustomEntityValues {
158
+ const out: CustomEntityValues = {}
159
+ for (const [k, v] of Object.entries(values || {})) {
160
+ // Never allow callers to override reserved identifiers in the doc
161
+ if (k === 'id' || k === 'entity_id' || k === 'entityId') continue
162
+ // Accept both 'cf_<key>' and 'cf:<key>' inputs and normalize to 'cf:<key>'
163
+ if (k.startsWith('cf_')) out[`cf:${k.slice(3)}`] = v
164
+ else out[k] = v
165
+ }
166
+ return out
167
+ }
168
+
169
+ private backcompatEavEnabled(): boolean {
170
+ try {
171
+ return parseBooleanToken(process.env.ENTITIES_BACKCOMPAT_EAV_FOR_CUSTOM ?? '') === true
172
+ } catch { return false }
173
+ }
174
+
175
+ private async ensureStorageTableExists(): Promise<void> {
176
+ const knex = this.em.getConnection().getKnex()
177
+ const exists = await knex('information_schema.tables')
178
+ .where({ table_name: 'custom_entities_storage' })
179
+ .first()
180
+ if (!exists) {
181
+ throw new Error('custom_entities_storage table is missing. Run migrations (yarn db:migrate).')
182
+ }
183
+ }
184
+
185
+ private normalizeValuesForValidation(values: Record<string, unknown> | undefined | null): Record<string, unknown> {
186
+ if (!values) return {}
187
+ const out: Record<string, unknown> = {}
188
+ for (const [key, value] of Object.entries(values)) {
189
+ if (value === undefined) continue
190
+ if (key.startsWith('cf_') || key.startsWith('cf:')) {
191
+ const normalized = key.slice(3)
192
+ if (normalized) out[normalized] = value
193
+ continue
194
+ }
195
+ out[key] = value
196
+ }
197
+ return out
198
+ }
199
+
200
+ private async validateCustomFieldValues(
201
+ entityId: string,
202
+ organizationId: string | null,
203
+ tenantId: string | null,
204
+ values: Record<string, unknown> | undefined | null,
205
+ ): Promise<void> {
206
+ const prepared = this.normalizeValuesForValidation(values)
207
+ if (!entityId || Object.keys(prepared).length === 0) return
208
+ const result = await validateCustomFieldValuesServer(this.em, {
209
+ entityId,
210
+ organizationId,
211
+ tenantId,
212
+ values: prepared,
213
+ })
214
+ if (!result.ok) {
215
+ throw new CrudHttpError(400, { error: 'Validation failed', fields: result.fieldErrors })
216
+ }
217
+ }
218
+
219
+ async createCustomEntityRecord(opts: Parameters<DataEngine['createCustomEntityRecord']>[0]): Promise<{ id: string }> {
220
+ const knex = this.em.getConnection().getKnex()
221
+ await this.ensureStorageTableExists()
222
+ await this.validateCustomFieldValues(opts.entityId, opts.organizationId ?? null, opts.tenantId ?? null, opts.values)
223
+ const rawId = String(opts.recordId ?? '').trim()
224
+ const isUuid = /^[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(rawId)
225
+ const sentinel = rawId.toLowerCase()
226
+ const shouldGenerate = !rawId || !isUuid || sentinel === 'create' || sentinel === 'new' || sentinel === 'null' || sentinel === 'undefined'
227
+ const id = shouldGenerate ? ((): string => {
228
+ const g = globalThis as { crypto?: { randomUUID?: () => string } }
229
+ if (g.crypto?.randomUUID) return g.crypto.randomUUID()
230
+ // Fallback UUIDv4 generator
231
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
232
+ const r = (Math.random() * 16) | 0
233
+ const v = c === 'x' ? r : (r & 0x3) | 0x8
234
+ return v.toString(16)
235
+ })
236
+ })() : rawId
237
+ const orgId = opts.organizationId ?? null
238
+ const tenantId = opts.tenantId ?? null
239
+ const doc: Record<string, unknown> = { id, ...this.normalizeDocValues(opts.values || {}) }
240
+
241
+ const payload = {
242
+ entity_type: opts.entityId,
243
+ entity_id: id,
244
+ organization_id: orgId,
245
+ tenant_id: tenantId,
246
+ doc,
247
+ updated_at: knex.fn.now(),
248
+ created_at: knex.fn.now(),
249
+ deleted_at: null,
250
+ }
251
+
252
+ // Upsert by scoped uniqueness
253
+ try {
254
+ await knex('custom_entities_storage')
255
+ .insert(payload)
256
+ .onConflict(['entity_type', 'entity_id', 'organization_id'])
257
+ .merge({ doc: payload.doc, updated_at: knex.fn.now(), deleted_at: null })
258
+ } catch {
259
+ // Fallback for global scope uniqueness
260
+ try {
261
+ const updated = await knex('custom_entities_storage')
262
+ .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
263
+ .update({ doc: payload.doc, updated_at: knex.fn.now(), deleted_at: null })
264
+ if (!updated) {
265
+ await knex('custom_entities_storage').insert(payload)
266
+ }
267
+ } catch (err) {
268
+ // Surface a clear error so it doesn't silently fall back only to EAV
269
+ throw err
270
+ }
271
+ }
272
+
273
+ // Optional EAV backward compatibility (disabled by default)
274
+ if (this.backcompatEavEnabled() && opts.values && Object.keys(opts.values).length > 0) {
275
+ await this.setCustomFields({
276
+ entityId: opts.entityId,
277
+ recordId: id,
278
+ organizationId: orgId,
279
+ tenantId: tenantId,
280
+ values: normalizeCustomFieldValues(opts.values),
281
+ notify: opts.notify, // defaults to true
282
+ })
283
+ }
284
+
285
+ return { id }
286
+ }
287
+
288
+ async updateCustomEntityRecord(opts: Parameters<DataEngine['updateCustomEntityRecord']>[0]): Promise<void> {
289
+ const knex = this.em.getConnection().getKnex()
290
+ await this.validateCustomFieldValues(opts.entityId, opts.organizationId ?? null, opts.tenantId ?? null, opts.values)
291
+ const id = String(opts.recordId)
292
+ const orgId = opts.organizationId ?? null
293
+ const tenantId = opts.tenantId ?? null
294
+
295
+ // Merge doc shallowly: load existing doc and overlay
296
+ await this.ensureStorageTableExists()
297
+ const row = await knex('custom_entities_storage')
298
+ .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
299
+ .first()
300
+ const prevDoc: Record<string, unknown> = row?.doc || { id }
301
+ const nextDoc: Record<string, unknown> = { ...prevDoc, ...this.normalizeDocValues(opts.values || {}), id }
302
+ try {
303
+ const updated = await knex('custom_entities_storage')
304
+ .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
305
+ .update({ doc: nextDoc, updated_at: knex.fn.now(), deleted_at: null })
306
+ if (!updated) {
307
+ await knex('custom_entities_storage').insert({
308
+ entity_type: opts.entityId,
309
+ entity_id: id,
310
+ organization_id: orgId,
311
+ tenant_id: tenantId,
312
+ doc: nextDoc,
313
+ created_at: knex.fn.now(),
314
+ updated_at: knex.fn.now(),
315
+ deleted_at: null,
316
+ })
317
+ }
318
+ } catch (err) {
319
+ throw err
320
+ }
321
+
322
+ // Optional EAV backward compatibility (disabled by default)
323
+ if (this.backcompatEavEnabled() && opts.values && Object.keys(opts.values).length > 0) {
324
+ await this.setCustomFields({
325
+ entityId: opts.entityId,
326
+ recordId: id,
327
+ organizationId: orgId,
328
+ tenantId: tenantId,
329
+ values: normalizeCustomFieldValues(opts.values),
330
+ notify: opts.notify, // defaults to true
331
+ })
332
+ }
333
+ }
334
+
335
+ async deleteCustomEntityRecord(opts: Parameters<DataEngine['deleteCustomEntityRecord']>[0]): Promise<void> {
336
+ const knex = this.em.getConnection().getKnex()
337
+ const id = String(opts.recordId)
338
+ const orgId = opts.organizationId ?? null
339
+ const soft = opts.soft !== false
340
+
341
+ if (soft) {
342
+ await knex('custom_entities_storage')
343
+ .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
344
+ .update({ deleted_at: knex.fn.now(), updated_at: knex.fn.now() })
345
+ } else {
346
+ await knex('custom_entities_storage')
347
+ .where({ entity_type: opts.entityId, entity_id: id, organization_id: orgId })
348
+ .delete()
349
+ }
350
+
351
+ // Soft-delete EAV values to preserve current behavior
352
+ try {
353
+ const { CustomFieldValue } = await import('@open-mercato/core/modules/entities/data/entities')
354
+ const values = await this.em.find(CustomFieldValue, {
355
+ entityId: opts.entityId,
356
+ recordId: id,
357
+ organizationId: orgId,
358
+ tenantId: opts.tenantId ?? null,
359
+ })
360
+ const now = new Date()
361
+ const mutated = values.filter((record) => {
362
+ if (record.deletedAt) return false
363
+ record.deletedAt = now
364
+ return true
365
+ })
366
+ if (mutated.length) await this.em.persistAndFlush(values)
367
+ } catch { /* non-blocking */ }
368
+ }
369
+
370
+ async createOrmEntity<T extends object>(opts: { entity: EntityName<T>; data: EntityData<T> }): Promise<T> {
371
+ const entity = this.em.create(
372
+ opts.entity,
373
+ opts.data as RequiredEntityData<T, never, true>
374
+ )
375
+ await this.em.persistAndFlush(entity)
376
+ return entity
377
+ }
378
+
379
+ async updateOrmEntity<T extends object>(opts: {
380
+ entity: EntityName<T>
381
+ where: FilterQuery<T>
382
+ apply: (current: T) => Promise<void> | void
383
+ }): Promise<T | null> {
384
+ const current = await this.em.findOne(opts.entity, opts.where)
385
+ if (!current) return null
386
+ await opts.apply(current)
387
+ await this.em.persistAndFlush(current)
388
+ return current
389
+ }
390
+
391
+ async deleteOrmEntity<T extends object>(opts: {
392
+ entity: EntityName<T>
393
+ where: FilterQuery<T>
394
+ soft?: boolean
395
+ softDeleteField?: keyof T & string
396
+ }): Promise<T | null> {
397
+ const current = await this.em.findOne(opts.entity, opts.where)
398
+ if (!current) return null
399
+ if (opts.soft !== false) {
400
+ const field = opts.softDeleteField || ('deletedAt' as keyof T & string)
401
+ if (typeof current === 'object' && current !== null) {
402
+ ;(current as Record<string, unknown>)[field] = new Date()
403
+ await this.em.persistAndFlush(current)
404
+ }
405
+ } else {
406
+ await this.em.removeAndFlush(current)
407
+ }
408
+ return current
409
+ }
410
+
411
+ async emitOrmEntityEvent<T>(opts: { action: CrudEventAction; entity: T; events?: CrudEventsConfig<T>; indexer?: CrudIndexerConfig<T>; identifiers: CrudEntityIdentifiers }): Promise<void> {
412
+ const { action, entity, events, indexer, identifiers } = opts
413
+ if (!events && !indexer) return
414
+ if (!identifiers?.id) return
415
+
416
+ let bus: EventBus | null = null
417
+ try {
418
+ bus = (this.container.resolve('eventBus') as EventBus)
419
+ } catch {
420
+ bus = null
421
+ }
422
+ if (!bus) return
423
+
424
+ const ctx = {
425
+ action,
426
+ entity,
427
+ identifiers: {
428
+ id: identifiers.id,
429
+ organizationId: identifiers.organizationId ?? null,
430
+ tenantId: identifiers.tenantId ?? null,
431
+ },
432
+ }
433
+
434
+ if (events) {
435
+ const eventName = `${events.module}.${events.entity}.${action}`
436
+ const payload = events.buildPayload
437
+ ? events.buildPayload(ctx)
438
+ : {
439
+ id: ctx.identifiers.id,
440
+ organizationId: ctx.identifiers.organizationId,
441
+ tenantId: ctx.identifiers.tenantId,
442
+ }
443
+ try {
444
+ await bus.emitEvent(eventName, payload, { persistent: !!events.persistent })
445
+ } catch {
446
+ // non-blocking
447
+ }
448
+ }
449
+
450
+ if (indexer) {
451
+ const resolveCoverageBaseDelta = (): number | undefined => {
452
+ if (action === 'created') return 1
453
+ if (action === 'deleted') return -1
454
+ return undefined
455
+ }
456
+ const coverageBaseDelta = resolveCoverageBaseDelta()
457
+
458
+ if (action === 'deleted') {
459
+ const payload = indexer.buildDeletePayload
460
+ ? indexer.buildDeletePayload(ctx)
461
+ : {
462
+ entityType: indexer.entityType,
463
+ recordId: ctx.identifiers.id,
464
+ organizationId: ctx.identifiers.organizationId,
465
+ tenantId: ctx.identifiers.tenantId,
466
+ }
467
+ const enrichedPayload = payload as Record<string, unknown>
468
+ enrichedPayload.crudAction = action
469
+ if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta
470
+ try {
471
+ await bus.emitEvent('query_index.delete_one', enrichedPayload)
472
+ } catch {
473
+ // non-blocking
474
+ }
475
+ } else {
476
+ const payload = indexer.buildUpsertPayload
477
+ ? indexer.buildUpsertPayload(ctx)
478
+ : {
479
+ entityType: indexer.entityType,
480
+ recordId: ctx.identifiers.id,
481
+ organizationId: ctx.identifiers.organizationId,
482
+ tenantId: ctx.identifiers.tenantId,
483
+ }
484
+ const enrichedPayload = payload as Record<string, unknown>
485
+ enrichedPayload.crudAction = action
486
+ if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta
487
+ try {
488
+ await bus.emitEvent('query_index.upsert_one', enrichedPayload)
489
+ } catch {
490
+ // non-blocking
491
+ }
492
+ }
493
+
494
+ if (shouldTriggerCoverageRefresh(indexer.entityType, ctx.identifiers.tenantId ?? null)) {
495
+ void bus.emitEvent('query_index.coverage.refresh', {
496
+ entityType: indexer.entityType,
497
+ tenantId: ctx.identifiers.tenantId ?? null,
498
+ organizationId: null,
499
+ delayMs: 0,
500
+ }).catch(() => undefined)
501
+ }
502
+ }
503
+ }
504
+
505
+ markOrmEntityChange<T>(opts: { action: CrudEventAction; entity: T | null | undefined; events?: CrudEventsConfig<T>; indexer?: CrudIndexerConfig<T>; identifiers: CrudEntityIdentifiers }): void {
506
+ const { entity, identifiers } = opts
507
+ if (!entity) return
508
+ if (!identifiers?.id) return
509
+ const key = this.buildSideEffectKey(opts.action, identifiers)
510
+ const existing = this.pendingSideEffects.get(key)
511
+ if (existing) {
512
+ existing.entity = entity
513
+ existing.identifiers = {
514
+ id: identifiers.id,
515
+ organizationId: identifiers.organizationId ?? null,
516
+ tenantId: identifiers.tenantId ?? null,
517
+ }
518
+ if (opts.events) existing.events = opts.events as CrudEventsConfig<unknown>
519
+ if (opts.indexer) existing.indexer = opts.indexer as CrudIndexerConfig<unknown>
520
+ this.pendingSideEffects.set(key, existing)
521
+ return
522
+ }
523
+ const entry: QueuedCrudSideEffect = {
524
+ action: opts.action,
525
+ entity,
526
+ identifiers: {
527
+ id: identifiers.id,
528
+ organizationId: identifiers.organizationId ?? null,
529
+ tenantId: identifiers.tenantId ?? null,
530
+ },
531
+ }
532
+ if (opts.events) entry.events = opts.events as CrudEventsConfig<unknown>
533
+ if (opts.indexer) entry.indexer = opts.indexer as CrudIndexerConfig<unknown>
534
+ this.pendingSideEffects.set(key, entry)
535
+ }
536
+
537
+ async flushOrmEntityChanges(): Promise<void> {
538
+ if (!this.pendingSideEffects.size) return
539
+ const entries = Array.from(this.pendingSideEffects.values())
540
+ this.pendingSideEffects.clear()
541
+ for (const entry of entries) {
542
+ try {
543
+ await this.emitOrmEntityEvent({
544
+ action: entry.action,
545
+ entity: entry.entity,
546
+ identifiers: entry.identifiers,
547
+ events: entry.events as CrudEventsConfig<unknown>,
548
+ indexer: entry.indexer as CrudIndexerConfig<unknown>,
549
+ })
550
+ } catch {
551
+ // best-effort; continue with remaining side effects
552
+ }
553
+ }
554
+ }
555
+
556
+ private buildSideEffectKey(action: CrudEventAction, identifiers: CrudEntityIdentifiers): string {
557
+ const id = identifiers.id ?? ''
558
+ const org = identifiers.organizationId ?? ''
559
+ const tenant = identifiers.tenantId ?? ''
560
+ return [action, id, org, tenant].join('|')
561
+ }
562
+ }
@@ -0,0 +1,2 @@
1
+ export const escapeLikePattern = (value: string): string =>
2
+ value.replace(/\\/g, '\\\\').replace(/[%_]/g, '\\$&')