@open-mercato/core 0.10.1-develop.7776.1.3032ea0741 → 0.10.1-develop.7781.1.079f42c863

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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/components/products/ProductsDataTable.js +6 -5
  3. package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
  4. package/dist/modules/customers/backend/customers/companies/page.js +5 -4
  5. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  6. package/dist/modules/customers/backend/customers/deals/page.js +6 -5
  7. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  8. package/dist/modules/customers/backend/customers/people/page.js +5 -4
  9. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  10. package/dist/modules/query_index/cli.js +9 -1
  11. package/dist/modules/query_index/cli.js.map +2 -2
  12. package/dist/modules/query_index/lib/coverage.js +13 -2
  13. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  14. package/dist/modules/query_index/lib/engine.js +28 -9
  15. package/dist/modules/query_index/lib/engine.js.map +2 -2
  16. package/dist/modules/query_index/lib/indexer.js +5 -1
  17. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  18. package/dist/modules/query_index/lib/reindexer.js +95 -32
  19. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  20. package/dist/modules/query_index/lib/search-scope.js +101 -0
  21. package/dist/modules/query_index/lib/search-scope.js.map +7 -0
  22. package/dist/modules/query_index/lib/stale.js +41 -1
  23. package/dist/modules/query_index/lib/stale.js.map +2 -2
  24. package/dist/modules/query_index/lib/subscriber-scope.js +30 -7
  25. package/dist/modules/query_index/lib/subscriber-scope.js.map +3 -3
  26. package/dist/modules/query_index/lib/tenant-global.js +41 -0
  27. package/dist/modules/query_index/lib/tenant-global.js.map +7 -0
  28. package/dist/modules/query_index/subscribers/reindex.js +9 -2
  29. package/dist/modules/query_index/subscribers/reindex.js.map +2 -2
  30. package/dist/modules/sales/components/documents/ItemsSection.js +4 -3
  31. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  32. package/package.json +7 -7
  33. package/src/modules/catalog/components/products/ProductsDataTable.tsx +6 -5
  34. package/src/modules/customers/backend/customers/companies/page.tsx +5 -4
  35. package/src/modules/customers/backend/customers/deals/page.tsx +6 -5
  36. package/src/modules/customers/backend/customers/people/page.tsx +5 -4
  37. package/src/modules/query_index/cli.ts +19 -1
  38. package/src/modules/query_index/lib/coverage.ts +29 -3
  39. package/src/modules/query_index/lib/engine.ts +34 -9
  40. package/src/modules/query_index/lib/indexer.ts +6 -1
  41. package/src/modules/query_index/lib/reindexer.ts +205 -36
  42. package/src/modules/query_index/lib/search-scope.ts +144 -0
  43. package/src/modules/query_index/lib/stale.ts +73 -0
  44. package/src/modules/query_index/lib/subscriber-scope.ts +38 -8
  45. package/src/modules/query_index/lib/tenant-global.ts +93 -0
  46. package/src/modules/query_index/subscribers/reindex.ts +24 -1
  47. package/src/modules/sales/components/documents/ItemsSection.tsx +6 -3
