@open-mercato/core 0.5.1-develop.2691.d8a0934b37 → 0.5.1-develop.2699.f8b50c8046

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 (414) hide show
  1. package/dist/modules/api_keys/data/entities.js +1 -1
  2. package/dist/modules/api_keys/data/entities.js.map +1 -1
  3. package/dist/modules/api_keys/services/apiKeyService.js +5 -5
  4. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  5. package/dist/modules/attachments/api/library/[id]/route.js +1 -1
  6. package/dist/modules/attachments/api/library/[id]/route.js.map +2 -2
  7. package/dist/modules/attachments/api/library/route.js +7 -9
  8. package/dist/modules/attachments/api/library/route.js.map +2 -2
  9. package/dist/modules/attachments/api/partitions/route.js +3 -3
  10. package/dist/modules/attachments/api/partitions/route.js.map +2 -2
  11. package/dist/modules/attachments/api/route.js +6 -5
  12. package/dist/modules/attachments/api/route.js.map +2 -2
  13. package/dist/modules/attachments/api/transfer/route.js +1 -1
  14. package/dist/modules/attachments/api/transfer/route.js.map +2 -2
  15. package/dist/modules/attachments/data/entities.js +2 -1
  16. package/dist/modules/attachments/data/entities.js.map +2 -2
  17. package/dist/modules/attachments/lib/ocrQueue.js +1 -1
  18. package/dist/modules/attachments/lib/ocrQueue.js.map +2 -2
  19. package/dist/modules/audit_logs/api/audit-logs/actions/export/route.js.map +2 -2
  20. package/dist/modules/audit_logs/api/audit-logs/actions/route.js.map +2 -2
  21. package/dist/modules/audit_logs/data/entities.js +1 -1
  22. package/dist/modules/audit_logs/data/entities.js.map +1 -1
  23. package/dist/modules/audit_logs/services/actionLogService.js +77 -70
  24. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  25. package/dist/modules/auth/api/roles/acl/route.js +1 -1
  26. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  27. package/dist/modules/auth/api/users/acl/route.js +2 -2
  28. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  29. package/dist/modules/auth/api/users/resend-invite/route.js +1 -1
  30. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  31. package/dist/modules/auth/cli.js +12 -6
  32. package/dist/modules/auth/cli.js.map +2 -2
  33. package/dist/modules/auth/commands/users.js +1 -1
  34. package/dist/modules/auth/commands/users.js.map +2 -2
  35. package/dist/modules/auth/data/entities.js +1 -1
  36. package/dist/modules/auth/data/entities.js.map +2 -2
  37. package/dist/modules/auth/lib/setup-app.js +3 -3
  38. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  39. package/dist/modules/auth/services/authService.js +2 -2
  40. package/dist/modules/auth/services/authService.js.map +2 -2
  41. package/dist/modules/business_rules/api/rules/route.js +3 -3
  42. package/dist/modules/business_rules/api/rules/route.js.map +2 -2
  43. package/dist/modules/business_rules/api/sets/[id]/members/route.js +7 -4
  44. package/dist/modules/business_rules/api/sets/[id]/members/route.js.map +2 -2
  45. package/dist/modules/business_rules/api/sets/route.js +3 -3
  46. package/dist/modules/business_rules/api/sets/route.js.map +2 -2
  47. package/dist/modules/business_rules/cli.js +1 -1
  48. package/dist/modules/business_rules/cli.js.map +2 -2
  49. package/dist/modules/business_rules/data/entities.js +2 -9
  50. package/dist/modules/business_rules/data/entities.js.map +2 -2
  51. package/dist/modules/business_rules/lib/rule-engine.js +1 -1
  52. package/dist/modules/business_rules/lib/rule-engine.js.map +2 -2
  53. package/dist/modules/catalog/api/option-schemas/route.js +0 -1
  54. package/dist/modules/catalog/api/option-schemas/route.js.map +2 -2
  55. package/dist/modules/catalog/data/entities.js +2 -11
  56. package/dist/modules/catalog/data/entities.js.map +2 -2
  57. package/dist/modules/configs/data/entities.js +2 -1
  58. package/dist/modules/configs/data/entities.js.map +2 -2
  59. package/dist/modules/currencies/commands/fetch-configs.js +3 -3
  60. package/dist/modules/currencies/commands/fetch-configs.js.map +2 -2
  61. package/dist/modules/currencies/data/entities.js +1 -1
  62. package/dist/modules/currencies/data/entities.js.map +2 -2
  63. package/dist/modules/customer_accounts/api/signup.js +1 -1
  64. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  65. package/dist/modules/customer_accounts/data/entities.js +1 -1
  66. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  67. package/dist/modules/customer_accounts/services/customerInvitationService.js +1 -1
  68. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  69. package/dist/modules/customer_accounts/services/customerSessionService.js +1 -1
  70. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  71. package/dist/modules/customer_accounts/services/customerTokenService.js +12 -7
  72. package/dist/modules/customer_accounts/services/customerTokenService.js.map +2 -2
  73. package/dist/modules/customers/api/interactions/conflicts/route.js +19 -17
  74. package/dist/modules/customers/api/interactions/conflicts/route.js.map +2 -2
  75. package/dist/modules/customers/api/interactions/counts/route.js +7 -6
  76. package/dist/modules/customers/api/interactions/counts/route.js.map +2 -2
  77. package/dist/modules/customers/api/interactions/route.js +28 -42
  78. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  79. package/dist/modules/customers/api/utils.js +29 -24
  80. package/dist/modules/customers/api/utils.js.map +2 -2
  81. package/dist/modules/customers/cli.js +45 -40
  82. package/dist/modules/customers/cli.js.map +2 -2
  83. package/dist/modules/customers/commands/dictionaries.js +1 -1
  84. package/dist/modules/customers/commands/dictionaries.js.map +2 -2
  85. package/dist/modules/customers/commands/tags.js +1 -1
  86. package/dist/modules/customers/commands/tags.js.map +2 -2
  87. package/dist/modules/customers/data/entities.js +2 -12
  88. package/dist/modules/customers/data/entities.js.map +2 -2
  89. package/dist/modules/customers/lib/interactionProjection.js +18 -15
  90. package/dist/modules/customers/lib/interactionProjection.js.map +2 -2
  91. package/dist/modules/customers/lib/personCompanyLinkTable.js +6 -8
  92. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  93. package/dist/modules/dashboards/api/roles/widgets/route.js +1 -1
  94. package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
  95. package/dist/modules/dashboards/api/users/widgets/route.js +1 -1
  96. package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
  97. package/dist/modules/dashboards/data/entities.js +1 -1
  98. package/dist/modules/dashboards/data/entities.js.map +1 -1
  99. package/dist/modules/data_sync/api/mappings/route.js +1 -1
  100. package/dist/modules/data_sync/api/mappings/route.js.map +2 -2
  101. package/dist/modules/data_sync/data/entities.js +2 -1
  102. package/dist/modules/data_sync/data/entities.js.map +2 -2
  103. package/dist/modules/data_sync/lib/id-mapping.js +1 -1
  104. package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
  105. package/dist/modules/data_sync/lib/sync-run-service.js +1 -1
  106. package/dist/modules/data_sync/lib/sync-run-service.js.map +2 -2
  107. package/dist/modules/dictionaries/commands/factory.js +1 -1
  108. package/dist/modules/dictionaries/commands/factory.js.map +2 -2
  109. package/dist/modules/dictionaries/data/entities.js +2 -9
  110. package/dist/modules/dictionaries/data/entities.js.map +2 -2
  111. package/dist/modules/directory/commands/organizations.js +4 -4
  112. package/dist/modules/directory/commands/organizations.js.map +2 -2
  113. package/dist/modules/directory/data/entities.js +2 -1
  114. package/dist/modules/directory/data/entities.js.map +2 -2
  115. package/dist/modules/entities/api/definitions.js +2 -2
  116. package/dist/modules/entities/api/definitions.js.map +2 -2
  117. package/dist/modules/entities/api/encryption.js +2 -2
  118. package/dist/modules/entities/api/encryption.js.map +2 -2
  119. package/dist/modules/entities/api/relations/options.js +2 -2
  120. package/dist/modules/entities/api/relations/options.js.map +2 -2
  121. package/dist/modules/entities/cli.js +4 -4
  122. package/dist/modules/entities/cli.js.map +2 -2
  123. package/dist/modules/entities/data/entities.js +1 -1
  124. package/dist/modules/entities/data/entities.js.map +2 -2
  125. package/dist/modules/entities/lib/field-definitions.js +2 -2
  126. package/dist/modules/entities/lib/field-definitions.js.map +2 -2
  127. package/dist/modules/entities/lib/register.js +1 -1
  128. package/dist/modules/entities/lib/register.js.map +2 -2
  129. package/dist/modules/feature_toggles/data/entities.js +2 -9
  130. package/dist/modules/feature_toggles/data/entities.js.map +2 -2
  131. package/dist/modules/inbox_ops/api/proposals/counts/route.js +3 -6
  132. package/dist/modules/inbox_ops/api/proposals/counts/route.js.map +2 -2
  133. package/dist/modules/inbox_ops/data/entities.js +2 -8
  134. package/dist/modules/inbox_ops/data/entities.js.map +2 -2
  135. package/dist/modules/inbox_ops/lib/messagesIntegration.js +6 -6
  136. package/dist/modules/inbox_ops/lib/messagesIntegration.js.map +2 -2
  137. package/dist/modules/integrations/data/entities.js +2 -1
  138. package/dist/modules/integrations/data/entities.js.map +2 -2
  139. package/dist/modules/integrations/lib/credentials-service.js +1 -1
  140. package/dist/modules/integrations/lib/credentials-service.js.map +2 -2
  141. package/dist/modules/integrations/lib/log-service.js +1 -1
  142. package/dist/modules/integrations/lib/log-service.js.map +2 -2
  143. package/dist/modules/integrations/lib/state-service.js +1 -1
  144. package/dist/modules/integrations/lib/state-service.js.map +2 -2
  145. package/dist/modules/messages/api/route.js +90 -93
  146. package/dist/modules/messages/api/route.js.map +2 -2
  147. package/dist/modules/messages/api/unread-count/route.js +8 -7
  148. package/dist/modules/messages/api/unread-count/route.js.map +2 -2
  149. package/dist/modules/messages/commands/confirmations.js +1 -1
  150. package/dist/modules/messages/commands/confirmations.js.map +2 -2
  151. package/dist/modules/messages/commands/messages.js +3 -3
  152. package/dist/modules/messages/commands/messages.js.map +2 -2
  153. package/dist/modules/messages/data/entities.js +2 -1
  154. package/dist/modules/messages/data/entities.js.map +2 -2
  155. package/dist/modules/messages/lib/email-sender.js +1 -1
  156. package/dist/modules/messages/lib/email-sender.js.map +2 -2
  157. package/dist/modules/messages/lib/searchLookup.js +8 -8
  158. package/dist/modules/messages/lib/searchLookup.js.map +2 -2
  159. package/dist/modules/messages/lib/tokenConsumption.js +9 -4
  160. package/dist/modules/messages/lib/tokenConsumption.js.map +2 -2
  161. package/dist/modules/notifications/data/entities.js +2 -1
  162. package/dist/modules/notifications/data/entities.js.map +2 -2
  163. package/dist/modules/notifications/lib/notificationRecipients.js +15 -5
  164. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  165. package/dist/modules/notifications/lib/notificationService.js +39 -34
  166. package/dist/modules/notifications/lib/notificationService.js.map +2 -2
  167. package/dist/modules/notifications/workers/create-notification.worker.js +14 -13
  168. package/dist/modules/notifications/workers/create-notification.worker.js.map +2 -2
  169. package/dist/modules/payment_gateways/api/transactions/route.js +2 -2
  170. package/dist/modules/payment_gateways/api/transactions/route.js.map +2 -2
  171. package/dist/modules/payment_gateways/data/entities.js +2 -1
  172. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  173. package/dist/modules/payment_gateways/lib/gateway-service.js +1 -1
  174. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  175. package/dist/modules/payment_gateways/lib/webhook-utils.js +2 -2
  176. package/dist/modules/payment_gateways/lib/webhook-utils.js.map +2 -2
  177. package/dist/modules/perspectives/data/entities.js +1 -1
  178. package/dist/modules/perspectives/data/entities.js.map +2 -2
  179. package/dist/modules/planner/data/entities.js +1 -1
  180. package/dist/modules/planner/data/entities.js.map +2 -2
  181. package/dist/modules/progress/data/entities.js +2 -1
  182. package/dist/modules/progress/data/entities.js.map +2 -2
  183. package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
  184. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  185. package/dist/modules/query_index/api/status.js +66 -57
  186. package/dist/modules/query_index/api/status.js.map +2 -2
  187. package/dist/modules/query_index/cli.js +39 -24
  188. package/dist/modules/query_index/cli.js.map +2 -2
  189. package/dist/modules/query_index/data/entities.js +1 -1
  190. package/dist/modules/query_index/data/entities.js.map +2 -2
  191. package/dist/modules/query_index/di.js +25 -13
  192. package/dist/modules/query_index/di.js.map +2 -2
  193. package/dist/modules/query_index/lib/batch.js +31 -33
  194. package/dist/modules/query_index/lib/batch.js.map +2 -2
  195. package/dist/modules/query_index/lib/coverage.js +63 -50
  196. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  197. package/dist/modules/query_index/lib/engine.js +592 -588
  198. package/dist/modules/query_index/lib/engine.js.map +2 -2
  199. package/dist/modules/query_index/lib/indexer.js +74 -47
  200. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  201. package/dist/modules/query_index/lib/jobs.js +37 -24
  202. package/dist/modules/query_index/lib/jobs.js.map +2 -2
  203. package/dist/modules/query_index/lib/purge.js +19 -11
  204. package/dist/modules/query_index/lib/purge.js.map +2 -2
  205. package/dist/modules/query_index/lib/reindexer.js +47 -44
  206. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  207. package/dist/modules/query_index/lib/search-tokens.js +47 -25
  208. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  209. package/dist/modules/query_index/lib/stale.js +14 -12
  210. package/dist/modules/query_index/lib/stale.js.map +2 -2
  211. package/dist/modules/query_index/lib/subscriber-scope.js +2 -2
  212. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  213. package/dist/modules/query_index/subscribers/delete_one.js +3 -2
  214. package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
  215. package/dist/modules/resources/commands/tag-assignments.js +1 -1
  216. package/dist/modules/resources/commands/tag-assignments.js.map +2 -2
  217. package/dist/modules/resources/commands/tags.js +1 -1
  218. package/dist/modules/resources/commands/tags.js.map +2 -2
  219. package/dist/modules/resources/data/entities.js +2 -1
  220. package/dist/modules/resources/data/entities.js.map +2 -2
  221. package/dist/modules/sales/commands/documentAddresses.js +2 -2
  222. package/dist/modules/sales/commands/documentAddresses.js.map +2 -2
  223. package/dist/modules/sales/commands/notes.js.map +2 -2
  224. package/dist/modules/sales/commands/tags.js +1 -1
  225. package/dist/modules/sales/commands/tags.js.map +2 -2
  226. package/dist/modules/sales/data/enrichers.js +9 -8
  227. package/dist/modules/sales/data/enrichers.js.map +2 -2
  228. package/dist/modules/sales/data/entities.js +2 -11
  229. package/dist/modules/sales/data/entities.js.map +2 -2
  230. package/dist/modules/shipping_carriers/data/entities.js +2 -1
  231. package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
  232. package/dist/modules/shipping_carriers/lib/shipping-service.js +1 -1
  233. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  234. package/dist/modules/shipping_carriers/lib/webhook-utils.js +2 -2
  235. package/dist/modules/shipping_carriers/lib/webhook-utils.js.map +2 -2
  236. package/dist/modules/staff/data/entities.js +1 -1
  237. package/dist/modules/staff/data/entities.js.map +2 -2
  238. package/dist/modules/translations/api/[entityType]/[entityId]/route.js +3 -5
  239. package/dist/modules/translations/api/[entityType]/[entityId]/route.js.map +2 -2
  240. package/dist/modules/translations/api/context.js +2 -2
  241. package/dist/modules/translations/api/context.js.map +2 -2
  242. package/dist/modules/translations/commands/translations.js +46 -39
  243. package/dist/modules/translations/commands/translations.js.map +2 -2
  244. package/dist/modules/translations/components/TranslationManager.js +19 -10
  245. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  246. package/dist/modules/translations/data/entities.js +1 -1
  247. package/dist/modules/translations/data/entities.js.map +2 -2
  248. package/dist/modules/translations/lib/apply.js +4 -4
  249. package/dist/modules/translations/lib/apply.js.map +2 -2
  250. package/dist/modules/translations/lib/batch.js +3 -2
  251. package/dist/modules/translations/lib/batch.js.map +2 -2
  252. package/dist/modules/translations/subscribers/cleanup.js +3 -5
  253. package/dist/modules/translations/subscribers/cleanup.js.map +2 -2
  254. package/dist/modules/workflows/api/definitions/route.js +1 -1
  255. package/dist/modules/workflows/api/definitions/route.js.map +2 -2
  256. package/dist/modules/workflows/cli.js +5 -5
  257. package/dist/modules/workflows/cli.js.map +2 -2
  258. package/dist/modules/workflows/data/entities.js +2 -1
  259. package/dist/modules/workflows/data/entities.js.map +2 -2
  260. package/dist/modules/workflows/lib/event-logger.js +2 -2
  261. package/dist/modules/workflows/lib/event-logger.js.map +2 -2
  262. package/dist/modules/workflows/lib/seeds.js +16 -1
  263. package/dist/modules/workflows/lib/seeds.js.map +2 -2
  264. package/dist/modules/workflows/lib/step-handler.js +3 -3
  265. package/dist/modules/workflows/lib/step-handler.js.map +2 -2
  266. package/dist/modules/workflows/lib/task-handler.js +1 -1
  267. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  268. package/dist/modules/workflows/lib/transition-handler.js +1 -1
  269. package/dist/modules/workflows/lib/transition-handler.js.map +2 -2
  270. package/dist/modules/workflows/lib/workflow-executor.js +2 -2
  271. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  272. package/jest.config.cjs +4 -2
  273. package/package.json +3 -3
  274. package/src/modules/api_keys/data/entities.ts +1 -1
  275. package/src/modules/api_keys/services/apiKeyService.ts +5 -5
  276. package/src/modules/attachments/api/library/[id]/route.ts +1 -1
  277. package/src/modules/attachments/api/library/route.ts +10 -12
  278. package/src/modules/attachments/api/partitions/route.ts +3 -3
  279. package/src/modules/attachments/api/route.ts +10 -8
  280. package/src/modules/attachments/api/transfer/route.ts +1 -1
  281. package/src/modules/attachments/data/entities.ts +2 -1
  282. package/src/modules/attachments/lib/ocrQueue.ts +1 -1
  283. package/src/modules/audit_logs/api/audit-logs/actions/export/route.ts +4 -4
  284. package/src/modules/audit_logs/api/audit-logs/actions/route.ts +4 -4
  285. package/src/modules/audit_logs/data/entities.ts +1 -1
  286. package/src/modules/audit_logs/services/actionLogService.ts +96 -87
  287. package/src/modules/auth/api/roles/acl/route.ts +1 -1
  288. package/src/modules/auth/api/users/acl/route.ts +2 -2
  289. package/src/modules/auth/api/users/resend-invite/route.ts +1 -1
  290. package/src/modules/auth/cli.ts +46 -40
  291. package/src/modules/auth/commands/users.ts +1 -1
  292. package/src/modules/auth/data/entities.ts +1 -1
  293. package/src/modules/auth/lib/setup-app.ts +3 -3
  294. package/src/modules/auth/services/authService.ts +2 -2
  295. package/src/modules/business_rules/api/rules/route.ts +3 -3
  296. package/src/modules/business_rules/api/sets/[id]/members/route.ts +7 -4
  297. package/src/modules/business_rules/api/sets/route.ts +3 -3
  298. package/src/modules/business_rules/cli.ts +1 -1
  299. package/src/modules/business_rules/data/entities.ts +2 -9
  300. package/src/modules/business_rules/lib/rule-engine.ts +1 -1
  301. package/src/modules/catalog/api/option-schemas/route.ts +0 -1
  302. package/src/modules/catalog/data/entities.ts +2 -11
  303. package/src/modules/configs/data/entities.ts +2 -1
  304. package/src/modules/currencies/commands/fetch-configs.ts +3 -3
  305. package/src/modules/currencies/data/entities.ts +1 -1
  306. package/src/modules/customer_accounts/api/signup.ts +1 -1
  307. package/src/modules/customer_accounts/data/entities.ts +1 -1
  308. package/src/modules/customer_accounts/services/customerInvitationService.ts +1 -1
  309. package/src/modules/customer_accounts/services/customerSessionService.ts +1 -1
  310. package/src/modules/customer_accounts/services/customerTokenService.ts +26 -15
  311. package/src/modules/customers/api/interactions/conflicts/route.ts +26 -23
  312. package/src/modules/customers/api/interactions/counts/route.ts +13 -11
  313. package/src/modules/customers/api/interactions/route.ts +32 -44
  314. package/src/modules/customers/api/utils.ts +45 -37
  315. package/src/modules/customers/cli.ts +88 -67
  316. package/src/modules/customers/commands/dictionaries.ts +1 -1
  317. package/src/modules/customers/commands/tags.ts +1 -1
  318. package/src/modules/customers/data/entities.ts +2 -12
  319. package/src/modules/customers/lib/interactionProjection.ts +36 -25
  320. package/src/modules/customers/lib/personCompanyLinkTable.ts +13 -18
  321. package/src/modules/dashboards/api/roles/widgets/route.ts +1 -1
  322. package/src/modules/dashboards/api/users/widgets/route.ts +1 -1
  323. package/src/modules/dashboards/data/entities.ts +1 -1
  324. package/src/modules/data_sync/api/mappings/route.ts +1 -1
  325. package/src/modules/data_sync/data/entities.ts +2 -1
  326. package/src/modules/data_sync/lib/id-mapping.ts +1 -1
  327. package/src/modules/data_sync/lib/sync-run-service.ts +1 -1
  328. package/src/modules/dictionaries/commands/factory.ts +1 -1
  329. package/src/modules/dictionaries/data/entities.ts +2 -9
  330. package/src/modules/directory/commands/organizations.ts +4 -4
  331. package/src/modules/directory/data/entities.ts +2 -1
  332. package/src/modules/entities/api/definitions.ts +2 -2
  333. package/src/modules/entities/api/encryption.ts +2 -2
  334. package/src/modules/entities/api/relations/options.ts +8 -3
  335. package/src/modules/entities/cli.ts +4 -4
  336. package/src/modules/entities/data/entities.ts +1 -1
  337. package/src/modules/entities/lib/field-definitions.ts +2 -2
  338. package/src/modules/entities/lib/register.ts +1 -1
  339. package/src/modules/feature_toggles/data/entities.ts +2 -9
  340. package/src/modules/inbox_ops/api/proposals/counts/route.ts +10 -10
  341. package/src/modules/inbox_ops/data/entities.ts +2 -8
  342. package/src/modules/inbox_ops/lib/messagesIntegration.ts +12 -11
  343. package/src/modules/integrations/data/entities.ts +2 -1
  344. package/src/modules/integrations/lib/credentials-service.ts +1 -1
  345. package/src/modules/integrations/lib/log-service.ts +1 -1
  346. package/src/modules/integrations/lib/state-service.ts +1 -1
  347. package/src/modules/messages/api/route.ts +134 -123
  348. package/src/modules/messages/api/unread-count/route.ts +19 -16
  349. package/src/modules/messages/commands/confirmations.ts +1 -1
  350. package/src/modules/messages/commands/messages.ts +3 -3
  351. package/src/modules/messages/data/entities.ts +2 -1
  352. package/src/modules/messages/lib/email-sender.ts +1 -1
  353. package/src/modules/messages/lib/searchLookup.ts +16 -13
  354. package/src/modules/messages/lib/tokenConsumption.ts +16 -8
  355. package/src/modules/notifications/data/entities.ts +2 -1
  356. package/src/modules/notifications/lib/notificationRecipients.ts +42 -26
  357. package/src/modules/notifications/lib/notificationService.ts +53 -42
  358. package/src/modules/notifications/workers/create-notification.worker.ts +20 -17
  359. package/src/modules/payment_gateways/api/transactions/route.ts +2 -2
  360. package/src/modules/payment_gateways/data/entities.ts +2 -1
  361. package/src/modules/payment_gateways/lib/gateway-service.ts +1 -1
  362. package/src/modules/payment_gateways/lib/webhook-utils.ts +2 -2
  363. package/src/modules/perspectives/data/entities.ts +1 -1
  364. package/src/modules/planner/data/entities.ts +1 -1
  365. package/src/modules/progress/data/entities.ts +2 -1
  366. package/src/modules/progress/lib/progressServiceImpl.ts +1 -1
  367. package/src/modules/query_index/api/status.ts +85 -71
  368. package/src/modules/query_index/cli.ts +51 -31
  369. package/src/modules/query_index/data/entities.ts +1 -1
  370. package/src/modules/query_index/di.ts +41 -16
  371. package/src/modules/query_index/lib/batch.ts +68 -55
  372. package/src/modules/query_index/lib/coverage.ts +115 -88
  373. package/src/modules/query_index/lib/engine.ts +1036 -1096
  374. package/src/modules/query_index/lib/indexer.ts +115 -79
  375. package/src/modules/query_index/lib/jobs.ts +51 -31
  376. package/src/modules/query_index/lib/purge.ts +25 -19
  377. package/src/modules/query_index/lib/reindexer.ts +97 -84
  378. package/src/modules/query_index/lib/search-tokens.ts +67 -36
  379. package/src/modules/query_index/lib/stale.ts +14 -17
  380. package/src/modules/query_index/lib/subscriber-scope.ts +6 -5
  381. package/src/modules/query_index/subscribers/delete_one.ts +9 -6
  382. package/src/modules/resources/commands/tag-assignments.ts +1 -1
  383. package/src/modules/resources/commands/tags.ts +1 -1
  384. package/src/modules/resources/data/entities.ts +2 -1
  385. package/src/modules/sales/commands/documentAddresses.ts +2 -2
  386. package/src/modules/sales/commands/notes.ts +1 -1
  387. package/src/modules/sales/commands/tags.ts +1 -1
  388. package/src/modules/sales/data/enrichers.ts +17 -13
  389. package/src/modules/sales/data/entities.ts +2 -11
  390. package/src/modules/shipping_carriers/data/entities.ts +2 -1
  391. package/src/modules/shipping_carriers/lib/shipping-service.ts +1 -1
  392. package/src/modules/shipping_carriers/lib/webhook-utils.ts +2 -2
  393. package/src/modules/staff/data/entities.ts +1 -1
  394. package/src/modules/translations/api/[entityType]/[entityId]/route.ts +14 -11
  395. package/src/modules/translations/api/context.ts +4 -4
  396. package/src/modules/translations/commands/translations.ts +116 -81
  397. package/src/modules/translations/components/TranslationManager.tsx +23 -14
  398. package/src/modules/translations/data/entities.ts +1 -1
  399. package/src/modules/translations/i18n/de.json +1 -0
  400. package/src/modules/translations/i18n/en.json +1 -0
  401. package/src/modules/translations/i18n/es.json +1 -0
  402. package/src/modules/translations/i18n/pl.json +1 -0
  403. package/src/modules/translations/lib/apply.ts +6 -6
  404. package/src/modules/translations/lib/batch.ts +9 -7
  405. package/src/modules/translations/subscribers/cleanup.ts +10 -11
  406. package/src/modules/workflows/api/definitions/route.ts +1 -1
  407. package/src/modules/workflows/cli.ts +5 -5
  408. package/src/modules/workflows/data/entities.ts +2 -1
  409. package/src/modules/workflows/lib/event-logger.ts +2 -2
  410. package/src/modules/workflows/lib/seeds.ts +16 -1
  411. package/src/modules/workflows/lib/step-handler.ts +3 -3
  412. package/src/modules/workflows/lib/task-handler.ts +1 -1
  413. package/src/modules/workflows/lib/transition-handler.ts +1 -1
  414. package/src/modules/workflows/lib/workflow-executor.ts +2 -2
