@open-mercato/core 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6784.1.f80b9afce5

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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +3 -3
  3. package/agentic/standalone-guide.md +13 -0
  4. package/dist/generated/entities/organization/index.js +2 -0
  5. package/dist/generated/entities/organization/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +1 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/auth/cli.js +1 -1
  9. package/dist/modules/auth/cli.js.map +2 -2
  10. package/dist/modules/auth/lib/backendChrome.js +4 -6
  11. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  12. package/dist/modules/auth/lib/setup-app.js +1 -0
  13. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  14. package/dist/modules/auth/services/rbacService.js +29 -44
  15. package/dist/modules/auth/services/rbacService.js.map +2 -2
  16. package/dist/modules/catalog/widgets/injection/product-seo/widget.js +1 -1
  17. package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
  18. package/dist/modules/communication_channels/lib/access-control.js +2 -2
  19. package/dist/modules/communication_channels/lib/access-control.js.map +2 -2
  20. package/dist/modules/customer_accounts/api/invitations/accept.js +1 -2
  21. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  22. package/dist/modules/customer_accounts/api/login.js +4 -2
  23. package/dist/modules/customer_accounts/api/login.js.map +2 -2
  24. package/dist/modules/customer_accounts/api/magic-link/verify.js +4 -2
  25. package/dist/modules/customer_accounts/api/magic-link/verify.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/portal/feature-check.js +5 -4
  27. package/dist/modules/customer_accounts/api/portal/feature-check.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/nav.js +6 -4
  29. package/dist/modules/customer_accounts/api/portal/nav.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/portal/profile.js +5 -1
  31. package/dist/modules/customer_accounts/api/portal/profile.js.map +2 -2
  32. package/dist/modules/customer_accounts/api/portal/sessions-refresh.js +3 -3
  33. package/dist/modules/customer_accounts/api/portal/sessions-refresh.js.map +2 -2
  34. package/dist/modules/customer_accounts/lib/customerAuth.js +7 -2
  35. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  36. package/dist/modules/customer_accounts/lib/customerAuthServer.js +2 -1
  37. package/dist/modules/customer_accounts/lib/customerAuthServer.js.map +2 -2
  38. package/dist/modules/customer_accounts/services/customerRbacService.js +12 -3
  39. package/dist/modules/customer_accounts/services/customerRbacService.js.map +2 -2
  40. package/dist/modules/customer_accounts/setup.js +6 -0
  41. package/dist/modules/customer_accounts/setup.js.map +2 -2
  42. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +1 -1
  43. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +2 -2
  44. package/dist/modules/customers/lib/visibilityFilter.js +6 -4
  45. package/dist/modules/customers/lib/visibilityFilter.js.map +2 -2
  46. package/dist/modules/dashboards/api/layout/[itemId]/route.js +6 -3
  47. package/dist/modules/dashboards/api/layout/[itemId]/route.js.map +2 -2
  48. package/dist/modules/dashboards/api/layout/route.js +22 -9
  49. package/dist/modules/dashboards/api/layout/route.js.map +2 -2
  50. package/dist/modules/dashboards/api/roles/widgets/route.js +9 -3
  51. package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
  52. package/dist/modules/dashboards/api/users/widgets/route.js +9 -3
  53. package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
  54. package/dist/modules/dashboards/api/widgets/catalog.js +6 -3
  55. package/dist/modules/dashboards/api/widgets/catalog.js.map +2 -2
  56. package/dist/modules/dashboards/lib/access.js +5 -3
  57. package/dist/modules/dashboards/lib/access.js.map +2 -2
  58. package/dist/modules/directory/api/organization-branding/route.js +10 -3
  59. package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
  60. package/dist/modules/directory/api/organizations/route.js +7 -0
  61. package/dist/modules/directory/api/organizations/route.js.map +3 -3
  62. package/dist/modules/directory/backend/directory/branding/page.js +31 -8
  63. package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
  64. package/dist/modules/directory/commands/organizations.js +8 -1
  65. package/dist/modules/directory/commands/organizations.js.map +2 -2
  66. package/dist/modules/directory/data/entities.js +4 -0
  67. package/dist/modules/directory/data/entities.js.map +2 -2
  68. package/dist/modules/directory/data/validators.js +2 -0
  69. package/dist/modules/directory/data/validators.js.map +2 -2
  70. package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js +13 -0
  71. package/dist/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.js.map +7 -0
  72. package/dist/modules/entities/lib/entityAcl.js +47 -15
  73. package/dist/modules/entities/lib/entityAcl.js.map +3 -3
  74. package/dist/modules/inbox_ops/ai-tools.js +5 -2
  75. package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
  76. package/dist/modules/messages/lib/routeHelpers.js +1 -3
  77. package/dist/modules/messages/lib/routeHelpers.js.map +2 -2
  78. package/dist/modules/notifications/lib/notificationRecipients.js +6 -7
  79. package/dist/modules/notifications/lib/notificationRecipients.js.map +2 -2
  80. package/dist/modules/query_index/di.js +3 -0
  81. package/dist/modules/query_index/di.js.map +2 -2
  82. package/dist/modules/query_index/lib/coverage.js +2 -2
  83. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  84. package/dist/modules/query_index/lib/indexer.js +83 -17
  85. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  86. package/dist/modules/query_index/lib/search-tokens.js +13 -7
  87. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  88. package/dist/modules/query_index/subscribers/coverage_refresh.js +4 -0
  89. package/dist/modules/query_index/subscribers/coverage_refresh.js.map +2 -2
  90. package/dist/modules/query_index/subscribers/delete_one.js +53 -1
  91. package/dist/modules/query_index/subscribers/delete_one.js.map +3 -3
  92. package/dist/modules/query_index/subscribers/upsert_one.js +60 -0
  93. package/dist/modules/query_index/subscribers/upsert_one.js.map +3 -3
  94. package/dist/modules/sales/commands/documents.js +17 -0
  95. package/dist/modules/sales/commands/documents.js.map +2 -2
  96. package/dist/modules/staff/api/interceptors.js +5 -3
  97. package/dist/modules/staff/api/interceptors.js.map +2 -2
  98. package/dist/modules/sync_excel/api/upload/route.js +15 -2
  99. package/dist/modules/sync_excel/api/upload/route.js.map +2 -2
  100. package/dist/modules/workflows/lib/activity-executor.js +7 -7
  101. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  102. package/generated/entities/organization/index.ts +1 -0
  103. package/generated/entity-fields-registry.ts +1 -0
  104. package/package.json +7 -7
  105. package/src/modules/auth/AGENTS.md +5 -3
  106. package/src/modules/auth/cli.ts +1 -1
  107. package/src/modules/auth/lib/backendChrome.tsx +5 -11
  108. package/src/modules/auth/lib/setup-app.ts +1 -0
  109. package/src/modules/auth/services/rbacService.ts +41 -46
  110. package/src/modules/catalog/widgets/injection/product-seo/widget.ts +1 -1
  111. package/src/modules/communication_channels/lib/access-control.ts +3 -3
  112. package/src/modules/customer_accounts/AGENTS.md +3 -3
  113. package/src/modules/customer_accounts/api/invitations/accept.ts +1 -2
  114. package/src/modules/customer_accounts/api/login.ts +4 -2
  115. package/src/modules/customer_accounts/api/magic-link/verify.ts +4 -2
  116. package/src/modules/customer_accounts/api/portal/feature-check.ts +5 -7
  117. package/src/modules/customer_accounts/api/portal/nav.ts +5 -3
  118. package/src/modules/customer_accounts/api/portal/profile.ts +5 -1
  119. package/src/modules/customer_accounts/api/portal/sessions-refresh.ts +3 -3
  120. package/src/modules/customer_accounts/lib/customerAuth.ts +8 -2
  121. package/src/modules/customer_accounts/lib/customerAuthServer.ts +1 -0
  122. package/src/modules/customer_accounts/services/customerRbacService.ts +16 -3
  123. package/src/modules/customer_accounts/setup.ts +7 -0
  124. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +1 -3
  125. package/src/modules/customers/lib/visibilityFilter.ts +7 -5
  126. package/src/modules/dashboards/api/layout/[itemId]/route.ts +6 -3
  127. package/src/modules/dashboards/api/layout/route.ts +22 -9
  128. package/src/modules/dashboards/api/roles/widgets/route.ts +9 -3
  129. package/src/modules/dashboards/api/users/widgets/route.ts +9 -3
  130. package/src/modules/dashboards/api/widgets/catalog.ts +6 -3
  131. package/src/modules/dashboards/lib/access.ts +5 -3
  132. package/src/modules/directory/api/organization-branding/route.ts +12 -1
  133. package/src/modules/directory/api/organizations/route.ts +7 -0
  134. package/src/modules/directory/backend/directory/branding/page.tsx +34 -9
  135. package/src/modules/directory/commands/organizations.ts +9 -1
  136. package/src/modules/directory/data/entities.ts +3 -0
  137. package/src/modules/directory/data/validators.ts +2 -0
  138. package/src/modules/directory/i18n/de.json +3 -1
  139. package/src/modules/directory/i18n/en.json +3 -1
  140. package/src/modules/directory/i18n/es.json +3 -1
  141. package/src/modules/directory/i18n/pl.json +3 -1
  142. package/src/modules/directory/migrations/.snapshot-open-mercato.json +18 -1
  143. package/src/modules/directory/migrations/Migration20260626145500_directory_logo_preserve_aspect_ratio.ts +13 -0
  144. package/src/modules/entities/lib/entityAcl.ts +47 -18
  145. package/src/modules/inbox_ops/ai-tools.ts +5 -3
  146. package/src/modules/messages/lib/routeHelpers.ts +4 -6
  147. package/src/modules/notifications/lib/notificationRecipients.ts +6 -8
  148. package/src/modules/query_index/di.ts +6 -0
  149. package/src/modules/query_index/lib/coverage.ts +9 -6
  150. package/src/modules/query_index/lib/indexer.ts +105 -19
  151. package/src/modules/query_index/lib/search-tokens.ts +19 -8
  152. package/src/modules/query_index/subscribers/coverage_refresh.ts +4 -0
  153. package/src/modules/query_index/subscribers/delete_one.ts +65 -1
  154. package/src/modules/query_index/subscribers/upsert_one.ts +68 -1
  155. package/src/modules/sales/commands/documents.ts +27 -0
  156. package/src/modules/sales/i18n/de.json +1 -0
  157. package/src/modules/sales/i18n/en.json +1 -0
  158. package/src/modules/sales/i18n/es.json +1 -0
  159. package/src/modules/sales/i18n/pl.json +1 -0
  160. package/src/modules/staff/api/interceptors.ts +6 -4
  161. package/src/modules/sync_excel/api/upload/route.ts +15 -2
  162. package/src/modules/workflows/lib/activity-executor.ts +12 -10