@@ -0,0 +1,144 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { sql, type Kysely, type RawBuilder } from 'kysely'
3
+ import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
4
+ import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
5
+ import { resolveQueryIndexSourceMetadata } from './subscriber-scope'
6
+ import { listTenantGlobalEntityTypes } from './tenant-global'
7
+
8
+ export type QueryIndexSearchScope = {
9
+ searchableEntityTypes: readonly string[]
10
+ blockedEntityTypes: readonly string[]
11
+ tenantGlobalEntityTypes: readonly string[]
12
+ registeredSourceTypes?: readonly string[]
13
+ customEntityStorage?: boolean
14
+ customEntityStorageSchema?: string | null
15
+ }
16
+
17
+ type ScopeSnapshot = QueryIndexSearchScope & { registeredSourceTypes: readonly string[]; customEntityStorage: boolean; customEntityStorageSchema: string | null }
18
+ type ScopeCacheEntry = { signature: string; result: Promise<ScopeSnapshot> }
19
+ const SEARCH_SCOPE_CACHE_KEY = Symbol.for('@open-mercato/query-index/verified-search-scope')
20
+ type ScopeGlobals = typeof globalThis & { [SEARCH_SCOPE_CACHE_KEY]?: WeakMap<object, ScopeCacheEntry> }
21
+
22
+ function scopeCache(): WeakMap<object, ScopeCacheEntry> {
23
+ const scope = globalThis as ScopeGlobals
24
+ return scope[SEARCH_SCOPE_CACHE_KEY] ??= new WeakMap<object, ScopeCacheEntry>()
25
+ }
26
+
27
+ export async function resolveQueryIndexSearchScope(entityManager?: EntityManager): Promise<QueryIndexSearchScope> {
28
+ let manager = entityManager
29
+ if (!manager) {
30
+ const { getOrm } = await import('@open-mercato/shared/lib/db/mikro')
31
+ try {
32
+ manager = (await getOrm()).em
33
+ } catch {
34
+ return { searchableEntityTypes: [], tenantGlobalEntityTypes: [], blockedEntityTypes: [...new Set(Object.values(getEntityIds(false)).flatMap((entities) => Object.values(entities)))] }
35
+ }
36
+ }
37
+ const entityTypes = [...new Set(Object.values(getEntityIds(false)).flatMap((entities) => Object.values(entities)))].sort((leftType, rightType) => leftType.localeCompare(rightType))
38
+ if (!entityTypes.length) throw new Error('[internal] Query-index source registry is unavailable; refusing unverified search')
39
+ const declaredTypes = listTenantGlobalEntityTypes().sort((leftType, rightType) => leftType.localeCompare(rightType))
40
+ const metadataSignature = listEntityMetadata(manager).map((metadata) => [metadata.className, metadata.tableName, metadata.schema, metadata.properties?.tenantId?.fieldNames, metadata.properties?.organizationId?.fieldNames])
41
+ const signature = JSON.stringify([entityTypes, declaredTypes, metadataSignature])
42
+ const key = manager.getDriver()
43
+ const cache = scopeCache()
44
+ const cached = cache.get(key)
45
+ const result = cached?.signature === signature ? cached.result : verifySearchScope(manager, entityTypes, new Set(declaredTypes))
46
+ const entry = cached?.signature === signature ? cached : { signature, result }
47
+ cache.set(key, entry)
48
+ try {
49
+ return await result
50
+ } catch (error) {
51
+ if (cache.get(key) === entry) cache.delete(key)
52
+ throw error
53
+ }
54
+ }
55
+
56
+ async function verifySearchScope(
57
+ em: EntityManager,
58
+ entityTypes: readonly string[],
59
+ declaredTypes: ReadonlySet<string>,
60
+ ): Promise<ScopeSnapshot> {
61
+ const metadata = listEntityMetadata(em)
62
+ const sources = entityTypes.flatMap((entityType) => {
63
+ try {
64
+ const source = resolveQueryIndexSourceMetadata(em, entityType)
65
+ return [{ entityType, ...source, table: source.tableName ?? source.table, schema: source.schema ?? null }]
66
+ } catch {
67
+ return []
68
+ }
69
+ })
70
+ const customStorageMatches = metadata.filter((entry) => entry.className === 'CustomEntityStorage' || entry.name === 'CustomEntityStorage')
71
+ const customStorageMetadata = customStorageMatches.length === 1 ? customStorageMatches[0] : undefined
72
+ const customStorageRegistered = Boolean(customStorageMetadata)
73
+ const customEntityStorageSchema = customStorageMetadata?.schema && customStorageMetadata.schema !== '*' ? customStorageMetadata.schema : null
74
+ const physicalSchemas = [...new Set([...sources.flatMap((source) => source.schema ? [source.schema] : []), ...(customEntityStorageSchema ? [customEntityStorageSchema] : [])])]
75
+ if (!sources.length && !customStorageRegistered) return { searchableEntityTypes: [], tenantGlobalEntityTypes: [], registeredSourceTypes: [], blockedEntityTypes: [...entityTypes], customEntityStorage: false, customEntityStorageSchema: null }
76
+ const db = em.getKysely<{ 'information_schema.columns': { table_schema: string; table_name: string; column_name: string } }>()
77
+ const rows = await db.selectFrom('information_schema.columns')
78
+ .select(['table_schema', 'table_name', 'column_name'])
79
+ .select(sql<boolean>`table_schema = current_schema()`.as('is_current_schema'))
80
+ .where((expression) => expression.or([sql<boolean>`table_schema = current_schema()`, ...(physicalSchemas.length ? [expression('table_schema', 'in', physicalSchemas)] : [])]))
81
+ .where('table_name', 'in', [...new Set([...sources.map((source) => source.table), ...(customStorageRegistered ? ['custom_entities_storage'] : [])])])
82
+ .execute()
83
+ const columnsByTable = new Map<string, Set<string>>()
84
+ for (const row of rows) {
85
+ const key = `${row.table_schema}.${row.table_name}`
86
+ const columns = columnsByTable.get(key) ?? new Set<string>()
87
+ columns.add(row.column_name)
88
+ columnsByTable.set(key, columns)
89
+ if (row.is_current_schema) columnsByTable.set(row.table_name, columns)
90
+ }
91
+ const searchableEntityTypes: string[] = []
92
+ const tenantGlobalEntityTypes: string[] = []
93
+ for (const source of sources) {
94
+ const columns = columnsByTable.get(source.schema ? `${source.schema}.${source.table}` : source.table)
95
+ if (!columns?.has('id')) continue
96
+ const hasTenant = columns.has('tenant_id') || Boolean(source.tenantColumn && columns.has(source.tenantColumn))
97
+ const hasOrganization = columns.has('organization_id') || Boolean(source.organizationColumn && columns.has(source.organizationColumn))
98
+ const global = !source.tenantColumn && !source.organizationColumn && !hasTenant && !hasOrganization && declaredTypes.has(source.entityType)
99
+ if (global) tenantGlobalEntityTypes.push(source.entityType)
100
+ if (hasTenant || global) searchableEntityTypes.push(source.entityType)
101
+ }
102
+ return { searchableEntityTypes, tenantGlobalEntityTypes, blockedEntityTypes: sources.map((source) => source.entityType).filter((entityType) => !searchableEntityTypes.includes(entityType)), registeredSourceTypes: sources.map((source) => source.entityType), customEntityStorageSchema, customEntityStorage: customStorageRegistered && ['tenant_id', 'organization_id', 'entity_type', 'entity_id'].every((column) => columnsByTable.get(customEntityStorageSchema ? `${customEntityStorageSchema}.custom_entities_storage` : 'custom_entities_storage')?.has(column)) }
103
+ }
104
+
105
+ type SearchRecordIdentity = { entityId: string; recordId: string; tenantId: string; organizationId?: string | null }
106
+ type CustomStorageDatabase = {
107
+ custom_entities_storage: { entity_type: string; entity_id: string; tenant_id: string | null; organization_id: string | null }
108
+ }
109
+
110
+ export function buildQueryIndexSearchScopePredicate(
111
+ scope: QueryIndexSearchScope,
112
+ target: { entityTypeRef: string; recordIdRef: string; organizationIdRef: string },
113
+ tenantId: string,
114
+ ): RawBuilder<boolean> {
115
+ const entityType = sql.ref(target.entityTypeRef)
116
+ const known = scope.searchableEntityTypes.length
117
+ ? sql<boolean>`${entityType} in (${sql.join(scope.searchableEntityTypes)})`
118
+ : sql<boolean>`false`
119
+ if (!scope.customEntityStorage) return known
120
+ const table = sql.id(...(scope.customEntityStorageSchema ? [scope.customEntityStorageSchema, 'custom_entities_storage'] : ['custom_entities_storage']))
121
+ const unknown = scope.registeredSourceTypes?.length
122
+ ? sql<boolean>`${entityType} not in (${sql.join(scope.registeredSourceTypes)})`
123
+ : sql<boolean>`true`
124
+ return sql<boolean>`(${known} or (${unknown} and exists (select 1 from ${table} as om_search_source where om_search_source.entity_type = ${entityType} and om_search_source.entity_id = (${sql.ref(target.recordIdRef)})::text and om_search_source.tenant_id = ${tenantId} and om_search_source.organization_id is not distinct from ${sql.ref(target.organizationIdRef)})))`
125
+ }
126
+
127
+ export async function filterQueryIndexSearchRecords<RecordType extends SearchRecordIdentity>(
128
+ db: Kysely<CustomStorageDatabase> | undefined,
129
+ scope: QueryIndexSearchScope,
130
+ records: readonly RecordType[],
131
+ ): Promise<RecordType[]> {
132
+ const known = new Set(scope.searchableEntityTypes)
133
+ const unknown = records.filter((record) => !known.has(record.entityId) && !scope.registeredSourceTypes?.includes(record.entityId))
134
+ if (!scope.customEntityStorage || !unknown.length) return records.filter((record) => known.has(record.entityId))
135
+ const source = db ?? (await (await import('@open-mercato/shared/lib/db/mikro')).getOrm()).em.getKysely<CustomStorageDatabase>()
136
+ const sourceDb = scope.customEntityStorageSchema ? source.withSchema(scope.customEntityStorageSchema) : source
137
+ const rows = await sourceDb.selectFrom('custom_entities_storage').select(['entity_type', 'entity_id', 'tenant_id', 'organization_id'])
138
+ .where('entity_type', 'in', [...new Set(unknown.map((record) => record.entityId))])
139
+ .where('entity_id', 'in', [...new Set(unknown.map((record) => record.recordId))])
140
+ .where('tenant_id', 'in', [...new Set(unknown.map((record) => record.tenantId))])
141
+ .execute()
142
+ const identities = new Set(rows.map((row) => JSON.stringify([row.entity_type, row.entity_id, row.tenant_id, row.organization_id])))
143
+ return records.filter((record) => known.has(record.entityId) || identities.has(JSON.stringify([record.entityId, record.recordId, record.tenantId, record.organizationId ?? null])))
144
+ }
@@ -130,3 +130,76 @@ export async function purgeOrphans(
130
130
  ).catch(() => undefined)
