@open-mercato/core 0.6.8-develop.7029.1.a1bb3363af → 0.6.8-develop.7031.1.005201cd70

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 (236) hide show
  1. package/dist/modules/api_keys/backend/api-keys/page.js +3 -1
  2. package/dist/modules/api_keys/backend/api-keys/page.js.map +2 -2
  3. package/dist/modules/attachments/components/AttachmentLibrary.js +2 -0
  4. package/dist/modules/attachments/components/AttachmentLibrary.js.map +2 -2
  5. package/dist/modules/audit_logs/backend/audit-logs/page.js +6 -0
  6. package/dist/modules/audit_logs/backend/audit-logs/page.js.map +2 -2
  7. package/dist/modules/auth/backend/roles/page.js +3 -1
  8. package/dist/modules/auth/backend/roles/page.js.map +2 -2
  9. package/dist/modules/auth/backend/users/page.js +2 -1
  10. package/dist/modules/auth/backend/users/page.js.map +2 -2
  11. package/dist/modules/business_rules/backend/logs/page.js +3 -1
  12. package/dist/modules/business_rules/backend/logs/page.js.map +2 -2
  13. package/dist/modules/business_rules/backend/rules/page.js +3 -1
  14. package/dist/modules/business_rules/backend/rules/page.js.map +2 -2
  15. package/dist/modules/business_rules/backend/sets/page.js +3 -1
  16. package/dist/modules/business_rules/backend/sets/page.js.map +2 -2
  17. package/dist/modules/catalog/ai-agents.js +1 -1
  18. package/dist/modules/catalog/ai-agents.js.map +1 -1
  19. package/dist/modules/catalog/ai-tools/merchandising-pack.js +2 -2
  20. package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +2 -2
  21. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +5 -2
  22. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
  23. package/dist/modules/catalog/components/categories/CategoriesDataTable.js +2 -0
  24. package/dist/modules/catalog/components/categories/CategoriesDataTable.js.map +2 -2
  25. package/dist/modules/catalog/components/products/ProductsDataTable.js +7 -1
  26. package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
  27. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +2 -0
  28. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +2 -2
  29. package/dist/modules/communication_channels/backend/communication_channels/channels/page.js +3 -0
  30. package/dist/modules/communication_channels/backend/communication_channels/channels/page.js.map +2 -2
  31. package/dist/modules/configs/lib/system-status.js +9 -0
  32. package/dist/modules/configs/lib/system-status.js.map +2 -2
  33. package/dist/modules/currencies/backend/currencies/page.js +3 -1
  34. package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
  35. package/dist/modules/currencies/backend/exchange-rates/page.js +3 -1
  36. package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
  37. package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js +3 -1
  38. package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js.map +2 -2
  39. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js +3 -1
  40. package/dist/modules/customer_accounts/backend/customer_accounts/users/page.js.map +2 -2
  41. package/dist/modules/customers/ai-tools/companies-pack.js +4 -1
  42. package/dist/modules/customers/ai-tools/companies-pack.js.map +2 -2
  43. package/dist/modules/customers/ai-tools/deals-pack.js +4 -1
  44. package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
  45. package/dist/modules/customers/ai-tools/people-pack.js +4 -1
  46. package/dist/modules/customers/ai-tools/people-pack.js.map +2 -2
  47. package/dist/modules/customers/api/deals/map/route.js +2 -1
  48. package/dist/modules/customers/api/deals/map/route.js.map +2 -2
  49. package/dist/modules/customers/backend/customers/companies/page.js +3 -1
  50. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  51. package/dist/modules/customers/backend/customers/deals/page.js +5 -2
  52. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  53. package/dist/modules/customers/backend/customers/people/page.js +3 -1
  54. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  55. package/dist/modules/customers/components/CustomerTodosTable.js +1 -0
  56. package/dist/modules/customers/components/CustomerTodosTable.js.map +2 -2
  57. package/dist/modules/data_sync/backend/data-sync/page.js +3 -1
  58. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  59. package/dist/modules/devices/backend/devices/page.js +3 -1
  60. package/dist/modules/devices/backend/devices/page.js.map +2 -2
  61. package/dist/modules/directory/backend/directory/organizations/page.js +2 -1
  62. package/dist/modules/directory/backend/directory/organizations/page.js.map +2 -2
  63. package/dist/modules/directory/backend/directory/tenants/page.js +2 -1
  64. package/dist/modules/directory/backend/directory/tenants/page.js.map +2 -2
  65. package/dist/modules/entities/api/records.js +4 -2
  66. package/dist/modules/entities/api/records.js.map +2 -2
  67. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +3 -1
  68. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  69. package/dist/modules/eudr/backend/eudr/evidence-submissions/page.js +3 -0
  70. package/dist/modules/eudr/backend/eudr/evidence-submissions/page.js.map +2 -2
  71. package/dist/modules/eudr/backend/eudr/plots/page.js +3 -0
  72. package/dist/modules/eudr/backend/eudr/plots/page.js.map +2 -2
  73. package/dist/modules/eudr/backend/eudr/product-mappings/page.js +3 -0
  74. package/dist/modules/eudr/backend/eudr/product-mappings/page.js.map +2 -2
  75. package/dist/modules/eudr/backend/eudr/risk-assessments/page.js +3 -0
  76. package/dist/modules/eudr/backend/eudr/risk-assessments/page.js.map +2 -2
  77. package/dist/modules/eudr/backend/eudr/statements/page.js +3 -0
  78. package/dist/modules/eudr/backend/eudr/statements/page.js.map +2 -2
  79. package/dist/modules/feature_toggles/components/FeatureTogglesTable.js +1 -0
  80. package/dist/modules/feature_toggles/components/FeatureTogglesTable.js.map +2 -2
  81. package/dist/modules/feature_toggles/components/OverridesTable.js +1 -0
  82. package/dist/modules/feature_toggles/components/OverridesTable.js.map +2 -2
  83. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js +3 -0
  84. package/dist/modules/inbox_ops/backend/inbox-ops/log/page.js.map +2 -2
  85. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +3 -0
  86. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  87. package/dist/modules/messages/components/MessagesInboxPageClient.js +4 -1
  88. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  89. package/dist/modules/payment_gateways/backend/payment-gateways/page.js +3 -1
  90. package/dist/modules/payment_gateways/backend/payment-gateways/page.js.map +2 -2
  91. package/dist/modules/planner/backend/planner/availability-rulesets/page.js +3 -0
  92. package/dist/modules/planner/backend/planner/availability-rulesets/page.js.map +2 -2
  93. package/dist/modules/push_notifications/backend/push_notifications/page.js +3 -1
  94. package/dist/modules/push_notifications/backend/push_notifications/page.js.map +2 -2
  95. package/dist/modules/query_index/lib/engine.js +108 -48
  96. package/dist/modules/query_index/lib/engine.js.map +2 -2
  97. package/dist/modules/resources/backend/resources/resource-types/page.js +3 -1
  98. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  99. package/dist/modules/resources/backend/resources/resources/page.js +3 -1
  100. package/dist/modules/resources/backend/resources/resources/page.js.map +2 -2
  101. package/dist/modules/sales/backend/sales/channels/offers/page.js +3 -0
  102. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  103. package/dist/modules/sales/backend/sales/channels/page.js +3 -0
  104. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  105. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js +3 -0
  106. package/dist/modules/sales/components/channels/SalesChannelOffersPanel.js.map +2 -2
  107. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +4 -0
  108. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  109. package/dist/modules/staff/backend/staff/leave-requests/page.js +3 -0
  110. package/dist/modules/staff/backend/staff/leave-requests/page.js.map +2 -2
  111. package/dist/modules/staff/backend/staff/my-leave-requests/page.js +3 -0
  112. package/dist/modules/staff/backend/staff/my-leave-requests/page.js.map +2 -2
  113. package/dist/modules/staff/backend/staff/team-members/page.js +3 -0
  114. package/dist/modules/staff/backend/staff/team-members/page.js.map +2 -2
  115. package/dist/modules/staff/backend/staff/team-roles/page.js +3 -0
  116. package/dist/modules/staff/backend/staff/team-roles/page.js.map +2 -2
  117. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js +3 -0
  118. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js.map +2 -2
  119. package/dist/modules/staff/backend/staff/teams/page.js +3 -0
  120. package/dist/modules/staff/backend/staff/teams/page.js.map +2 -2
  121. package/dist/modules/staff/backend/staff/timesheets/projects/page.js +3 -0
  122. package/dist/modules/staff/backend/staff/timesheets/projects/page.js.map +2 -2
  123. package/dist/modules/warranty_claims/backend/page.js +3 -0
  124. package/dist/modules/warranty_claims/backend/page.js.map +2 -2
  125. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js +3 -0
  126. package/dist/modules/warranty_claims/backend/warranty_claims/registrations/page.js.map +2 -2
  127. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js +3 -0
  128. package/dist/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.js.map +2 -2
  129. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js +3 -0
  130. package/dist/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.js.map +2 -2
  131. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js +3 -0
  132. package/dist/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.js.map +2 -2
  133. package/dist/modules/wms/commands/inventory-actions.js +3 -0
  134. package/dist/modules/wms/commands/inventory-actions.js.map +2 -2
  135. package/dist/modules/wms/components/backend/WmsConfigurationPage.js +4 -0
  136. package/dist/modules/wms/components/backend/WmsConfigurationPage.js.map +2 -2
  137. package/dist/modules/wms/components/backend/WmsInventoryConsolePage.js +1 -0
  138. package/dist/modules/wms/components/backend/WmsInventoryConsolePage.js.map +2 -2
  139. package/dist/modules/wms/components/backend/WmsLotsListPage.js +1 -0
  140. package/dist/modules/wms/components/backend/WmsLotsListPage.js.map +2 -2
  141. package/dist/modules/wms/components/backend/inventoryMutationLoaders.js +27 -20
  142. package/dist/modules/wms/components/backend/inventoryMutationLoaders.js.map +2 -2
  143. package/dist/modules/workflows/backend/definitions/page.js +3 -1
  144. package/dist/modules/workflows/backend/definitions/page.js.map +2 -2
  145. package/dist/modules/workflows/backend/events/page.js +3 -1
  146. package/dist/modules/workflows/backend/events/page.js.map +2 -2
  147. package/dist/modules/workflows/backend/instances/page.js +3 -1
  148. package/dist/modules/workflows/backend/instances/page.js.map +2 -2
  149. package/dist/modules/workflows/backend/tasks/page.js +3 -1
  150. package/dist/modules/workflows/backend/tasks/page.js.map +2 -2
  151. package/package.json +7 -7
  152. package/src/modules/api_keys/backend/api-keys/page.tsx +4 -1
  153. package/src/modules/attachments/components/AttachmentLibrary.tsx +3 -0
  154. package/src/modules/audit_logs/backend/audit-logs/page.tsx +8 -0
  155. package/src/modules/auth/backend/roles/page.tsx +4 -2
  156. package/src/modules/auth/backend/users/page.tsx +3 -2
  157. package/src/modules/business_rules/backend/logs/page.tsx +4 -1
  158. package/src/modules/business_rules/backend/rules/page.tsx +4 -1
  159. package/src/modules/business_rules/backend/sets/page.tsx +4 -1
  160. package/src/modules/catalog/ai-agents.ts +1 -1
  161. package/src/modules/catalog/ai-tools/merchandising-pack.ts +2 -2
  162. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +12 -5
  163. package/src/modules/catalog/components/categories/CategoriesDataTable.tsx +3 -0
  164. package/src/modules/catalog/components/products/ProductsDataTable.tsx +7 -0
  165. package/src/modules/catalog/i18n/de.json +1 -0
  166. package/src/modules/catalog/i18n/en.json +1 -0
  167. package/src/modules/catalog/i18n/es.json +1 -0
  168. package/src/modules/catalog/i18n/ko.json +1 -0
  169. package/src/modules/catalog/i18n/pl.json +1 -0
  170. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +4 -0
  171. package/src/modules/communication_channels/backend/communication_channels/channels/page.tsx +4 -0
  172. package/src/modules/configs/i18n/de.json +2 -0
  173. package/src/modules/configs/i18n/en.json +2 -0
  174. package/src/modules/configs/i18n/es.json +2 -0
  175. package/src/modules/configs/i18n/ko.json +2 -0
  176. package/src/modules/configs/i18n/pl.json +2 -0
  177. package/src/modules/configs/lib/system-status.ts +9 -0
  178. package/src/modules/currencies/backend/currencies/page.tsx +4 -1
  179. package/src/modules/currencies/backend/exchange-rates/page.tsx +4 -1
  180. package/src/modules/customer_accounts/backend/customer_accounts/roles/page.tsx +4 -1
  181. package/src/modules/customer_accounts/backend/customer_accounts/users/page.tsx +4 -1
  182. package/src/modules/customers/ai-tools/companies-pack.ts +4 -1
  183. package/src/modules/customers/ai-tools/deals-pack.ts +4 -1
  184. package/src/modules/customers/ai-tools/people-pack.ts +4 -1
  185. package/src/modules/customers/api/deals/map/route.ts +1 -0
  186. package/src/modules/customers/backend/customers/companies/page.tsx +4 -1
  187. package/src/modules/customers/backend/customers/deals/page.tsx +8 -2
  188. package/src/modules/customers/backend/customers/people/page.tsx +4 -1
  189. package/src/modules/customers/components/CustomerTodosTable.tsx +2 -0
  190. package/src/modules/data_sync/backend/data-sync/page.tsx +4 -1
  191. package/src/modules/devices/backend/devices/page.tsx +4 -1
  192. package/src/modules/directory/backend/directory/organizations/page.tsx +3 -1
  193. package/src/modules/directory/backend/directory/tenants/page.tsx +3 -1
  194. package/src/modules/entities/api/records.ts +2 -0
  195. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +4 -1
  196. package/src/modules/eudr/backend/eudr/evidence-submissions/page.tsx +4 -0
  197. package/src/modules/eudr/backend/eudr/plots/page.tsx +4 -0
  198. package/src/modules/eudr/backend/eudr/product-mappings/page.tsx +4 -0
  199. package/src/modules/eudr/backend/eudr/risk-assessments/page.tsx +4 -0
  200. package/src/modules/eudr/backend/eudr/statements/page.tsx +4 -0
  201. package/src/modules/feature_toggles/components/FeatureTogglesTable.tsx +2 -1
  202. package/src/modules/feature_toggles/components/OverridesTable.tsx +2 -1
  203. package/src/modules/inbox_ops/backend/inbox-ops/log/page.tsx +4 -0
  204. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +4 -0
  205. package/src/modules/messages/components/MessagesInboxPageClient.tsx +4 -0
  206. package/src/modules/payment_gateways/backend/payment-gateways/page.tsx +4 -1
  207. package/src/modules/planner/backend/planner/availability-rulesets/page.tsx +4 -0
  208. package/src/modules/push_notifications/backend/push_notifications/page.tsx +4 -1
  209. package/src/modules/query_index/lib/engine.ts +156 -53
  210. package/src/modules/resources/backend/resources/resource-types/page.tsx +4 -1
  211. package/src/modules/resources/backend/resources/resources/page.tsx +4 -1
  212. package/src/modules/sales/backend/sales/channels/offers/page.tsx +4 -0
  213. package/src/modules/sales/backend/sales/channels/page.tsx +4 -0
  214. package/src/modules/sales/components/channels/SalesChannelOffersPanel.tsx +4 -0
  215. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +5 -0
  216. package/src/modules/staff/backend/staff/leave-requests/page.tsx +4 -0
  217. package/src/modules/staff/backend/staff/my-leave-requests/page.tsx +4 -0
  218. package/src/modules/staff/backend/staff/team-members/page.tsx +6 -0
  219. package/src/modules/staff/backend/staff/team-roles/page.tsx +4 -0
  220. package/src/modules/staff/backend/staff/teams/[id]/edit/page.tsx +4 -0
  221. package/src/modules/staff/backend/staff/teams/page.tsx +4 -0
  222. package/src/modules/staff/backend/staff/timesheets/projects/page.tsx +4 -0
  223. package/src/modules/warranty_claims/backend/page.tsx +4 -0
  224. package/src/modules/warranty_claims/backend/warranty_claims/registrations/page.tsx +4 -0
  225. package/src/modules/warranty_claims/backend/warranty_claims/troubleshooting-guides/page.tsx +4 -0
  226. package/src/modules/warranty_claims/backend/warranty_claims/vendor-policies/page.tsx +4 -0
  227. package/src/modules/warranty_claims/frontend/[orgSlug]/portal/claims/page.tsx +4 -0
  228. package/src/modules/wms/commands/inventory-actions.ts +3 -0
  229. package/src/modules/wms/components/backend/WmsConfigurationPage.tsx +5 -0
  230. package/src/modules/wms/components/backend/WmsInventoryConsolePage.tsx +2 -0
  231. package/src/modules/wms/components/backend/WmsLotsListPage.tsx +2 -0
  232. package/src/modules/wms/components/backend/inventoryMutationLoaders.ts +36 -20
  233. package/src/modules/workflows/backend/definitions/page.tsx +4 -1
  234. package/src/modules/workflows/backend/events/page.tsx +4 -1
  235. package/src/modules/workflows/backend/instances/page.tsx +4 -1
  236. package/src/modules/workflows/backend/tasks/page.tsx +4 -1