@@ -3,7 +3,7 @@ import { SortDir } from '@open-mercato/shared/lib/query/types'
3
3
  import type { EntityId } from '@open-mercato/shared/modules/entities'
4
4
  import type { EntityManager } from '@mikro-orm/postgresql'
5
5
  import { BasicQueryEngine, resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
6
- import type { Knex } from 'knex'
6
+ import { type Kysely, sql, type RawBuilder } from 'kysely'
7
7
  import type { EventBus } from '@open-mercato/events'
8
8
  import { readCoverageSnapshot, refreshCoverageSnapshot } from './coverage'
9
9
  import { createProfiler, shouldEnableProfiler, type Profiler } from '@open-mercato/shared/lib/profiler'
@@ -58,20 +58,17 @@ function resolveBooleanEnv(names: readonly string[], defaultValue: boolean): boo
58
58
  }
59
59
 
60
60
  function resolveDebugVerbosity(): boolean {
61
- // Check explicit OM_QUERY_INDEX_DEBUG flag first
62
61
  const queryIndexDebug = process.env.OM_QUERY_INDEX_DEBUG
63
62
  if (queryIndexDebug !== undefined) {
64
63
  return parseBooleanToken(queryIndexDebug) ?? false
65
64
  }
66
- // Fall back to log level or NODE_ENV
67
65
  const level = (process.env.LOG_VERBOSITY ?? process.env.LOG_LEVEL ?? '').toLowerCase()
68
66
  if (['debug', 'trace', 'silly'].includes(level)) return true
69
- // Default to false (don't spam logs in development)
70
67
  return false
71
68
  }
72
69
 
73
- type ResultRow = Record<string, unknown>
74
- type ResultBuilder<TResult = ResultRow[]> = Knex.QueryBuilder<ResultRow, TResult>
70
+ type AnyDb = Kysely<any>
71
+ type AnyBuilder = any
75
72
  type NormalizedFilter = { field: string; op: FilterOp; value?: unknown }
76
73
  type IndexDocSource = { alias: string; entityId: EntityId; recordIdColumn: string }
77
74
  type PreparedCustomFieldSource = {
@@ -143,8 +140,13 @@ export class HybridQueryEngine implements QueryEngine {
143
140
  }
144
141
  }
145
142
 
143
+ private getDb(): AnyDb {
144
+ const emAny = this.em as any
145
+ if (typeof emAny.getKysely === 'function') return emAny.getKysely() as AnyDb
146
+ throw new Error('HybridQueryEngine requires an EntityManager exposing getKysely() (MikroORM v7)')
147
+ }
148
+
146
149
  async query<T = unknown>(entity: EntityId, opts: QueryOptions = {}): Promise<QueryResult<T>> {
147
- // --- UMES query extension: before-query pipeline ---
148
150
  const ext: QueryExtensionsConfig | undefined = opts.extensions
149
151
  let hybridExtCtx: QueryExtensionContext | null = null
150
152
  const noopDi = { resolve: <R = unknown>(_name: string): R => { throw new Error('No DI context') } }
@@ -167,7 +169,6 @@ export class HybridQueryEngine implements QueryEngine {
167
169
  }
168
170
  opts = beforeResult.query
169
171
  }
170
- // Strip extensions so fallback to BasicQueryEngine doesn't double-execute
171
172
  const { extensions: _stripExt, ...coreOpts } = opts
172
173
  opts = coreOpts
173
174
 
@@ -217,8 +218,8 @@ export class HybridQueryEngine implements QueryEngine {
217
218
  }
218
219
  }
219
220
 
220
- const knex = this.getKnex()
221
- profiler.mark('query:knex_ready')
221
+ const db = this.getDb()
222
+ profiler.mark('query:db_ready')
222
223
  const baseTable = resolveEntityTableName(this.em, entity)
223
224
  profiler.mark('query:base_table_resolved')
224
225
  const searchConfig = resolveSearchConfig()
@@ -343,688 +344,583 @@ export class HybridQueryEngine implements QueryEngine {
343
344
  }
344
345
 
345
346
  const qualify = (col: string) => `b.${col}`
346
- let builder: ResultBuilder = knex({ b: baseTable })
347
- const hasCustomFieldFilters = cfFilters.length > 0
348
- const canOptimizeCount = !hasCustomFieldFilters
349
- let optimizedCountBuilder: ResultBuilder | null = canOptimizeCount ? knex({ b: baseTable }) : null
350
-
351
- const resolvedJoinsConfig = resolveJoins(
352
- baseTable,
353
- [...(opts.joins ?? []), ...buildFilterableCustomFieldJoins(opts.customFieldSources)],
354
- (entityId) => resolveEntityTableName(this.em, entityId as any),
355
- )
356
- const joinMap = new Map<string, ResolvedJoin>()
357
- const aliasTables = new Map<string, string>()
358
- aliasTables.set('b', baseTable)
359
- aliasTables.set('base', baseTable)
360
- aliasTables.set(baseTable, baseTable)
361
- for (const join of resolvedJoinsConfig) {
362
- joinMap.set(join.alias, join)
363
- aliasTables.set(join.alias, join.table)
364
- }
365
- const { baseFilters, joinFilters } = partitionFilters(baseTable, normalizedFilters, joinMap)
347
+ const columns = await this.getBaseColumnsForEntity(entity)
348
+ const hasOrganizationColumn = await this.columnExists(baseTable, 'organization_id')
349
+ const hasTenantColumn = await this.columnExists(baseTable, 'tenant_id')
350
+ const hasDeletedColumn = await this.columnExists(baseTable, 'deleted_at')
366
351
 
367
- if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
352
+ if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
368
353
 
369
- const hasOrganizationColumn = await this.columnExists(baseTable, 'organization_id')
370
- const hasTenantColumn = await this.columnExists(baseTable, 'tenant_id')
371
- const hasDeletedColumn = await this.columnExists(baseTable, 'deleted_at')
372
- const searchRuntimeBase = {
373
- enabled: false,
374
- config: searchConfig,
375
- organizationScope: orgScope,
376
- tenantId: opts.tenantId ?? null,
377
- }
354
+ const resolvedJoinsConfig = resolveJoins(
355
+ baseTable,
356
+ [...(opts.joins ?? []), ...buildFilterableCustomFieldJoins(opts.customFieldSources)],
357
+ (entityId) => resolveEntityTableName(this.em, entityId as any),
358
+ )
359
+ const joinMap = new Map<string, ResolvedJoin>()
360
+ const aliasTables = new Map<string, string>()
361
+ aliasTables.set('b', baseTable)
362
+ aliasTables.set('base', baseTable)
363
+ aliasTables.set(baseTable, baseTable)
364
+ for (const join of resolvedJoinsConfig) {
365
+ joinMap.set(join.alias, join)
366
+ aliasTables.set(join.alias, join.table)
367
+ }
368
+ const { baseFilters, joinFilters } = partitionFilters(baseTable, normalizedFilters, joinMap)
378
369
 
379
- if (orgScope && hasOrganizationColumn) {
380
- builder = this.applyOrganizationScope(builder, qualify('organization_id'), orgScope)
381
- if (optimizedCountBuilder) optimizedCountBuilder = this.applyOrganizationScope(optimizedCountBuilder, qualify('organization_id'), orgScope)
382
- }
383
- if (hasTenantColumn) {
384
- builder = builder.where(qualify('tenant_id'), opts.tenantId)
385
- if (optimizedCountBuilder) optimizedCountBuilder = optimizedCountBuilder.where(qualify('tenant_id'), opts.tenantId)
386
- }
387
- if (!opts.withDeleted && hasDeletedColumn) {
388
- builder = builder.whereNull(qualify('deleted_at'))
389
- if (optimizedCountBuilder) optimizedCountBuilder = optimizedCountBuilder.whereNull(qualify('deleted_at'))
390
- }
370
+ const searchRuntimeBase = {
371
+ enabled: false,
372
+ config: searchConfig,
373
+ organizationScope: orgScope,
374
+ tenantId: opts.tenantId ?? null,
375
+ }
391
376
 