131
131
  }
132
132
  }
133
+
134
+ type RetireScopedCatalogueRowsOptions = {
135
+ entityType: string
136
+ partitionIndex: number | null
137
+ partitionCount: number | null
138
+ /** Records this run failed to write keep their old rows, as in `purgeOrphans`. */
139
+ excludeRecordIds?: string[]
140
+ }
141
+
142
+ type CatalogueRetirementDatabase = {
143
+ entity_indexes: { entity_type: string; entity_id: string; tenant_id: string | null; organization_id: string | null }
144
+ search_tokens: { entity_type: string; entity_id: string; tenant_id: string | null; organization_id: string | null }
145
+ }
146
+
147
+ /**
148
+ * Retire the projections and tokens a declared platform-wide catalogue accumulated before
149
+ * its canonical scope became `tenant_id = NULL, organization_id = NULL`.
150
+ *
151
+ * Earlier sweeps stamped the caller's tenant and organization. `entity_indexes` is unique on
152
+ * (entity_type, entity_id, organization_id_coalesced), so a row stamped with an organization
153
+ * is a different key from the canonical one: the upsert never rewrites it, and
154
+ * `purgeOrphans`, scoped to the canonical row, never selects it - it survives every sweep,
155
+ * even the deletion of its source record. Nothing writes a scoped row for such a type any
156
+ * more (the event path requires a null scope for it, and the sweep now writes one), so every
157
+ * scoped row is residue whichever tenant stamped it. Only call this for an entity type
158
+ * `reindexEntity` writes as a global projection.
159
+ */
160
+ export async function retireScopedCatalogueRows(
161
+ db: Kysely<CatalogueRetirementDatabase>,
162
+ options: RetireScopedCatalogueRowsOptions,
163
+ ): Promise<void> {
164
+ const { entityType, partitionIndex, partitionCount } = options
165
+ const scoped = sql<boolean>`(tenant_id is not null or organization_id is not null)`
166
+ const inPartition = partitionIndex != null && partitionCount != null
167
+ ? sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`
168
+ : null
169
+
170
+ let projections = db.deleteFrom('entity_indexes').where('entity_type', '=', entityType).where(scoped)
171
+ if (inPartition) projections = projections.where(inPartition)
172
+ if (options.excludeRecordIds?.length) {
173
+ projections = projections.where('entity_id', 'not in', options.excludeRecordIds)
174
+ }
175
+ await projections.execute()
176
+
177
+ try {
178
+ if (!(await searchTokensTableExists(db))) return
179
+ let tokens = db.deleteFrom('search_tokens').where('entity_type', '=', entityType).where(scoped)
180
+ if (inPartition) tokens = tokens.where(inPartition)
181
+ if (options.excludeRecordIds?.length) {
182
+ tokens = tokens.where('entity_id', 'not in', options.excludeRecordIds)
183
+ }
184
+ tokens = tokens.where(sql<boolean>`not exists (
185
+ select 1 from entity_indexes as surviving
186
+ where surviving.entity_type = ${entityType}
187
+ and surviving.entity_id = search_tokens.entity_id
188
+ and surviving.tenant_id is not distinct from search_tokens.tenant_id
189
+ and surviving.organization_id is not distinct from search_tokens.organization_id
190
+ )`)
191
+ await tokens.execute()
192
+ } catch (searchTokenError) {
193
+ await recordIndexerError(
194
+ { db },
195
+ {
196
+ source: 'fulltext',
197
+ handler: 'query_index:retire-scoped-catalogue-rows',
198
+ error: searchTokenError,
199
+ entityType,
200
+ tenantId: null,
201
+ organizationId: null,
202
+ },
203
+ ).catch(() => undefined)
204
+ }
205
+ }
@@ -10,6 +10,8 @@ export type QueryIndexScope = {
10
10
 
11
11
  export type QueryIndexSourceMetadata = {
12
12
  table: string
13
+ schema?: string
14
+ tableName?: string
13
15
  organizationColumn: string | null
14
16
  tenantColumn: string | null
15
17
  }
@@ -21,6 +23,9 @@ export type QueryIndexSourceScope =
21
23
 
22
24
  type QueryIndexEntityMetadata = {
23
25
  tableName?: unknown
26
+ className?: unknown
27
+ name?: unknown
28
+ schema?: unknown
24
29
  properties?: Record<string, { fieldNames?: unknown }>
25
30
  }
26
31
 
@@ -54,19 +59,44 @@ export function resolveQueryIndexSourceMetadata(
54
59
  throw new QueryIndexScopeError(`Query index entity type is not registered: ${entityType}`)
55
60
  }
56
61
 
57
- const table = resolveRegisteredEntityTableName(em, entityType as never)
58
- if (!table) {
59
- throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)
60
- }
62
+ const source = resolveQueryIndexSourceTableMetadata(em, entityType)
63
+ if (!source) throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)
64
+ const listed = listEntityMetadata(em).some((metadata) => metadata.tableName === (source.tableName ?? source.table) && (metadata.schema && metadata.schema !== '*' ? metadata.schema : undefined) === source.schema)
65
+ if (!listed) throw new QueryIndexScopeError(`[internal] Query index entity metadata was not found for table: ${source.table}`)
66
+ return source
67
+ }
61
68
 
69
+ export function resolveQueryIndexSourceTableMetadata(
70
+ em: EntityManager,
71
+ entityType: string,
72
+ ): QueryIndexSourceMetadata | null {
73
+ const rawName = entityType.split(':').pop() ?? ''
74
+ const className = rawName.split(/[_\s]+/).filter(Boolean)
75
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join('')
62
76
  const allMetadata = listEntityMetadata(em) as unknown as QueryIndexEntityMetadata[]
63
- const metadata = allMetadata.find((candidate) => String(candidate.tableName ?? '') === table)
64
- if (!metadata) {
65
- throw new QueryIndexScopeError(`Query index entity metadata was not found for table: ${table}`)
77
+ let metadata: QueryIndexEntityMetadata | undefined
78
+ for (const candidate of [className, `${className}Entity`]) {
79
+ const matches = allMetadata.filter((entry) => entry.className === candidate || entry.name === candidate)
80
+ if (matches.length > 1) throw new QueryIndexScopeError(`[internal] Ambiguous query-index entity metadata: ${entityType}`)
81
+ if (matches.length === 1) {
82
+ metadata = matches[0]
83
+ break
84
+ }
66
85
  }
67
-
86
+ if (!metadata) {
87
+ const table = resolveRegisteredEntityTableName(em, entityType)
88
+ if (!table) return null
89
+ const matches = allMetadata.filter((entry) => entry.tableName === table)
90
+ if (matches.length !== 1) throw new QueryIndexScopeError(`[internal] Ambiguous query-index table metadata: ${entityType}`)
91
+ metadata = matches[0]
92
+ }
93
+ const tableName = String(metadata.tableName ?? '')
94
+ if (!tableName) return null
95
+ const schema = typeof metadata.schema === 'string' && metadata.schema !== '*' ? metadata.schema : undefined
96
+ const table = schema ? `${schema}.${tableName}` : tableName
68
97
  return {
69
98
  table,
99
+ ...(schema ? { schema, tableName } : {}),
70
100
  organizationColumn: resolveScopeColumn(metadata, 'organizationId', table),
71
101
  tenantColumn: resolveScopeColumn(metadata, 'tenantId', table),
72
102
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The narrow exception to the rule `reindexEntity` enforces: entity types whose
3
+ * source table has no `tenant_id` column BECAUSE the rows are one platform-wide
4
+ * catalogue that every tenant is meant to read.
5
+ *
6
+ * A declared type whose table has neither scope column is swept whole and its
7
+ * projection is written once, under `tenant_id = NULL, organization_id = NULL` -
8
+ * where the event path files it too - and every reader widens for exactly this
9
+ * physically verified set (see `search-scope`): `TokenSearchStrategy.search()` on both the tenant and the organization
10
+ * axis, `createPresenterEnricher()`'s `fetchDocsBatch()` on the tenant axis, and
11
+ * `SearchService`'s organization filter. A sweep also retires the rows earlier
12
+ * sweeps stamped with a caller's tenant and organization. Vector work is the one
13
+ * exception: it keeps the caller's scope, because its consumers need a real
14
+ * tenant. The null scope alone is never the signal - it is also how every
15
+ * private table with neither scope column is stored - which is why each branch
16
+ * is gated on this declaration.
17
+ *
18
+ * Everything else with no `tenant_id` column fails closed, INCLUDING entity
19
+ * types nobody has classified yet. That is the point of expressing this as an
20
+ * allowlist over a derived condition rather than as a denylist of known-private
21
+ * tables: a tenant-less entity type introduced by a later release is refused on
22
+ * arrival instead of quietly leaking until someone notices.
23
+ *
24
+ * The bar for admitting an entity type is "is every tenant MEANT to read every
25
+ * row", not "a reindex started refusing it". Of the fourteen entity types in
26
+ * this repository whose table has no `tenant_id` column, exactly one clears it;
27
+ * the other thirteen are private rows that must fail closed —
28
+ * `directory:tenant` (one row per tenant, `name` tokenised),
29
+ * `auth:user_role`, `auth:session`, `auth:password_reset`,
30
+ * `customers:customer_deal_person_link`, `messages:message_recipient` and the
31
+ * rest of that shape.
32
+ */
33
+ const CORE_TENANT_GLOBAL_ENTITY_TYPES: readonly string[] = [
34
+ // No `tenant_id`; `feature_toggle_overrides` carries the per-tenant value.
35
+ // Its `default_value` is `jsonb`, which contributes no tokens.
36
+ 'feature_toggles:feature_toggle',
37
+ ]
38
+
39
+ const TENANT_GLOBAL_ENTITY_TYPES_KEY = Symbol.for('@open-mercato/query-index/tenant-global-entity-types')
40
+
41
+ type GlobalWithTenantGlobalEntityTypes = typeof globalThis & {
42
+ [TENANT_GLOBAL_ENTITY_TYPES_KEY]?: Set<string>
43
+ }
44
+
45
+ function getTenantGlobalEntityTypes(): Set<string> {
46
+ const scope = globalThis as GlobalWithTenantGlobalEntityTypes
47
+ if (!scope[TENANT_GLOBAL_ENTITY_TYPES_KEY]) {
48
+ scope[TENANT_GLOBAL_ENTITY_TYPES_KEY] = new Set<string>(CORE_TENANT_GLOBAL_ENTITY_TYPES)
49
+ }
50
+ return scope[TENANT_GLOBAL_ENTITY_TYPES_KEY]
51
+ }
52
+
53
+ function normalize(entityType: string): string {
54
+ return String(entityType || '').trim()
55
+ }
56
+
57
+ /**
58
+ * Declare that an entity type outside this package is a platform-wide catalogue.
59
+ *
60
+ * Modules shipped as their own package — and apps with their own tenant-less
61
+ * catalogue tables — cannot be listed above, so they opt in here. Call it during
62
+ * module registration (a module's `di.ts` is the usual place), before any
63
+ * reindex job runs.
64
+ *
65
+ * Forgetting the call is safe in the only direction that matters: the entity
66
+ * type simply keeps failing closed, and `reindexEntity` logs a warning naming
67
+ * it, so the missing declaration is diagnosable from the log rather than from a
68
+ * cross-tenant search result.
69
+ */
70
+ export function registerTenantGlobalEntityTypes(...entityTypes: string[]): void {
71
+ const tenantGlobalEntityTypes = getTenantGlobalEntityTypes()
72
+ for (const entityType of entityTypes) {
73
+ const normalized = normalize(entityType)
74
+ if (normalized) tenantGlobalEntityTypes.add(normalized)
75
+ }
76
+ }
77
+
78
+ /** Declaration intent only; runtime widening also requires physical verification in search-scope. */
79
+ export function isTenantGlobalEntityType(entityType: string): boolean {
80
+ return getTenantGlobalEntityTypes().has(normalize(entityType))
81
+ }
82
+
83
+ /** The current allowlist, for diagnostics and tests. */
84
+ export function listTenantGlobalEntityTypes(): string[] {
85
+ return Array.from(getTenantGlobalEntityTypes())
86
+ }
87
+
88
+ /** Test-only: drop every registration and restore this package's own entries. */
89
+ export function resetTenantGlobalEntityTypes(): void {
90
+ const tenantGlobalEntityTypes = getTenantGlobalEntityTypes()
91
+ tenantGlobalEntityTypes.clear()
92
+ for (const entityType of CORE_TENANT_GLOBAL_ENTITY_TYPES) tenantGlobalEntityTypes.add(entityType)
93
+ }
@@ -2,10 +2,25 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
3
3
  import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
4
4
  import { reindexEntity } from '../lib/reindexer'
5
+ import type { ReindexRefusalReason } from '../lib/reindexer'
5
6
  import type { VectorIndexService } from '@open-mercato/search/vector'
6
7
  import type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'
7
8
  import { resolveQueryIndexReindexScope } from '../lib/subscriber-scope'
8
9
 
10
+ /** Operator-facing text for a sweep that deliberately did no work. */
11
+ const REINDEX_REFUSAL_MESSAGES: Record<ReindexRefusalReason, string> = {
12
+ 'no-tenant-column':
13
+ 'the source table has no tenant_id column and the entity type is not declared tenant-global. ' +
14
+ 'Declare it with registerTenantGlobalEntityTypes() if it is a global catalogue.',
15
+ 'column-probe-failed':
16
+ 'the source table\'s columns could not be read, so no scope could be proven. This is usually ' +
17
+ 'transient - retry the reindex.',
18
+ 'declared-global-but-org-scoped':
19
+ 'the entity type is declared tenant-global but its table carries an organization_id column. ' +
20
+ 'A global projection requires a table with neither scope column; remove the declaration or ' +
21
+ 'the column.',
22
+ }
23
+
9
24
  export const metadata = { event: 'query_index.reindex', persistent: true }
10
25
 
11
26
  function forkReindexEntityManager(em: EntityManager): EntityManager {
@@ -167,12 +182,19 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
167
182
  { processed: result.processed, total: result.total },
168
183
  { complete: true },
169
184
  )
185
+ // A refused sweep is byte-identical to a successful zero-row one, so without this the
186
+ // status page - the one screen built to answer "why is this entity type not indexed" -
187
+ // reports a green completion with no stated reason. That undercuts the guard's own
188
+ // safety argument, which is that a missing declaration "shows up in the log".
170
189
  await recordIndexerLog(
171
190
  { em },
172
191
  {
173
192
  source: 'query_index',
174
193
  handler: 'event:query_index.reindex',
175
- message: `Reindex completed for ${entityType}`,
194
+ level: result.refused ? 'warn' : undefined,
195
+ message: result.refused
196
+ ? `Reindex refused for ${entityType}: ${REINDEX_REFUSAL_MESSAGES[result.refused]}`
197
+ : `Reindex completed for ${entityType}`,
176
198
  entityType,
177
199
  tenantId: tenantId ?? null,
178
200
  organizationId: organizationId ?? null,
@@ -181,6 +203,7 @@ export default async function handle(payload: any, ctx: { resolve: <T=any>(name:
181
203
  total: result.total,
182
204
  tenantScopes: result.tenantScopes,
183
205
  scopes: result.scopes,
206
+ ...(result.refused ? { refused: result.refused } : {}),
184
207
  },
185
208
  },
186
209
  )
@@ -6,7 +6,7 @@ import { apiCall, withScopedApiRequestHeaders } from "@open-mercato/ui/backend/u
6
6
  import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimisticLock";
7
7
  import { deleteCrud } from "@open-mercato/ui/backend/utils/crud";
8
8
  import { normalizeCrudServerError } from "@open-mercato/ui/backend/utils/serverErrors";
9
- import { LoadingMessage, TabEmptyState } from "@open-mercato/ui/backend/detail";
9
+ import { ErrorMessage, LoadingMessage, TabEmptyState } from "@open-mercato/ui/backend/detail";
10
10
  import { Button } from "@open-mercato/ui/primitives/button";
11
11
  import { flash } from "@open-mercato/ui/backend/FlashMessages";
12
12
  import { Pencil, Trash2 } from "lucide-react";
@@ -192,7 +192,7 @@ export function SalesDocumentItemsSection({
192
192
  kind !== "order",
193
193
  );
194
194
 
195
- const { widgets: allColumnWidgets } = useInjectionDataWidgets(
195
+ const { widgets: allColumnWidgets, isLoading: injectedColumnsLoading, error: injectedColumnsError } = useInjectionDataWidgets(
196
196
  extensionPoints.hosts.orderItemColumns.spotId,
197
197
  );
198
198
  const columnWidgets = kind === "order" ? allColumnWidgets : [];
@@ -719,7 +719,10 @@ export function SalesDocumentItemsSection({
719
719
  return (
720
720
  <OrderItemsInjectionContext.Provider value={{ documentId, kind }}>
721
721
  <div className="space-y-4">
722
- {loading ? (
722
+ {kind === "order" && injectedColumnsError ? (
723
+ <ErrorMessage label={t('ui.dataTable.extensions.error', 'Some table extensions could not load. Reload the page to retry.')} />
724
+ ) : null}
725
+ {loading || (kind === "order" && injectedColumnsLoading) ? (
723
726
  <LoadingMessage
724
727
  label={t("sales.documents.items.loading", "Loading items…")}
725
728
  className="border-0 bg-transparent p-0 py-8 justify-center"