@@ -1,4 +1,4 @@
1
- import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, FilterOp, Filter, QueryCustomFieldSource, PartialIndexWarning, QueryExtensionsConfig, Sort } from '@open-mercato/shared/lib/query/types'
1
+ import type { QueryEngine, QueryOptions, QueryResult, QueryResultMeta, EncryptedSortRowCapWarning, ListCountCapWarning, FilterOp, Filter, QueryCustomFieldSource, PartialIndexWarning, QueryExtensionsConfig, Sort } from '@open-mercato/shared/lib/query/types'
2
2
  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'
@@ -34,6 +34,7 @@ import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
34
34
  import { runBeforeQueryPipeline, runAfterQueryPipeline, type QueryExtensionContext } from '@open-mercato/shared/lib/query/query-extension-runner'
35
35
  import { warnOnCiphertextLikeFallback } from '@open-mercato/shared/lib/query/ciphertext-search-warning'
36
36
  import { resolveEncryptedSortFields, resolveEncryptedSortMaxRows, sortRowsInMemory } from '@open-mercato/shared/lib/query/encrypted-sort'
37
+ import { resolveListCountCap } from '@open-mercato/shared/lib/query/count-cap'
37
38
  import { mapWithConcurrency } from '@open-mercato/shared/lib/query/bounded-decrypt'