@@ -1,7 +1,7 @@
1
1
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
2
- import { hasAllFeatures } from '@open-mercato/shared/security/features'
3
2
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
4
3
  import { getModules } from '@open-mercato/shared/lib/i18n/server'
4
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
5
5
  import { deriveCustomEntityRecordFeature } from './recordFeatures'
6
6
 
7
7
  export type EntityAclRequirement = {
@@ -102,7 +102,10 @@ export function canReadAllEntityMetadata(acl: {
102
102
  isSuperAdmin?: boolean
103
103
  features?: readonly string[]
104
104
  }): boolean {
105
- return Boolean(acl.isSuperAdmin) || hasAllFeatures(acl.features, ['entities.definitions.view'])
105
+ return authorizeFeatures(['entities.definitions.view'], {
106
+ grantedFeatures: acl.features ?? [],
107
+ unrestricted: Boolean(acl.isSuperAdmin),
108
+ })
106
109
  }
107
110
 
108
111
  export function canReadEntityMetadata(args: {
@@ -111,20 +114,29 @@ export function canReadEntityMetadata(args: {
111
114
  isRestricted?: boolean
112
115
  acl: { isSuperAdmin?: boolean; features?: readonly string[] }
113
116
  }): boolean {
114
- if (args.acl.isSuperAdmin) return true
115
-
116
117
  const requirement = resolveEntityAclRequirement(args.entityId)
117
- if (requirement?.platformOnly) return false
118
+ if (requirement?.platformOnly) {
119
+ return Boolean(args.acl.isSuperAdmin) && authorizeFeatures(requirement.view, {
120
+ grantedFeatures: args.acl.features ?? [],
121
+ unrestricted: true,
122
+ })
123
+ }
118
124
  if (canReadAllEntityMetadata(args.acl)) return true
119
125
  if (args.isCustomEntity) {
120
126
  const requiredFeatures = ['entities.records.view']
121
127
  if (args.isRestricted) {
122
128
  requiredFeatures.push(deriveCustomEntityRecordFeature(args.entityId, 'view'))
123
129
  }
124
- return hasAllFeatures(args.acl.features, requiredFeatures)
130
+ return authorizeFeatures(requiredFeatures, {
131
+ grantedFeatures: args.acl.features ?? [],
132
+ unrestricted: Boolean(args.acl.isSuperAdmin),
133
+ })
125
134
  }
126
- if (!requirement) return false
127
- return hasAllFeatures(args.acl.features, requirement.view)
135
+ if (!requirement) return Boolean(args.acl.isSuperAdmin)
136
+ return authorizeFeatures(requirement.view, {
137
+ grantedFeatures: args.acl.features ?? [],
138
+ unrestricted: Boolean(args.acl.isSuperAdmin),
139
+ })
128
140
  }
129
141
 
130
142
  type EntityAclActor = {
@@ -163,11 +175,16 @@ export async function assertEntityAclForRequest(args: AssertEntityAclArgs): Prom
163
175
  // entities.records.view/.manage route guard is the whole authorization.
164
176
  if (!args.isRestricted) return
165
177
 
166
- const acl = await loadActorAcl(args)
167
- if (acl?.isSuperAdmin) return
168
-
169
178
  const required = deriveCustomEntityRecordFeature(args.entityId, args.action)
170
- if (!hasAllFeatures(acl?.features, [required])) {
179
+ const allowed = await args.rbac.userHasAllFeatures(
180
+ args.auth.sub ?? '',
181
+ [required],
182
+ {
183
+ tenantId: args.auth.tenantId ?? null,
184
+ organizationId: args.auth.orgId ?? null,
185
+ },
186
+ )
187
+ if (!allowed) {
171
188
  throw forbiddenEntityAccess()
172
189
  }
173
190
  return
@@ -175,14 +192,26 @@ export async function assertEntityAclForRequest(args: AssertEntityAclArgs): Prom
175
192
 
176
193
  const requirement = resolveEntityAclRequirement(args.entityId)
177
194
 
178
- const acl = await loadActorAcl(args)
179
-
180
- if (acl?.isSuperAdmin) return
195
+ if (!requirement) {
196
+ const acl = await loadActorAcl(args)
197
+ if (!acl.isSuperAdmin) throw forbiddenEntityAccess()
198
+ return
199
+ }
181
200
 
182
- if (!requirement) throw forbiddenEntityAccess()
183
- if (requirement.platformOnly) throw forbiddenEntityAccess()
201
+ if (requirement.platformOnly) {
202
+ const acl = await loadActorAcl(args)
203
+ if (!acl.isSuperAdmin) throw forbiddenEntityAccess()
204
+ }
184
205
 
185
- if (!hasAllFeatures(acl?.features, requirement[args.action])) {
206
+ const allowed = await args.rbac.userHasAllFeatures(
207
+ args.auth.sub ?? '',
208
+ requirement[args.action],
209
+ {
210
+ tenantId: args.auth.tenantId ?? null,
211
+ organizationId: args.auth.orgId ?? null,
212
+ },
213
+ )
214
+ if (!allowed) {
186
215
  throw forbiddenEntityAccess()
187
216
  }
188
217
  }
@@ -3,7 +3,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import type { AwilixContainer } from 'awilix'
4
4
  import { runWithCacheTenant } from '@open-mercato/cache'
5
5
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
- import { hasFeature } from '@open-mercato/shared/security/features'
6
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
7
7
  import { InboxProposal, InboxProposalAction, InboxDiscrepancy } from './data/entities'
8
8
  import { inboxProposalCategoryEnum } from './data/validators'
9
9
  import { executeAction } from './lib/executionEngine'
@@ -311,8 +311,10 @@ Returns on error: error message with appropriate detail.`,
311
311
 
312
312
  // Check target module permission
313
313
  if (action.requiredFeature) {
314
- const featureGranted =
315
- ctx.isSuperAdmin || hasFeature(ctx.userFeatures, action.requiredFeature)
314
+ const featureGranted = authorizeFeatures([action.requiredFeature], {
315
+ grantedFeatures: ctx.userFeatures,
316
+ unrestricted: ctx.isSuperAdmin,
317
+ })
316
318
  if (!featureGranted) {
317
319
  return {
318
320
  error: 'Insufficient permissions',
@@ -1,5 +1,4 @@
1
1
  import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
2
- import { hasFeature } from '@open-mercato/shared/security/features'
3
2
 
4
3
  export function hasOrganizationAccess(
5
4
  scopeOrganizationId: string | null,
@@ -33,10 +32,11 @@ export async function resolveMessageContext(req: Request): Promise<{
33
32
  }
34
33
 
35
34
  type RbacService = {
36
- loadAcl: (
35
+ userHasAllFeatures: (
37
36
  userId: string,
37
+ required: string[],
38
38
  scope: { tenantId: string | null; organizationId: string | null }
39
- ) => Promise<{ features?: string[]; isSuperAdmin?: boolean }>
39
+ ) => Promise<boolean>
40
40
  }
41
41
 
42
42
  export async function parseRequestBodySafe(req: Request): Promise<unknown> {
@@ -56,10 +56,8 @@ export async function canUseMessageEmailFeature(
56
56
  if (!scope.userId || !scope.tenantId) return false
57
57
 
58
58
  const rbac = ctx.container.resolve('rbacService') as RbacService
59
- const acl = await rbac.loadAcl(scope.userId, {
59
+ return rbac.userHasAllFeatures(scope.userId, ['messages.email'], {
60
60
  tenantId: scope.tenantId,
61
61
  organizationId: scope.organizationId,
62
62
  })
63
-
64
- return Boolean(acl.isSuperAdmin) || hasFeature(acl.features, 'messages.email')
65
63
  }
@@ -1,5 +1,5 @@
1
1
  import type { Kysely } from 'kysely'
2
- import { hasFeature } from '@open-mercato/shared/security/features'
2
+ import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
3
3
 
4
4
  interface AclRow {
5
5
  user_id: string
@@ -22,13 +22,11 @@ function collectUsersWithFeature(
22
22
  requiredFeature: string
23
23
  ): void {
24
24
  for (const row of rows) {
25
- if (row.is_super_admin) {
26
- userIdsSet.add(row.user_id)
27
- continue
28
- }
29
-
30
- const features = normalizeFeatures(row.features_json)
31
- if (features && hasFeature(features, requiredFeature)) {
25
+ const features = normalizeFeatures(row.features_json) ?? []
26
+ if (authorizeFeatures([requiredFeature], {
27
+ grantedFeatures: features,
28
+ unrestricted: row.is_super_admin,
29
+ })) {
32
30
  userIdsSet.add(row.user_id)
33
31
  }
34
32
  }
@@ -4,6 +4,7 @@ import { HybridQueryEngine } from './lib/engine'
4
4
  import { markDeleted } from './lib/indexer'
5
5
  import type { EventBus } from '@open-mercato/events'
6
6
  import type { VectorIndexService } from '@open-mercato/search/vector'
7
+ import { CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY } from '@open-mercato/shared/lib/crud/types'
7
8
 
8
9
  function toEntityTypeFromEvent(event: string): string | null {
9
10
  // Expect '<module>.<entity>.<action>'
@@ -65,6 +66,10 @@ export function register(container: AppContainer) {
65
66
 
66
67
  const makeUpsertHandler = (entityType: string) => async (payload: any, ctx: any) => {
67
68
  try {
69
+ // DataEngine emits the canonical query_index.upsert_one itself. The
70
+ // bridge only covers domain events from write paths that do not own an
71
+ // indexer, otherwise failures and error logs are duplicated.
72
+ if (payload?.[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY] === true) return
68
73
  const em = ctx.resolve('em')
69
74
  let orgId = payload?.organizationId || payload?.orgId || ctx?.organizationId || null
70
75
  let tenantId = payload?.tenantId || ctx?.tenantId || null
@@ -118,6 +123,7 @@ export function register(container: AppContainer) {
118
123
  }
119
124
  const makeDeleteHandler = (entityType: string) => async (payload: any, ctx: any) => {
120
125
  try {
126
+ if (payload?.[CRUD_QUERY_INDEX_MANAGED_PAYLOAD_KEY] === true) return
121
127
  const em = ctx.resolve('em')
122
128
  let orgId = payload?.organizationId || payload?.orgId || ctx?.organizationId || null
123
129
  let tenantId = payload?.tenantId || ctx?.tenantId || null
@@ -1,10 +1,12 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import { type Kysely, sql } from 'kysely'
2
+ import { type Kysely, type Transaction, sql } from 'kysely'
3
3
  import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
4
4
  import { createLogger } from '@open-mercato/shared/lib/logger'
5
5
 
6
6
  const logger = createLogger('query_index').child({ component: 'coverage' })
7
7
 
8
+ type CoverageExecutor = Kysely<any> | Transaction<any>
9
+
8
10
  export type CoverageScope = {
9
11
  entityType: string
10
12
  tenantId?: string | null
@@ -95,7 +97,7 @@ function applyOrganizationCondition<QB extends { where: (...args: any[]) => QB }
95
97
  }
96
98
 
97
99
  async function fetchCoverageRow(
98
- db: Kysely<any>,
100
+ db: CoverageExecutor,
99
101
  scope: CoverageScope
100
102
  ): Promise<(CoverageRow & { organization_id: string | null }) | null> {
101
103
  const { entityType, tenantId, organizationId, withDeleted } = scope
@@ -120,7 +122,7 @@ async function fetchCoverageRow(
120
122
  }
121
123
 
122
124
  async function pruneDuplicateCoverageRows(
123
- db: Kysely<any>,
125
+ db: CoverageExecutor,
124
126
  scope: CoverageScope,
125
127
  keepId: string | null
126
128
  ): Promise<void> {
@@ -139,7 +141,7 @@ async function pruneDuplicateCoverageRows(
139
141
  }
140
142
 
141
143
  async function upsertCoverageRow(
142
- db: Kysely<any>,
144
+ db: CoverageExecutor,
143
145
  scope: CoverageScope,
144
146
  counts: { baseCount: number; indexedCount: number; vectorIndexedCount: number }
145
147
  ): Promise<void> {
@@ -260,10 +262,11 @@ export async function readCoverageSnapshots(
260
262
 
261
263
  export async function applyCoverageAdjustments(
262
264
  em: EntityManager,
263
- adjustments: CoverageAdjustment[]
265
+ adjustments: CoverageAdjustment[],
266
+ options?: { trx?: CoverageExecutor },
264
267
  ): Promise<void> {
265
268
  if (!adjustments.length) return
266
- const db = (em as any).getKysely() as Kysely<any>
269
+ const db = options?.trx ?? ((em as any).getKysely() as Kysely<any>)
267
270
  const aggregated = aggregateAdjustments(adjustments)
268
271
  for (const entry of aggregated) {
269
272
  const scope = entry.scope
@@ -2,13 +2,21 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
3
3
  import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
4
4
  import { decryptIndexDocForSearch, encryptIndexDocForStorage } from '@open-mercato/shared/lib/encryption/indexDoc'
5
- import { sql } from 'kysely'
5
+ import {
6
+ buildCustomFieldDefinitionIndexFromRows,
7
+ normalizeDefinitionKey,
8
+ selectDefinitionForRecord,
9
+ type CustomFieldDefinitionRow,
10
+ } from '@open-mercato/shared/lib/crud/custom-field-definition-index'
11
+ import { type Kysely, type Transaction, sql } from 'kysely'
6
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
7
13
  import { replaceSearchTokensForRecord, deleteSearchTokensForRecord } from './search-tokens'
8
14
  import { attachAggregateSearchField } from './document'
9
15
 
10
16
  const logger = createLogger('query_index').child({ component: 'indexer' })
11
17
 
18
+ type QueryIndexExecutor = Kysely<any> | Transaction<any>
19
+
12
20
  type BuildDocParams = {
13
21
  entityType: string // '<module>:<entity>'
14
22
  recordId: string
@@ -96,9 +104,74 @@ export async function buildIndexDoc(em: EntityManager, params: BuildDocParams):
96
104
  if (!cfMap[cfKey]) cfMap[cfKey] = []
97
105
  cfMap[cfKey].push(val)
98
106
  }
107
+
108
+ const multiKeys = new Set<string>()
109
+ const fieldKeys = Object.keys(cfMap).map((key) => key.slice(3))
110
+ if (fieldKeys.length > 0) {
111
+ let defQuery = db
112
+ .selectFrom('custom_field_defs' as any)
113
+ .select([
114
+ 'key' as any,
115
+ 'kind' as any,
116
+ 'config_json' as any,
117
+ 'organization_id' as any,
118
+ 'tenant_id' as any,
119
+ 'deleted_at' as any,
120
+ 'updated_at' as any,
121
+ ])
122
+ .where('entity_id' as any, '=', params.entityType)
123
+ .where('key' as any, 'in', fieldKeys)
124
+ .where('is_active' as any, '=', true)
125
+ .where('deleted_at' as any, 'is', null)
126
+
127
+ if (params.organizationId != null) {
128
+ defQuery = defQuery.where((eb: any) => eb.or([
129
+ eb('organization_id' as any, '=', params.organizationId),
130
+ eb('organization_id' as any, 'is', null),
131
+ ]))
132
+ } else {
133
+ defQuery = defQuery.where('organization_id' as any, 'is', null)
134
+ }
135
+
136
+ if (params.tenantId != null) {
137
+ defQuery = defQuery.where((eb: any) => eb.or([
138
+ eb('tenant_id' as any, '=', params.tenantId),
139
+ eb('tenant_id' as any, 'is', null),
140
+ ]))
141
+ } else {
142
+ defQuery = defQuery.where('tenant_id' as any, 'is', null)
143
+ }
144
+
145
+ const defRows = await defQuery.execute() as Array<Record<string, any>>
146
+ const definitionIndex = buildCustomFieldDefinitionIndexFromRows(
147
+ defRows.map((row): CustomFieldDefinitionRow => ({
148
+ key: String(row.key ?? ''),
149
+ entityId: params.entityType,
150
+ kind: typeof row.kind === 'string' ? row.kind : null,
151
+ configJson: row.config_json,
152
+ organizationId: row.organization_id ?? null,
153
+ tenantId: row.tenant_id ?? null,
154
+ deletedAt: row.deleted_at ?? null,
155
+ updatedAt: row.updated_at ?? null,
156
+ })),
157
+ { organizationIds: params.organizationId ? [params.organizationId] : [] },
158
+ )
159
+ for (const key of fieldKeys) {
160
+ const definitions = definitionIndex.get(normalizeDefinitionKey(key)) ?? []
161
+ const definition = selectDefinitionForRecord(
162
+ definitions,
163
+ params.organizationId ?? null,
164
+ params.tenantId ?? null,
165
+ )
166
+ if (definition?.multi) multiKeys.add(key)
167
+ }
168
+ }
169
+
99
170
  for (const [key, arr] of Object.entries(cfMap)) {
100
- // Store singletons as simple value; multis as array
101
- doc[key] = arr.length <= 1 ? arr[0] : arr
171
+ // Cardinality belongs to the field definition, not the current row count:
172
+ // a multi field containing one value must still hydrate as an array.
173
+ const fieldKey = key.slice(3)
174
+ doc[key] = multiKeys.has(fieldKey) || arr.length > 1 ? arr : arr[0]
102
175
  }
103
176
 
104
177
  // Attach translations under flat keys 'l10n:{locale}:{field}'
@@ -181,12 +254,13 @@ function scopeEntityIndexes<QB extends { where: (...args: any[]) => QB }>(
181
254
 
182
255
  export async function upsertIndexRow(
183
256
  em: EntityManager,
184
- args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null; deferSearchTokens?: boolean }
257
+ args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; searchTokenDoc?: Record<string, unknown> | null; deferSearchTokens?: boolean; trx?: QueryIndexExecutor }
185
258
  ): Promise<UpsertIndexResult> {
186
259
  const db = (em as any).getKysely()
260
+ const executor = args.trx ?? db
187
261
 
188
262
  const existing = await scopeEntityIndexes(
189
- db.selectFrom('entity_indexes' as any).select(['id' as any, 'deleted_at' as any]),
263
+ executor.selectFrom('entity_indexes' as any).select(['id' as any, 'deleted_at' as any]),
190
264
  args,
191
265
  ).executeTakeFirst() as { id: string; deleted_at: Date | null } | undefined
192
266
 
@@ -205,12 +279,15 @@ export async function upsertIndexRow(
205
279
  organizationId: args.organizationId ?? null,
206
280
  tenantId: args.tenantId ?? null,
207
281
  doc: null,
282
+ trx: args.trx,
208
283
  })
209
- } catch {}
284
+ } catch (error) {
285
+ if (args.trx) throw error
286
+ }
210
287
  }
211
288
  if (existed) {
212
289
  await scopeEntityIndexes(
213
- db.deleteFrom('entity_indexes' as any) as any,
290
+ executor.deleteFrom('entity_indexes' as any) as any,
214
291
  args,
215
292
  ).execute()
216
293
  }
@@ -230,7 +307,7 @@ export async function upsertIndexRow(
230
307
 
231
308
  // Prefer modern upsert keyed by coalesced org id when available; fallback to update-then-insert
232
309
  try {
233
- await db
310
+ await executor
234
311
  .insertInto('entity_indexes' as any)
235
312
  .values({ ...payload, created_at: sql`now()` } as any)
236
313
  .onConflict((oc: any) => oc
@@ -246,16 +323,18 @@ export async function upsertIndexRow(
246
323
  } catch {
247
324
  // Fallback for schemas without organization_id_coalesced column/index
248
325
  const updated = await scopeEntityIndexes(
249
- db.updateTable('entity_indexes' as any).set(payload as any) as any,
326
+ executor.updateTable('entity_indexes' as any).set(payload as any) as any,
250
327
  args,
251
328
  ).executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined
252
329
  if (!updated || Number(updated.numUpdatedRows ?? 0) === 0) {
253
330
  try {
254
- await db
331
+ await executor
255
332
  .insertInto('entity_indexes' as any)
256
333
  .values({ ...payload, created_at: sql`now()` } as any)
257
334
  .execute()
258
- } catch {}
335
+ } catch (error) {
336
+ if (args.trx) throw error
337
+ }
259
338
  }
260
339
  }
261
340
 
@@ -273,8 +352,11 @@ export async function upsertIndexRow(
273
352
  tenantId: args.tenantId ?? null,
274
353
  doc,
275
354
  searchTokenDoc: args.searchTokenDoc ?? null,
355
+ trx: args.trx,
276
356
  })
277
- } catch {}
357
+ } catch (error) {
358
+ if (args.trx) throw error
359
+ }
278
360
  }
279
361
  return { doc, existed, wasDeleted, created, revived }
280
362
  }
@@ -294,6 +376,7 @@ export async function reindexSearchTokensForRecord(
294
376
  tenantId?: string | null
295
377
  doc: Record<string, any> | null
296
378
  searchTokenDoc?: Record<string, unknown> | null
379
+ trx?: QueryIndexExecutor
297
380
  },
298
381
  ): Promise<void> {
299
382
  const db = (em as any).getKysely()
@@ -303,7 +386,7 @@ export async function reindexSearchTokensForRecord(
303
386
  recordId: args.recordId,
304
387
  organizationId: args.organizationId ?? null,
305
388
  tenantId: args.tenantId ?? null,
306
- })
389
+ }, { trx: args.trx })
307
390
  return
308
391
  }
309
392
  const tokenDoc = args.searchTokenDoc ?? (() => {
@@ -323,16 +406,17 @@ export async function reindexSearchTokensForRecord(
323
406
  organizationId: args.organizationId ?? null,
324
407
  tenantId: args.tenantId ?? null,
325
408
  doc: await tokenDoc,
326
- })
409
+ }, { trx: args.trx })
327
410
  }
328
411
 
329
412
  export async function markDeleted(
330
413
  em: EntityManager,
331
- args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null }
414
+ args: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null; trx?: QueryIndexExecutor }
332
415
  ): Promise<{ wasActive: boolean }> {
333
416
  const db = (em as any).getKysely()
417
+ const executor = args.trx ?? db
334
418
  const existing = await scopeEntityIndexes(
335
- db.selectFrom('entity_indexes' as any).select(['deleted_at' as any]),
419
+ executor.selectFrom('entity_indexes' as any).select(['deleted_at' as any]),
336
420
  args,
337
421
  ).executeTakeFirst() as { deleted_at: Date | null } | undefined
338
422
 
@@ -345,10 +429,12 @@ export async function markDeleted(
345
429
  recordId: args.recordId,
346
430
  organizationId: args.organizationId ?? null,
347
431
  tenantId: args.tenantId ?? null,
348
- })
349
- } catch {}
432
+ }, { trx: args.trx })
433
+ } catch (error) {
434
+ if (args.trx) throw error
435
+ }
350
436
  await scopeEntityIndexes(
351
- db.deleteFrom('entity_indexes' as any) as any,
437
+ executor.deleteFrom('entity_indexes' as any) as any,
352
438
  args,
353
439
  ).execute()
354
440
  }
@@ -1,4 +1,4 @@
1
- import { type Kysely, sql } from 'kysely'
1
+ import { type Kysely, type Transaction, sql } from 'kysely'
2
2
  import {
3
3
  isSearchFieldBlocklisted,
4
4
  resolveSearchConfig,
@@ -40,6 +40,7 @@ type BuildTokenOptions = {
40
40
 
41
41
  const DEFAULT_SCOPE = { organizationId: null, tenantId: null }
42
42
  type EntityFieldPair = [string, string]
43
+ type SearchTokenExecutor = Kysely<any> | Transaction<any>
43
44
 
44
45
  export const isSearchDebugEnabled = (): boolean => {
45
46
  return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true
@@ -151,7 +152,8 @@ function buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null)
151
152
 
152
153
  export async function replaceSearchTokensForRecord(
153
154
  db: Kysely<any>,
154
- params: BuildTokenOptions
155
+ params: BuildTokenOptions,
156
+ options?: { trx?: SearchTokenExecutor },
155
157
  ): Promise<void> {
156
158
  const rows = buildSearchTokenRows(params)
157
159
  const config = params.config ?? resolveSearchConfig()
@@ -160,8 +162,8 @@ export async function replaceSearchTokensForRecord(
160
162
  const tenantId = params.tenantId ?? null
161
163
  const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)
162
164
 
163
- await db.transaction().execute(async (trx) => {
164
- let deleteQuery = trx
165
+ const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {
166
+ let deleteQuery = executor
165
167
  .deleteFrom('search_tokens' as any)
166
168
  .where('entity_type' as any, '=', params.entityType)
167
169
  .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)
@@ -180,18 +182,27 @@ export async function replaceSearchTokensForRecord(
180
182
  if (!rows.length) return
181
183
  const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))
182
184
  for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {
183
- await trx.insertInto('search_tokens' as any).values(batch as any).execute()
185
+ await executor.insertInto('search_tokens' as any).values(batch as any).execute()
184
186
  }
185
- })
187
+ }
188
+
189
+ if (options?.trx) {
190
+ await writeTokens(options.trx)
191
+ return
192
+ }
193
+
194
+ await db.transaction().execute(writeTokens)
186
195
  }
187
196
 
188
197
  export async function deleteSearchTokensForRecord(
189
198
  db: Kysely<any>,
190
- params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null }
199
+ params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },
200
+ options?: { trx?: SearchTokenExecutor },
191
201
  ): Promise<void> {
192
202
  const organizationId = params.organizationId ?? null
193
203
  const tenantId = params.tenantId ?? null
194
- await db
204
+ const executor = options?.trx ?? db
205
+ await executor
195
206
  .deleteFrom('search_tokens' as any)
196
207
  .where('entity_type' as any, '=', params.entityType)
197
208
  .where('entity_id' as any, '=', String(params.recordId))
@@ -1,5 +1,6 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
3
+ import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
3
4
  import { refreshCoverageSnapshot } from '../lib/coverage'
4
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
5
6
 
@@ -70,6 +71,9 @@ export default async function handle(payload: Payload, ctx: { resolve: <T = any>
70
71
  payload,
71
72
  },
72
73
  )
74
+ if (isReadProjectionAlwaysConsistent()) {
75
+ throw err
76
+ }
73
77
  }
74
78
  }
75
79