@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,837 @@
1
+ import type { QueryEngine, QueryOptions, QueryResult, QueryCustomFieldSource } from './types'
2
+ import type { EntityId } from '@open-mercato/shared/modules/entities'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import type { Knex } from 'knex'
5
+ import {
6
+ applyJoinFilters,
7
+ normalizeFilters,
8
+ partitionFilters,
9
+ resolveJoins,
10
+ type BaseFilter,
11
+ type NormalizedFilter,
12
+ type ResolvedJoin,
13
+ } from './join-utils'
14
+ import { resolveSearchConfig } from '../search/config'
15
+ import { tokenizeText } from '../search/tokenize'
16
+
17
+ const entityTableCache = new Map<string, string>()
18
+
19
+ type EncryptionResolver = () => {
20
+ decryptEntityPayload?: (entityId: EntityId, payload: Record<string, unknown>, tenantId?: string | null, organizationId?: string | null) => Promise<Record<string, unknown>>
21
+ isEnabled?: () => boolean
22
+ } | null
23
+
24
+ type ResolvedCustomFieldSource = {
25
+ entityId: EntityId
26
+ alias: string
27
+ table: string
28
+ recordIdExpr: any
29
+ }
30
+
31
+ type ResultRow = Record<string, unknown>
32
+
33
+ const pluralizeBaseName = (name: string): string => {
34
+ if (!name) return name
35
+ if (name.endsWith('s')) return name
36
+ if (name.endsWith('y')) return `${name.slice(0, -1)}ies`
37
+ return `${name}s`
38
+ }
39
+
40
+ const toPascalCase = (value: string): string => {
41
+ return value
42
+ .split(/[_\s]+/)
43
+ .filter(Boolean)
44
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
45
+ .join('')
46
+ }
47
+
48
+ const candidateClassNames = (rawName: string): string[] => {
49
+ const base = toPascalCase(rawName)
50
+ const candidates = new Set<string>()
51
+ if (base) candidates.add(base)
52
+ if (base && !base.endsWith('Entity')) candidates.add(`${base}Entity`)
53
+ return Array.from(candidates)
54
+ }
55
+
56
+ export function resolveEntityTableName(em: EntityManager | undefined, entity: EntityId): string {
57
+ if (entityTableCache.has(entity)) {
58
+ return entityTableCache.get(entity)!
59
+ }
60
+ const parts = String(entity || '').split(':')
61
+ const rawName = (parts[1] && parts[1].trim().length > 0) ? parts[1] : (parts[0] || '').trim()
62
+ const metadata = (em as any)?.getMetadata?.()
63
+
64
+ if (metadata && rawName) {
65
+ const candidates = candidateClassNames(rawName)
66
+ for (const candidate of candidates) {
67
+ try {
68
+ const meta = metadata.find?.(candidate)
69
+ if (meta?.tableName) {
70
+ const tableName = String(meta.tableName)
71
+ entityTableCache.set(entity, tableName)
72
+ return tableName
73
+ }
74
+ } catch {}
75
+ }
76
+ }
77
+
78
+ const fallback = pluralizeBaseName(rawName || '')
79
+ entityTableCache.set(entity, fallback)
80
+ return fallback
81
+ }
82
+
83
+
84
+ // Minimal default implementation placeholder.
85
+ // For now, only supports basic base-entity querying by table name inferred from EntityId ('<module>:<entity>' -> '<entities>') via convention.
86
+ // Extensions and custom fields will be added iteratively.
87
+
88
+ export class BasicQueryEngine implements QueryEngine {
89
+ private columnCache = new Map<string, boolean>()
90
+ private tableCache = new Map<string, boolean>()
91
+ private searchAliasSeq = 0
92
+
93
+ constructor(
94
+ private em: EntityManager,
95
+ private getKnexFn?: () => any,
96
+ private resolveEncryptionService?: EncryptionResolver,
97
+ ) {}
98
+
99
+ private getEncryptionService() {
100
+ try {
101
+ return this.resolveEncryptionService?.() ?? null
102
+ } catch {
103
+ return null
104
+ }
105
+ }
106
+
107
+ async query<T = any>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
108
+ // Heuristic: map '<module>:user' -> table 'users'
109
+ const table = resolveEntityTableName(this.em, entity)
110
+ const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
111
+
112
+ let q = knex(table)
113
+ const qualify = (col: string) => `${table}.${col}`
114
+ const orgScope = this.resolveOrganizationScope(opts)
115
+ this.searchAliasSeq = 0
116
+ // Require tenant scope for all queries
117
+ if (!opts.tenantId) {
118
+ throw new Error(
119
+ 'QueryEngine: tenantId is now required for all queries (breaking change). ' +
120
+ 'Please provide a tenantId in QueryOptions, e.g., query(entity, { tenantId: ... }). ' +
121
+ 'See migration guide or documentation for details.'
122
+ )
123
+ }
124
+ // Optional organization filter (when present in schema)
125
+ if (orgScope && await this.columnExists(table, 'organization_id')) {
126
+ q = this.applyOrganizationScope(q, qualify('organization_id'), orgScope)
127
+ }
128
+ // Tenant guard (required) when present in schema
129
+ if (await this.columnExists(table, 'tenant_id')) {
130
+ q = q.where(qualify('tenant_id'), opts.tenantId)
131
+ }
132
+ // Default soft-delete guard: exclude rows with deleted_at when column exists
133
+ if (!opts.withDeleted && await this.columnExists(table, 'deleted_at')) {
134
+ q = q.whereNull(qualify('deleted_at'))
135
+ }
136
+
137
+ const normalizedFilters = normalizeFilters(opts.filters)
138
+ const resolvedJoins = resolveJoins(table, opts.joins, (entityId) => resolveEntityTableName(this.em, entityId as any))
139
+ const joinMap = new Map<string, ResolvedJoin>()
140
+ const aliasTables = new Map<string, string>()
141
+ aliasTables.set(table, table)
142
+ aliasTables.set('base', table)
143
+ for (const join of resolvedJoins) {
144
+ joinMap.set(join.alias, join)
145
+ aliasTables.set(join.alias, join.table)
146
+ }
147
+ const { baseFilters, joinFilters } = partitionFilters(table, normalizedFilters, joinMap)
148
+ const cfFilters = normalizedFilters.filter((filter) => String(filter.field).startsWith('cf:'))
149
+ const searchConfig = resolveSearchConfig()
150
+ const searchEnabled = searchConfig.enabled && await this.tableExists('search_tokens')
151
+ const hasSearchTokens = searchEnabled
152
+ ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope)
153
+ : false
154
+ const searchActive = searchEnabled && hasSearchTokens
155
+ const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === 'like' || filter.op === 'ilike')
156
+ if (searchFilters.length) {
157
+ const fields = searchFilters.map((filter) => String(filter.field))
158
+ this.logSearchDebug('search:init', {
159
+ entity: String(entity),
160
+ table,
161
+ tenantId: opts.tenantId ?? null,
162
+ organizationScope: orgScope,
163
+ fields,
164
+ searchEnabled,
165
+ hasSearchTokens,
166
+ searchActive,
167
+ searchConfig: {
168
+ enabled: searchConfig.enabled,
169
+ minTokenLength: searchConfig.minTokenLength,
170
+ enablePartials: searchConfig.enablePartials,
171
+ hashAlgorithm: searchConfig.hashAlgorithm,
172
+ blocklistedFields: searchConfig.blocklistedFields,
173
+ },
174
+ })
175
+ if (!searchEnabled) {
176
+ this.logSearchDebug('search:disabled', { entity: String(entity), table })
177
+ } else if (!hasSearchTokens) {
178
+ this.logSearchDebug('search:no-search-tokens', {
179
+ entity: String(entity),
180
+ table,
181
+ tenantId: opts.tenantId ?? null,
182
+ organizationScope: orgScope,
183
+ })
184
+ }
185
+ }
186
+ const recordIdColumn = qualify('id')
187
+
188
+ const applyFilterOp = (builder: any, column: string, op: any, value: any, fieldName?: string) => {
189
+ if (
190
+ (op === 'like' || op === 'ilike') &&
191
+ searchActive &&
192
+ typeof value === 'string' &&
193
+ fieldName
194
+ ) {
195
+ const tokens = tokenizeText(String(value), searchConfig)
196
+ const hashes = tokens.hashes
197
+ if (hashes.length) {
198
+ const applied = this.applySearchTokens(builder, {
199
+ entity: String(entity),
200
+ field: fieldName,
201
+ hashes,
202
+ recordIdColumn,
203
+ tenantId: opts.tenantId ?? null,
204
+ organizationScope: orgScope,
205
+ tokens: tokens.tokens,
206
+ })
207
+ this.logSearchDebug('search:filter', {
208
+ entity: String(entity),
209
+ field: fieldName,
210
+ tokens: tokens.tokens,
211
+ hashes,
212
+ applied,
213
+ tenantId: opts.tenantId ?? null,
214
+ organizationScope: orgScope,
215
+ })
216
+ if (applied) return builder
217
+ } else {
218
+ this.logSearchDebug('search:skip-empty-hashes', {
219
+ entity: String(entity),
220
+ field: fieldName,
221
+ value,
222
+ })
223
+ }
224
+ }
225
+ switch (op) {
226
+ case 'eq': builder.where(column, value); break
227
+ case 'ne': builder.whereNot(column, value); break
228
+ case 'gt': builder.where(column, '>', value); break
229
+ case 'gte': builder.where(column, '>=', value); break
230
+ case 'lt': builder.where(column, '<', value); break
231
+ case 'lte': builder.where(column, '<=', value); break
232
+ case 'in': builder.whereIn(column, Array.isArray(value) ? value : [value]); break
233
+ case 'nin': builder.whereNotIn(column, Array.isArray(value) ? value : [value]); break
234
+ case 'like': builder.where(column, 'like', value); break
235
+ case 'ilike': builder.where(column, 'ilike', value); break
236
+ case 'exists': value ? builder.whereNotNull(column) : builder.whereNull(column); break
237
+ default: break
238
+ }
239
+ return builder
240
+ }
241
+
242
+ for (const filter of baseFilters) {
243
+ let qualified = filter.qualified ?? null
244
+ if (!qualified) {
245
+ const column = await this.resolveBaseColumn(table, String(filter.field))
246
+ if (!column) continue
247
+ qualified = qualify(column)
248
+ }
249
+ applyFilterOp(q, qualified, filter.op, filter.value, String(filter.field))
250
+ }
251
+
252
+ const applyAliasScopes = async (builder: any, aliasName: string) => {
253
+ const targetTable = aliasTables.get(aliasName)
254
+ if (!targetTable) return
255
+ if (orgScope && await this.columnExists(targetTable, 'organization_id')) {
256
+ this.applyOrganizationScope(builder, `${aliasName}.organization_id`, orgScope)
257
+ }
258
+ if (opts.tenantId && await this.columnExists(targetTable, 'tenant_id')) {
259
+ builder.where(`${aliasName}.tenant_id`, opts.tenantId)
260
+ }
261
+ }
262
+ await applyJoinFilters({
263
+ knex,
264
+ baseTable: table,
265
+ builder: q,
266
+ joinMap,
267
+ joinFilters,
268
+ aliasTables,
269
+ qualifyBase: (column) => qualify(column),
270
+ applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias),
271
+ applyFilterOp,
272
+ columnExists: (tbl, column) => this.columnExists(tbl, column),
273
+ })
274
+ // Selection (base columns only here; cf:* handled later)
275
+ if (opts.fields && opts.fields.length) {
276
+ const cols = opts.fields.filter((f) => !f.startsWith('cf:'))
277
+ if (cols.length) {
278
+ // Qualify and alias to base names to avoid ambiguity
279
+ const baseSelects = cols.map((c) => knex.raw('?? as ??', [qualify(c), c]))
280
+ q = q.select(baseSelects)
281
+ }
282
+ } else {
283
+ // Default to selecting only base table columns to avoid ambiguity when joining
284
+ q = q.select(knex.raw('??.*', [table]))
285
+ }
286
+
287
+ // Resolve which custom fields to include
288
+ const tenantId = opts.tenantId
289
+ const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_]/g, '_')
290
+ const cfSources = this.configureCustomFieldSources(q, table, entity, knex, opts, qualify)
291
+ const entityIdToSource = new Map<string, ResolvedCustomFieldSource>()
292
+ for (const source of cfSources) {
293
+ entityIdToSource.set(String(source.entityId), source)
294
+ }
295
+ const requestedCustomFieldKeys = Array.isArray(opts.includeCustomFields)
296
+ ? opts.includeCustomFields.map((key) => String(key))
297
+ : []
298
+ const cfKeys = new Set<string>()
299
+ const keySource = new Map<string, ResolvedCustomFieldSource>()
300
+ // Explicit in fields/filters
301
+ for (const f of (opts.fields || [])) {
302
+ if (typeof f === 'string' && f.startsWith('cf:')) cfKeys.add(f.slice(3))
303
+ }
304
+ for (const f of cfFilters) {
305
+ if (typeof f.field === 'string' && f.field.startsWith('cf:')) cfKeys.add(f.field.slice(3))
306
+ }
307
+ if (opts.includeCustomFields === true) {
308
+ if (entityIdToSource.size > 0) {
309
+ const entityIdList = Array.from(entityIdToSource.keys())
310
+ const entityOrder = new Map<string, number>()
311
+ entityIdList.forEach((id, idx) => entityOrder.set(id, idx))
312
+ const rows = await knex('custom_field_defs')
313
+ .select('key', 'entity_id', 'config_json', 'kind')
314
+ .whereIn('entity_id', entityIdList)
315
+ .andWhere('is_active', true)
316
+ .modify((qb: any) => {
317
+ qb.andWhere((inner: any) => {
318
+ inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
319
+ })
320
+ })
321
+ type CustomFieldDefinitionRow = {
322
+ key: string
323
+ entityId: string
324
+ kind: string
325
+ config: Record<string, unknown>
326
+ }
327
+ const sorted: CustomFieldDefinitionRow[] = rows.map((row: any) => {
328
+ const raw = row.config_json
329
+ let cfg: Record<string, any> = {}
330
+ if (raw && typeof raw === 'string') {
331
+ try { cfg = JSON.parse(raw) } catch { cfg = {} }
332
+ } else if (raw && typeof raw === 'object') {
333
+ cfg = raw
334
+ }
335
+ return {
336
+ key: String(row.key),
337
+ entityId: String(row.entity_id),
338
+ kind: String(row.kind || ''),
339
+ config: cfg,
340
+ }
341
+ })
342
+ sorted.sort((a: CustomFieldDefinitionRow, b: CustomFieldDefinitionRow) => {
343
+ const ai = entityOrder.get(a.entityId) ?? Number.MAX_SAFE_INTEGER
344
+ const bi = entityOrder.get(b.entityId) ?? Number.MAX_SAFE_INTEGER
345
+ if (ai !== bi) return ai - bi
346
+ return a.key.localeCompare(b.key)
347
+ })
348
+ const selectedSources = new Map<string, { source: ResolvedCustomFieldSource; score: number; penalty: number; entityIndex: number }>()
349
+ for (const row of sorted) {
350
+ const source = entityIdToSource.get(row.entityId)
351
+ if (!source) continue
352
+ const cfg = row.config || {}
353
+ const entityIndex = entityOrder.get(row.entityId) ?? Number.MAX_SAFE_INTEGER
354
+ const scores = computeScore(cfg, row.kind, entityIndex)
355
+ const existing = selectedSources.get(row.key)
356
+ if (!existing || scores.base > existing.score || (scores.base === existing.score && (scores.penalty < existing.penalty || (scores.penalty === existing.penalty && scores.entityIndex < existing.entityIndex)))) {
357
+ selectedSources.set(row.key, { source, score: scores.base, penalty: scores.penalty, entityIndex: scores.entityIndex })
358
+ }
359
+ cfKeys.add(row.key)
360
+ }
361
+ for (const [key, entry] of selectedSources.entries()) {
362
+ keySource.set(key, entry.source)
363
+ }
364
+ }
365
+ } else if (requestedCustomFieldKeys.length > 0) {
366
+ for (const key of requestedCustomFieldKeys) cfKeys.add(key)
367
+ }
368
+ const unresolvedKeys = Array.from(cfKeys).filter((key) => !keySource.has(key))
369
+ if (unresolvedKeys.length > 0 && entityIdToSource.size > 0) {
370
+ const rows = await knex('custom_field_defs')
371
+ .select('key', 'entity_id')
372
+ .whereIn('entity_id', Array.from(entityIdToSource.keys()))
373
+ .whereIn('key', unresolvedKeys)
374
+ .andWhere('is_active', true)
375
+ .modify((qb: any) => {
376
+ qb.andWhere((inner: any) => {
377
+ inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
378
+ })
379
+ })
380
+ for (const row of rows) {
381
+ const source = entityIdToSource.get(String(row.entity_id))
382
+ if (!source) continue
383
+ if (!keySource.has(row.key)) keySource.set(row.key, source)
384
+ }
385
+ }
386
+
387
+ const cfValueExprByKey: Record<string, any> = {}
388
+ const cfSelectedAliases: string[] = []
389
+ const cfJsonAliases = new Set<string>()
390
+ const cfMultiAliasByAlias = new Map<string, string>()
391
+ for (const key of cfKeys) {
392
+ const source = keySource.get(key)
393
+ if (!source) continue
394
+ const entityIdForKey = source.entityId
395
+ const recordIdExpr = source.recordIdExpr
396
+ const sourceAliasSafe = sanitize(source.alias || 'src')
397
+ const keyAliasSafe = sanitize(key)
398
+ const defAlias = `cfd_${sourceAliasSafe}_${keyAliasSafe}`
399
+ const valAlias = `cfv_${sourceAliasSafe}_${keyAliasSafe}`
400
+ // Join definitions for kind resolution
401
+ q = q.leftJoin({ [defAlias]: 'custom_field_defs' }, function (this: any) {
402
+ this.on(`${defAlias}.entity_id`, '=', knex.raw('?', [entityIdForKey]))
403
+ .andOn(`${defAlias}.key`, '=', knex.raw('?', [key]))
404
+ .andOn(`${defAlias}.is_active`, '=', knex.raw('true'))
405
+ .andOn(knex.raw(`(${defAlias}.tenant_id = ? OR ${defAlias}.tenant_id IS NULL)`, [tenantId]))
406
+ })
407
+ // Join values with record match
408
+ q = q.leftJoin({ [valAlias]: 'custom_field_values' }, function (this: any) {
409
+ this.on(`${valAlias}.entity_id`, '=', knex.raw('?', [entityIdForKey]))
410
+ .andOn(`${valAlias}.field_key`, '=', knex.raw('?', [key]))
411
+ .andOn(`${valAlias}.record_id`, '=', recordIdExpr)
412
+ .andOn(knex.raw(`(${valAlias}.tenant_id = ? OR ${valAlias}.tenant_id IS NULL)`, [tenantId]))
413
+ })
414
+ // Force a common SQL type across branches to avoid Postgres CASE type conflicts
415
+ const caseExpr = knex.raw(
416
+ `CASE ${defAlias}.kind
417
+ WHEN 'integer' THEN (${valAlias}.value_int)::text
418
+ WHEN 'float' THEN (${valAlias}.value_float)::text
419
+ WHEN 'boolean' THEN (${valAlias}.value_bool)::text
420
+ WHEN 'multiline' THEN (${valAlias}.value_multiline)::text
421
+ ELSE (${valAlias}.value_text)::text
422
+ END`
423
+ )
424
+ cfValueExprByKey[key] = caseExpr
425
+ const alias = sanitize(`cf:${key}`)
426
+ // Project as aggregated to avoid duplicates when multi values exist
427
+ if ((opts.fields || []).includes(`cf:${key}`) || opts.includeCustomFields === true || (requestedCustomFieldKeys.length > 0 && requestedCustomFieldKeys.includes(key))) {
428
+ // Use bool_or over config_json->>multi so it's valid under GROUP BY
429
+ const isMulti = knex.raw(`bool_or(coalesce((${defAlias}.config_json->>'multi')::boolean, false))`)
430
+ const aggregatedArray = `array_remove(array_agg(DISTINCT ${caseExpr.toString()}), NULL)`
431
+ const expr = `CASE WHEN ${isMulti.toString()}
432
+ THEN to_jsonb(${aggregatedArray})
433
+ ELSE to_jsonb(max(${caseExpr.toString()}))
434
+ END`
435
+ const multiAlias = `${alias}__is_multi`
436
+ q = q.select(knex.raw(`${expr} as ??`, [alias]))
437
+ q = q.select(knex.raw(`${isMulti.toString()} as ??`, [multiAlias]))
438
+ cfSelectedAliases.push(alias)
439
+ cfJsonAliases.add(alias)
440
+ cfMultiAliasByAlias.set(alias, multiAlias)
441
+ }
442
+ }
443
+
444
+ // Apply cf:* filters (on raw expressions)
445
+ for (const f of cfFilters) {
446
+ if (!f.field.startsWith('cf:')) continue
447
+ const key = f.field.slice(3)
448
+ const expr = cfValueExprByKey[key]
449
+ if (!expr) continue
450
+ if ((f.op === 'like' || f.op === 'ilike') && searchActive && typeof f.value === 'string') {
451
+ const tokens = tokenizeText(String(f.value), searchConfig)
452
+ const hashes = tokens.hashes
453
+ if (hashes.length) {
454
+ const applied = this.applySearchTokens(q, {
455
+ entity: String(entity),
456
+ field: f.field,
457
+ hashes,
458
+ recordIdColumn,
459
+ tenantId: opts.tenantId ?? null,
460
+ organizationScope: orgScope,
461
+ tokens: tokens.tokens,
462
+ })
463
+ this.logSearchDebug('search:cf-filter', {
464
+ entity: String(entity),
465
+ field: f.field,
466
+ tokens: tokens.tokens,
467
+ hashes,
468
+ applied,
469
+ tenantId: opts.tenantId ?? null,
470
+ organizationScope: orgScope,
471
+ })
472
+ if (applied) continue
473
+ } else {
474
+ this.logSearchDebug('search:cf-skip-empty-hashes', {
475
+ entity: String(entity),
476
+ field: f.field,
477
+ value: f.value,
478
+ })
479
+ }
480
+ }
481
+ switch (f.op) {
482
+ case 'eq': q = q.where(expr, '=', f.value); break
483
+ case 'ne': q = q.where(expr, '!=', f.value); break
484
+ case 'gt': q = q.where(expr, '>', f.value); break
485
+ case 'gte': q = q.where(expr, '>=', f.value); break
486
+ case 'lt': q = q.where(expr, '<', f.value); break
487
+ case 'lte': q = q.where(expr, '<=', f.value); break
488
+ case 'in': q = q.whereIn(expr as any, f.value ?? []); break
489
+ case 'nin': q = q.whereNotIn(expr as any, f.value ?? []); break
490
+ case 'like': q = q.where(expr, 'like', f.value); break
491
+ case 'ilike': q = q.where(expr, 'ilike', f.value); break
492
+ case 'exists': f.value ? q = q.whereNotNull(expr) : q = q.whereNull(expr); break
493
+ }
494
+ }
495
+
496
+ // Entity extensions joins (no selection yet; enables future filters/projections)
497
+ if (opts.includeExtensions) {
498
+ const { getModules } = await import('@open-mercato/shared/lib/i18n/server')
499
+ const allMods = getModules() as any[]
500
+ const allExts = allMods.flatMap((m) => (m as any).entityExtensions || [])
501
+ const exts = allExts.filter((e: any) => e.base === entity)
502
+ const chosen = Array.isArray(opts.includeExtensions)
503
+ ? exts.filter((e: any) => (opts.includeExtensions as string[]).includes(e.extension))
504
+ : exts
505
+ for (const e of chosen) {
506
+ const [, extName] = (e.extension as string).split(':')
507
+ const extTable = extName.endsWith('s') ? extName : `${extName}s`
508
+ const alias = `ext_${sanitize(extName)}`
509
+ q = q.leftJoin({ [alias]: extTable }, function (this: any) {
510
+ this.on(`${alias}.${e.join.extensionKey}`, '=', knex.raw('??', [`${table}.${e.join.baseKey}`]))
511
+ })
512
+ }
513
+ }
514
+
515
+ // Sorting: base fields and cf:* (use aggregated alias for cf)
516
+ for (const s of opts.sort || []) {
517
+ if (s.field.startsWith('cf:')) {
518
+ const key = s.field.slice(3)
519
+ const alias = sanitize(`cf:${key}`)
520
+ // Ensure included in projection to sort by
521
+ if (!cfSelectedAliases.includes(alias)) {
522
+ const expr = cfValueExprByKey[key]
523
+ if (expr) {
524
+ q = q.select(knex.raw(`max(${expr.toString()}) as ??`, [alias]))
525
+ cfSelectedAliases.push(alias)
526
+ }
527
+ }
528
+ q = q.orderBy(alias, s.dir ?? 'asc')
529
+ } else {
530
+ const column = await this.resolveBaseColumn(table, s.field)
531
+ if (!column) continue
532
+ q = q.orderBy(qualify(column), s.dir ?? 'asc')
533
+ }
534
+ }
535
+
536
+ // Pagination
537
+ const page = opts.page?.page ?? 1
538
+ const pageSize = opts.page?.pageSize ?? 20
539
+ // Deduplicate if we joined CFs or extensions by grouping on base id
540
+ if ((opts.includeExtensions && (Array.isArray(opts.includeExtensions) ? (opts.includeExtensions.length > 0) : true)) || Object.keys(cfValueExprByKey).length > 0) {
541
+ q = q.groupBy(`${table}.id`)
542
+ }
543
+ const countClone: any = q.clone()
544
+ if (typeof countClone.clearSelect === 'function') countClone.clearSelect()
545
+ if (typeof countClone.clearOrder === 'function') countClone.clearOrder()
546
+ if (typeof countClone.clearGroup === 'function') countClone.clearGroup()
547
+ const countRow = await countClone
548
+ .countDistinct(`${table}.id as count`)
549
+ .first()
550
+ const total = Number((countRow as any)?.count ?? 0)
551
+ const items = await q.limit(pageSize).offset((page - 1) * pageSize)
552
+
553
+ if (cfJsonAliases.size > 0) {
554
+ for (const row of items as any[]) {
555
+ for (const alias of cfJsonAliases) {
556
+ const multiAlias = cfMultiAliasByAlias.get(alias)
557
+ const isMulti = multiAlias ? Boolean(row[multiAlias]) : false
558
+ let raw = row[alias]
559
+ if (typeof raw === 'string') {
560
+ try { raw = JSON.parse(raw) } catch { /* ignore malformed json */ }
561
+ }
562
+ if (isMulti) {
563
+ if (raw == null) row[alias] = []
564
+ else if (Array.isArray(raw)) row[alias] = raw
565
+ else row[alias] = [raw]
566
+ } else {
567
+ if (Array.isArray(raw)) row[alias] = raw.length > 0 ? raw[0] : null
568
+ else row[alias] = raw
569
+ }
570
+ if (multiAlias) delete row[multiAlias]
571
+ }
572
+ }
573
+ }
574
+
575
+ const svc = this.getEncryptionService()
576
+ const decryptPayload =
577
+ svc?.decryptEntityPayload?.bind(svc) as
578
+ | ((
579
+ entityId: EntityId,
580
+ payload: Record<string, unknown>,
581
+ tenantId: string | null,
582
+ organizationId: string | null,
583
+ ) => Promise<Record<string, unknown>>)
584
+ | null
585
+ let decryptedItems = items
586
+ if (decryptPayload) {
587
+ const fallbackOrgId =
588
+ opts.organizationId
589
+ ?? (Array.isArray(opts.organizationIds) && opts.organizationIds.length === 1 ? opts.organizationIds[0] : null)
590
+ decryptedItems = await Promise.all(
591
+ (items as any[]).map(async (item) => {
592
+ try {
593
+ const decrypted = await decryptPayload(
594
+ entity,
595
+ item,
596
+ item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
597
+ item?.organization_id ?? item?.organizationId ?? fallbackOrgId ?? null,
598
+ )
599
+ return { ...item, ...decrypted }
600
+ } catch (err) {
601
+ console.error('QueryEngine: error decrypting entity payload', err);
602
+ return item
603
+ }
604
+ })
605
+ )
606
+ }
607
+
608
+ return { items: decryptedItems, page, pageSize, total }
609
+ }
610
+
611
+ private async resolveBaseColumn(table: string, field: string): Promise<string | null> {
612
+ if (await this.columnExists(table, field)) return field
613
+ if (field === 'organization_id' && await this.columnExists(table, 'id')) return 'id'
614
+ return null
615
+ }
616
+
617
+ private async columnExists(table: string, column: string): Promise<boolean> {
618
+ const key = `${table}.${column}`
619
+ if (this.columnCache.has(key)) {
620
+ const cached = this.columnCache.get(key)
621
+ if (cached === true) return true
622
+ this.columnCache.delete(key)
623
+ }
624
+ const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
625
+ const exists = await knex('information_schema.columns')
626
+ .where({ table_name: table, column_name: column })
627
+ .first()
628
+ const present = !!exists
629
+ if (present) this.columnCache.set(key, true)
630
+ else this.columnCache.delete(key)
631
+ return present
632
+ }
633
+
634
+ private async tableExists(table: string): Promise<boolean> {
635
+ if (this.tableCache.has(table)) return this.tableCache.get(table) ?? false
636
+ const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
637
+ const exists = await knex('information_schema.tables')
638
+ .where({ table_name: table })
639
+ .first()
640
+ const present = !!exists
641
+ this.tableCache.set(table, present)
642
+ return present
643
+ }
644
+
645
+ private async hasSearchTokens(
646
+ entity: string,
647
+ tenantId: string | null,
648
+ orgScope?: { ids: string[]; includeNull: boolean } | null
649
+ ): Promise<boolean> {
650
+ try {
651
+ const knex = this.getKnexFn ? this.getKnexFn() : (this.em as any).getConnection().getKnex()
652
+ const query = knex('search_tokens').select(1).where('entity_type', entity).limit(1)
653
+ if (tenantId !== undefined) {
654
+ query.andWhereRaw('tenant_id is not distinct from ?', [tenantId])
655
+ }
656
+ if (orgScope) {
657
+ this.applyOrganizationScope(query as any, 'search_tokens.organization_id', orgScope)
658
+ }
659
+ const row = await query.first()
660
+ return !!row
661
+ } catch (err) {
662
+ this.logSearchDebug('search:has-tokens-error', {
663
+ entity,
664
+ tenantId,
665
+ organizationScope: orgScope,
666
+ error: err instanceof Error ? err.message : String(err),
667
+ })
668
+ return false
669
+ }
670
+ }
671
+
672
+ private applySearchTokens<TRecord extends ResultRow, TResult>(
673
+ q: Knex.QueryBuilder<TRecord, TResult>,
674
+ opts: {
675
+ entity: string
676
+ field: string
677
+ hashes: string[]
678
+ recordIdColumn: string
679
+ tenantId?: string | null
680
+ organizationScope?: { ids: string[]; includeNull: boolean } | null
681
+ combineWith?: 'and' | 'or'
682
+ tokens?: string[]
683
+ }
684
+ ): boolean {
685
+ if (!opts.hashes.length) {
686
+ this.logSearchDebug('search:skip-no-hashes', {
687
+ entity: opts.entity,
688
+ field: opts.field,
689
+ tenantId: opts.tenantId ?? null,
690
+ organizationScope: opts.organizationScope,
691
+ })
692
+ return false
693
+ }
694
+ const alias = `st_${this.searchAliasSeq++}`
695
+ const combineWith = opts.combineWith === 'or' ? 'orWhereExists' : 'whereExists'
696
+ const engine = this
697
+ this.logSearchDebug('search:apply-search-tokens', {
698
+ entity: opts.entity,
699
+ field: opts.field,
700
+ alias,
701
+ tokenCount: opts.hashes.length,
702
+ tokens: opts.tokens,
703
+ tenantId: opts.tenantId ?? null,
704
+ organizationScope: opts.organizationScope,
705
+ combineWith: opts.combineWith ?? 'and',
706
+ })
707
+ ;(q as any)[combineWith](function (this: Knex.QueryBuilder) {
708
+ this.select(1)
709
+ .from({ [alias]: 'search_tokens' })
710
+ .where(`${alias}.entity_type`, opts.entity)
711
+ .andWhere(`${alias}.field`, opts.field)
712
+ .andWhereRaw('?? = ??::text', [`${alias}.entity_id`, opts.recordIdColumn])
713
+ .whereIn(`${alias}.token_hash`, opts.hashes)
714
+ .groupBy(`${alias}.entity_id`, `${alias}.field`)
715
+ .havingRaw(`count(distinct ${alias}.token_hash) >= ?`, [opts.hashes.length])
716
+ if (opts.tenantId !== undefined) {
717
+ this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null])
718
+ }
719
+ if (opts.organizationScope) {
720
+ engine.applyOrganizationScope(this as any, `${alias}.organization_id`, opts.organizationScope)
721
+ }
722
+ })
723
+ return true
724
+ }
725
+
726
+ private configureCustomFieldSources(
727
+ q: any,
728
+ baseTable: string,
729
+ baseEntity: EntityId,
730
+ knex: any,
731
+ opts: QueryOptions,
732
+ qualify: (column: string) => string
733
+ ): ResolvedCustomFieldSource[] {
734
+ const sources: ResolvedCustomFieldSource[] = [
735
+ {
736
+ entityId: baseEntity,
737
+ alias: 'base',
738
+ table: baseTable,
739
+ recordIdExpr: knex.raw('??::text', [`${baseTable}.id`]),
740
+ },
741
+ ]
742
+ const extras: QueryCustomFieldSource[] = opts.customFieldSources ?? []
743
+ extras.forEach((srcOpt, index) => {
744
+ const joinTable = srcOpt.table ?? resolveEntityTableName(this.em, srcOpt.entityId)
745
+ const alias = srcOpt.alias ?? `cfs_${index}`
746
+ const join = srcOpt.join
747
+ if (!join) {
748
+ throw new Error(`QueryEngine: customFieldSources entry for ${String(srcOpt.entityId)} requires a join configuration`)
749
+ }
750
+ const joinArgs = { [alias]: joinTable }
751
+ const joinCallback = function (this: any) {
752
+ this.on(`${alias}.${join.toField}`, '=', qualify(join.fromField))
753
+ }
754
+ const joinType = join.type ?? 'left'
755
+ if (joinType === 'inner') q.join(joinArgs, joinCallback)
756
+ else q.leftJoin(joinArgs, joinCallback)
757
+ const recordColumn = srcOpt.recordIdColumn ?? 'id'
758
+ sources.push({
759
+ entityId: srcOpt.entityId,
760
+ alias,
761
+ table: joinTable,
762
+ recordIdExpr: knex.raw('??::text', [`${alias}.${recordColumn}`]),
763
+ })
764
+ })
765
+ return sources
766
+ }
767
+
768
+ private logSearchDebug(event: string, payload: Record<string, unknown>) {
769
+ try {
770
+ console.info('[query:search]', event, JSON.stringify(payload))
771
+ } catch {
772
+ console.info('[query:search]', event, payload)
773
+ }
774
+ }
775
+
776
+ private resolveOrganizationScope(opts: QueryOptions): { ids: string[]; includeNull: boolean } | null {
777
+ if (opts.organizationIds !== undefined) {
778
+ const raw = (opts.organizationIds ?? []).map((id) => (typeof id === 'string' ? id.trim() : id))
779
+ const includeNull = raw.some((id) => id == null || id === '')
780
+ const ids = raw.filter((id): id is string => typeof id === 'string' && id.length > 0)
781
+ return { ids: Array.from(new Set(ids)), includeNull }
782
+ }
783
+ if (typeof opts.organizationId === 'string' && opts.organizationId.trim().length > 0) {
784
+ return { ids: [opts.organizationId], includeNull: false }
785
+ }
786
+ return null
787
+ }
788
+
789
+ private applyOrganizationScope(q: any, column: string, scope: { ids: string[]; includeNull: boolean }): any {
790
+ if (!scope) return q
791
+ if (scope.ids.length === 0 && !scope.includeNull) {
792
+ return q.whereRaw('1 = 0')
793
+ }
794
+ return q.where((builder: any) => {
795
+ let applied = false
796
+ if (scope.ids.length > 0) {
797
+ builder.whereIn(column as any, scope.ids)
798
+ applied = true
799
+ }
800
+ if (scope.includeNull) {
801
+ if (applied) builder.orWhereNull(column)
802
+ else builder.whereNull(column)
803
+ applied = true
804
+ }
805
+ if (!applied) builder.whereRaw('1 = 0')
806
+ })
807
+ }
808
+
809
+ }
810
+ const computeScore = (cfg: Record<string, unknown>, kind: string, entityIndex: number) => {
811
+ const listVisibleScore = cfg.listVisible === false ? 0 : 1
812
+ const formEditableScore = cfg.formEditable === false ? 0 : 1
813
+ const filterableScore = cfg.filterable ? 1 : 0
814
+ const kindScore = (() => {
815
+ switch (kind) {
816
+ case 'dictionary':
817
+ return 8
818
+ case 'relation':
819
+ return 6
820
+ case 'select':
821
+ return 4
822
+ case 'multiline':
823
+ return 3
824
+ case 'boolean':
825
+ case 'integer':
826
+ case 'float':
827
+ return 2
828
+ default:
829
+ return 1
830
+ }
831
+ })()
832
+ const optionsBonus = Array.isArray(cfg.options) && cfg.options.length ? 2 : 0
833
+ const dictionaryBonus = typeof cfg.dictionaryId === 'string' && cfg.dictionaryId.trim().length ? 5 : 0
834
+ const base = (listVisibleScore * 16) + (formEditableScore * 8) + (filterableScore * 4) + kindScore + optionsBonus + dictionaryBonus
835
+ const penalty = typeof cfg.priority === 'number' ? cfg.priority : 0
836
+ return { base, penalty, entityIndex }
837
+ }