392
- const baseJoinParts: string[] = []
393
- baseJoinParts.push(`ei.entity_type = ${knex.raw('?', [entity]).toString()}`)
394
- baseJoinParts.push(`ei.entity_id = (${qualify('id')}::text)`)
395
- if (hasOrganizationColumn) {
396
- baseJoinParts.push(`ei.organization_id = ${qualify('organization_id')}`)
397
- baseJoinParts.push('ei.organization_id is not null')
398
- }
399
- if (hasTenantColumn) {
400
- baseJoinParts.push(`ei.tenant_id = ${qualify('tenant_id')}`)
401
- baseJoinParts.push('ei.tenant_id is not null')
402
- }
403
- if (!opts.withDeleted) baseJoinParts.push(`ei.deleted_at is null`)
404
- builder = builder.leftJoin({ ei: 'entity_indexes' }, knex.raw(baseJoinParts.join(' AND ')))
405
-
406
- const columns = await this.getBaseColumnsForEntity(entity)
407
- const indexSources: IndexDocSource[] = [{ alias: 'ei', entityId: entity, recordIdColumn: 'b.id' }]
408
-
409
- const shouldAttachCustomSources = Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && (wantsCf || searchEnabled)
410
- if (shouldAttachCustomSources) {
411
- const prepared = this.prepareCustomFieldSources(knex, builder, opts.customFieldSources ?? [], qualify)
412
- builder = prepared.builder
413
- for (const source of prepared.sources) {
414
- const fragments: string[] = []
415
- fragments.push(`${source.indexAlias}.entity_type = ${knex.raw('?', [source.entityId]).toString()}`)
416
- fragments.push(`${source.indexAlias}.entity_id = (${knex.raw('??::text', [`${source.alias}.${source.recordIdColumn}`]).toString()})`)
417
- const orgExpr = source.organizationField
418
- ? knex.raw('??', [`${source.alias}.${source.organizationField}`]).toString()
419
- : (columns.has('organization_id') ? qualify('organization_id') : null)
420
- if (orgExpr) {
421
- fragments.push(`${source.indexAlias}.organization_id = ${orgExpr}`)
422
- fragments.push(`${source.indexAlias}.organization_id is not null`)
377
+ // Prepare index sources for JSONB custom-field access.
378
+ const indexSources: IndexDocSource[] = [{ alias: 'ei', entityId: entity, recordIdColumn: 'b.id' }]
379
+ let preparedCfSources: PreparedCustomFieldSource[] = []
380
+ const shouldAttachCustomSources = Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && (wantsCf || searchEnabled)
381
+ if (shouldAttachCustomSources) {
382
+ preparedCfSources = this.prepareCustomFieldSources(opts.customFieldSources ?? [])
383
+ for (const source of preparedCfSources) {
384
+ indexSources.push({ alias: source.indexAlias, entityId: source.entityId, recordIdColumn: `${source.alias}.${source.recordIdColumn}` })
423
385
  }
424
- const tenantExpr = source.tenantField
425
- ? knex.raw('??', [`${source.alias}.${source.tenantField}`]).toString()
426
- : (columns.has('tenant_id') ? qualify('tenant_id') : null)
427
- if (tenantExpr) {
428
- fragments.push(`${source.indexAlias}.tenant_id = ${tenantExpr}`)
429
- fragments.push(`${source.indexAlias}.tenant_id is not null`)
430
- }
431
- if (!opts.withDeleted) fragments.push(`${source.indexAlias}.deleted_at is null`)
432
- builder = builder.leftJoin({ [source.indexAlias]: 'entity_indexes' }, knex.raw(fragments.join(' AND ')))
433
- indexSources.push({ alias: source.indexAlias, entityId: source.entityId, recordIdColumn: `${source.alias}.${source.recordIdColumn}` })
434
386
  }
435
- }
436
-
437
- if (debugEnabled) {
438
- this.debug('query:index-sources', {
439
- entity,
440
- sources: indexSources.map((src) => ({ alias: src.alias, entity: src.entityId })),
441
- })
442
- }
443
387
 
444
- const searchSources: SearchTokenSource[] = indexSources
445
- .map((src) => ({
446
- entity: String(src.entityId),
447
- recordIdColumn: src.recordIdColumn,
448
- }))
449
- .filter((src) => src.recordIdColumn && src.entity)
450
- const hasSearchTokens = searchEnabled && searchSources.length
451
- ? await this.searchSourcesHaveTokens(searchSources, opts.tenantId ?? null, orgScope)
452
- : false
453
- const searchRuntime: SearchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens }
454
- const joinSearchAvailability = new Map<string, boolean>()
455
- const searchFilters = normalizeFilters(opts.filters).filter((filter) => filter.op === 'like' || filter.op === 'ilike')
456
- if (searchFilters.length) {
457
- this.logSearchDebug('search:init', {
458
- entity,
459
- baseTable,
460
- tenantId: opts.tenantId ?? null,
461
- organizationScope: orgScope,
462
- fields: searchFilters.map((filter) => String(filter.field)),
463
- searchEnabled,
464
- hasSearchTokens,
465
- searchSources,
466
- searchConfig: {
467
- enabled: searchConfig.enabled,
468
- minTokenLength: searchConfig.minTokenLength,
469
- enablePartials: searchConfig.enablePartials,
470
- hashAlgorithm: searchConfig.hashAlgorithm,
471
- blocklistedFields: searchConfig.blocklistedFields,
472
- },
473
- })
474
- if (!searchEnabled) {
475
- this.logSearchDebug('search:disabled', { entity, baseTable })
476
- } else if (!hasSearchTokens) {
477
- this.logSearchDebug('search:no-search-tokens', {
388
+ const searchSources: SearchTokenSource[] = indexSources
389
+ .map((src) => ({ entity: String(src.entityId), recordIdColumn: src.recordIdColumn }))
390
+ .filter((src) => src.recordIdColumn && src.entity)
391
+ const hasSearchTokens = searchEnabled && searchSources.length
392
+ ? await this.searchSourcesHaveTokens(searchSources, opts.tenantId ?? null, orgScope)
393
+ : false
394
+ const searchRuntime: SearchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens }
395
+ const joinSearchAvailability = new Map<string, boolean>()
396
+ const searchFilters = normalizeFilters(opts.filters).filter((filter) => filter.op === 'like' || filter.op === 'ilike')
397
+ if (searchFilters.length) {
398
+ this.logSearchDebug('search:init', {
478
399
  entity,
479
400
  baseTable,
480
401
  tenantId: opts.tenantId ?? null,
481
402
  organizationScope: orgScope,
403
+ fields: searchFilters.map((filter) => String(filter.field)),
404
+ searchEnabled,
405
+ hasSearchTokens,
406
+ searchSources,
407
+ searchConfig: {
408
+ enabled: searchConfig.enabled,
409
+ minTokenLength: searchConfig.minTokenLength,
410
+ enablePartials: searchConfig.enablePartials,
411
+ hashAlgorithm: searchConfig.hashAlgorithm,
412
+ blocklistedFields: searchConfig.blocklistedFields,
413
+ },
414
+ })
415
+ if (!searchEnabled) this.logSearchDebug('search:disabled', { entity, baseTable })
416
+ else if (!hasSearchTokens) this.logSearchDebug('search:no-search-tokens', {
417
+ entity, baseTable,
418
+ tenantId: opts.tenantId ?? null,
419
+ organizationScope: orgScope,
482
420
  searchSources,
483
421
  })
484
422
  }
485
- }
486
- const hasNonBaseSearchSource = searchSources.some(
487
- (src) => src.entity !== String(entity) || src.recordIdColumn !== 'b.id'
488
- )
489
- if (hasNonBaseSearchSource) {
490
- optimizedCountBuilder = null
491
- }
423
+ const hasNonBaseSearchSource = searchSources.some(
424
+ (src) => src.entity !== String(entity) || src.recordIdColumn !== 'b.id'
425
+ )
492
426
 
493
- if (!partialIndexWarning && Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && this.isForcePartialIndexEnabled()) {
494
- const seen = new Set<string>([entity])
495
- for (const source of opts.customFieldSources) {
496
- const targetEntity = source?.entityId ? String(source.entityId) : null
497
- if (!targetEntity || seen.has(targetEntity)) continue
498
- seen.add(targetEntity)
499
- const sourceHasCustomFields = await this.entityHasActiveCustomFields(targetEntity, opts.tenantId ?? null)
500
- if (!sourceHasCustomFields) {
501
- if (debugEnabled) this.debug('query:coverage:skip-no-custom-fields', { entity: targetEntity })
502
- continue
503
- }
504
- const sourceTable = source.table ?? resolveEntityTableName(this.em, targetEntity)
505
- try {
506
- const gap = await profiler.measure(
507
- 'resolve_coverage_gap',
508
- () => this.resolveCoverageGap(targetEntity, opts, coverageScope, sourceTable),
509
- (value) => (value
510
- ? {
511
- entity: targetEntity,
512
- scope: value.scope,
513
- baseCount: value.stats?.baseCount ?? null,
514
- indexedCount: value.stats?.indexedCount ?? null,
515
- }
516
- : { entity: targetEntity, scope: null })
517
- )
518
- if (!gap) continue
519
- if (!opts.skipAutoReindex) {
520
- this.scheduleAutoReindex(targetEntity, opts, gap.stats, coverageScope?.organizationId ?? null)
521
- }
522
- partialIndexWarning = {
523
- entity: targetEntity,
524
- entityLabel: this.resolveEntityLabel(targetEntity),
525
- baseCount: gap.stats?.baseCount ?? null,
526
- indexedCount: gap.stats?.indexedCount ?? null,
527
- scope: gap.stats ? gap.scope : undefined,
427
+ // Additional partial-coverage checks for customFieldSources
428
+ if (!partialIndexWarning && Array.isArray(opts.customFieldSources) && opts.customFieldSources.length > 0 && this.isForcePartialIndexEnabled()) {
429
+ const seen = new Set<string>([entity])
430
+ for (const source of opts.customFieldSources) {
431
+ const targetEntity = source?.entityId ? String(source.entityId) : null
432
+ if (!targetEntity || seen.has(targetEntity)) continue
433
+ seen.add(targetEntity)
434
+ const sourceHasCustomFields = await this.entityHasActiveCustomFields(targetEntity, opts.tenantId ?? null)
435
+ if (!sourceHasCustomFields) {
436
+ if (debugEnabled) this.debug('query:coverage:skip-no-custom-fields', { entity: targetEntity })
437
+ continue
528
438
  }
529
- if (debugEnabled) {
530
- if (gap.stats) this.debug('query:partial-coverage:forced', { entity: targetEntity, baseCount: gap.stats.baseCount, indexedCount: gap.stats.indexedCount, scope: gap.scope })
531
- else this.debug('query:partial-coverage:forced', { entity: targetEntity })
439
+ const sourceTable = source.table ?? resolveEntityTableName(this.em, targetEntity)
440
+ try {
441
+ const gap = await profiler.measure(
442
+ 'resolve_coverage_gap',
443
+ () => this.resolveCoverageGap(targetEntity, opts, coverageScope, sourceTable),
444
+ (value) => (value
445
+ ? {
446
+ entity: targetEntity, scope: value.scope,
447
+ baseCount: value.stats?.baseCount ?? null,
448
+ indexedCount: value.stats?.indexedCount ?? null,
449
+ }
450
+ : { entity: targetEntity, scope: null })
451
+ )
452
+ if (!gap) continue
453
+ if (!opts.skipAutoReindex) {
454
+ this.scheduleAutoReindex(targetEntity, opts, gap.stats, coverageScope?.organizationId ?? null)
455
+ }
456
+ partialIndexWarning = {
457
+ entity: targetEntity,
458
+ entityLabel: this.resolveEntityLabel(targetEntity),
459
+ baseCount: gap.stats?.baseCount ?? null,
460
+ indexedCount: gap.stats?.indexedCount ?? null,
461
+ scope: gap.stats ? gap.scope : undefined,
462
+ }
463
+ if (debugEnabled) {
464
+ if (gap.stats) this.debug('query:partial-coverage:forced', { entity: targetEntity, baseCount: gap.stats.baseCount, indexedCount: gap.stats.indexedCount, scope: gap.scope })
465
+ else this.debug('query:partial-coverage:forced', { entity: targetEntity })
466
+ }
467
+ break
468
+ } catch (err) {
469
+ if (debugEnabled) this.debug('query:partial-coverage:check-failed', { entity: targetEntity, error: err instanceof Error ? err.message : err })
532
470
  }
533
- break
534
- } catch (err) {
535
- if (debugEnabled) this.debug('query:partial-coverage:check-failed', { entity: targetEntity, error: err instanceof Error ? err.message : err })
536
471
  }
537
472
  }
538
- }
539
473
 
540
- if (
541
- !partialIndexWarning &&
542
- wantsCf &&
543
- entityHasActiveCustomFields &&
544
- this.isForcePartialIndexEnabled() &&
545
- opts.tenantId
546
- ) {
547
- try {
548
- await this.indexCoverageStats(entity, opts, coverageScope)
549
- const globalStats = await this.indexCoverageStats(entity, opts, coverageScope)
550
- if (globalStats) {
551
- const globalBase = globalStats.baseCount
552
- const globalIndexed = globalStats.indexedCount
553
- const globalGap = (globalBase > 0 && globalIndexed < globalBase) || globalIndexed > globalBase
554
- if (globalGap) {
555
- console.warn('[HybridQueryEngine] Partial index coverage detected at global scope; forcing query index usage due to FORCE_QUERY_INDEX_ON_PARTIAL_INDEXES:', { entity, baseCount: globalBase, indexedCount: globalIndexed, scope: 'global' })
556
- if (debugEnabled) {
557
- this.debug('query:partial-coverage:forced', {
558
- entity,
559
- baseCount: globalBase,
560
- indexedCount: globalIndexed,
561
- scope: 'global',
562
- })
563
- }
564
- partialIndexWarning = {
565
- entity,
566
- entityLabel: this.resolveEntityLabel(entity),
567
- baseCount: globalBase,
568
- indexedCount: globalIndexed,
569
- scope: 'global',
474
+ if (
475
+ !partialIndexWarning &&
476
+ wantsCf &&
477
+ entityHasActiveCustomFields &&
478
+ this.isForcePartialIndexEnabled() &&
479
+ opts.tenantId
480
+ ) {
481
+ try {
482
+ await this.indexCoverageStats(entity, opts, coverageScope)
483
+ const globalStats = await this.indexCoverageStats(entity, opts, coverageScope)
484
+ if (globalStats) {
485
+ const globalBase = globalStats.baseCount
486
+ const globalIndexed = globalStats.indexedCount
487
+ const globalGap = (globalBase > 0 && globalIndexed < globalBase) || globalIndexed > globalBase
488
+ if (globalGap) {
489
+ console.warn('[HybridQueryEngine] Partial index coverage detected at global scope; forcing query index usage due to FORCE_QUERY_INDEX_ON_PARTIAL_INDEXES:', { entity, baseCount: globalBase, indexedCount: globalIndexed, scope: 'global' })
490
+ if (debugEnabled) this.debug('query:partial-coverage:forced', { entity, baseCount: globalBase, indexedCount: globalIndexed, scope: 'global' })
491
+ partialIndexWarning = {
492
+ entity, entityLabel: this.resolveEntityLabel(entity),
493
+ baseCount: globalBase, indexedCount: globalIndexed, scope: 'global',
494
+ }
570
495
  }
571
496
  }
572
- }
573
- } catch (err) {
574
- if (debugEnabled) {
575
- this.debug('query:partial-coverage:global-check-failed', {
576
- entity,
577
- error: err instanceof Error ? err.message : err,
578
- })
497
+ } catch (err) {
498
+ if (debugEnabled) this.debug('query:partial-coverage:global-check-failed', { entity, error: err instanceof Error ? err.message : err })
579
499
  }
580
500
  }
581
- }
582
501
 
583
- const resolveBaseColumn = (field: string): string | null => {
584
- if (columns.has(field)) return field
585
- if (field === 'organization_id' && columns.has('id')) return 'id'
586
- return null
587
- }
502
+ const resolveBaseColumn = (field: string): string | null => {
503
+ if (columns.has(field)) return field
504
+ if (field === 'organization_id' && columns.has('id')) return 'id'
505
+ return null
506
+ }
588
507
 
589
- for (const filter of cfFilters) {
590
- builder = this.applyCfFilterAcrossSources(
591
- knex,
592
- builder,
593
- filter.field,
594
- filter.op,
595
- filter.value,
596
- indexSources,
597
- searchRuntime
598
- )
599
- }
508
+ // ────────────────────────────────────────────────────────────────
509
+ // Build a reusable "applyQueryShape" function that applies every
510
+ // WHERE/JOIN/scope to a fresh SelectQueryBuilder. We use this in
511
+ // place of knex's `.clone()` for producing count + data queries.
512
+ // ────────────────────────────────────────────────────────────────
600
513
 
601
- const regularBaseFilters = baseFilters.filter((filter) => !filter.orGroup)
602
- const orGroupFilters = baseFilters.filter((filter) => filter.orGroup)
603
-
604
- for (const filter of regularBaseFilters) {
605
- const fieldName = String(filter.field)
606
- const baseField = resolveBaseColumn(fieldName)
607
- if (!baseField) {
608
- builder = this.applyIndexDocFilterFromAlias(
609
- knex,
610
- builder,
611
- 'ei',
612
- entity,
613
- fieldName,
614
- filter.op,
615
- filter.value,
616
- 'b.id',
617
- searchRuntime,
618
- )
619
- if (optimizedCountBuilder) {
620
- optimizedCountBuilder = this.applyIndexDocFilterFromAlias(
621
- knex,
622
- optimizedCountBuilder,
623
- 'ei',
624
- entity,
625
- fieldName,
626
- filter.op,
627
- filter.value,
628
- 'b.id',
629
- searchRuntime,
630
- )
514
+ const applyBaseScope = (q: AnyBuilder): AnyBuilder => {
515
+ let next = q
516
+ if (orgScope && hasOrganizationColumn) {
517
+ next = this.applyOrganizationScope(next, qualify('organization_id'), orgScope)
631
518
  }
632
- continue
633
- }
634
- const column = qualify(baseField)
635
- builder = this.applyColumnFilter(builder, column, filter, {
636
- ...searchRuntime,
637
- knex,
638
- entity,
639
- field: fieldName,
640
- recordIdColumn: 'b.id',
641
- })
642
- if (optimizedCountBuilder) {
643
- optimizedCountBuilder = this.applyColumnFilter(optimizedCountBuilder, column, filter, {
644
- ...searchRuntime,
645
- knex,
646
- entity,
647
- field: fieldName,
648
- recordIdColumn: 'b.id',
519
+ if (hasTenantColumn) {
520
+ next = next.where(qualify('tenant_id'), '=', opts.tenantId)
521
+ }
522
+ if (!opts.withDeleted && hasDeletedColumn) {
523
+ next = next.where(qualify('deleted_at'), 'is', null)
524
+ }
525
+ return next
526
+ }
527
+
528
+ const applyEntityIndexesJoin = (q: AnyBuilder): AnyBuilder => {
529
+ return q.leftJoin('entity_indexes as ei', (jb: any) => {
530
+ let jc = jb
531
+ .on('ei.entity_type', '=', String(entity))
532
+ .onRef('ei.entity_id', '=', sql<string>`(${sql.ref(qualify('id'))}::text)`)
533
+ if (hasOrganizationColumn) {
534
+ jc = jc
535
+ .onRef('ei.organization_id', '=', qualify('organization_id'))
536
+ .on('ei.organization_id', 'is not', null)
537
+ }
538
+ if (hasTenantColumn) {
539
+ jc = jc
540
+ .onRef('ei.tenant_id', '=', qualify('tenant_id'))
541
+ .on('ei.tenant_id', 'is not', null)
542
+ }
543
+ if (!opts.withDeleted) {
544
+ jc = jc.on('ei.deleted_at', 'is', null)
545
+ }
546
+ return jc
649
547
  })
650
548
  }
651
- }
652
549
 
653
- const applyOrGroupedBaseFilters = (target: ResultBuilder | null): ResultBuilder | null => {
654
- if (!target || orGroupFilters.length === 0) return target
655
- const groups = new Map<string, BaseFilter[]>()
656
- for (const filter of orGroupFilters) {
657
- if (!filter.orGroup) continue
658
- const existing = groups.get(filter.orGroup) ?? []
659
- existing.push(filter)
660
- groups.set(filter.orGroup, existing)
661
- }
662
- let next = target
663
- for (const [, groupFilters] of groups) {
664
- if (!groupFilters.length) continue
665
- next = next.where((groupBuilder) => {
666
- groupFilters.forEach((filter, index) => {
667
- const fieldName = String(filter.field)
668
- const baseField = resolveBaseColumn(fieldName)
669
- const applyCondition = (conditionBuilder: ResultBuilder) => {
670
- if (!baseField) {
671
- this.applyIndexDocFilterFromAlias(
672
- knex,
673
- conditionBuilder,
674
- 'ei',
675
- entity,
676
- fieldName,
677
- filter.op,
678
- filter.value,
679
- 'b.id',
680
- searchRuntime,
681
- )
682
- return
683
- }
684
- this.applyColumnFilter(conditionBuilder, qualify(baseField), filter, {
685
- ...searchRuntime,
686
- knex,
687
- entity,
688
- field: fieldName,
689
- recordIdColumn: 'b.id',
690
- })
550
+ const applyCustomFieldSourceJoins = (q: AnyBuilder): AnyBuilder => {
551
+ let next = q
552
+ for (const source of preparedCfSources) {
553
+ const join = (opts.customFieldSources ?? []).find((s) => s && (s.alias ?? undefined) === source.alias)?.join
554
+ if (!join) continue
555
+ const joinType = (join.type ?? 'left') === 'inner' ? 'innerJoin' : 'leftJoin'
556
+ next = (next as any)[joinType](`${source.table} as ${source.alias}`, (jb: any) =>
557
+ jb.onRef(`${source.alias}.${join.toField}`, '=', qualify(join.fromField)))
558
+ // Index join for source
559
+ next = next.leftJoin(`entity_indexes as ${source.indexAlias}`, (jb: any) => {
560
+ let jc = jb
561
+ .on(`${source.indexAlias}.entity_type`, '=', String(source.entityId))
562
+ .onRef(`${source.indexAlias}.entity_id`, '=', sql<string>`(${sql.ref(`${source.alias}.${source.recordIdColumn}`)}::text)`)
563
+ const orgRef = source.organizationField
564
+ ? `${source.alias}.${source.organizationField}`
565
+ : (columns.has('organization_id') ? qualify('organization_id') : null)
566
+ if (orgRef) {
567
+ jc = jc
568
+ .onRef(`${source.indexAlias}.organization_id`, '=', orgRef)
569
+ .on(`${source.indexAlias}.organization_id`, 'is not', null)
691
570
  }
692
- if (index === 0) {
693
- applyCondition(groupBuilder as ResultBuilder)
694
- return
571
+ const tenantRef = source.tenantField
572
+ ? `${source.alias}.${source.tenantField}`
573
+ : (columns.has('tenant_id') ? qualify('tenant_id') : null)
574
+ if (tenantRef) {
575
+ jc = jc
576
+ .onRef(`${source.indexAlias}.tenant_id`, '=', tenantRef)
577
+ .on(`${source.indexAlias}.tenant_id`, 'is not', null)
695
578
  }
696
- groupBuilder.orWhere((conditionBuilder) => {
697
- applyCondition(conditionBuilder as ResultBuilder)
698
- })
579
+ if (!opts.withDeleted) jc = jc.on(`${source.indexAlias}.deleted_at`, 'is', null)
580
+ return jc
699
581
  })
700
- })
582
+ }
583
+ return next
701
584
  }
702
- return next
703
- }
704
585
 
705
- builder = applyOrGroupedBaseFilters(builder) ?? builder
706
- optimizedCountBuilder = applyOrGroupedBaseFilters(optimizedCountBuilder)
586
+ const applyCfFilters = (q: AnyBuilder): AnyBuilder => {
587
+ let next = q
588
+ for (const filter of cfFilters) {
589
+ next = this.applyCfFilterAcrossSources(
590
+ next, filter.field, filter.op, filter.value, indexSources, searchRuntime,
591
+ )
592
+ }
593
+ return next
594
+ }
707
595
 
708
- const applyAliasScopes = async (target: ResultBuilder, aliasName: string) => {
709
- const tableName = aliasTables.get(aliasName)
710
- if (!tableName) return
711
- if (orgScope && await this.columnExists(tableName, 'organization_id')) {
712
- this.applyOrganizationScope(target, `${aliasName}.organization_id`, orgScope)
596
+ const regularBaseFilters = baseFilters.filter((filter) => !filter.orGroup)
597
+ const orGroupFilters = baseFilters.filter((filter) => filter.orGroup)
598
+
599
+ const applyRegularBaseFilters = (q: AnyBuilder): AnyBuilder => {
600
+ let next = q
601
+ for (const filter of regularBaseFilters) {
602
+ const fieldName = String(filter.field)
603
+ const baseField = resolveBaseColumn(fieldName)
604
+ if (!baseField) {
605
+ next = this.applyIndexDocFilterFromAlias(
606
+ next, 'ei', entity, fieldName, filter.op, filter.value, 'b.id', searchRuntime,
607
+ )
608
+ continue
609
+ }
610
+ const column = qualify(baseField)
611
+ next = this.applyColumnFilter(next, column, filter, {
612
+ ...searchRuntime,
613
+ entity, field: fieldName, recordIdColumn: 'b.id',
614
+ })
615
+ }
616
+ return next
713
617
  }
714
- if (opts.tenantId && await this.columnExists(tableName, 'tenant_id')) {
715
- target.where(`${aliasName}.tenant_id`, opts.tenantId)
618
+
619
+ const applyOrGroupedBaseFilters = (q: AnyBuilder): AnyBuilder => {
620
+ if (orGroupFilters.length === 0) return q
621
+ const groups = new Map<string, BaseFilter[]>()
622
+ for (const filter of orGroupFilters) {
623
+ if (!filter.orGroup) continue
624
+ const existing = groups.get(filter.orGroup) ?? []
625
+ existing.push(filter)
626
+ groups.set(filter.orGroup, existing)
627
+ }
628
+ let next = q
629
+ for (const [, groupFilters] of groups) {
630
+ if (!groupFilters.length) continue
631
+ next = next.where((eb: any) => eb.or(
632
+ groupFilters.map((filter) => this.buildBaseFilterExpression(eb, filter, resolveBaseColumn, qualify, entity, searchRuntime))
633
+ ))
634
+ }
635
+ return next
716
636
  }
717
- if (!opts.withDeleted && await this.columnExists(tableName, 'deleted_at')) {
718
- target.whereNull(`${aliasName}.deleted_at`)
637
+
638
+ const applyAliasScopes = async (target: AnyBuilder, aliasName: string): Promise<AnyBuilder> => {
639
+ let next = target
640
+ const tableName = aliasTables.get(aliasName)
641
+ if (!tableName) return next
642
+ if (orgScope && await this.columnExists(tableName, 'organization_id')) {
643
+ next = this.applyOrganizationScope(next, `${aliasName}.organization_id`, orgScope)
644
+ }
645
+ if (opts.tenantId && await this.columnExists(tableName, 'tenant_id')) {
646
+ next = next.where(`${aliasName}.tenant_id`, '=', opts.tenantId)
647
+ }
648
+ if (!opts.withDeleted && await this.columnExists(tableName, 'deleted_at')) {
649
+ next = next.where(`${aliasName}.deleted_at`, 'is', null)
650
+ }
651
+ return next
719
652
  }
720
- }
721
653
 
722
- const applyJoinFilterOp = (target: ResultBuilder, column: string, op: FilterOp, value?: unknown) => {
723
- switch (op) {
724
- case 'eq':
725
- target.where(column, value as Knex.Value)
726
- break
727
- case 'ne':
728
- target.whereNot(column, value as Knex.Value)
729
- break
730
- case 'gt':
731
- case 'gte':
732
- case 'lt':
733
- case 'lte': {
734
- const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
735
- target.where(column, operator, value as Knex.Value)
736
- break
654
+ const applyJoinFilterOpFn = (target: AnyBuilder, column: string, op: FilterOp, value?: unknown): AnyBuilder => {
655
+ switch (op) {
656
+ case 'eq': return target.where(column, '=', value as any)
657
+ case 'ne': return target.where(column, '!=', value as any)
658
+ case 'gt': return target.where(column, '>', value as any)
659
+ case 'gte': return target.where(column, '>=', value as any)
660
+ case 'lt': return target.where(column, '<', value as any)
661
+ case 'lte': return target.where(column, '<=', value as any)
662
+ case 'in': return target.where(column, 'in', this.toArray(value))
663
+ case 'nin': return target.where(column, 'not in', this.toArray(value))
664
+ case 'like': return target.where(column, 'like', value as any)
665
+ case 'ilike': return target.where(column, 'ilike', value as any)
666
+ case 'exists': return value ? target.where(column, 'is not', null) : target.where(column, 'is', null)
667
+ default: return target
737
668
  }
738
- case 'in':
739
- target.whereIn(column, this.toArray(value) as readonly Knex.Value[])
740
- break
741
- case 'nin':
742
- target.whereNotIn(column, this.toArray(value) as readonly Knex.Value[])
743
- break
744
- case 'like':
745
- target.where(column, 'like', value as Knex.Value)
746
- break
747
- case 'ilike':
748
- target.where(column, 'ilike', value as Knex.Value)
749
- break
750
- case 'exists':
751
- value ? target.whereNotNull(column) : target.whereNull(column)
752
- break
753
669
  }
754
- }
755
670
 
756
- // `eq` is accepted alongside `like`/`ilike` so that filters against
757
- // encrypted joined columns (whose ciphertext cannot be compared for
758
- // equality in SQL) can still resolve via tokenized search. Routing
759
- // only applies when `searchEnabled` is true AND the joined entity has
760
- // search tokens installed (`searchAvailable`); non-searchable columns
761
- // still fall through to exact SQL equality. Token match is approximate —
762
- // callers needing strict equality on encrypted fields should filter on
763
- // the deterministic `*_hash` column instead.
764
- const applyJoinSearchFilterOp = async (
765
- target: ResultBuilder,
766
- filter: { column: string; op: FilterOp; value?: unknown },
767
- _qualified: string,
768
- join: ResolvedJoin,
769
- ): Promise<boolean> => {
770
- if (!searchEnabled || !join.entityId) return false
771
- if (!['eq', 'like', 'ilike'].includes(filter.op)) return false
772
- if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false
773
-
774
- let searchAvailable = joinSearchAvailability.get(join.entityId)
775
- if (searchAvailable === undefined) {
776
- searchAvailable = await this.hasSearchTokens(String(join.entityId), opts.tenantId ?? null, orgScope)
777
- joinSearchAvailability.set(join.entityId, searchAvailable)
778
- }
779
- if (!searchAvailable) return false
780
-
781
- const tokens = tokenizeText(String(filter.value), searchConfig)
782
- if (!tokens.hashes.length) return false
783
-
784
- return this.applySearchTokens(target, {
785
- knex,
786
- entity: String(join.entityId),
787
- field: filter.column,
788
- hashes: tokens.hashes,
789
- recordIdColumn: `${join.alias}.id`,
790
- tenantId: opts.tenantId ?? null,
791
- organizationScope: orgScope,
792
- })
793
- }
671
+ const applyJoinSearchFilterOp = async (
672
+ target: AnyBuilder,
673
+ filter: { column: string; op: FilterOp; value?: unknown },
674
+ _qualified: string,
675
+ join: ResolvedJoin,
676
+ ): Promise<boolean> => {
677
+ if (!searchEnabled || !join.entityId) return false
678
+ if (!['like', 'ilike'].includes(filter.op)) return false
679
+ if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false
680
+
681
+ let searchAvailable = joinSearchAvailability.get(join.entityId)
682
+ if (searchAvailable === undefined) {
683
+ searchAvailable = await this.hasSearchTokens(String(join.entityId), opts.tenantId ?? null, orgScope)
684
+ joinSearchAvailability.set(join.entityId, searchAvailable)
685
+ }
686
+ if (!searchAvailable) return false
794
687
 
795
- await applyJoinFilters({
796
- knex,
797
- baseTable,
798
- builder,
799
- joinMap,
800
- joinFilters,
801
- aliasTables,
802
- qualifyBase: (column) => qualify(column),
803
- applyAliasScope: (target, alias) => applyAliasScopes(target, alias),
804
- applyFilterOp: (target, column, op, value) => applyJoinFilterOp(target as ResultBuilder, column, op, value),
805
- applyJoinFilterOp: (target, filter, qualified, join) =>
806
- applyJoinSearchFilterOp(target as ResultBuilder, filter, qualified, join),
807
- columnExists: (tbl, column) => this.columnExists(tbl, column),
808
- }) as ResultBuilder
809
-
810
- if (optimizedCountBuilder) {
811
- await applyJoinFilters({
812
- knex,
813
- baseTable,
814
- builder: optimizedCountBuilder,
815
- joinMap,
816
- joinFilters,
817
- aliasTables,
818
- qualifyBase: (column) => qualify(column),
819
- applyAliasScope: (target, alias) => applyAliasScopes(target, alias),
820
- applyFilterOp: (target, column, op, value) => applyJoinFilterOp(target as ResultBuilder, column, op, value),
821
- applyJoinFilterOp: (target, filter, qualified, join) =>
822
- applyJoinSearchFilterOp(target as ResultBuilder, filter, qualified, join),
823
- columnExists: (tbl, column) => this.columnExists(tbl, column),
824
- })
825
- }
688
+ const tokens = tokenizeText(String(filter.value), searchConfig)
689
+ if (!tokens.hashes.length) return false
826
690
 
827
- // When no fields specified, select all base table columns (like BasicQueryEngine does)
828
- const selectFieldSet = new Set<string>((opts.fields && opts.fields.length) ? opts.fields.map(String) : Array.from(columns.keys()))
829
- if (opts.includeCustomFields === true) {
830
- const entityIds = Array.from(new Set(indexSources.map((src) => String(src.entityId))))
831
- try {
832
- const resolvedKeys = await this.resolveAvailableCustomFieldKeys(entityIds, opts.tenantId ?? null)
833
- resolvedKeys.forEach((key) => selectFieldSet.add(`cf:${key}`))
834
- if (this.isDebugVerbosity()) {
835
- this.debug('query:cf:resolved-keys', { entity, keys: resolvedKeys })
691
+ return this.applySearchTokens(target, {
692
+ entity: String(join.entityId),
693
+ field: filter.column,
694
+ hashes: tokens.hashes,
695
+ recordIdColumn: `${join.alias}.id`,
696
+ tenantId: opts.tenantId ?? null,
697
+ organizationScope: orgScope,
698
+ })
699
+ }
700
+
701
+ const applyQueryShape = async (q: AnyBuilder): Promise<AnyBuilder> => {
702
+ let next = applyBaseScope(q)
703
+ next = applyEntityIndexesJoin(next)
704
+ next = applyCustomFieldSourceJoins(next)
705
+ next = applyCfFilters(next)
706
+ next = applyRegularBaseFilters(next)
707
+ next = applyOrGroupedBaseFilters(next)
708
+ // applyJoinFilters is the shared helper that handles `joinFilters` (ALIAS:col -> value).
709
+ next = await applyJoinFilters({
710
+ db,
711
+ baseTable,
712
+ builder: next,
713
+ joinMap,
714
+ joinFilters,
715
+ aliasTables,
716
+ qualifyBase: (column) => qualify(column),
717
+ applyAliasScope: async (target: any, alias: string) => applyAliasScopes(target as AnyBuilder, alias),
718
+ applyFilterOp: (target, column, op, value) => applyJoinFilterOpFn(target as AnyBuilder, column, op, value),
719
+ applyJoinFilterOp: async (target, filter, qualified, join) => {
720
+ const applied = await applyJoinSearchFilterOp(target as AnyBuilder, filter, qualified, join)
721
+ return { applied, builder: target }
722
+ },
723
+ columnExists: (tbl, column) => this.columnExists(tbl, column),
724
+ })
725
+ return next
726
+ }
727
+
728
+ const hasCustomFieldFilters = cfFilters.length > 0
729
+ const canOptimizeCount = !hasCustomFieldFilters && !hasNonBaseSearchSource
730
+
731
+ // Selection (for data query)
732
+ const selectFieldSet = new Set<string>((opts.fields && opts.fields.length) ? opts.fields.map(String) : Array.from(columns.keys()))
733
+ if (opts.includeCustomFields === true) {
734
+ const entityIds = Array.from(new Set(indexSources.map((src) => String(src.entityId))))
735
+ try {
736
+ const resolvedKeys = await this.resolveAvailableCustomFieldKeys(entityIds, opts.tenantId ?? null)
737
+ resolvedKeys.forEach((key) => selectFieldSet.add(`cf:${key}`))
738
+ if (this.isDebugVerbosity()) this.debug('query:cf:resolved-keys', { entity, keys: resolvedKeys })
739
+ } catch (err) {
740
+ console.warn('[HybridQueryEngine] Failed to resolve custom field keys for', entity, err)
836
741
  }
837
- } catch (err) {
838
- console.warn('[HybridQueryEngine] Failed to resolve custom field keys for', entity, err)
742
+ } else if (Array.isArray(opts.includeCustomFields)) {
743
+ opts.includeCustomFields.map((key) => String(key)).forEach((key) => selectFieldSet.add(`cf:${key}`))
839
744
  }
840
- } else if (Array.isArray(opts.includeCustomFields)) {
841
- opts.includeCustomFields
842
- .map((key) => String(key))
843
- .forEach((key) => selectFieldSet.add(`cf:${key}`))
844
- }
845
- const selectFields = Array.from(selectFieldSet)
846
- for (const field of selectFields) {
847
- const fieldName = String(field)
848
- if (fieldName.startsWith('cf:')) {
849
- const alias = this.sanitize(fieldName)
850
- const { jsonSql } = this.buildCfExpressions(knex, fieldName, indexSources)
851
- const exprSql = jsonSql === 'NULL' ? 'NULL::jsonb' : jsonSql
852
- builder = builder.select(knex.raw(`${exprSql} as ??`, [alias]))
853
- } else if (columns.has(fieldName)) {
854
- builder = builder.select(knex.raw('?? as ??', [qualify(fieldName), fieldName]))
745
+ const selectFields = Array.from(selectFieldSet)
746
+
747
+ const applySelection = (q: AnyBuilder): AnyBuilder => {
748
+ let next = q
749
+ for (const field of selectFields) {
750
+ const fieldName = String(field)
751
+ if (fieldName.startsWith('cf:')) {
752
+ const alias = this.sanitize(fieldName)
753
+ const jsonExpr = this.buildCfJsonExprSql(fieldName, indexSources)
754
+ const exprRaw = jsonExpr ?? sql`NULL::jsonb`
755
+ next = next.select(exprRaw.as(alias))
756
+ } else if (columns.has(fieldName)) {
757
+ next = next.select(`${qualify(fieldName)} as ${fieldName}`)
758
+ }
759
+ }
760
+ return next
855
761
  }
856
- }
857
762
 
858
- for (const sort of opts.sort || []) {
859
- const fieldName = String(sort.field)
860
- if (fieldName.startsWith('cf:')) {
861
- const { textSql } = this.buildCfExpressions(knex, fieldName, indexSources)
862
- if (textSql !== 'NULL') {
863
- const direction = sort.dir ?? SortDir.Asc
864
- builder = builder.orderByRaw(`${textSql} ${direction}`)
763
+ const applySort = (q: AnyBuilder): AnyBuilder => {
764
+ let next = q
765
+ for (const s of opts.sort || []) {
766
+ const fieldName = String(s.field)
767
+ if (fieldName.startsWith('cf:')) {
768
+ const textExpr = this.buildCfTextExprSql(fieldName, indexSources)
769
+ if (textExpr) {
770
+ const direction = sql.raw(String(s.dir ?? SortDir.Asc))
771
+ next = next.orderBy(sql`${textExpr} ${direction}`)
772
+ }
773
+ } else {
774
+ const baseField = resolveBaseColumn(fieldName)
775
+ if (!baseField) continue
776
+ next = next.orderBy(qualify(baseField), s.dir ?? SortDir.Asc)
777
+ }
865
778
  }
866
- } else {
867
- const baseField = resolveBaseColumn(fieldName)
868
- if (!baseField) continue
869
- builder = builder.orderBy(qualify(baseField), sort.dir ?? SortDir.Asc)
779
+ return next
870
780
  }
871
- }
872
781
 
873
- const page = opts.page?.page ?? 1
874
- const pageSize = opts.page?.pageSize ?? 20
782
+ const page = opts.page?.page ?? 1
783
+ const pageSize = opts.page?.pageSize ?? 20
784
+ const sqlDebugEnabled = this.isSqlDebugEnabled()
785
+
786
+ let total: number
787
+
788
+ if (canOptimizeCount) {
789
+ // Optimized count: apply only base-scope + regular filters + or-group filters (no index joins).
790
+ const optimizedRoot = db.selectFrom(`${baseTable} as b` as any)
791
+ let countCore = applyBaseScope(optimizedRoot)
792
+ countCore = applyRegularBaseFilters(countCore)
793
+ countCore = applyOrGroupedBaseFilters(countCore)
794
+ // joinFilters still need to be re-applied in the optimized path
795
+ countCore = await applyJoinFilters({
796
+ db,
797
+ baseTable,
798
+ builder: countCore,
799
+ joinMap,
800
+ joinFilters,
801
+ aliasTables,
802
+ qualifyBase: (column) => qualify(column),
803
+ applyAliasScope: async (target: any, alias: string) => applyAliasScopes(target as AnyBuilder, alias),
804
+ applyFilterOp: (target, column, op, value) => applyJoinFilterOpFn(target as AnyBuilder, column, op, value),
805
+ applyJoinFilterOp: async (target, filter, qualified, join) => {
806
+ const applied = await applyJoinSearchFilterOp(target as AnyBuilder, filter, qualified, join)
807
+ return { applied, builder: target }
808
+ },
809
+ columnExists: (tbl, column) => this.columnExists(tbl, column),
810
+ })
811
+ const sub = countCore.select(sql.ref(qualify('id')).as('id')).groupBy(qualify('id')).as('sq')
812
+ const countQuery = db.selectFrom(sub as any).select(sql<string>`count(*)`.as('count'))
813
+ if (debugEnabled && sqlDebugEnabled) {
814
+ const compiled = countQuery.compile()
815
+ this.debug('query:sql:count', { entity, sql: compiled.sql, bindings: compiled.parameters })
816
+ }
817
+ const countRow = await this.captureSqlTiming(
818
+ 'query:sql:count', entity,
819
+ () => countQuery.executeTakeFirst(),
820
+ { optimized: true }, profiler,
821
+ )
822
+ total = this.parseCount(countRow)
823
+ } else {
824
+ const countRoot = db.selectFrom(`${baseTable} as b` as any)
825
+ const countBuilder = (await applyQueryShape(countRoot))
826
+ .select(sql<string>`count(distinct ${sql.ref(qualify('id'))})`.as('count'))
827
+ if (debugEnabled && sqlDebugEnabled) {
828
+ const compiled = countBuilder.compile()
829
+ this.debug('query:sql:count', { entity, sql: compiled.sql, bindings: compiled.parameters })
830
+ }
831
+ const countRow = await this.captureSqlTiming(
832
+ 'query:sql:count', entity,
833
+ () => countBuilder.executeTakeFirst(),
834
+ { optimized: false }, profiler,
835
+ )
836
+ total = this.parseCount(countRow)
837
+ }
875
838
 
876
- const sqlDebugEnabled = this.isSqlDebugEnabled()
877
- let total: number
839
+ const dataRoot = db.selectFrom(`${baseTable} as b` as any)
840
+ let dataBuilder = await applyQueryShape(dataRoot)
841
+ dataBuilder = applySelection(dataBuilder)
842
+ dataBuilder = applySort(dataBuilder)
843
+ dataBuilder = dataBuilder.limit(pageSize).offset((page - 1) * pageSize)
878
844
 
879
- if (optimizedCountBuilder) {
880
- const countSource = optimizedCountBuilder.clone().clearSelect().clearOrder().select(knex.raw(`${qualify('id')} as id`)).groupBy(qualify('id'))
881
- const countQuery = knex.from(countSource.as('sq')).count({ count: knex.raw('*') })
882
- if (debugEnabled && sqlDebugEnabled) {
883
- const { sql, bindings } = countQuery.clone().toSQL()
884
- this.debug('query:sql:count', { entity, sql, bindings })
885
- }
886
- const countRow = await this.captureSqlTiming(
887
- 'query:sql:count',
888
- entity,
889
- () => countQuery.first(),
890
- { optimized: true },
891
- profiler
892
- )
893
- total = this.parseCount(countRow)
894
- } else {
895
- const countBuilder = builder.clone().clearSelect().clearOrder().countDistinct(`${qualify('id')} as count`)
896
845
  if (debugEnabled && sqlDebugEnabled) {
897
- const { sql, bindings } = countBuilder.clone().toSQL()
898
- this.debug('query:sql:count', { entity, sql, bindings })
899
- }
900
- const countRow = await this.captureSqlTiming(
901
- 'query:sql:count',
902
- entity,
903
- () => countBuilder.first(),
904
- { optimized: false },
905
- profiler
906
- )
907
- total = this.parseCount(countRow)
908
- }
909
-
910
- const dataBuilder = builder.clone().limit(pageSize).offset((page - 1) * pageSize)
911
-
912
- if (debugEnabled && sqlDebugEnabled) {
913
- const { sql, bindings } = dataBuilder.clone().toSQL()
914
- this.debug('query:sql:data', { entity, sql, bindings, page, pageSize })
915
- }
916
- const itemsRaw = await this.captureSqlTiming(
917
- 'query:sql:data',
918
- entity,
919
- () => dataBuilder,
920
- { page, pageSize },
921
- profiler
922
- )
923
- if (debugEnabled) this.debug('query:complete', { entity, total, items: Array.isArray(itemsRaw) ? itemsRaw.length : 0 })
924
-
925
- let items = itemsRaw as any[]
926
- const encSvc = this.getEncryptionService()
927
- const dekKeyCache = new Map<string | null, string | null>()
928
- if (encSvc?.decryptEntityPayload) {
929
- const decrypt = encSvc.decryptEntityPayload.bind(encSvc) as (
930
- entityId: EntityId,
931
- payload: Record<string, unknown>,
932
- tenantId: string | null,
933
- organizationId: string | null,
934
- ) => Promise<Record<string, unknown>>
935
- items = await Promise.all(
936
- items.map(async (item) => {
937
- try {
938
- const decrypted = await decrypt(
939
- entity,
940
- item,
941
- item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
942
- item?.organization_id ?? item?.organizationId ?? null,
943
- )
944
- return { ...item, ...decrypted }
945
- } catch (err) {
946
- console.error('Error decrypting entity payload', err);
947
- return item
948
- }
949
- })
950
- )
951
- }
952
- if (encSvc) {
953
- items = await Promise.all(
954
- items.map(async (item) => {
955
- try {
956
- return await decryptIndexDocCustomFields(
957
- item,
958
- {
959
- tenantId: item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
960
- organizationId: item?.organization_id ?? item?.organizationId ?? null,
961
- },
962
- encSvc as any,
963
- dekKeyCache,
964
- )
965
- } catch {
966
- return item
967
- }
968
- }),
846
+ const compiled = dataBuilder.compile()
847
+ this.debug('query:sql:data', { entity, sql: compiled.sql, bindings: compiled.parameters, page, pageSize })
848
+ }
849
+ const itemsRaw = await this.captureSqlTiming(
850
+ 'query:sql:data', entity,
851
+ () => dataBuilder.execute(),
852
+ { page, pageSize }, profiler,
969
853
  )
970
- }
971
-
972
- const typedItems = items as unknown as T[]
973
- let result: QueryResult<T> = { items: typedItems, page, pageSize, total }
974
- if (partialIndexWarning) {
975
- result.meta = { partialIndexWarning }
976
- }
977
-
978
- // --- UMES query extension: after-query pipeline ---
979
- result = await applyAfterExtensions(result)
854
+ if (debugEnabled) this.debug('query:complete', { entity, total, items: Array.isArray(itemsRaw) ? itemsRaw.length : 0 })
855
+
856
+ let items = itemsRaw as any[]
857
+ const encSvc = this.getEncryptionService()
858
+ const dekKeyCache = new Map<string | null, string | null>()
859
+ if (encSvc?.decryptEntityPayload) {
860
+ const decrypt = encSvc.decryptEntityPayload.bind(encSvc) as (
861
+ entityId: EntityId, payload: Record<string, unknown>, tenantId: string | null, organizationId: string | null,
862
+ ) => Promise<Record<string, unknown>>
863
+ items = await Promise.all(
864
+ items.map(async (item) => {
865
+ try {
866
+ const decrypted = await decrypt(
867
+ entity, item,
868
+ item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
869
+ item?.organization_id ?? item?.organizationId ?? null,
870
+ )
871
+ return { ...item, ...decrypted }
872
+ } catch (err) {
873
+ console.error('Error decrypting entity payload', err)
874
+ return item
875
+ }
876
+ })
877
+ )
878
+ }
879
+ if (encSvc) {
880
+ items = await Promise.all(
881
+ items.map(async (item) => {
882
+ try {
883
+ return await decryptIndexDocCustomFields(
884
+ item,
885
+ {
886
+ tenantId: item?.tenant_id ?? item?.tenantId ?? opts.tenantId ?? null,
887
+ organizationId: item?.organization_id ?? item?.organizationId ?? null,
888
+ },
889
+ encSvc as any, dekKeyCache,
890
+ )
891
+ } catch { return item }
892
+ }),
893
+ )
894
+ }
980
895
 
981
- finishProfile({
982
- result: 'ok',
983
- total,
984
- page,
985
- pageSize,
986
- itemCount: Array.isArray(items) ? items.length : undefined,
987
- partialIndexWarning: partialIndexWarning ? true : false,
988
- })
989
- return result
990
- } catch (err) {
991
- finishProfile({ result: 'error', error: err instanceof Error ? err.message : String(err) })
992
- throw err
993
- }
994
- }
896
+ const typedItems = items as unknown as T[]
897
+ let result: QueryResult<T> = { items: typedItems, page, pageSize, total }
898
+ if (partialIndexWarning) result.meta = { partialIndexWarning }
995
899
 
996
- private getKnex(): Knex {
997
- const connection = this.em.getConnection()
998
- const withKnex = connection as { getKnex?: () => Knex }
999
- if (typeof withKnex.getKnex === 'function') {
1000
- return withKnex.getKnex()
900
+ result = await applyAfterExtensions(result)
901
+ finishProfile({
902
+ result: 'ok', total, page, pageSize,
903
+ itemCount: Array.isArray(items) ? items.length : undefined,
904
+ partialIndexWarning: partialIndexWarning ? true : false,
905
+ })
906
+ return result
907
+ } catch (err) {
908
+ finishProfile({ result: 'error', error: err instanceof Error ? err.message : String(err) })
909
+ throw err
1001
910
  }
1002
- throw new Error('HybridQueryEngine requires a SQL connection that exposes getKnex()')
1003
911
  }
1004
912
 
1005
913
  private prepareCustomFieldSources(
1006
- knex: Knex,
1007
- builder: ResultBuilder,
1008
914
  sources: QueryCustomFieldSource[],
1009
- qualify: (column: string) => string
1010
- ): { builder: ResultBuilder; sources: PreparedCustomFieldSource[] } {
1011
- let current = builder
915
+ ): PreparedCustomFieldSource[] {
1012
916
  const prepared: PreparedCustomFieldSource[] = []
1013
917
  sources.forEach((source, index) => {
1014
918
  if (!source) return
1015
919
  const joinTable = source.table ?? resolveEntityTableName(this.em, source.entityId)
1016
920
  const alias = source.alias ?? `cfs_${index}`
1017
- const join = source.join
1018
- if (!join) {
921
+ if (!source.join) {
1019
922
  throw new Error(`QueryEngine: customFieldSources entry for ${String(source.entityId)} requires a join configuration`)
1020
923
  }
1021
- const joinArgs = { [alias]: joinTable }
1022
- const joinCallback = function (this: Knex.JoinClause) {
1023
- this.on(`${alias}.${join.toField}`, '=', qualify(join.fromField))
1024
- }
1025
- current = (join.type ?? 'left') === 'inner'
1026
- ? current.join(joinArgs, joinCallback)
1027
- : current.leftJoin(joinArgs, joinCallback)
1028
924
  prepared.push({
1029
925
  alias,
1030
926
  indexAlias: `ei_${alias}`,
@@ -1035,23 +931,36 @@ export class HybridQueryEngine implements QueryEngine {
1035
931
  table: joinTable,
1036
932
  })
1037
933
  })
1038
- return { builder: current, sources: prepared }
934
+ return prepared
1039
935
  }
1040
936
 
1041
937
  private async isCustomEntity(entity: string): Promise<boolean> {
1042
938
  try {
1043
- const knex = this.getKnex()
1044
- const row = await knex('custom_entities').where({ entity_id: entity, is_active: true }).first()
939
+ const db = this.getDb() as any
940
+ const row = await db
941
+ .selectFrom('custom_entities')
942
+ .select('id')
943
+ .where('entity_id', '=', entity)
944
+ .where('is_active', '=', true)
945
+ .executeTakeFirst()
1045
946
  return !!row
1046
947
  } catch {
1047
948
  return false
1048
949
  }
1049
950
  }
1050
951
 
1051
- private applySearchTokens<TRecord extends ResultRow, TResult>(
1052
- q: Knex.QueryBuilder<TRecord, TResult>,
952
+ /**
953
+ * Adds a WHERE EXISTS / OR WHERE EXISTS subquery that matches
954
+ * `search_tokens` for the supplied (entity, field) against the
955
+ * provided record id column.
956
+ *
957
+ * Returns true when the sub-query was applied (i.e. tokens were
958
+ * non-empty). Caller is responsible for the calling context
959
+ * (direct where vs. inside `eb.or([...])`).
960
+ */
961
+ private applySearchTokens(
962
+ q: AnyBuilder,
1053
963
  opts: {
1054
- knex: Knex
1055
964
  entity: string
1056
965
  field: string
1057
966
  hashes: string[]
@@ -1063,244 +972,288 @@ export class HybridQueryEngine implements QueryEngine {
1063
972
  ): boolean {
1064
973
  if (!opts.hashes.length) {
1065
974
  this.logSearchDebug('search:skip-no-hashes', {
1066
- entity: opts.entity,
1067
- field: opts.field,
1068
- tenantId: opts.tenantId ?? null,
1069
- organizationScope: opts.organizationScope,
975
+ entity: opts.entity, field: opts.field,
976
+ tenantId: opts.tenantId ?? null, organizationScope: opts.organizationScope,
1070
977
  })
1071
978
  return false
1072
979
  }
1073
980
  const alias = `st_${this.searchAliasSeq++}`
1074
- const combineWith = opts.combineWith === 'or' ? 'orWhereExists' : 'whereExists'
1075
- const engine = this
1076
981
  this.logSearchDebug('search:apply-search-tokens', {
1077
- entity: opts.entity,
1078
- field: opts.field,
1079
- alias,
982
+ entity: opts.entity, field: opts.field, alias,
1080
983
  tokenCount: opts.hashes.length,
1081
984
  tenantId: opts.tenantId ?? null,
1082
985
  organizationScope: opts.organizationScope,
1083
986
  combineWith: opts.combineWith ?? 'and',
1084
987
  })
1085
- ;(q as any)[combineWith](function (this: Knex.QueryBuilder) {
1086
- this.select(1)
1087
- .from({ [alias]: 'search_tokens' })
1088
- .where(`${alias}.entity_type`, opts.entity)
1089
- .andWhere(`${alias}.field`, opts.field)
1090
- .andWhereRaw('?? = ??::text', [`${alias}.entity_id`, opts.recordIdColumn])
1091
- .whereIn(`${alias}.token_hash`, opts.hashes)
1092
- .groupBy(`${alias}.entity_id`, `${alias}.field`)
1093
- .havingRaw(`count(distinct ${alias}.token_hash) >= ?`, [opts.hashes.length])
988
+
989
+ const engine = this
990
+ const buildSub = (eb: any) => {
991
+ let sub = eb
992
+ .selectFrom(`search_tokens as ${alias}`)
993
+ .select(sql<number>`1`.as('one'))
994
+ .where(`${alias}.entity_type`, '=', opts.entity)
995
+ .where(`${alias}.field`, '=', opts.field)
996
+ .where(sql<boolean>`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`)
997
+ .where(`${alias}.token_hash`, 'in', opts.hashes)
998
+ .groupBy([`${alias}.entity_id`, `${alias}.field`])
999
+ .having(sql<boolean>`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`)
1094
1000
  if (opts.tenantId !== undefined) {
1095
- this.andWhereRaw(`${alias}.tenant_id is not distinct from ?`, [opts.tenantId ?? null])
1001
+ sub = sub.where(sql<boolean>`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`)
1096
1002
  }
1097
1003
  if (opts.organizationScope) {
1098
- engine.applyOrganizationScope(this as any, `${alias}.organization_id`, opts.organizationScope)
1004
+ sub = engine.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
1099
1005
  }
1100
- })
1006
+ return sub
1007
+ }
1008
+
1009
+ if (opts.combineWith === 'or') {
1010
+ // When called inside an .or([...]) array the caller supplied `eb`.
1011
+ // `q` is the ExpressionBuilder callable (eb) itself in that case.
1012
+ // We return the expression node rather than mutating q.
1013
+ ;(q as any).__pendingOrExists = buildSub(q)
1014
+ return true
1015
+ }
1016
+
1017
+ // Default: append WHERE EXISTS (...) to the outer builder.
1018
+ ;(q as any).__applied = true
1019
+ const built = buildSub(q)
1020
+ // If q is a Kysely builder (has .where), use eb => eb.exists(sub)
1021
+ if (typeof q.where === 'function') {
1022
+ ;(q as any) = q.where((eb: any) => eb.exists(built))
1023
+ }
1101
1024
  return true
1102
1025
  }
1103
1026
 
1104
- private jsonbRawAlias(knex: Knex, alias: string, key: string): Knex.Raw {
1105
- // Prefer cf:<key> but fall back to bare <key> for legacy docs
1027
+ /** SQL fragment for `cf:<key>` (or legacy bare key) as JSON across a single alias. */
1028
+ private jsonbSqlAlias(alias: string, key: string): RawBuilder<unknown> {
1106
1029
  if (key.startsWith('cf:')) {
1107
1030
  const bare = key.slice(3)
1108
- return knex.raw(`coalesce(${alias}.doc -> ?, ${alias}.doc -> ?)`, [key, bare])
1031
+ return sql`coalesce(${sql.ref(alias + '.doc')} -> ${key}, ${sql.ref(alias + '.doc')} -> ${bare})`
1109
1032
  }
1110
- return knex.raw(`${alias}.doc -> ?`, [key])
1033
+ return sql`${sql.ref(alias + '.doc')} -> ${key}`
1111
1034
  }
1112
- private cfTextExprAlias(knex: Knex, alias: string, key: string): Knex.Raw {
1035
+
1036
+ /** SQL fragment for `cf:<key>` (or legacy bare key) as text across a single alias. */
1037
+ private cfTextExprAlias(alias: string, key: string): RawBuilder<string | null> {
1113
1038
  if (key.startsWith('cf:')) {
1114
1039
  const bare = key.slice(3)
1115
- return knex.raw(`coalesce((${alias}.doc ->> ?), (${alias}.doc ->> ?))`, [key, bare])
1040
+ return sql<string | null>`coalesce((${sql.ref(alias + '.doc')} ->> ${key}), (${sql.ref(alias + '.doc')} ->> ${bare}))`
1116
1041
  }
1117
- return knex.raw(`(${alias}.doc ->> ?)`, [key])
1042
+ return sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
1118
1043
  }
1119
- private buildCfExpressions(knex: Knex, key: string, sources: IndexDocSource[]): { jsonSql: string; textSql: string } {
1120
- if (!sources.length) return { jsonSql: 'NULL', textSql: 'NULL' }
1121
- const jsonFragments = sources.map((source) => this.jsonbRawAlias(knex, source.alias, key).toString())
1122
- const textFragments = sources.map((source) => this.cfTextExprAlias(knex, source.alias, key).toString())
1123
- const jsonSql = jsonFragments.length === 1 ? jsonFragments[0] : `coalesce(${jsonFragments.join(', ')})`
1124
- const textSql = textFragments.length === 1 ? textFragments[0] : `coalesce(${textFragments.join(', ')})`
1125
- return { jsonSql, textSql }
1044
+
1045
+ /** Build JSON/text SQL expressions across multiple index alias sources (coalesce over them). */
1046
+ private buildCfJsonExprSql(key: string, sources: IndexDocSource[]): RawBuilder<unknown> | null {
1047
+ if (!sources.length) return null
1048
+ const parts = sources.map((src) => this.jsonbSqlAlias(src.alias, key))
1049
+ if (parts.length === 1) return parts[0]
1050
+ return sql`coalesce(${sql.join(parts, sql`, `)})`
1051
+ }
1052
+
1053
+ private buildCfTextExprSql(key: string, sources: IndexDocSource[]): RawBuilder<string | null> | null {
1054
+ if (!sources.length) return null
1055
+ const parts = sources.map((src) => this.cfTextExprAlias(src.alias, key))
1056
+ if (parts.length === 1) return parts[0]
1057
+ return sql<string | null>`coalesce(${sql.join(parts, sql`, `)})`
1126
1058
  }
1127
1059
 
1128
1060
  private applyCfFilterAcrossSources(
1129
- knex: Knex,
1130
- builder: ResultBuilder,
1061
+ builder: AnyBuilder,
1131
1062
  key: string,
1132
1063
  op: FilterOp,
1133
1064
  value: unknown,
1134
1065
  sources: IndexDocSource[],
1135
1066
  search?: SearchRuntime
1136
- ): ResultBuilder {
1067
+ ): AnyBuilder {
1137
1068
  if (!sources.length) return builder
1138
1069
  if ((op === 'like' || op === 'ilike') && search?.enabled && typeof value === 'string') {
1139
1070
  const tokens = tokenizeText(String(value), search.config)
1140
1071
  const hashes = tokens.hashes
1141
1072
  if (hashes.length) {
1142
- let applied = false
1143
- if (sources.length) {
1144
- builder = builder.where((qb) => {
1145
- sources.forEach((source, idx) => {
1146
- const ok = this.applySearchTokens(qb as any, {
1147
- knex,
1148
- entity: source.entityId,
1149
- field: key,
1150
- hashes,
1151
- recordIdColumn: `${source.alias}.entity_id`,
1152
- tenantId: search.tenantId ?? null,
1153
- organizationScope: search.organizationScope ?? null,
1154
- combineWith: idx === 0 ? 'and' : 'or',
1155
- })
1156
- if (ok) applied = true
1157
- })
1158
- })
1159
- }
1073
+ const applied = this.applyMultiSourceSearchExists(builder, sources, key, hashes, search)
1160
1074
  this.logSearchDebug('search:cf-filter-across', {
1161
1075
  entity: sources.map((src) => src.entityId),
1162
- field: key,
1163
- tokens: tokens.tokens,
1164
- hashes,
1165
- applied,
1166
- tenantId: search.tenantId ?? null,
1167
- organizationScope: search.organizationScope,
1076
+ field: key, tokens: tokens.tokens, hashes, applied,
1077
+ tenantId: search.tenantId ?? null, organizationScope: search.organizationScope,
1168
1078
  })
1169
- if (applied) return builder
1079
+ if (applied.builder !== builder) return applied.builder
1170
1080
  } else {
1171
1081
  this.logSearchDebug('search:cf-skip-empty-hashes', {
1172
- entity: sources.map((src) => src.entityId),
1173
- field: key,
1174
- value,
1082
+ entity: sources.map((src) => src.entityId), field: key, value,
1175
1083
  })
1176
1084
  }
1177
1085
  return builder
1178
1086
  }
1179
- const { jsonSql, textSql } = this.buildCfExpressions(knex, key, sources)
1180
- if (jsonSql === 'NULL' || textSql === 'NULL') return builder
1181
- const textExpr = knex.raw(textSql)
1182
- const arrContains = (val: unknown) => knex.raw(`${jsonSql} @> ?::jsonb`, [JSON.stringify([val])])
1087
+
1088
+ const textExpr = this.buildCfTextExprSql(key, sources)
1089
+ const jsonExpr = this.buildCfJsonExprSql(key, sources)
1090
+ if (!textExpr || !jsonExpr) return builder
1091
+
1092
+ const arrContains = (val: unknown) => sql<boolean>`${jsonExpr} @> ${JSON.stringify([val])}::jsonb`
1093
+
1183
1094
  switch (op) {
1184
1095
  case 'eq':
1185
- return builder.where((qb) => {
1186
- qb.orWhere(textExpr, '=', value as Knex.Value)
1187
- qb.orWhere(arrContains(value))
1188
- })
1096
+ return builder.where((eb: any) => eb.or([
1097
+ sql<boolean>`${textExpr} = ${value}`,
1098
+ arrContains(value),
1099
+ ]))
1189
1100
  case 'ne':
1190
- return builder.whereNot(textExpr, '=', value as Knex.Value)
1101
+ return builder.where(sql<boolean>`${textExpr} <> ${value}`)
1191
1102
  case 'in': {
1192
1103
  const values = this.toArray(value)
1193
- return builder.where((qb) => {
1194
- values.forEach((val) => {
1195
- qb.orWhere(textExpr, '=', val as Knex.Value)
1196
- qb.orWhere(arrContains(val))
1197
- })
1198
- })
1104
+ return builder.where((eb: any) => eb.or(
1105
+ values.flatMap((val) => [
1106
+ sql<boolean>`${textExpr} = ${val}`,
1107
+ arrContains(val),
1108
+ ])
1109
+ ))
1199
1110
  }
1200
1111
  case 'nin': {
1201
- const values = this.toArray(value) as readonly Knex.Value[]
1202
- return builder.whereNotIn(textExpr as any, values as any)
1112
+ const values = this.toArray(value)
1113
+ return builder.where(sql<boolean>`${textExpr} not in (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`)
1203
1114
  }
1204
1115
  case 'like':
1205
- return builder.where(textExpr, 'like', value as Knex.Value)
1116
+ return builder.where(sql<boolean>`${textExpr} like ${value}`)
1206
1117
  case 'ilike':
1207
- return builder.where(textExpr, 'ilike', value as Knex.Value)
1118
+ return builder.where(sql<boolean>`${textExpr} ilike ${value}`)
1208
1119
  case 'exists':
1209
1120
  return value
1210
- ? builder.whereRaw(`${textExpr.toString()} is not null`)
1211
- : builder.whereRaw(`${textExpr.toString()} is null`)
1121
+ ? builder.where(sql<boolean>`${textExpr} is not null`)
1122
+ : builder.where(sql<boolean>`${textExpr} is null`)
1212
1123
  case 'gt':
1213
1124
  case 'gte':
1214
1125
  case 'lt':
1215
1126
  case 'lte': {
1216
- const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
1217
- return builder.where(textExpr, operator, value as Knex.Value)
1127
+ const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
1128
+ return builder.where(sql<boolean>`${textExpr} ${operator} ${value}`)
1218
1129
  }
1219
1130
  default:
1220
1131
  return builder
1221
1132
  }
1222
1133
  }
1223
1134
 
1135
+ /** Apply a search-token EXISTS subquery across multiple sources (OR-joined). */
1136
+ private applyMultiSourceSearchExists(
1137
+ builder: AnyBuilder,
1138
+ sources: IndexDocSource[],
1139
+ key: string,
1140
+ hashes: string[],
1141
+ search: SearchRuntime,
1142
+ ): { builder: AnyBuilder; applied: boolean } {
1143
+ if (!sources.length || !hashes.length) return { builder, applied: false }
1144
+ const next = builder.where((eb: any) => eb.or(
1145
+ sources.map((source) =>
1146
+ eb.exists(this.buildSearchTokensSub(eb, {
1147
+ entity: String(source.entityId),
1148
+ field: key, hashes,
1149
+ recordIdColumn: `${source.alias}.entity_id`,
1150
+ tenantId: search.tenantId ?? null,
1151
+ organizationScope: search.organizationScope ?? null,
1152
+ }))
1153
+ )
1154
+ ))
1155
+ return { builder: next, applied: true }
1156
+ }
1157
+
1158
+ /** Construct a search-token EXISTS subquery using the given ExpressionBuilder. */
1159
+ private buildSearchTokensSub(
1160
+ eb: any,
1161
+ opts: {
1162
+ entity: string
1163
+ field: string
1164
+ hashes: string[]
1165
+ recordIdColumn: string
1166
+ tenantId?: string | null
1167
+ organizationScope?: { ids: string[]; includeNull: boolean } | null
1168
+ }
1169
+ ): any {
1170
+ const alias = `st_${this.searchAliasSeq++}`
1171
+ let sub = eb
1172
+ .selectFrom(`search_tokens as ${alias}`)
1173
+ .select(sql<number>`1`.as('one'))
1174
+ .where(`${alias}.entity_type`, '=', opts.entity)
1175
+ .where(`${alias}.field`, '=', opts.field)
1176
+ .where(sql<boolean>`${sql.ref(`${alias}.entity_id`)} = ${sql.ref(opts.recordIdColumn)}::text`)
1177
+ .where(`${alias}.token_hash`, 'in', opts.hashes)
1178
+ .groupBy([`${alias}.entity_id`, `${alias}.field`])
1179
+ .having(sql<boolean>`count(distinct ${sql.ref(`${alias}.token_hash`)}) >= ${opts.hashes.length}`)
1180
+ if (opts.tenantId !== undefined) {
1181
+ sub = sub.where(sql<boolean>`${sql.ref(`${alias}.tenant_id`)} is not distinct from ${opts.tenantId ?? null}`)
1182
+ }
1183
+ if (opts.organizationScope) {
1184
+ sub = this.applyOrganizationScope(sub, `${alias}.organization_id`, opts.organizationScope)
1185
+ }
1186
+ return sub
1187
+ }
1188
+
1224
1189
  private applyCfFilterFromAlias(
1225
- knex: Knex,
1226
- q: ResultBuilder,
1190
+ q: AnyBuilder,
1227
1191
  alias: string,
1228
1192
  entityType: string,
1229
1193
  key: string,
1230
1194
  op: FilterOp,
1231
1195
  value: unknown,
1232
1196
  search?: SearchRuntime
1233
- ): ResultBuilder {
1234
- const text = this.cfTextExprAlias(knex, alias, key)
1235
- const arrExpr = knex.raw(`(${alias}.doc -> ?)`, [key])
1236
- const arrContains = (val: unknown) => knex.raw(`${arrExpr.toString()} @> ?::jsonb`, [JSON.stringify([val])])
1197
+ ): AnyBuilder {
1198
+ const textExpr = this.cfTextExprAlias(alias, key)
1199
+ const arrExpr = sql<unknown>`(${sql.ref(alias + '.doc')} -> ${key})`
1200
+ const arrContains = (val: unknown) => sql<boolean>`${arrExpr} @> ${JSON.stringify([val])}::jsonb`
1201
+
1237
1202
  if ((op === 'like' || op === 'ilike') && search?.enabled && typeof value === 'string') {
1238
1203
  const tokens = tokenizeText(String(value), search.config)
1239
1204
  const hashes = tokens.hashes
1240
1205
  if (hashes.length) {
1241
- const applied = this.applySearchTokens(q, {
1242
- knex,
1243
- entity: entityType,
1244
- field: key,
1245
- hashes,
1206
+ const applied = q.where((eb: any) => eb.exists(this.buildSearchTokensSub(eb, {
1207
+ entity: entityType, field: key, hashes,
1246
1208
  recordIdColumn: `${alias}.entity_id`,
1247
1209
  tenantId: search.tenantId ?? null,
1248
1210
  organizationScope: search.organizationScope ?? null,
1249
- })
1211
+ })))
1250
1212
  this.logSearchDebug('search:cf-filter', {
1251
- entity: entityType,
1252
- field: key,
1253
- tokens: tokens.tokens,
1254
- hashes,
1255
- applied,
1256
- tenantId: search.tenantId ?? null,
1257
- organizationScope: search.organizationScope,
1213
+ entity: entityType, field: key, tokens: tokens.tokens, hashes, applied: true,
1214
+ tenantId: search.tenantId ?? null, organizationScope: search.organizationScope,
1258
1215
  })
1259
- if (applied) return q
1216
+ return applied
1260
1217
  } else {
1261
- this.logSearchDebug('search:cf-skip-empty-hashes', {
1262
- entity: entityType,
1263
- field: key,
1264
- value,
1265
- })
1218
+ this.logSearchDebug('search:cf-skip-empty-hashes', { entity: entityType, field: key, value })
1266
1219
  }
1267
1220
  return q
1268
1221
  }
1269
1222
  switch (op) {
1270
1223
  case 'eq':
1271
- return q.where((builder) => {
1272
- builder.orWhere(text, '=', value as Knex.Value)
1273
- builder.orWhere(arrContains(value))
1274
- })
1224
+ return q.where((eb: any) => eb.or([
1225
+ sql<boolean>`${textExpr} = ${value}`,
1226
+ arrContains(value),
1227
+ ]))
1275
1228
  case 'ne':
1276
- return q.whereNot(text, '=', value as Knex.Value)
1229
+ return q.where(sql<boolean>`${textExpr} <> ${value}`)
1277
1230
  case 'in': {
1278
1231
  const vals = this.toArray(value)
1279
- return q.where((builder) => {
1280
- vals.forEach((val) => {
1281
- builder.orWhere(text, '=', val as Knex.Value)
1282
- builder.orWhere(arrContains(val))
1283
- })
1284
- })
1232
+ return q.where((eb: any) => eb.or(
1233
+ vals.flatMap((val) => [
1234
+ sql<boolean>`${textExpr} = ${val}`,
1235
+ arrContains(val),
1236
+ ])
1237
+ ))
1285
1238
  }
1286
1239
  case 'nin': {
1287
- const vals = this.toArray(value) as readonly Knex.Value[]
1288
- return q.whereNotIn(text as any, vals as any)
1240
+ const vals = this.toArray(value)
1241
+ return q.where(sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
1289
1242
  }
1290
1243
  case 'like':
1291
- return q.where(text, 'like', value as Knex.Value)
1244
+ return q.where(sql<boolean>`${textExpr} like ${value}`)
1292
1245
  case 'ilike':
1293
- return q.where(text, 'ilike', value as Knex.Value)
1246
+ return q.where(sql<boolean>`${textExpr} ilike ${value}`)
1294
1247
  case 'exists':
1295
1248
  return value
1296
- ? q.whereRaw(`${text.toString()} is not null`)
1297
- : q.whereRaw(`${text.toString()} is null`)
1249
+ ? q.where(sql<boolean>`${textExpr} is not null`)
1250
+ : q.where(sql<boolean>`${textExpr} is null`)
1298
1251
  case 'gt':
1299
1252
  case 'gte':
1300
1253
  case 'lt':
1301
1254
  case 'lte': {
1302
- const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
1303
- return q.where(text, operator, value as Knex.Value)
1255
+ const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
1256
+ return q.where(sql<boolean>`${textExpr} ${operator} ${value}`)
1304
1257
  }
1305
1258
  default:
1306
1259
  return q
@@ -1308,8 +1261,7 @@ export class HybridQueryEngine implements QueryEngine {
1308
1261
  }
1309
1262
 
1310
1263
  private applyIndexDocFilterFromAlias(
1311
- knex: Knex,
1312
- q: ResultBuilder,
1264
+ q: AnyBuilder,
1313
1265
  alias: string,
1314
1266
  entityType: string,
1315
1267
  key: string,
@@ -1317,83 +1269,148 @@ export class HybridQueryEngine implements QueryEngine {
1317
1269
  value: unknown,
1318
1270
  recordIdColumn: string,
1319
1271
  search?: SearchRuntime,
1320
- ): ResultBuilder {
1321
- const text = knex.raw(`(${alias}.doc ->> ?)`, [key])
1272
+ ): AnyBuilder {
1273
+ const textExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
1322
1274
  if ((op === 'like' || op === 'ilike') && search?.enabled && typeof value === 'string') {
1323
1275
  const tokens = tokenizeText(String(value), search.config)
1324
1276
  const hashes = tokens.hashes
1325
1277
  if (hashes.length) {
1326
- const applied = this.applySearchTokens(q, {
1327
- knex,
1328
- entity: entityType,
1329
- field: key,
1330
- hashes,
1331
- recordIdColumn,
1278
+ const applied = q.where((eb: any) => eb.exists(this.buildSearchTokensSub(eb, {
1279
+ entity: entityType, field: key, hashes, recordIdColumn,
1332
1280
  tenantId: search.tenantId ?? null,
1333
1281
  organizationScope: search.organizationScope ?? null,
1334
- })
1282
+ })))
1335
1283
  this.logSearchDebug('search:index-doc-filter', {
1336
- entity: entityType,
1337
- field: key,
1338
- tokens: tokens.tokens,
1339
- hashes,
1340
- applied,
1341
- tenantId: search.tenantId ?? null,
1342
- organizationScope: search.organizationScope,
1284
+ entity: entityType, field: key, tokens: tokens.tokens, hashes, applied: true,
1285
+ tenantId: search.tenantId ?? null, organizationScope: search.organizationScope,
1343
1286
  })
1344
- if (applied) return q
1287
+ return applied
1345
1288
  } else {
1346
- this.logSearchDebug('search:index-doc-skip-empty-hashes', {
1347
- entity: entityType,
1348
- field: key,
1349
- value,
1350
- })
1289
+ this.logSearchDebug('search:index-doc-skip-empty-hashes', { entity: entityType, field: key, value })
1351
1290
  }
1352
1291
  return q
1353
1292
  }
1354
1293
  switch (op) {
1355
1294
  case 'eq':
1356
- return q.where(text, '=', value as Knex.Value)
1295
+ return q.where(sql<boolean>`${textExpr} = ${value}`)
1357
1296
  case 'ne':
1358
- return q.where(text, '!=', value as Knex.Value)
1359
- case 'in':
1360
- return q.whereIn(text as any, this.toArray(value) as readonly Knex.Value[])
1361
- case 'nin':
1362
- return q.whereNotIn(text as any, this.toArray(value) as readonly Knex.Value[])
1297
+ return q.where(sql<boolean>`${textExpr} <> ${value}`)
1298
+ case 'in': {
1299
+ const vals = this.toArray(value)
1300
+ return q.where(sql<boolean>`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
1301
+ }
1302
+ case 'nin': {
1303
+ const vals = this.toArray(value)
1304
+ return q.where(sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
1305
+ }
1363
1306
  case 'like':
1364
- return q.where(text, 'like', value as Knex.Value)
1307
+ return q.where(sql<boolean>`${textExpr} like ${value}`)
1365
1308
  case 'ilike':
1366
- return q.where(text, 'ilike', value as Knex.Value)
1309
+ return q.where(sql<boolean>`${textExpr} ilike ${value}`)
1367
1310
  case 'exists':
1368
1311
  return value
1369
- ? q.whereRaw(`${text.toString()} is not null`)
1370
- : q.whereRaw(`${text.toString()} is null`)
1312
+ ? q.where(sql<boolean>`${textExpr} is not null`)
1313
+ : q.where(sql<boolean>`${textExpr} is null`)
1371
1314
  case 'gt':
1372
1315
  case 'gte':
1373
1316
  case 'lt':
1374
1317
  case 'lte': {
1375
- const operator = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<='
1376
- return q.where(text, operator, value as Knex.Value)
1318
+ const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
1319
+ return q.where(sql<boolean>`${textExpr} ${operator} ${value}`)
1377
1320
  }
1378
1321
  default:
1379
1322
  return q
1380
1323
  }
1381
1324
  }
1382
1325
 
1326
+ /**
1327
+ * Build a single OR-group base filter expression as a Kysely predicate
1328
+ * (no side effects on the outer builder).
1329
+ */
1330
+ private buildBaseFilterExpression(
1331
+ eb: any,
1332
+ filter: BaseFilter,
1333
+ resolveBaseColumn: (field: string) => string | null,
1334
+ qualify: (col: string) => string,
1335
+ entity: EntityId,
1336
+ searchRuntime: SearchRuntime,
1337
+ ): any {
1338
+ const fieldName = String(filter.field)
1339
+ const baseField = resolveBaseColumn(fieldName)
1340
+ if (!baseField) {
1341
+ // Doc-based filter via `ei` alias — returned as EXISTS where possible
1342
+ return this.buildIndexDocFilterExpression(eb, 'ei', entity, fieldName, filter.op, filter.value, 'b.id', searchRuntime)
1343
+ }
1344
+ return this.buildColumnFilterExpression(eb, qualify(baseField), filter.op, filter.value)
1345
+ }
1346
+
1347
+ private buildColumnFilterExpression(
1348
+ eb: any,
1349
+ column: string,
1350
+ op: FilterOp,
1351
+ value: unknown,
1352
+ ): any {
1353
+ switch (op) {
1354
+ case 'eq': return eb(column, '=', value)
1355
+ case 'ne': return eb(column, '!=', value)
1356
+ case 'gt': return eb(column, '>', value)
1357
+ case 'gte': return eb(column, '>=', value)
1358
+ case 'lt': return eb(column, '<', value)
1359
+ case 'lte': return eb(column, '<=', value)
1360
+ case 'in': return eb(column, 'in', this.toArray(value))
1361
+ case 'nin': return eb(column, 'not in', this.toArray(value))
1362
+ case 'like': return eb(column, 'like', value)
1363
+ case 'ilike': return eb(column, 'ilike', value)
1364
+ case 'exists': return eb(column, value ? 'is not' : 'is', null)
1365
+ default: return sql<boolean>`true`
1366
+ }
1367
+ }
1368
+
1369
+ private buildIndexDocFilterExpression(
1370
+ eb: any,
1371
+ alias: string,
1372
+ _entity: EntityId,
1373
+ key: string,
1374
+ op: FilterOp,
1375
+ value: unknown,
1376
+ _recordIdColumn: string,
1377
+ _search?: SearchRuntime,
1378
+ ): any {
1379
+ const textExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
1380
+ switch (op) {
1381
+ case 'eq': return sql<boolean>`${textExpr} = ${value}`
1382
+ case 'ne': return sql<boolean>`${textExpr} <> ${value}`
1383
+ case 'gt':
1384
+ case 'gte':
1385
+ case 'lt':
1386
+ case 'lte': {
1387
+ const operator = sql.raw(op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : '<=')
1388
+ return sql<boolean>`${textExpr} ${operator} ${value}`
1389
+ }
1390
+ case 'like': return sql<boolean>`${textExpr} like ${value}`
1391
+ case 'ilike': return sql<boolean>`${textExpr} ilike ${value}`
1392
+ case 'in': {
1393
+ const vals = this.toArray(value)
1394
+ return sql<boolean>`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
1395
+ }
1396
+ case 'nin': {
1397
+ const vals = this.toArray(value)
1398
+ return sql<boolean>`${textExpr} not in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`
1399
+ }
1400
+ case 'exists':
1401
+ return value ? sql<boolean>`${textExpr} is not null` : sql<boolean>`${textExpr} is null`
1402
+ default:
1403
+ return sql<boolean>`true`
1404
+ }
1405
+ }
1406
+
1383
1407
  private async queryCustomEntity<T = unknown>(entity: string, opts: QueryOptions = {}): Promise<QueryResult<T>> {
1384
- const knex = this.getKnex()
1408
+ const db = this.getDb() as any
1385
1409
  const alias = 'ce'
1386
- let q = knex({ [alias]: 'custom_entities_storage' }).where(`${alias}.entity_type`, entity)
1387
1410
 
1388
1411
  const orgScope = this.resolveOrganizationScope(opts)
1389
-
1390
- // Require tenant scope; custom entities are tenant-scoped only
1391
1412
  if (!opts.tenantId) throw new Error('QueryEngine: tenantId is required')
1392
- q = q.andWhere(`${alias}.tenant_id`, opts.tenantId)
1393
- if (orgScope) {
1394
- q = this.applyOrganizationScope(q, `${alias}.organization_id`, orgScope)
1395
- }
1396
- if (!opts.withDeleted) q = q.whereNull(`${alias}.deleted_at`)
1413
+
1397
1414
  const searchConfig = resolveSearchConfig()
1398
1415
  const searchEnabled = searchConfig.enabled && await this.tableExists('search_tokens')
1399
1416
  const hasSearchTokens = searchEnabled
@@ -1408,31 +1425,33 @@ export class HybridQueryEngine implements QueryEngine {
1408
1425
 
1409
1426
  const normalizedFilters = normalizeFilters(opts.filters)
1410
1427
 
1411
- // Apply filters: cf:* via JSONB; other keys: special-case id/created_at/updated_at/deleted_at, otherwise from doc
1412
- for (const filter of normalizedFilters) {
1413
- if (filter.field.startsWith('cf:')) {
1414
- q = this.applyCfFilterFromAlias(knex, q, alias, entity, filter.field, filter.op, filter.value, searchRuntime)
1415
- continue
1416
- }
1417
- const column = this.resolveCustomEntityColumn(alias, String(filter.field))
1418
- if (column) {
1419
- q = this.applyColumnFilter(q, column, filter, {
1420
- ...searchRuntime,
1421
- knex,
1422
- entity,
1423
- field: String(filter.field),
1424
- recordIdColumn: `${alias}.entity_id`,
1428
+ const applyScope = (q: AnyBuilder): AnyBuilder => {
1429
+ let next = q
1430
+ .where(`${alias}.entity_type`, '=', entity)
1431
+ .where(`${alias}.tenant_id`, '=', opts.tenantId)
1432
+ if (orgScope) {
1433
+ next = this.applyOrganizationScope(next, `${alias}.organization_id`, orgScope)
1434
+ }
1435
+ if (!opts.withDeleted) next = next.where(`${alias}.deleted_at`, 'is', null)
1436
+ for (const filter of normalizedFilters) {
1437
+ if (filter.field.startsWith('cf:')) {
1438
+ next = this.applyCfFilterFromAlias(next, alias, entity, filter.field, filter.op, filter.value, searchRuntime)
1439
+ continue
1440
+ }
1441
+ const column = this.resolveCustomEntityColumn(alias, String(filter.field))
1442
+ if (column) {
1443
+ next = this.applyColumnFilter(next, column, filter, {
1444
+ ...searchRuntime, entity, field: String(filter.field), recordIdColumn: `${alias}.entity_id`,
1445
+ })
1446
+ continue
1447
+ }
1448
+ // Unknown field → filter on doc JSON text
1449
+ const docExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${String(filter.field)})`
1450
+ next = this.applyColumnFilter(next, docExpr, filter, {
1451
+ ...searchRuntime, entity, field: String(filter.field), recordIdColumn: `${alias}.entity_id`,
1425
1452
  })
1426
- continue
1427
- }
1428
- const docExpr = knex.raw(`(${alias}.doc ->> ?)`, [String(filter.field)])
1429
- q = this.applyColumnFilter(q, docExpr, filter, {
1430
- ...searchRuntime,
1431
- knex,
1432
- entity,
1433
- field: String(filter.field),
1434
- recordIdColumn: `${alias}.entity_id`,
1435
- })
1453
+ }
1454
+ return next
1436
1455
  }
1437
1456
 
1438
1457
  // Determine CFs and l10n keys to include
@@ -1447,93 +1466,92 @@ export class HybridQueryEngine implements QueryEngine {
1447
1466
  }
1448
1467
  if (opts.includeCustomFields === true) {
1449
1468
  try {
1450
- const rows = await knex('custom_field_defs')
1469
+ const rows = await db
1470
+ .selectFrom('custom_field_defs')
1451
1471
  .select('key')
1452
- .where({ entity_id: entity, is_active: true })
1453
- .modify((qb) => {
1454
- qb.andWhere({ tenant_id: opts.tenantId })
1455
- // NOTE: organization-level scoping intentionally disabled for custom fields
1456
- // if (opts.organizationId != null) qb.andWhere((b: any) => b.where({ organization_id: opts.organizationId }).orWhereNull('organization_id'))
1457
- // else qb.whereNull('organization_id')
1458
- })
1472
+ .where('entity_id', '=', entity)
1473
+ .where('is_active', '=', true)
1474
+ .where('tenant_id', '=', opts.tenantId)
1475
+ .execute() as Array<{ key: unknown }>
1459
1476
  for (const row of rows) {
1460
- const key = (row as Record<string, unknown>).key
1461
- if (typeof key === 'string') {
1462
- cfKeys.add(key)
1463
- } else if (key != null) {
1464
- cfKeys.add(String(key))
1465
- }
1477
+ const key = row.key
1478
+ if (typeof key === 'string') cfKeys.add(key)
1479
+ else if (key != null) cfKeys.add(String(key))
1466
1480
  }
1467
1481
  } catch {
1468
- // ignore and fall back to whatever keys we already have
1482
+ // ignore
1469
1483
  }
1470
1484
  } else if (Array.isArray(opts.includeCustomFields)) {
1471
1485
  for (const k of opts.includeCustomFields) cfKeys.add(k)
1472
1486
  }
1473
1487
 
1474
- // Selection
1475
- const requested = (opts.fields && opts.fields.length) ? opts.fields : ['id']
1476
- for (const field of requested) {
1477
- const f = String(field)
1478
- if (f.startsWith('cf:')) {
1479
- const aliasName = this.sanitize(f)
1480
- const expr = this.jsonbRawAlias(knex, alias, f)
1481
- q = q.select({ [aliasName]: expr })
1482
- } else if (f === 'id') {
1483
- q = q.select(knex.raw(`${alias}.entity_id as ??`, ['id']))
1484
- } else if (f === 'created_at' || f === 'updated_at' || f === 'deleted_at') {
1485
- q = q.select(knex.raw(`${alias}.?? as ??`, [f, f]))
1486
- } else {
1487
- // Non-cf from doc
1488
- const expr = knex.raw(`(${alias}.doc ->> ?)`, [f])
1489
- q = q.select({ [f]: expr })
1488
+ const applySelection = (q: AnyBuilder): AnyBuilder => {
1489
+ let next = q
1490
+ const requested = (opts.fields && opts.fields.length) ? opts.fields : ['id']
1491
+ for (const field of requested) {
1492
+ const f = String(field)
1493
+ if (f.startsWith('cf:')) {
1494
+ const aliasName = this.sanitize(f)
1495
+ next = next.select(this.jsonbSqlAlias(alias, f).as(aliasName))
1496
+ } else if (f === 'id') {
1497
+ next = next.select(`${alias}.entity_id as id`)
1498
+ } else if (f === 'created_at' || f === 'updated_at' || f === 'deleted_at') {
1499
+ next = next.select(`${alias}.${f} as ${f}`)
1500
+ } else {
1501
+ const expr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${f})`
1502
+ next = next.select(expr.as(f))
1503
+ }
1490
1504
  }
1491
- }
1492
- // Ensure CFs necessary for sort are selected
1493
- const cfSelectedAliases: string[] = []
1494
- for (const key of cfKeys) {
1495
- const aliasName = this.sanitize(`cf:${key}`)
1496
- const expr = this.jsonbRawAlias(knex, alias, `cf:${key}`)
1497
- q = q.select({ [aliasName]: expr })
1498
- cfSelectedAliases.push(aliasName)
1505
+ // Ensure CF fields for sort / includeCustomFields are selected
1506
+ for (const key of cfKeys) {
1507
+ const aliasName = this.sanitize(`cf:${key}`)
1508
+ next = next.select(this.jsonbSqlAlias(alias, `cf:${key}`).as(aliasName))
1509
+ }
1510
+ return next
1499
1511
  }
1500
1512
 
1501
- // Sorting
1502
- for (const s of opts.sort || []) {
1503
- if (s.field.startsWith('cf:')) {
1504
- const key = s.field.slice(3)
1505
- const aliasName = this.sanitize(`cf:${key}`)
1506
- if (!cfSelectedAliases.includes(aliasName)) {
1507
- const expr = this.jsonbRawAlias(knex, alias, `cf:${key}`)
1508
- q = q.select({ [aliasName]: expr })
1509
- cfSelectedAliases.push(aliasName)
1513
+ const applySort = (q: AnyBuilder): AnyBuilder => {
1514
+ let next = q
1515
+ for (const s of opts.sort || []) {
1516
+ if (s.field.startsWith('cf:')) {
1517
+ const key = s.field.slice(3)
1518
+ const aliasName = this.sanitize(`cf:${key}`)
1519
+ next = next.orderBy(aliasName, s.dir ?? SortDir.Asc)
1520
+ } else if (s.field === 'id') {
1521
+ next = next.orderBy(`${alias}.entity_id`, s.dir ?? SortDir.Asc)
1522
+ } else if (s.field === 'created_at' || s.field === 'updated_at' || s.field === 'deleted_at') {
1523
+ next = next.orderBy(`${alias}.${s.field}`, s.dir ?? SortDir.Asc)
1524
+ } else {
1525
+ const direction = sql.raw(String(s.dir ?? SortDir.Asc))
1526
+ next = next.orderBy(sql`(${sql.ref(alias + '.doc')} ->> ${s.field}) ${direction}`)
1510
1527
  }
1511
- q = q.orderBy(aliasName, s.dir ?? SortDir.Asc)
1512
- } else if (s.field === 'id') {
1513
- q = q.orderBy(`${alias}.entity_id`, s.dir ?? SortDir.Asc)
1514
- } else if (s.field === 'created_at' || s.field === 'updated_at' || s.field === 'deleted_at') {
1515
- q = q.orderBy(`${alias}.${s.field}`, s.dir ?? SortDir.Asc)
1516
- } else {
1517
- const direction = s.dir ?? SortDir.Asc
1518
- q = q.orderByRaw(`(${alias}.doc ->> ?) ${direction}`, [s.field])
1519
1528
  }
1529
+ return next
1520
1530
  }
1521
1531
 
1522
- // Pagination + totals
1523
1532
  const page = opts.page?.page ?? 1
1524
1533
  const pageSize = opts.page?.pageSize ?? 20
1525
- const countClone = q.clone()
1526
- if (typeof countClone.clearSelect === 'function') countClone.clearSelect()
1527
- if (typeof countClone.clearOrder === 'function') countClone.clearOrder()
1528
- const countRow = await countClone.countDistinct(`${alias}.entity_id as count`).first()
1534
+
1535
+ const root = db.selectFrom(`custom_entities_storage as ${alias}`)
1536
+ const countQuery = applyScope(root).select(sql<string>`count(distinct ${sql.ref(`${alias}.entity_id`)})`.as('count'))
1537
+ const countRow = await countQuery.executeTakeFirst()
1529
1538
  const total = this.parseCount(countRow)
1530
- const items = await q.limit(pageSize).offset((page - 1) * pageSize)
1539
+
1540
+ let dataQuery = applyScope(db.selectFrom(`custom_entities_storage as ${alias}`))
1541
+ dataQuery = applySelection(dataQuery)
1542
+ dataQuery = applySort(dataQuery)
1543
+ dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize)
1544
+ const items = await dataQuery.execute()
1531
1545
  return { items, page, pageSize, total }
1532
1546
  }
1533
1547
 
1534
1548
  private async tableExists(table: string): Promise<boolean> {
1535
- const knex = this.getKnex()
1536
- const exists = await knex('information_schema.tables').where({ table_name: table }).first()
1549
+ const db = this.getDb() as any
1550
+ const exists = await db
1551
+ .selectFrom('information_schema.tables')
1552
+ .select(sql<number>`1`.as('one'))
1553
+ .where('table_name', '=', table)
1554
+ .executeTakeFirst()
1537
1555
  return !!exists
1538
1556
  }
1539
1557
 
@@ -1543,21 +1561,22 @@ export class HybridQueryEngine implements QueryEngine {
1543
1561
  orgScope?: { ids: string[]; includeNull: boolean } | null
1544
1562
  ): Promise<boolean> {
1545
1563
  try {
1546
- const knex = this.getKnex()
1547
- const query = knex('search_tokens').select(1).where('entity_type', entity).limit(1)
1564
+ const db = this.getDb() as any
1565
+ let query = db
1566
+ .selectFrom('search_tokens')
1567
+ .select(sql<number>`1`.as('one'))
1568
+ .where('entity_type', '=', entity)
1548
1569
  if (tenantId !== undefined) {
1549
- query.andWhereRaw('tenant_id is not distinct from ?', [tenantId])
1570
+ query = query.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
1550
1571
  }
1551
1572
  if (orgScope) {
1552
- this.applyOrganizationScope(query as any, 'search_tokens.organization_id', orgScope)
1573
+ query = this.applyOrganizationScope(query, 'search_tokens.organization_id', orgScope)
1553
1574
  }
1554
- const row = await query.first()
1575
+ const row = await query.limit(1).executeTakeFirst()
1555
1576
  return !!row
1556
1577
  } catch (err) {
1557
1578
  this.logSearchDebug('search:has-tokens-error', {
1558
- entity,
1559
- tenantId,
1560
- organizationScope: orgScope,
1579
+ entity, tenantId, organizationScope: orgScope,
1561
1580
  error: err instanceof Error ? err.message : String(err),
1562
1581
  })
1563
1582
  return false
@@ -1572,11 +1591,8 @@ export class HybridQueryEngine implements QueryEngine {
1572
1591
  for (const source of sources) {
1573
1592
  const ok = await this.hasSearchTokens(source.entity, tenantId, orgScope)
1574
1593
  this.logSearchDebug('search:source-has-tokens', {
1575
- entity: source.entity,
1576
- recordIdColumn: source.recordIdColumn,
1577
- tenantId,
1578
- organizationScope: orgScope,
1579
- hasTokens: ok,
1594
+ entity: source.entity, recordIdColumn: source.recordIdColumn,
1595
+ tenantId, organizationScope: orgScope, hasTokens: ok,
1580
1596
  })
1581
1597
  if (ok) return true
1582
1598
  }
@@ -1588,23 +1604,22 @@ export class HybridQueryEngine implements QueryEngine {
1588
1604
  const cacheKey = this.customFieldKeysCacheKey(entityIds, tenantId)
1589
1605
  const now = Date.now()
1590
1606
  const cached = this.customFieldKeysCache.get(cacheKey)
1591
- if (cached && cached.expiresAt > now) {
1592
- return cached.value.slice()
1593
- }
1607
+ if (cached && cached.expiresAt > now) return cached.value.slice()
1594
1608
 
1595
- const knex = this.getKnex()
1596
- const rows = await knex('custom_field_defs')
1609
+ const db = this.getDb() as any
1610
+ const rows = await db
1611
+ .selectFrom('custom_field_defs')
1597
1612
  .select('key')
1598
- .whereIn('entity_id', entityIds)
1599
- .andWhere('is_active', true)
1600
- .modify((qb: any) => {
1601
- qb.andWhere((inner: any) => {
1602
- inner.where({ tenant_id: tenantId }).orWhereNull('tenant_id')
1603
- })
1604
- })
1613
+ .where('entity_id', 'in', entityIds)
1614
+ .where('is_active', '=', true)
1615
+ .where((eb: any) => eb.or([
1616
+ eb('tenant_id', '=', tenantId),
1617
+ eb('tenant_id', 'is', null),
1618
+ ]))
1619
+ .execute() as Array<{ key: unknown }>
1605
1620
  const keys = new Set<string>()
1606
- for (const row of rows || []) {
1607
- const key = (row as Record<string, unknown>).key
1621
+ for (const row of rows) {
1622
+ const key = row.key
1608
1623
  if (typeof key === 'string' && key.trim().length) keys.add(key.trim())
1609
1624
  else if (key != null) keys.add(String(key))
1610
1625
  }
@@ -1622,8 +1637,7 @@ export class HybridQueryEngine implements QueryEngine {
1622
1637
  } catch (err) {
1623
1638
  if (this.isDebugVerbosity()) {
1624
1639
  this.debug('query:cf:check-error', {
1625
- entity: entityId,
1626
- tenantId: tenantId ?? null,
1640
+ entity: entityId, tenantId: tenantId ?? null,
1627
1641
  error: err instanceof Error ? err.message : err,
1628
1642
  })
1629
1643
  }
@@ -1650,17 +1664,22 @@ export class HybridQueryEngine implements QueryEngine {
1650
1664
  }
1651
1665
 
1652
1666
  private async indexAnyRows(entity: string): Promise<boolean> {
1653
- const knex = this.getKnex()
1654
- // Prefer coverage snapshots cheap and already scoped by maintenance jobs.
1655
- const coverage = await knex('entity_index_coverage')
1656
- .select(1)
1657
- .where('entity_type', entity)
1667
+ const db = this.getDb() as any
1668
+ const coverage = await db
1669
+ .selectFrom('entity_index_coverage')
1670
+ .select(sql<number>`1`.as('one'))
1671
+ .where('entity_type', '=', entity)
1658
1672
  .where('indexed_count', '>', 0)
1659
- .first()
1673
+ .executeTakeFirst()
1660
1674
  if (coverage) return true
1661
- const exists = await knex('entity_indexes').select('entity_id').where({ entity_type: entity }).first()
1675
+ const exists = await db
1676
+ .selectFrom('entity_indexes')
1677
+ .select('entity_id')
1678
+ .where('entity_type', '=', entity)
1679
+ .executeTakeFirst()
1662
1680
  return !!exists
1663
1681
  }
1682
+
1664
1683
  private async getStoredCoverageSnapshot(
1665
1684
  entity: string,
1666
1685
  tenantId: string | null,
@@ -1669,32 +1688,20 @@ export class HybridQueryEngine implements QueryEngine {
1669
1688
  ): Promise<{ baseCount: number; indexedCount: number } | null> {
1670
1689
  try {
1671
1690
  if (!this.isCoverageOptimizationEnabled()) {
1672
- await refreshCoverageSnapshot(
1673
- this.em,
1674
- {
1675
- entityType: entity,
1676
- tenantId,
1677
- organizationId,
1678
- withDeleted,
1679
- },
1680
- )
1691
+ await refreshCoverageSnapshot(this.em, {
1692
+ entityType: entity, tenantId, organizationId, withDeleted,
1693
+ })
1681
1694
  }
1682
- const knex = this.getKnex()
1683
- const row = await readCoverageSnapshot(knex, {
1684
- entityType: entity,
1685
- tenantId,
1686
- organizationId,
1687
- withDeleted,
1695
+ const db = this.getDb()
1696
+ const row = await readCoverageSnapshot(db as any, {
1697
+ entityType: entity, tenantId, organizationId, withDeleted,
1688
1698
  })
1689
1699
  if (!row) return null
1690
1700
  return { baseCount: row.baseCount, indexedCount: row.indexedCount }
1691
1701
  } catch (err) {
1692
1702
  if (this.isDebugVerbosity()) {
1693
1703
  this.debug('coverage:snapshot:read-error', {
1694
- entity,
1695
- tenantId,
1696
- organizationId,
1697
- withDeleted,
1704
+ entity, tenantId, organizationId, withDeleted,
1698
1705
  error: err instanceof Error ? err.message : err,
1699
1706
  })
1700
1707
  }
@@ -1709,7 +1716,6 @@ export class HybridQueryEngine implements QueryEngine {
1709
1716
  organizationIdOverride?: string | null
1710
1717
  ) {
1711
1718
  if (!this.isAutoReindexEnabled()) return
1712
-
1713
1719
  const bus = this.resolveEventBus()
1714
1720
  if (!bus) return
1715
1721
  const payload = {
@@ -1719,27 +1725,19 @@ export class HybridQueryEngine implements QueryEngine {
1719
1725
  force: false,
1720
1726
  }
1721
1727
  const context = stats
1722
- ? {
1723
- entity,
1724
- tenantId: payload.tenantId,
1725
- organizationId: payload.organizationId,
1726
- baseCount: stats.baseCount,
1727
- indexedCount: stats.indexedCount,
1728
- }
1728
+ ? { entity, tenantId: payload.tenantId, organizationId: payload.organizationId, baseCount: stats.baseCount, indexedCount: stats.indexedCount }
1729
1729
  : { entity, tenantId: payload.tenantId, organizationId: payload.organizationId }
1730
1730
 
1731
- void Promise.resolve()
1732
- .then(async () => {
1733
- try {
1734
- await bus.emitEvent('query_index.reindex', payload, { persistent: true })
1735
- if (this.isDebugVerbosity()) this.debug('query:auto-reindex:scheduled', context)
1736
- } catch (err) {
1737
- console.warn('[HybridQueryEngine] Failed to schedule auto reindex:', {
1738
- ...context,
1739
- error: err instanceof Error ? err.message : err,
1740
- })
1741
- }
1742
- })
1731
+ void Promise.resolve().then(async () => {
1732
+ try {
1733
+ await bus.emitEvent('query_index.reindex', payload, { persistent: true })
1734
+ if (this.isDebugVerbosity()) this.debug('query:auto-reindex:scheduled', context)
1735
+ } catch (err) {
1736
+ console.warn('[HybridQueryEngine] Failed to schedule auto reindex:', {
1737
+ ...context, error: err instanceof Error ? err.message : err,
1738
+ })
1739
+ }
1740
+ })
1743
1741
  }
1744
1742
 
1745
1743
  private scheduleCoverageRefresh(
@@ -1750,12 +1748,7 @@ export class HybridQueryEngine implements QueryEngine {
1750
1748
  ): void {
1751
1749
  const bus = this.resolveEventBus()
1752
1750
  if (!bus) return
1753
- const key = [
1754
- entity,
1755
- tenantId ?? '__tenant__',
1756
- organizationId ?? '__org__',
1757
- withDeleted ? '1' : '0',
1758
- ].join('|')
1751
+ const key = [entity, tenantId ?? '__tenant__', organizationId ?? '__org__', withDeleted ? '1' : '0'].join('|')
1759
1752
  if (this.pendingCoverageRefreshKeys.has(key)) return
1760
1753
  this.pendingCoverageRefreshKeys.add(key)
1761
1754
  void Promise.resolve()
@@ -1763,34 +1756,24 @@ export class HybridQueryEngine implements QueryEngine {
1763
1756
  try {
1764
1757
  await bus.emitEvent('query_index.coverage.refresh', {
1765
1758
  entityType: entity,
1766
- tenantId: tenantId ?? null,
1767
- organizationId: organizationId ?? null,
1768
- withDeleted,
1769
- delayMs: 0,
1759
+ tenantId: tenantId ?? null, organizationId: organizationId ?? null,
1760
+ withDeleted, delayMs: 0,
1770
1761
  })
1771
1762
  if (this.isDebugVerbosity()) {
1772
1763
  this.debug('coverage:refresh:scheduled', {
1773
- entity,
1774
- tenantId: tenantId ?? null,
1775
- organizationId: organizationId ?? null,
1776
- withDeleted,
1764
+ entity, tenantId: tenantId ?? null, organizationId: organizationId ?? null, withDeleted,
1777
1765
  })
1778
1766
  }
1779
1767
  } catch (err) {
1780
1768
  if (this.isDebugVerbosity()) {
1781
1769
  this.debug('coverage:refresh:failed', {
1782
- entity,
1783
- tenantId: tenantId ?? null,
1784
- organizationId: organizationId ?? null,
1785
- withDeleted,
1770
+ entity, tenantId: tenantId ?? null, organizationId: organizationId ?? null, withDeleted,
1786
1771
  error: err instanceof Error ? err.message : err,
1787
1772
  })
1788
1773
  }
1789
1774
  }
1790
1775
  })
1791
- .finally(() => {
1792
- this.pendingCoverageRefreshKeys.delete(key)
1793
- })
1776
+ .finally(() => { this.pendingCoverageRefreshKeys.delete(key) })
1794
1777
  }
1795
1778
 
1796
1779
  private resolveEventBus(): Pick<EventBus, 'emitEvent'> | null {
@@ -1805,17 +1788,8 @@ export class HybridQueryEngine implements QueryEngine {
1805
1788
 
1806
1789
  private isAutoReindexEnabled(): boolean {
1807
1790
  if (this.autoReindexEnabled != null) return this.autoReindexEnabled
1808
- const raw = (
1809
- process.env.SCHEDULE_AUTO_REINDEX ??
1810
- process.env.QUERY_INDEX_AUTO_REINDEX ??
1811
- ''
1812
- )
1813
- .trim()
1814
- .toLowerCase()
1815
- if (!raw) {
1816
- this.autoReindexEnabled = true
1817
- return true
1818
- }
1791
+ const raw = (process.env.SCHEDULE_AUTO_REINDEX ?? process.env.QUERY_INDEX_AUTO_REINDEX ?? '').trim().toLowerCase()
1792
+ if (!raw) { this.autoReindexEnabled = true; return true }
1819
1793
  const parsed = parseBooleanToken(raw)
1820
1794
  this.autoReindexEnabled = parsed === null ? true : parsed
1821
1795
  return this.autoReindexEnabled
@@ -1824,10 +1798,7 @@ export class HybridQueryEngine implements QueryEngine {
1824
1798
  private isCoverageOptimizationEnabled(): boolean {
1825
1799
  if (this.coverageOptimizationEnabled != null) return this.coverageOptimizationEnabled
1826
1800
  const raw = (process.env.OPTIMIZE_INDEX_COVERAGE_STATS ?? '').trim().toLowerCase()
1827
- if (!raw) {
1828
- this.coverageOptimizationEnabled = false
1829
- return false
1830
- }
1801
+ if (!raw) { this.coverageOptimizationEnabled = false; return false }
1831
1802
  this.coverageOptimizationEnabled = parseBooleanToken(raw) === true
1832
1803
  return this.coverageOptimizationEnabled
1833
1804
  }
@@ -1839,10 +1810,13 @@ export class HybridQueryEngine implements QueryEngine {
1839
1810
  if (cached === true) return true
1840
1811
  this.columnCache.delete(key)
1841
1812
  }
1842
- const knex = this.getKnex()
1843
- const exists = await knex('information_schema.columns')
1844
- .where({ table_name: table, column_name: column })
1845
- .first()
1813
+ const db = this.getDb() as any
1814
+ const exists = await db
1815
+ .selectFrom('information_schema.columns')
1816
+ .select(sql<number>`1`.as('one'))
1817
+ .where('table_name', '=', table)
1818
+ .where('column_name', '=', column)
1819
+ .executeTakeFirst()
1846
1820
  const present = !!exists
1847
1821
  if (present) this.columnCache.set(key, true)
1848
1822
  else this.columnCache.delete(key)
@@ -1850,11 +1824,13 @@ export class HybridQueryEngine implements QueryEngine {
1850
1824
  }
1851
1825
 
1852
1826
  private async getBaseColumnsForEntity(entity: string): Promise<Map<string, string>> {
1853
- const knex = this.getKnex()
1827
+ const db = this.getDb() as any
1854
1828
  const table = resolveEntityTableName(this.em, entity)
1855
- const rows = await knex('information_schema.columns')
1856
- .select('column_name', 'data_type')
1857
- .where({ table_name: table })
1829
+ const rows = await db
1830
+ .selectFrom('information_schema.columns')
1831
+ .select(['column_name', 'data_type'])
1832
+ .where('table_name', '=', table)
1833
+ .execute() as Array<{ column_name: string; data_type: string }>
1858
1834
  const map = new Map<string, string>()
1859
1835
  for (const r of rows) map.set(r.column_name, r.data_type)
1860
1836
  return map
@@ -1889,26 +1865,20 @@ export class HybridQueryEngine implements QueryEngine {
1889
1865
  return null
1890
1866
  }
1891
1867
 
1892
- private applyOrganizationScope<TRecord extends ResultRow, TResult>(
1893
- q: Knex.QueryBuilder<TRecord, TResult>,
1868
+ private applyOrganizationScope(
1869
+ q: AnyBuilder,
1894
1870
  column: string,
1895
1871
  scope: { ids: string[]; includeNull: boolean }
1896
- ): Knex.QueryBuilder<TRecord, TResult> {
1872
+ ): AnyBuilder {
1897
1873
  if (scope.ids.length === 0 && !scope.includeNull) {
1898
- return q.whereRaw('1 = 0')
1899
- }
1900
- return q.where((builder) => {
1901
- let applied = false
1902
- if (scope.ids.length > 0) {
1903
- builder.whereIn(column, scope.ids as readonly string[])
1904
- applied = true
1905
- }
1906
- if (scope.includeNull) {
1907
- if (applied) builder.orWhereNull(column)
1908
- else builder.whereNull(column)
1909
- } else if (!applied) {
1910
- builder.whereRaw('1 = 0')
1911
- }
1874
+ return q.where(sql<boolean>`1 = 0`)
1875
+ }
1876
+ return q.where((eb: any) => {
1877
+ const parts: any[] = []
1878
+ if (scope.ids.length > 0) parts.push(eb(column, 'in', scope.ids))
1879
+ if (scope.includeNull) parts.push(eb(column, 'is', null))
1880
+ if (parts.length === 1) return parts[0]
1881
+ return eb.or(parts)
1912
1882
  })
1913
1883
  }
1914
1884
 
@@ -1918,8 +1888,7 @@ export class HybridQueryEngine implements QueryEngine {
1918
1888
  if (Array.isArray(filters)) {
1919
1889
  return (filters as Filter[]).map((filter) => ({
1920
1890
  field: normalizeField(String(filter.field)),
1921
- op: filter.op,
1922
- value: filter.value,
1891
+ op: filter.op, value: filter.value,
1923
1892
  }))
1924
1893
  }
1925
1894
  const out: NormalizedFilter[] = []
@@ -1955,12 +1924,8 @@ export class HybridQueryEngine implements QueryEngine {
1955
1924
  }
1956
1925
 
1957
1926
  private toArray(value: unknown): readonly unknown[] {
1958
- if (Array.isArray(value)) {
1959
- return value
1960
- }
1961
- if (value === undefined) {
1962
- return []
1963
- }
1927
+ if (Array.isArray(value)) return value
1928
+ if (value === undefined) return []
1964
1929
  return [value]
1965
1930
  }
1966
1931
 
@@ -1972,6 +1937,7 @@ export class HybridQueryEngine implements QueryEngine {
1972
1937
  const parsed = Number(value)
1973
1938
  return Number.isNaN(parsed) ? 0 : parsed
1974
1939
  }
1940
+ if (typeof value === 'bigint') return Number(value)
1975
1941
  }
1976
1942
  return 0
1977
1943
  }
@@ -1985,12 +1951,12 @@ export class HybridQueryEngine implements QueryEngine {
1985
1951
  }
1986
1952
  }
1987
1953
 
1988
- private applyColumnFilter<TRecord extends ResultRow, TResult>(
1989
- q: Knex.QueryBuilder<TRecord, TResult>,
1990
- column: string | Knex.Raw,
1954
+ private applyColumnFilter(
1955
+ q: AnyBuilder,
1956
+ column: string | RawBuilder<unknown>,
1991
1957
  filter: NormalizedFilter,
1992
- search?: SearchRuntime & { knex: Knex; entity: string; field: string; recordIdColumn?: string }
1993
- ): Knex.QueryBuilder<TRecord, TResult> {
1958
+ search?: SearchRuntime & { entity: string; field: string; recordIdColumn?: string },
1959
+ ): AnyBuilder {
1994
1960
  if (
1995
1961
  (filter.op === 'like' || filter.op === 'ilike') &&
1996
1962
  search?.enabled &&
@@ -2003,71 +1969,53 @@ export class HybridQueryEngine implements QueryEngine {
2003
1969
  ? search.searchSources
2004
1970
  : [{ entity: search.entity, recordIdColumn: search.recordIdColumn ?? '' }]
2005
1971
  ).filter((src) => src.recordIdColumn && src.entity)
2006
- let applied = false
2007
1972
  if (sources.length) {
2008
- q = q.where((qb) => {
2009
- sources.forEach((src, idx) => {
2010
- const ok = this.applySearchTokens(qb as any, {
2011
- knex: search.knex,
2012
- entity: src.entity,
2013
- field: search.field,
2014
- hashes,
1973
+ const engine = this
1974
+ q = q.where((eb: any) => eb.or(
1975
+ sources.map((src) =>
1976
+ eb.exists(engine.buildSearchTokensSub(eb, {
1977
+ entity: src.entity, field: search.field, hashes,
2015
1978
  recordIdColumn: src.recordIdColumn,
2016
1979
  tenantId: search.tenantId ?? null,
2017
1980
  organizationScope: search.organizationScope ?? null,
2018
- combineWith: idx === 0 ? 'and' : 'or',
2019
- })
2020
- if (ok) applied = true
2021
- })
1981
+ })))
1982
+ ))
1983
+ this.logSearchDebug('search:filter', {
1984
+ entity: search.entity, field: search.field, tokens: tokens.tokens, hashes,
1985
+ applied: true, tenantId: search.tenantId ?? null,
1986
+ organizationScope: search.organizationScope,
1987
+ sources: sources.map((src) => ({ entity: src.entity, recordIdColumn: src.recordIdColumn })),
2022
1988
  })
1989
+ return q
2023
1990
  }
2024
- this.logSearchDebug('search:filter', {
2025
- entity: search.entity,
2026
- field: search.field,
2027
- tokens: tokens.tokens,
2028
- hashes,
2029
- applied,
2030
- tenantId: search.tenantId ?? null,
2031
- organizationScope: search.organizationScope,
2032
- sources: sources.map((src) => ({ entity: src.entity, recordIdColumn: src.recordIdColumn })),
2033
- })
2034
- if (applied) return q
2035
1991
  } else {
2036
1992
  this.logSearchDebug('search:skip-empty-hashes', {
2037
- entity: search.entity,
2038
- field: search.field,
2039
- value: filter.value,
1993
+ entity: search.entity, field: search.field, value: filter.value,
2040
1994
  })
2041
1995
  }
2042
1996
  return q
2043
1997
  }
2044
- const col = column as any
1998
+ const col: any = column
2045
1999
  switch (filter.op) {
2046
- case 'eq':
2047
- return q.where(col, filter.value as Knex.Value)
2048
- case 'ne':
2049
- return q.whereNot(col, filter.value as Knex.Value)
2000
+ case 'eq': return q.where(col, '=', filter.value as any)
2001
+ case 'ne': return q.where(col, '!=', filter.value as any)
2050
2002
  case 'gt':
2051
2003
  case 'gte':
2052
2004
  case 'lt':
2053
2005
  case 'lte': {
2054
2006
  const operator = filter.op === 'gt' ? '>' : filter.op === 'gte' ? '>=' : filter.op === 'lt' ? '<' : '<='
2055
- return q.where(col, operator, filter.value as Knex.Value)
2056
- }
2057
- case 'in': {
2058
- const values = this.toArray(filter.value) as readonly Knex.Value[]
2059
- return q.whereIn(col, values)
2060
- }
2061
- case 'nin': {
2062
- const values = this.toArray(filter.value) as readonly Knex.Value[]
2063
- return q.whereNotIn(col, values)
2007
+ return q.where(col, operator, filter.value as any)
2064
2008
  }
2009
+ case 'in':
2010
+ return q.where(col, 'in', this.toArray(filter.value))
2011
+ case 'nin':
2012
+ return q.where(col, 'not in', this.toArray(filter.value))
2065
2013
  case 'like':
2066
- return q.where(col, 'like', filter.value as Knex.Value)
2014
+ return q.where(col, 'like', filter.value as any)
2067
2015
  case 'ilike':
2068
- return q.where(col, 'ilike', filter.value as Knex.Value)
2016
+ return q.where(col, 'ilike', filter.value as any)
2069
2017
  case 'exists':
2070
- return filter.value ? q.whereNotNull(col) : q.whereNull(col)
2018
+ return filter.value ? q.where(col, 'is not', null) : q.where(col, 'is', null)
2071
2019
  default:
2072
2020
  return q
2073
2021
  }
@@ -2120,10 +2068,7 @@ export class HybridQueryEngine implements QueryEngine {
2120
2068
  const baseCount = snapshot.baseCount
2121
2069
  const indexCount = snapshot.indexedCount
2122
2070
  const hasGap = baseCount > 0 && indexCount < baseCount
2123
- if (hasGap || indexCount > baseCount) {
2124
- return { stats: snapshot, scope: 'scoped' }
2125
- }
2126
-
2071
+ if (hasGap || indexCount > baseCount) return { stats: snapshot, scope: 'scoped' }
2127
2072
  return null
2128
2073
  }
2129
2074
 
@@ -2146,18 +2091,13 @@ export class HybridQueryEngine implements QueryEngine {
2146
2091
  ): Promise<TResult> {
2147
2092
  const shouldDebug = this.isSqlDebugEnabled() && this.isDebugVerbosity()
2148
2093
  const shouldProfile = profiler?.enabled === true
2149
- if (!shouldDebug && !shouldProfile) {
2150
- return Promise.resolve(execute())
2151
- }
2094
+ if (!shouldDebug && !shouldProfile) return Promise.resolve(execute())
2152
2095
  const startedAt = process.hrtime.bigint()
2153
2096
  try {
2154
2097
  return await Promise.resolve(execute())
2155
2098
  } finally {
2156
2099
  const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
2157
- const context: Record<string, unknown> = {
2158
- entity,
2159
- durationMs: Math.round(elapsedMs * 1000) / 1000,
2160
- }
2100
+ const context: Record<string, unknown> = { entity, durationMs: Math.round(elapsedMs * 1000) / 1000 }
2161
2101
  if (extra) Object.assign(context, extra)
2162
2102
  if (shouldProfile) profiler!.record(label, context.durationMs as number, extra)
2163
2103
  if (shouldDebug) this.debug(`${label}:timing`, context)