38
39
  import { createLogger } from '@open-mercato/shared/lib/logger'
39
40
  import { parseNumberWithDefault } from '@open-mercato/shared/lib/number'
@@ -844,10 +845,9 @@ export class HybridQueryEngine implements QueryEngine {
844
845
  return next
845
846
  }
846
847
 
847
- // Also used by the optimized count path, which builds from a bare base table with
848
- // no index/custom-field joins. Emitting a cf predicate there would reference an
849
- // alias that query has never joined, so this stays safe only while
850
- // `canOptimizeCount` is false whenever any cf filter exists (see `hasCustomFieldFilters`).
848
+ // Also used by the count shape: cf leaves (and doc-based base leaves) reference
849
+ // the index rowset aliases, so any group containing one routes the whole
850
+ // disjunction inside the seeded-rowset EXISTS, where those aliases exist.
851
851
  const applyOrGroupedBaseFilters = (q: AnyBuilder): AnyBuilder => {
852
852
  if (orGroupFilters.length === 0 && orGroupCfFilters.length === 0) return q
853
853
  // `BaseFilter` here is just the normalized-leaf shape; the `cf` bucket holds
@@ -996,6 +996,101 @@ export class HybridQueryEngine implements QueryEngine {
996
996
  const hasCustomFieldFilters = cfFilters.length > 0
997
997
  const canOptimizeCount = !hasCustomFieldFilters && !hasNonBaseSearchSource
998
998
 
999
+ // ── Count shape (#4552 Phase 2) ─────────────────────────────────
1000
+ // The count query is rebuilt rather than derived from the display shape:
1001
+ // base scope + filters only. Predicates that read the left-joined index
1002
+ // rowset (cf filters, doc filters, or-groups with doc members, token
1003
+ // searches over joined sources) evaluate against the *same* rowset as the
1004
+ // display query, but confined inside a single correlated EXISTS built
1005
+ // from a one-row seed — so the per-base-row rowset is identical, yet a
1006
+ // semi-join cannot multiply base rows. The count therefore needs no
1007
+ // DISTINCT or GROUP BY, and an outer LIMIT actually bounds the scan (no
1008
+ // blocking aggregate between the LIMIT and the scan).
1009
+ const isRowsetDependentFilter = (filter: BaseFilter): boolean => {
1010
+ const baseField = resolveBaseColumn(String(filter.field))
1011
+ if (!baseField) return true
1012
+ // Token search may probe joined sources' record ids, which only exist
1013
+ // on the index rowset.
1014
+ if (
1015
+ (filter.op === 'like' || filter.op === 'ilike') &&
1016
+ searchRuntime.enabled &&
1017
+ typeof filter.value === 'string' &&
1018
+ hasNonBaseSearchSource
1019
+ ) return true
1020
+ return false
1021
+ }
1022
+ const rowsetRegularFilters = regularBaseFilters.filter((filter) => isRowsetDependentFilter(filter))
1023
+ const outerRegularFilters = regularBaseFilters.filter((filter) => !isRowsetDependentFilter(filter))
1024
+ // Or-groups are disjuncts of one $or, so they cannot be split between the
1025
+ // outer query and the rowset — one doc-dependent member moves them all in.
1026
+ const orGroupsRowsetDependent =
1027
+ orGroupCfFilters.length > 0 ||
1028
+ orGroupFilters.some((filter) => isRowsetDependentFilter(filter))
1029
+ const hasInnerTypedCfSource = preparedCfSources.some((source) =>
1030
+ ((opts.customFieldSources ?? []).find((s) => s && (s.alias ?? undefined) === source.alias)?.join?.type ?? 'left') === 'inner')
1031
+ const needsIndexRowset =
1032
+ hasCustomFieldFilters ||
1033
+ rowsetRegularFilters.length > 0 ||
1034
+ orGroupsRowsetDependent ||
1035
+ hasInnerTypedCfSource
1036
+
1037
+ const applyCountShape = async (q: AnyBuilder): Promise<AnyBuilder> => {
1038
+ let next = applyBaseScope(q)
1039
+ for (const filter of outerRegularFilters) {
1040
+ const baseField = resolveBaseColumn(String(filter.field))
1041
+ if (!baseField) continue
1042
+ next = this.applyColumnFilter(next, qualify(baseField), filter, {
1043
+ ...searchRuntime, entity, field: String(filter.field), recordIdColumn: qualify('id'),
1044
+ })
1045
+ }
1046
+ if (!orGroupsRowsetDependent) next = applyOrGroupedBaseFilters(next)
1047
+ if (needsIndexRowset) {
1048
+ // One seed row per base row, then the display query's own join
1049
+ // builders — the rowset inside the EXISTS is the display rowset.
1050
+ // Built from `db` (not the where-callback's expression builder),
1051
+ // matching applyJoinFilters' detached-subquery idiom; correlated
1052
+ // references to the outer base alias resolve at compile time.
1053
+ let rowset: AnyBuilder = db
1054
+ .selectFrom(sql`(select 1)`.as('om_count_seed'))
1055
+ .select(sql<number>`1`.as('one'))
1056
+ rowset = applyEntityIndexesJoin(rowset)
1057
+ rowset = applyCustomFieldSourceJoins(rowset)
1058
+ rowset = applyCfFilters(rowset)
1059
+ for (const filter of rowsetRegularFilters) {
1060
+ const baseField = resolveBaseColumn(String(filter.field))
1061
+ if (!baseField) {
1062
+ rowset = this.applyIndexDocFilterFromAlias(
1063
+ rowset, 'ei', entity, String(filter.field), filter.op, filter.value, qualify('id'), searchRuntime,
1064
+ )
1065
+ continue
1066
+ }
1067
+ rowset = this.applyColumnFilter(rowset, qualify(baseField), filter, {
1068
+ ...searchRuntime, entity, field: String(filter.field), recordIdColumn: qualify('id'),
1069
+ })
1070
+ }
1071
+ if (orGroupsRowsetDependent) rowset = applyOrGroupedBaseFilters(rowset)
1072
+ const capturedRowset = rowset
1073
+ next = next.where((eb: any) => eb.exists(capturedRowset))
1074
+ }
1075
+ next = await applyJoinFilters({
1076
+ db,
1077
+ baseTable,
1078
+ builder: next,
1079
+ joinMap,
1080
+ joinFilters,
1081
+ aliasTables,
1082
+ qualifyBase: (column) => qualify(column),
1083
+ applyAliasScope: async (target: any, alias: string) => applyAliasScopes(target as AnyBuilder, alias),
1084
+ applyFilterOp: (target, column, op, value) => applyJoinFilterOpFn(target as AnyBuilder, column, op, value),
1085
+ applyJoinFilterOp: async (target, filter, qualified, join) => {
1086
+ const applied = await applyJoinSearchFilterOp(target as AnyBuilder, filter, qualified, join)
1087
+ return { applied, builder: target }
1088
+ },
1089
+ columnExists: (tbl, column) => this.columnExists(tbl, column),
1090
+ })
1091
+ return next
1092
+ }
1093
+
999
1094
  // Selection (for data query)
1000
1095
  const selectFieldSet = new Set<string>((opts.fields && opts.fields.length) ? opts.fields.map(String) : Array.from(columns.keys()))
1001
1096
  if (requiresPlaintextSort) {
@@ -1056,33 +1151,23 @@ export class HybridQueryEngine implements QueryEngine {
1056
1151
  const pageSize = opts.page?.pageSize ?? 20
1057
1152
  const sqlDebugEnabled = this.isSqlDebugEnabled()
1058
1153
 
1059
- let total: number
1154
+ const countCap = resolveListCountCap()
1060
1155
 
1061
- if (canOptimizeCount) {
1062
- // Optimized count: apply only base-scope + regular filters + or-group filters (no index joins).
1063
- const optimizedRoot = db.selectFrom(`${baseTable} as b` as any)
1064
- let countCore = applyBaseScope(optimizedRoot)
1065
- countCore = applyRegularBaseFilters(countCore)
1066
- countCore = applyOrGroupedBaseFilters(countCore)
1067
- // joinFilters still need to be re-applied in the optimized path
1068
- countCore = await applyJoinFilters({
1069
- db,
1070
- baseTable,
1071
- builder: countCore,
1072
- joinMap,
1073
- joinFilters,
1074
- aliasTables,
1075
- qualifyBase: (column) => qualify(column),
1076
- applyAliasScope: async (target: any, alias: string) => applyAliasScopes(target as AnyBuilder, alias),
1077
- applyFilterOp: (target, column, op, value) => applyJoinFilterOpFn(target as AnyBuilder, column, op, value),
1078
- applyJoinFilterOp: async (target, filter, qualified, join) => {
1079
- const applied = await applyJoinSearchFilterOp(target as AnyBuilder, filter, qualified, join)
1080
- return { applied, builder: target }
1081
- },
1082
- columnExists: (tbl, column) => this.columnExists(tbl, column),
1083
- })
1084
- const sub = countCore.select(sql.ref(qualify('id')).as('id')).groupBy(qualify('id')).as('sq')
1085
- const countQuery = db.selectFrom(sub as any).select(sql<string>`count(*)`.as('count'))
1156
+ // Both count paths build the same rebuilt shape; for `canOptimizeCount`
1157
+ // queries `needsIndexRowset` is false, so the shape degenerates to
1158
+ // base-scope + filters exactly as the optimized path always had.
1159
+ // `wasOptimizable` carries NO control flow — it only labels the debug
1160
+ // timing so operators can keep telling the two shapes apart.
1161
+ // TODO(2026-08-12): remove `canOptimizeCount` once the shape convergence
1162
+ // has soaked (tracked in the #4552 spec as the Phase 2 follow-up).
1163
+ const runBoundedCount = async (wasOptimizable: boolean): Promise<{ total: number; warning?: ListCountCapWarning }> => {
1164
+ const countRoot = db.selectFrom(`${baseTable} as b` as any)
1165
+ const shape = await applyCountShape(countRoot)
1166
+ const countQuery = countCap !== null
1167
+ ? db
1168
+ .selectFrom(shape.select(sql<number>`1`.as('one')).limit(countCap + 1).as('om_count_probe') as any)
1169
+ .select(sql<string>`count(*)`.as('count'))
1170
+ : shape.select(sql<string>`count(*)`.as('count'))
1086
1171
  if (debugEnabled && sqlDebugEnabled) {
1087
1172
  const compiled = countQuery.compile()
1088
1173
  this.debug('query:sql:count', { entity, sql: compiled.sql, bindings: compiled.parameters })
@@ -1090,25 +1175,19 @@ export class HybridQueryEngine implements QueryEngine {
1090
1175
  const countRow = await this.captureSqlTiming(
1091
1176
  'query:sql:count', entity,
1092
1177
  () => countQuery.executeTakeFirst(),
1093
- { optimized: true }, profiler,
1178
+ { optimized: wasOptimizable }, profiler,
1094
1179
  )
1095
- total = this.parseCount(countRow)
1096
- } else {
1097
- const countRoot = db.selectFrom(`${baseTable} as b` as any)
1098
- const countBuilder = (await applyQueryShape(countRoot))
1099
- .select(sql<string>`count(distinct ${sql.ref(qualify('id'))})`.as('count'))
1100
- if (debugEnabled && sqlDebugEnabled) {
1101
- const compiled = countBuilder.compile()
1102
- this.debug('query:sql:count', { entity, sql: compiled.sql, bindings: compiled.parameters })
1180
+ const probed = this.parseCount(countRow)
1181
+ if (countCap !== null && probed > countCap) {
1182
+ return { total: countCap, warning: { entity, cap: countCap } }
1103
1183
  }
1104
- const countRow = await this.captureSqlTiming(
1105
- 'query:sql:count', entity,
1106
- () => countBuilder.executeTakeFirst(),
1107
- { optimized: false }, profiler,
1108
- )
1109
- total = this.parseCount(countRow)
1184
+ return { total: probed }
1110
1185
  }
1111
1186
 
1187
+ const counted = await runBoundedCount(canOptimizeCount)
1188
+ const total: number = counted.total
1189
+ const listCountCapWarning: ListCountCapWarning | undefined = counted.warning
1190
+
1112
1191
  const dekKeyCache = new Map<string | null, string | null>()
1113
1192
 
1114
1193
  const decryptRow = async (item: Record<string, unknown>): Promise<Record<string, unknown>> => {
@@ -1159,24 +1238,29 @@ export class HybridQueryEngine implements QueryEngine {
1159
1238
  const sortFieldNames = Array.from(new Set(['id', ...scopeFieldNames, ...resolvedSorts.map((s) => String(s.field))]))
1160
1239
  phase1 = applySelection(phase1, sortFieldNames)
1161
1240
  if (cap !== null) {
1162
- phase1 = phase1.limit(cap).orderBy(qualify('id'), 'asc' as any)
1241
+ // Probe one row past the cap: truncation is detected from the candidate
1242
+ // scan itself, not by comparing against `total` — which may itself be
1243
+ // capped (`OM_LIST_COUNT_CAP`) and would then never exceed the sort cap.
1244
+ phase1 = phase1.limit(cap + 1).orderBy(qualify('id'), 'asc' as any)
1163
1245
  }
1164
1246
  if (debugEnabled && sqlDebugEnabled) {
1165
1247
  const compiled = phase1.compile()
1166
1248
  this.debug('query:sql:data:phase1', { entity, sql: compiled.sql, bindings: compiled.parameters })
1167
1249
  }
1168
- const candidateRows = await this.captureSqlTiming(
1250
+ const candidateRowsRaw = await this.captureSqlTiming(
1169
1251
  'query:sql:data:phase1', entity,
1170
1252
  () => phase1.execute(),
1171
1253
  { phase: 1 }, profiler,
1172
1254
  ) as Record<string, unknown>[]
1255
+ const sortTruncated = cap !== null && candidateRowsRaw.length > cap
1256
+ const candidateRows = sortTruncated && cap !== null ? candidateRowsRaw.slice(0, cap) : candidateRowsRaw
1173
1257
  const decryptedCandidates = await mapWithConcurrency(candidateRows, DECRYPT_CONCURRENCY, decryptRow)
1174
1258
  const orderedCandidates = sortRowsInMemory(decryptedCandidates, resolvedSorts)
1175
1259
  const pageIds = orderedCandidates
1176
1260
  .slice((page - 1) * pageSize, page * pageSize)
1177
1261
  .map((row) => row.id)
1178
1262
 
1179
- if (cap !== null && total > cap) {
1263
+ if (sortTruncated && cap !== null) {
1180
1264
  encryptedSortRowCapWarning = {
1181
1265
  entity,
1182
1266
  sortFields: resolvedSorts.map((s) => String(s.field)),
@@ -1232,10 +1316,11 @@ export class HybridQueryEngine implements QueryEngine {
1232
1316
 
1233
1317
  const typedItems = items as unknown as T[]
1234
1318
  let result: QueryResult<T> = { items: typedItems, page, pageSize, total }
1235
- if (partialIndexWarning || encryptedSortRowCapWarning) {
1319
+ if (partialIndexWarning || encryptedSortRowCapWarning || listCountCapWarning) {
1236
1320
  const meta: QueryResultMeta = {}
1237
1321
  if (partialIndexWarning) meta.partialIndexWarning = partialIndexWarning
1238
1322
  if (encryptedSortRowCapWarning) meta.encryptedSortRowCapWarning = encryptedSortRowCapWarning
1323
+ if (listCountCapWarning) meta.listCountCapWarning = listCountCapWarning
1239
1324
  result.meta = meta
1240
1325
  }
1241
1326
 
@@ -2036,17 +2121,35 @@ export class HybridQueryEngine implements QueryEngine {
2036
2121
  const page = opts.page?.page ?? 1
2037
2122
  const pageSize = opts.page?.pageSize ?? 20
2038
2123
 
2124
+ // Single-table count: `applyScope` adds only WHERE predicates (cf filters
2125
+ // included — no join), and doc storage holds one row per record within a
2126
+ // scope, so `count(*)` needs no DISTINCT and — when the cap is active — a
2127
+ // LIMIT on the row-producing inner query bounds the scan (#4552 Phase 2).
2128
+ const countCap = resolveListCountCap()
2039
2129
  const root = db.selectFrom(`custom_entities_storage as ${alias}`)
2040
- const countQuery = applyScope(root).select(sql<string>`count(distinct ${sql.ref(`${alias}.entity_id`)})`.as('count'))
2130
+ const countShape = applyScope(root)
2131
+ const countQuery = countCap !== null
2132
+ ? db
2133
+ .selectFrom(countShape.select(sql<number>`1`.as('one')).limit(countCap + 1).as('om_count_probe') as any)
2134
+ .select(sql<string>`count(*)`.as('count'))
2135
+ : countShape.select(sql<string>`count(*)`.as('count'))
2041
2136
  const countRow = await countQuery.executeTakeFirst()
2042
- const total = this.parseCount(countRow)
2137
+ const probed = this.parseCount(countRow)
2138
+ let total = probed
2139
+ let listCountCapWarning: ListCountCapWarning | undefined
2140
+ if (countCap !== null && probed > countCap) {
2141
+ total = countCap
2142
+ listCountCapWarning = { entity: entity as EntityId, cap: countCap }
2143
+ }
2043
2144
 
2044
2145
  let dataQuery = applyScope(db.selectFrom(`custom_entities_storage as ${alias}`))
2045
2146
  dataQuery = applySelection(dataQuery)
2046
2147
  dataQuery = applySort(dataQuery)
2047
2148
  dataQuery = dataQuery.limit(pageSize).offset((page - 1) * pageSize)
2048
2149
  const items = await dataQuery.execute()
2049
- return { items, page, pageSize, total }
2150
+ const result: QueryResult<T> = { items, page, pageSize, total }
2151
+ if (listCountCapWarning) result.meta = { listCountCapWarning }
2152
+ return result
2050
2153
  }
2051
2154
 
2052
2155
  private async tableExists(table: string): Promise<boolean> {
@@ -45,6 +45,7 @@ type ResourceTypesResponse = {
45
45
  items?: Array<Record<string, unknown>>
46
46
  total?: number
47
47
  totalPages?: number
48
+ totalIsCapped?: boolean
48
49
  }
49
50
 
50
51
  type ResourceTypesMutationContext = {
@@ -63,6 +64,7 @@ export default function ResourcesResourceTypesPage() {
63
64
  const [page, setPage] = React.useState(1)
64
65
  const [total, setTotal] = React.useState(0)
65
66
  const [totalPages, setTotalPages] = React.useState(1)
67
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
66
68
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'name', desc: false }])
67
69
  const [search, setSearch] = React.useState('')
68
70
  const [isLoading, setIsLoading] = React.useState(true)
@@ -232,6 +234,7 @@ export default function ResourcesResourceTypesPage() {
232
234
  setRows(items.map(mapApiResourceType))
233
235
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
234
236
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : Math.max(1, Math.ceil(items.length / PAGE_SIZE)))
237
+ setTotalIsCapped(payload?.totalIsCapped === true)
235
238
  } catch (error) {
236
239
  logger.error('Failed to list resource types', { err: error })
237
240
  flash(translations.errors.load, 'error')
@@ -308,7 +311,7 @@ export default function ResourcesResourceTypesPage() {
308
311
  sortable
309
312
  sorting={sorting}
310
313
  onSortingChange={setSorting}
311
- pagination={{ page, pageSize: PAGE_SIZE, total, totalPages, onPageChange: setPage }}
314
+ pagination={{ page, pageSize: PAGE_SIZE, total, totalPages, totalIsCapped, onPageChange: setPage }}
312
315
  rowActions={(row) => (
313
316
  <RowActions
314
317
  items={[
@@ -63,6 +63,7 @@ type ResourcesResponse = {
63
63
  total: number
64
64
  page: number
65
65
  totalPages: number
66
+ totalIsCapped?: boolean
66
67
  }
67
68
 
68
69
  type ResourceTypesResponse = {
@@ -81,6 +82,7 @@ export default function ResourcesResourcesPage() {
81
82
  const [page, setPage] = React.useState(1)
82
83
  const [total, setTotal] = React.useState(0)
83
84
  const [totalPages, setTotalPages] = React.useState(1)
85
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
84
86
  const [search, setSearch] = React.useState('')
85
87
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
86
88
  const [isLoading, setIsLoading] = React.useState(true)
@@ -360,6 +362,7 @@ export default function ResourcesResourcesPage() {
360
362
  setRows(mapped)
361
363
  setTotal(payload.total || 0)
362
364
  setTotalPages(payload.totalPages || 1)
365
+ setTotalIsCapped(payload?.totalIsCapped === true)
363
366
  }
364
367
  } catch (error) {
365
368
  if (!cancelled) {
@@ -546,7 +549,7 @@ export default function ResourcesResourcesPage() {
546
549
  createLabel={t('resources.resources.list.actions.create', 'New resource')}
547
550
  />
548
551
  )}
549
- pagination={{ page, pageSize: PAGE_SIZE, total, totalPages, onPageChange: setPage }}
552
+ pagination={{ page, pageSize: PAGE_SIZE, total, totalPages, totalIsCapped, onPageChange: setPage }}
550
553
  isLoading={isLoading}
551
554
  />
552
555
  </PageBody>
@@ -29,6 +29,7 @@ type OffersResponse = {
29
29
  items?: Array<Record<string, unknown>>
30
30
  total?: number
31
31
  totalPages?: number
32
+ totalIsCapped?: boolean
32
33
  }
33
34
 
34
35
  const PAGE_SIZE = 25
@@ -61,6 +62,7 @@ export default function SalesChannelOffersListPage() {
61
62
  const [page, setPage] = React.useState(1)
62
63
  const [total, setTotal] = React.useState(0)
63
64
  const [totalPages, setTotalPages] = React.useState(1)
65
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
64
66
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'updatedAt', desc: true }])
65
67
  const [search, setSearch] = React.useState('')
66
68
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
@@ -271,6 +273,7 @@ export default function SalesChannelOffersListPage() {
271
273
  setRows(mapped)
272
274
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
273
275
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : Math.max(1, Math.ceil(items.length / PAGE_SIZE)))
276
+ setTotalIsCapped(payload?.totalIsCapped === true)
274
277
  const ids = mapped
275
278
  .map((row) => row.channelId)
276
279
  .filter((value): value is string => typeof value === 'string' && value.length > 0)
@@ -362,6 +365,7 @@ export default function SalesChannelOffersListPage() {
362
365
  pageSize: PAGE_SIZE,
363
366
  total,
364
367
  totalPages,
368
+ totalIsCapped,
365
369
  onPageChange: setPage,
366
370
  }}
367
371
  refreshButton={{
@@ -37,6 +37,7 @@ type ChannelsResponse = {
37
37
  items?: Array<Record<string, unknown>>
38
38
  total?: number
39
39
  totalPages?: number
40
+ totalIsCapped?: boolean
40
41
  }
41
42
 
42
43
  const PAGE_SIZE = 25
@@ -68,6 +69,7 @@ export default function SalesChannelsPage() {
68
69
  const [page, setPage] = React.useState(1)
69
70
  const [total, setTotal] = React.useState(0)
70
71
  const [totalPages, setTotalPages] = React.useState(1)
72
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
71
73
  const [sorting, setSorting] = React.useState<SortingState>([])
72
74
  const [search, setSearch] = React.useState('')
73
75
  const [isLoading, setLoading] = React.useState(true)
@@ -142,6 +144,7 @@ export default function SalesChannelsPage() {
142
144
  setRows(items.map(mapApiChannel))
143
145
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
144
146
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : Math.max(1, Math.ceil(items.length / PAGE_SIZE)))
147
+ setTotalIsCapped(payload?.totalIsCapped === true)
145
148
  } catch (err) {
146
149
  logger.error('sales.channels.list', { err })
147
150
  flash(t('sales.channels.table.errors.load', 'Failed to load channels.'), 'error')
@@ -230,6 +233,7 @@ export default function SalesChannelsPage() {
230
233
  pageSize: PAGE_SIZE,
231
234
  total,
232
235
  totalPages,
236
+ totalIsCapped,
233
237
  onPageChange: setPage,
234
238
  }}
235
239
  refreshButton={{
@@ -25,6 +25,7 @@ type OffersResponse = {
25
25
  items?: Array<Record<string, unknown>>
26
26
  total?: number
27
27
  totalPages?: number
28
+ totalIsCapped?: boolean
28
29
  }
29
30
 
30
31
  const PAGE_SIZE = 25
@@ -54,6 +55,7 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
54
55
  const [page, setPage] = React.useState(1)
55
56
  const [total, setTotal] = React.useState(0)
56
57
  const [totalPages, setTotalPages] = React.useState(1)
58
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
57
59
  const [search, setSearch] = React.useState('')
58
60
  const [isLoading, setLoading] = React.useState(true)
59
61
  const [sorting, setSorting] = React.useState<SortingState>([])
@@ -134,6 +136,7 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
134
136
  setRows(items.map(mapOfferRow))
135
137
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
136
138
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : Math.max(1, Math.ceil(items.length / PAGE_SIZE)))
139
+ setTotalIsCapped(payload?.totalIsCapped === true)
137
140
  } catch (err) {
138
141
  logger.error('sales.channels.offers', { err })
139
142
  flash(t('sales.channels.offers.errors.load', 'Failed to load offers.'), 'error')
@@ -208,6 +211,7 @@ export function SalesChannelOffersPanel({ channelId, channelName }: { channelId:
208
211
  pageSize: PAGE_SIZE,
209
212
  total,
210
213
  totalPages,
214
+ totalIsCapped,
211
215
  onPageChange: setPage,
212
216
  }}
213
217
  refreshButton={{
@@ -71,6 +71,7 @@ type DocumentsResponse = {
71
71
  items?: Array<Record<string, unknown>>
72
72
  total?: number
73
73
  totalPages?: number
74
+ totalIsCapped?: boolean
74
75
  }
75
76
 
76
77
  type SalesDocumentRow = {
@@ -173,6 +174,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
173
174
  const [page, setPage] = React.useState(1)
174
175
  const [total, setTotal] = React.useState(0)
175
176
  const [totalPages, setTotalPages] = React.useState(1)
177
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
176
178
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'createdAt', desc: true }])
177
179
  const [search, setSearch] = React.useState('')
178
180
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
@@ -564,6 +566,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
564
566
  setRows([])
565
567
  setTotal(0)
566
568
  setTotalPages(1)
569
+ setTotalIsCapped(false)
567
570
  return
568
571
  }
569
572
  const payload = call.result ?? {}
@@ -575,6 +578,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
575
578
  ? payload.totalPages
576
579
  : Math.max(1, Math.ceil(count / PAGE_SIZE))
577
580
  setTotalPages(pages)
581
+ setTotalIsCapped(payload.totalIsCapped === true)
578
582
  setCacheStatus(call.cacheStatus ?? null)
579
583
  } catch (err) {
580
584
  logger.error('sales.documents.list', { err })
@@ -800,6 +804,7 @@ export function SalesDocumentsTable({ kind }: { kind: SalesDocumentKind }) {
800
804
  pageSize: PAGE_SIZE,
801
805
  total,
802
806
  totalPages,
807
+ totalIsCapped,
803
808
  onPageChange: setPage,
804
809
  cacheStatus,
805
810
  }}
@@ -30,6 +30,7 @@ type LeaveRequestsResponse = {
30
30
  items?: Array<Record<string, unknown>>
31
31
  total?: number
32
32
  totalPages?: number
33
+ totalIsCapped?: boolean
33
34
  }
34
35
 
35
36
  export default function StaffLeaveRequestsPage() {
@@ -40,6 +41,7 @@ export default function StaffLeaveRequestsPage() {
40
41
  const [page, setPage] = React.useState(1)
41
42
  const [total, setTotal] = React.useState(0)
42
43
  const [totalPages, setTotalPages] = React.useState(1)
44
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
43
45
  const [search, setSearch] = React.useState('')
44
46
  const [isLoading, setIsLoading] = React.useState(true)
45
47
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'startDate', desc: true }])
@@ -123,6 +125,7 @@ export default function StaffLeaveRequestsPage() {
123
125
  setRows(items.map(mapLeaveRequest))
124
126
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
125
127
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : 1)
128
+ setTotalIsCapped(payload?.totalIsCapped === true)
126
129
  } catch {
127
130
  setRows([])
128
131
  setTotal(0)
@@ -177,6 +180,7 @@ export default function StaffLeaveRequestsPage() {
177
180
  pageSize: PAGE_SIZE,
178
181
  total,
179
182
  totalPages,
183
+ totalIsCapped,
180
184
  onPageChange: setPage,
181
185
  }}
182
186
  onRowClick={(row) => {
@@ -29,6 +29,7 @@ type LeaveRequestsResponse = {
29
29
  total?: number
30
30
  totalPages?: number
31
31
  viewer?: { memberId?: string | null; canSend?: boolean }
32
+ totalIsCapped?: boolean
32
33
  }
33
34
 
34
35
  export default function StaffMyLeaveRequestsPage() {
@@ -39,6 +40,7 @@ export default function StaffMyLeaveRequestsPage() {
39
40
  const [page, setPage] = React.useState(1)
40
41
  const [total, setTotal] = React.useState(0)
41
42
  const [totalPages, setTotalPages] = React.useState(1)
43
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
42
44
  const [search, setSearch] = React.useState('')
43
45
  const [isLoading, setIsLoading] = React.useState(true)
44
46
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'startDate', desc: true }])
@@ -118,6 +120,7 @@ export default function StaffMyLeaveRequestsPage() {
118
120
  setRows(items.map(mapLeaveRequest))
119
121
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
120
122
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : 1)
123
+ setTotalIsCapped(payload?.totalIsCapped === true)
121
124
  const viewerMemberId = typeof payload.viewer?.memberId === 'string' ? payload.viewer.memberId : null
122
125
  setMemberId(viewerMemberId)
123
126
  setCanSend(payload.viewer?.canSend === true)
@@ -182,6 +185,7 @@ export default function StaffMyLeaveRequestsPage() {
182
185
  pageSize: PAGE_SIZE,
183
186
  total,
184
187
  totalPages,
188
+ totalIsCapped,
185
189
  onPageChange: setPage,
186
190
  }}
187
191
  onRowClick={(row) => {
@@ -47,6 +47,7 @@ type TeamMembersResponse = {
47
47
  items?: Array<Record<string, unknown>>
48
48
  total?: number
49
49
  totalPages?: number
50
+ totalIsCapped?: boolean
50
51
  }
51
52
 
52
53
  type TeamsResponse = {
@@ -68,6 +69,7 @@ export default function StaffTeamMembersPage() {
68
69
  const [page, setPage] = React.useState(1)
69
70
  const [total, setTotal] = React.useState(0)
70
71
  const [totalPages, setTotalPages] = React.useState(1)
72
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
71
73
  const [sorting, setSorting] = React.useState<SortingState>([{ id: 'displayName', desc: false }])
72
74
  const [search, setSearch] = React.useState('')
73
75
  const [isLoading, setIsLoading] = React.useState(true)
@@ -272,6 +274,9 @@ export default function StaffTeamMembersPage() {
272
274
  : typeof payload.totalPages === 'number'
273
275
  ? payload.totalPages
274
276
  : Math.max(1, Math.ceil(items.length / PAGE_SIZE)))
277
+ // A role filter is applied client-side over the fetched page, so the total
278
+ // shown is that array's length — exact by construction, never a capped floor.
279
+ setTotalIsCapped(!roleFilterApplied && payload.totalIsCapped === true)
275
280
  } catch (error) {
276
281
  logger.error('staff.team-members.list', { err: error })
277
282
  flash(labels.errors.load, 'error')
@@ -452,6 +457,7 @@ export default function StaffTeamMembersPage() {
452
457
  pageSize: PAGE_SIZE,
453
458
  total,
454
459
  totalPages,
460
+ totalIsCapped,
455
461
  onPageChange: setPage,
456
462
  }}
457
463
  rowActions={(row) => row.kind === 'member' ? (
@@ -41,6 +41,7 @@ type TeamRolesResponse = {
41
41
  items?: Array<Record<string, unknown>>
42
42
  total?: number
43
43
  totalPages?: number
44
+ totalIsCapped?: boolean
44
45
  }
45
46
 
46
47
  type TeamsResponse = {
@@ -56,6 +57,7 @@ export default function StaffTeamRolesPage() {
56
57
  const [page, setPage] = React.useState(1)
57
58
  const [total, setTotal] = React.useState(0)
58
59
  const [totalPages, setTotalPages] = React.useState(1)
60
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
59
61
  const [search, setSearch] = React.useState('')
60
62
  const [isLoading, setIsLoading] = React.useState(true)
61
63
  const [reloadToken, setReloadToken] = React.useState(0)
@@ -203,6 +205,7 @@ export default function StaffTeamRolesPage() {
203
205
  setRows(buildTeamRoleRows(items.map(mapApiTeamRole), labels.groups.unassigned))
204
206
  setTotal(typeof payload.total === 'number' ? payload.total : items.length)
205
207
  setTotalPages(typeof payload.totalPages === 'number' ? payload.totalPages : Math.max(1, Math.ceil(items.length / PAGE_SIZE)))
208
+ setTotalIsCapped(payload?.totalIsCapped === true)
206
209
  } catch (error) {
207
210
  logger.error('staff.team-roles.list', { err: error })
208
211
  flash(labels.errors.load, 'error')
@@ -329,6 +332,7 @@ export default function StaffTeamRolesPage() {
329
332
  pageSize: PAGE_SIZE,
330
333
  total,
331
334
  totalPages,
335
+ totalIsCapped,
332
336
  onPageChange: setPage,
333
337
  }}
334
338
  rowActions={(row) => row.kind === 'role' ? (