@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/components/products/ProductsDataTable.js +6 -5
- package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies/page.js +5 -4
- package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +6 -5
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/page.js +5 -4
- package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
- package/dist/modules/query_index/cli.js +9 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +13 -2
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +28 -9
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +5 -1
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +95 -32
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-scope.js +101 -0
- package/dist/modules/query_index/lib/search-scope.js.map +7 -0
- package/dist/modules/query_index/lib/stale.js +41 -1
- package/dist/modules/query_index/lib/stale.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +30 -7
- package/dist/modules/query_index/lib/subscriber-scope.js.map +3 -3
- package/dist/modules/query_index/lib/tenant-global.js +41 -0
- package/dist/modules/query_index/lib/tenant-global.js.map +7 -0
- package/dist/modules/query_index/subscribers/reindex.js +9 -2
- package/dist/modules/query_index/subscribers/reindex.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +4 -3
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/components/products/ProductsDataTable.tsx +6 -5
- package/src/modules/customers/backend/customers/companies/page.tsx +5 -4
- package/src/modules/customers/backend/customers/deals/page.tsx +6 -5
- package/src/modules/customers/backend/customers/people/page.tsx +5 -4
- package/src/modules/query_index/cli.ts +19 -1
- package/src/modules/query_index/lib/coverage.ts +29 -3
- package/src/modules/query_index/lib/engine.ts +34 -9
- package/src/modules/query_index/lib/indexer.ts +6 -1
- package/src/modules/query_index/lib/reindexer.ts +205 -36
- package/src/modules/query_index/lib/search-scope.ts +144 -0
- package/src/modules/query_index/lib/stale.ts +73 -0
- package/src/modules/query_index/lib/subscriber-scope.ts +38 -8
- package/src/modules/query_index/lib/tenant-global.ts +93 -0
- package/src/modules/query_index/subscribers/reindex.ts +24 -1
- 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
|
|
58
|
-
if (!
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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"
|