@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,101 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import { listEntityMetadata } from "@open-mercato/shared/lib/db/entityMetadata";
|
|
3
|
+
import { getEntityIds } from "@open-mercato/shared/lib/encryption/entityIds";
|
|
4
|
+
import { resolveQueryIndexSourceMetadata } from "./subscriber-scope.js";
|
|
5
|
+
import { listTenantGlobalEntityTypes } from "./tenant-global.js";
|
|
6
|
+
const SEARCH_SCOPE_CACHE_KEY = /* @__PURE__ */ Symbol.for("@open-mercato/query-index/verified-search-scope");
|
|
7
|
+
function scopeCache() {
|
|
8
|
+
const scope = globalThis;
|
|
9
|
+
return scope[SEARCH_SCOPE_CACHE_KEY] ??= /* @__PURE__ */ new WeakMap();
|
|
10
|
+
}
|
|
11
|
+
async function resolveQueryIndexSearchScope(entityManager) {
|
|
12
|
+
let manager = entityManager;
|
|
13
|
+
if (!manager) {
|
|
14
|
+
const { getOrm } = await import("@open-mercato/shared/lib/db/mikro");
|
|
15
|
+
try {
|
|
16
|
+
manager = (await getOrm()).em;
|
|
17
|
+
} catch {
|
|
18
|
+
return { searchableEntityTypes: [], tenantGlobalEntityTypes: [], blockedEntityTypes: [...new Set(Object.values(getEntityIds(false)).flatMap((entities) => Object.values(entities)))] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const entityTypes = [...new Set(Object.values(getEntityIds(false)).flatMap((entities) => Object.values(entities)))].sort((leftType, rightType) => leftType.localeCompare(rightType));
|
|
22
|
+
if (!entityTypes.length) throw new Error("[internal] Query-index source registry is unavailable; refusing unverified search");
|
|
23
|
+
const declaredTypes = listTenantGlobalEntityTypes().sort((leftType, rightType) => leftType.localeCompare(rightType));
|
|
24
|
+
const metadataSignature = listEntityMetadata(manager).map((metadata) => [metadata.className, metadata.tableName, metadata.schema, metadata.properties?.tenantId?.fieldNames, metadata.properties?.organizationId?.fieldNames]);
|
|
25
|
+
const signature = JSON.stringify([entityTypes, declaredTypes, metadataSignature]);
|
|
26
|
+
const key = manager.getDriver();
|
|
27
|
+
const cache = scopeCache();
|
|
28
|
+
const cached = cache.get(key);
|
|
29
|
+
const result = cached?.signature === signature ? cached.result : verifySearchScope(manager, entityTypes, new Set(declaredTypes));
|
|
30
|
+
const entry = cached?.signature === signature ? cached : { signature, result };
|
|
31
|
+
cache.set(key, entry);
|
|
32
|
+
try {
|
|
33
|
+
return await result;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (cache.get(key) === entry) cache.delete(key);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function verifySearchScope(em, entityTypes, declaredTypes) {
|
|
40
|
+
const metadata = listEntityMetadata(em);
|
|
41
|
+
const sources = entityTypes.flatMap((entityType) => {
|
|
42
|
+
try {
|
|
43
|
+
const source = resolveQueryIndexSourceMetadata(em, entityType);
|
|
44
|
+
return [{ entityType, ...source, table: source.tableName ?? source.table, schema: source.schema ?? null }];
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
const customStorageMatches = metadata.filter((entry) => entry.className === "CustomEntityStorage" || entry.name === "CustomEntityStorage");
|
|
50
|
+
const customStorageMetadata = customStorageMatches.length === 1 ? customStorageMatches[0] : void 0;
|
|
51
|
+
const customStorageRegistered = Boolean(customStorageMetadata);
|
|
52
|
+
const customEntityStorageSchema = customStorageMetadata?.schema && customStorageMetadata.schema !== "*" ? customStorageMetadata.schema : null;
|
|
53
|
+
const physicalSchemas = [.../* @__PURE__ */ new Set([...sources.flatMap((source) => source.schema ? [source.schema] : []), ...customEntityStorageSchema ? [customEntityStorageSchema] : []])];
|
|
54
|
+
if (!sources.length && !customStorageRegistered) return { searchableEntityTypes: [], tenantGlobalEntityTypes: [], registeredSourceTypes: [], blockedEntityTypes: [...entityTypes], customEntityStorage: false, customEntityStorageSchema: null };
|
|
55
|
+
const db = em.getKysely();
|
|
56
|
+
const rows = await db.selectFrom("information_schema.columns").select(["table_schema", "table_name", "column_name"]).select(sql`table_schema = current_schema()`.as("is_current_schema")).where((expression) => expression.or([sql`table_schema = current_schema()`, ...physicalSchemas.length ? [expression("table_schema", "in", physicalSchemas)] : []])).where("table_name", "in", [.../* @__PURE__ */ new Set([...sources.map((source) => source.table), ...customStorageRegistered ? ["custom_entities_storage"] : []])]).execute();
|
|
57
|
+
const columnsByTable = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
const key = `${row.table_schema}.${row.table_name}`;
|
|
60
|
+
const columns = columnsByTable.get(key) ?? /* @__PURE__ */ new Set();
|
|
61
|
+
columns.add(row.column_name);
|
|
62
|
+
columnsByTable.set(key, columns);
|
|
63
|
+
if (row.is_current_schema) columnsByTable.set(row.table_name, columns);
|
|
64
|
+
}
|
|
65
|
+
const searchableEntityTypes = [];
|
|
66
|
+
const tenantGlobalEntityTypes = [];
|
|
67
|
+
for (const source of sources) {
|
|
68
|
+
const columns = columnsByTable.get(source.schema ? `${source.schema}.${source.table}` : source.table);
|
|
69
|
+
if (!columns?.has("id")) continue;
|
|
70
|
+
const hasTenant = columns.has("tenant_id") || Boolean(source.tenantColumn && columns.has(source.tenantColumn));
|
|
71
|
+
const hasOrganization = columns.has("organization_id") || Boolean(source.organizationColumn && columns.has(source.organizationColumn));
|
|
72
|
+
const global = !source.tenantColumn && !source.organizationColumn && !hasTenant && !hasOrganization && declaredTypes.has(source.entityType);
|
|
73
|
+
if (global) tenantGlobalEntityTypes.push(source.entityType);
|
|
74
|
+
if (hasTenant || global) searchableEntityTypes.push(source.entityType);
|
|
75
|
+
}
|
|
76
|
+
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)) };
|
|
77
|
+
}
|
|
78
|
+
function buildQueryIndexSearchScopePredicate(scope, target, tenantId) {
|
|
79
|
+
const entityType = sql.ref(target.entityTypeRef);
|
|
80
|
+
const known = scope.searchableEntityTypes.length ? sql`${entityType} in (${sql.join(scope.searchableEntityTypes)})` : sql`false`;
|
|
81
|
+
if (!scope.customEntityStorage) return known;
|
|
82
|
+
const table = sql.id(...scope.customEntityStorageSchema ? [scope.customEntityStorageSchema, "custom_entities_storage"] : ["custom_entities_storage"]);
|
|
83
|
+
const unknown = scope.registeredSourceTypes?.length ? sql`${entityType} not in (${sql.join(scope.registeredSourceTypes)})` : sql`true`;
|
|
84
|
+
return sql`(${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)})))`;
|
|
85
|
+
}
|
|
86
|
+
async function filterQueryIndexSearchRecords(db, scope, records) {
|
|
87
|
+
const known = new Set(scope.searchableEntityTypes);
|
|
88
|
+
const unknown = records.filter((record) => !known.has(record.entityId) && !scope.registeredSourceTypes?.includes(record.entityId));
|
|
89
|
+
if (!scope.customEntityStorage || !unknown.length) return records.filter((record) => known.has(record.entityId));
|
|
90
|
+
const source = db ?? (await (await import("@open-mercato/shared/lib/db/mikro")).getOrm()).em.getKysely();
|
|
91
|
+
const sourceDb = scope.customEntityStorageSchema ? source.withSchema(scope.customEntityStorageSchema) : source;
|
|
92
|
+
const rows = await sourceDb.selectFrom("custom_entities_storage").select(["entity_type", "entity_id", "tenant_id", "organization_id"]).where("entity_type", "in", [...new Set(unknown.map((record) => record.entityId))]).where("entity_id", "in", [...new Set(unknown.map((record) => record.recordId))]).where("tenant_id", "in", [...new Set(unknown.map((record) => record.tenantId))]).execute();
|
|
93
|
+
const identities = new Set(rows.map((row) => JSON.stringify([row.entity_type, row.entity_id, row.tenant_id, row.organization_id])));
|
|
94
|
+
return records.filter((record) => known.has(record.entityId) || identities.has(JSON.stringify([record.entityId, record.recordId, record.tenantId, record.organizationId ?? null])));
|
|
95
|
+
}
|
|
96
|
+
export {
|
|
97
|
+
buildQueryIndexSearchScopePredicate,
|
|
98
|
+
filterQueryIndexSearchRecords,
|
|
99
|
+
resolveQueryIndexSearchScope
|
|
100
|
+
};
|
|
101
|
+
//# sourceMappingURL=search-scope.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/query_index/lib/search-scope.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { sql, type Kysely, type RawBuilder } from 'kysely'\nimport { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { resolveQueryIndexSourceMetadata } from './subscriber-scope'\nimport { listTenantGlobalEntityTypes } from './tenant-global'\n\nexport type QueryIndexSearchScope = {\n searchableEntityTypes: readonly string[]\n blockedEntityTypes: readonly string[]\n tenantGlobalEntityTypes: readonly string[]\n registeredSourceTypes?: readonly string[]\n customEntityStorage?: boolean\n customEntityStorageSchema?: string | null\n}\n\ntype ScopeSnapshot = QueryIndexSearchScope & { registeredSourceTypes: readonly string[]; customEntityStorage: boolean; customEntityStorageSchema: string | null }\ntype ScopeCacheEntry = { signature: string; result: Promise<ScopeSnapshot> }\nconst SEARCH_SCOPE_CACHE_KEY = Symbol.for('@open-mercato/query-index/verified-search-scope')\ntype ScopeGlobals = typeof globalThis & { [SEARCH_SCOPE_CACHE_KEY]?: WeakMap<object, ScopeCacheEntry> }\n\nfunction scopeCache(): WeakMap<object, ScopeCacheEntry> {\n const scope = globalThis as ScopeGlobals\n return scope[SEARCH_SCOPE_CACHE_KEY] ??= new WeakMap<object, ScopeCacheEntry>()\n}\n\nexport async function resolveQueryIndexSearchScope(entityManager?: EntityManager): Promise<QueryIndexSearchScope> {\n let manager = entityManager\n if (!manager) {\n const { getOrm } = await import('@open-mercato/shared/lib/db/mikro')\n try {\n manager = (await getOrm()).em\n } catch {\n return { searchableEntityTypes: [], tenantGlobalEntityTypes: [], blockedEntityTypes: [...new Set(Object.values(getEntityIds(false)).flatMap((entities) => Object.values(entities)))] }\n }\n }\n const entityTypes = [...new Set(Object.values(getEntityIds(false)).flatMap((entities) => Object.values(entities)))].sort((leftType, rightType) => leftType.localeCompare(rightType))\n if (!entityTypes.length) throw new Error('[internal] Query-index source registry is unavailable; refusing unverified search')\n const declaredTypes = listTenantGlobalEntityTypes().sort((leftType, rightType) => leftType.localeCompare(rightType))\n const metadataSignature = listEntityMetadata(manager).map((metadata) => [metadata.className, metadata.tableName, metadata.schema, metadata.properties?.tenantId?.fieldNames, metadata.properties?.organizationId?.fieldNames])\n const signature = JSON.stringify([entityTypes, declaredTypes, metadataSignature])\n const key = manager.getDriver()\n const cache = scopeCache()\n const cached = cache.get(key)\n const result = cached?.signature === signature ? cached.result : verifySearchScope(manager, entityTypes, new Set(declaredTypes))\n const entry = cached?.signature === signature ? cached : { signature, result }\n cache.set(key, entry)\n try {\n return await result\n } catch (error) {\n if (cache.get(key) === entry) cache.delete(key)\n throw error\n }\n}\n\nasync function verifySearchScope(\n em: EntityManager,\n entityTypes: readonly string[],\n declaredTypes: ReadonlySet<string>,\n): Promise<ScopeSnapshot> {\n const metadata = listEntityMetadata(em)\n const sources = entityTypes.flatMap((entityType) => {\n try {\n const source = resolveQueryIndexSourceMetadata(em, entityType)\n return [{ entityType, ...source, table: source.tableName ?? source.table, schema: source.schema ?? null }]\n } catch {\n return []\n }\n })\n const customStorageMatches = metadata.filter((entry) => entry.className === 'CustomEntityStorage' || entry.name === 'CustomEntityStorage')\n const customStorageMetadata = customStorageMatches.length === 1 ? customStorageMatches[0] : undefined\n const customStorageRegistered = Boolean(customStorageMetadata)\n const customEntityStorageSchema = customStorageMetadata?.schema && customStorageMetadata.schema !== '*' ? customStorageMetadata.schema : null\n const physicalSchemas = [...new Set([...sources.flatMap((source) => source.schema ? [source.schema] : []), ...(customEntityStorageSchema ? [customEntityStorageSchema] : [])])]\n if (!sources.length && !customStorageRegistered) return { searchableEntityTypes: [], tenantGlobalEntityTypes: [], registeredSourceTypes: [], blockedEntityTypes: [...entityTypes], customEntityStorage: false, customEntityStorageSchema: null }\n const db = em.getKysely<{ 'information_schema.columns': { table_schema: string; table_name: string; column_name: string } }>()\n const rows = await db.selectFrom('information_schema.columns')\n .select(['table_schema', 'table_name', 'column_name'])\n .select(sql<boolean>`table_schema = current_schema()`.as('is_current_schema'))\n .where((expression) => expression.or([sql<boolean>`table_schema = current_schema()`, ...(physicalSchemas.length ? [expression('table_schema', 'in', physicalSchemas)] : [])]))\n .where('table_name', 'in', [...new Set([...sources.map((source) => source.table), ...(customStorageRegistered ? ['custom_entities_storage'] : [])])])\n .execute()\n const columnsByTable = new Map<string, Set<string>>()\n for (const row of rows) {\n const key = `${row.table_schema}.${row.table_name}`\n const columns = columnsByTable.get(key) ?? new Set<string>()\n columns.add(row.column_name)\n columnsByTable.set(key, columns)\n if (row.is_current_schema) columnsByTable.set(row.table_name, columns)\n }\n const searchableEntityTypes: string[] = []\n const tenantGlobalEntityTypes: string[] = []\n for (const source of sources) {\n const columns = columnsByTable.get(source.schema ? `${source.schema}.${source.table}` : source.table)\n if (!columns?.has('id')) continue\n const hasTenant = columns.has('tenant_id') || Boolean(source.tenantColumn && columns.has(source.tenantColumn))\n const hasOrganization = columns.has('organization_id') || Boolean(source.organizationColumn && columns.has(source.organizationColumn))\n const global = !source.tenantColumn && !source.organizationColumn && !hasTenant && !hasOrganization && declaredTypes.has(source.entityType)\n if (global) tenantGlobalEntityTypes.push(source.entityType)\n if (hasTenant || global) searchableEntityTypes.push(source.entityType)\n }\n 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)) }\n}\n\ntype SearchRecordIdentity = { entityId: string; recordId: string; tenantId: string; organizationId?: string | null }\ntype CustomStorageDatabase = {\n custom_entities_storage: { entity_type: string; entity_id: string; tenant_id: string | null; organization_id: string | null }\n}\n\nexport function buildQueryIndexSearchScopePredicate(\n scope: QueryIndexSearchScope,\n target: { entityTypeRef: string; recordIdRef: string; organizationIdRef: string },\n tenantId: string,\n): RawBuilder<boolean> {\n const entityType = sql.ref(target.entityTypeRef)\n const known = scope.searchableEntityTypes.length\n ? sql<boolean>`${entityType} in (${sql.join(scope.searchableEntityTypes)})`\n : sql<boolean>`false`\n if (!scope.customEntityStorage) return known\n const table = sql.id(...(scope.customEntityStorageSchema ? [scope.customEntityStorageSchema, 'custom_entities_storage'] : ['custom_entities_storage']))\n const unknown = scope.registeredSourceTypes?.length\n ? sql<boolean>`${entityType} not in (${sql.join(scope.registeredSourceTypes)})`\n : sql<boolean>`true`\n 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)})))`\n}\n\nexport async function filterQueryIndexSearchRecords<RecordType extends SearchRecordIdentity>(\n db: Kysely<CustomStorageDatabase> | undefined,\n scope: QueryIndexSearchScope,\n records: readonly RecordType[],\n): Promise<RecordType[]> {\n const known = new Set(scope.searchableEntityTypes)\n const unknown = records.filter((record) => !known.has(record.entityId) && !scope.registeredSourceTypes?.includes(record.entityId))\n if (!scope.customEntityStorage || !unknown.length) return records.filter((record) => known.has(record.entityId))\n const source = db ?? (await (await import('@open-mercato/shared/lib/db/mikro')).getOrm()).em.getKysely<CustomStorageDatabase>()\n const sourceDb = scope.customEntityStorageSchema ? source.withSchema(scope.customEntityStorageSchema) : source\n const rows = await sourceDb.selectFrom('custom_entities_storage').select(['entity_type', 'entity_id', 'tenant_id', 'organization_id'])\n .where('entity_type', 'in', [...new Set(unknown.map((record) => record.entityId))])\n .where('entity_id', 'in', [...new Set(unknown.map((record) => record.recordId))])\n .where('tenant_id', 'in', [...new Set(unknown.map((record) => record.tenantId))])\n .execute()\n const identities = new Set(rows.map((row) => JSON.stringify([row.entity_type, row.entity_id, row.tenant_id, row.organization_id])))\n return records.filter((record) => known.has(record.entityId) || identities.has(JSON.stringify([record.entityId, record.recordId, record.tenantId, record.organizationId ?? null])))\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -59,7 +59,47 @@ async function purgeOrphans(db, options) {
|
|
|
59
59
|
).catch(() => void 0);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
async function retireScopedCatalogueRows(db, options) {
|
|
63
|
+
const { entityType, partitionIndex, partitionCount } = options;
|
|
64
|
+
const scoped = sql`(tenant_id is not null or organization_id is not null)`;
|
|
65
|
+
const inPartition = partitionIndex != null && partitionCount != null ? sql`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}` : null;
|
|
66
|
+
let projections = db.deleteFrom("entity_indexes").where("entity_type", "=", entityType).where(scoped);
|
|
67
|
+
if (inPartition) projections = projections.where(inPartition);
|
|
68
|
+
if (options.excludeRecordIds?.length) {
|
|
69
|
+
projections = projections.where("entity_id", "not in", options.excludeRecordIds);
|
|
70
|
+
}
|
|
71
|
+
await projections.execute();
|
|
72
|
+
try {
|
|
73
|
+
if (!await searchTokensTableExists(db)) return;
|
|
74
|
+
let tokens = db.deleteFrom("search_tokens").where("entity_type", "=", entityType).where(scoped);
|
|
75
|
+
if (inPartition) tokens = tokens.where(inPartition);
|
|
76
|
+
if (options.excludeRecordIds?.length) {
|
|
77
|
+
tokens = tokens.where("entity_id", "not in", options.excludeRecordIds);
|
|
78
|
+
}
|
|
79
|
+
tokens = tokens.where(sql`not exists (
|
|
80
|
+
select 1 from entity_indexes as surviving
|
|
81
|
+
where surviving.entity_type = ${entityType}
|
|
82
|
+
and surviving.entity_id = search_tokens.entity_id
|
|
83
|
+
and surviving.tenant_id is not distinct from search_tokens.tenant_id
|
|
84
|
+
and surviving.organization_id is not distinct from search_tokens.organization_id
|
|
85
|
+
)`);
|
|
86
|
+
await tokens.execute();
|
|
87
|
+
} catch (searchTokenError) {
|
|
88
|
+
await recordIndexerError(
|
|
89
|
+
{ db },
|
|
90
|
+
{
|
|
91
|
+
source: "fulltext",
|
|
92
|
+
handler: "query_index:retire-scoped-catalogue-rows",
|
|
93
|
+
error: searchTokenError,
|
|
94
|
+
entityType,
|
|
95
|
+
tenantId: null,
|
|
96
|
+
organizationId: null
|
|
97
|
+
}
|
|
98
|
+
).catch(() => void 0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
62
101
|
export {
|
|
63
|
-
purgeOrphans
|
|
102
|
+
purgeOrphans,
|
|
103
|
+
retireScopedCatalogueRows
|
|
64
104
|
};
|
|
65
105
|
//# sourceMappingURL=stale.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/lib/stale.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\n\ntype PurgeOrphansOptions = {\n entityType: string\n tenantId?: string | null\n organizationId?: string | null\n partitionIndex: number | null\n partitionCount: number | null\n startedAt: Date\n /**\n * Records whose index rows must be preserved even though this run did not refresh\n * them. A row that failed to be written still looks untouched to the `updated_at`\n * predicate below, so without this the purge would delete the very entries the run\n * failed to rebuild.\n */\n excludeRecordIds?: string[]\n}\n\n/**\n * `entity_indexes` ships in an earlier migration than `search_tokens`, so a deployment\n * running this code against a not-yet-migrated database has the projection table and not\n * the token table. The query engine probes the same way before reading the table\n * (`shared/lib/search/availability.ts`).\n */\nasync function searchTokensTableExists(db: Kysely<any>): Promise<boolean> {\n const row = await db\n .selectFrom('information_schema.tables' as any)\n .select(sql<number>`1`.as('one') as any)\n .where('table_name' as any, '=', 'search_tokens')\n .limit(1)\n .executeTakeFirst()\n return !!row\n}\n\nexport async function purgeOrphans(\n db: Kysely<any>,\n options: PurgeOrphansOptions,\n): Promise<void> {\n const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options\n\n let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)\n if (tenantId !== undefined) {\n q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)\n }\n if (options.organizationId !== undefined) {\n q = q.where(sql<boolean>`organization_id is not distinct from ${options.organizationId ?? null}`)\n }\n if (partitionIndex != null && partitionCount != null) {\n q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n q = q.where('updated_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n await q.execute()\n\n // The token sweep runs as its own statement, after the projection delete has committed, and\n // deliberately cannot fail the run. Its anti-join only needs those deletes to be *visible*,\n // and a committed delete is as visible as an uncommitted one inside a shared transaction \u2014\n // so wrapping the pair buys no correctness, only a new fatal path: `reindexer.ts` does not\n // guard this call and marks the whole job failed on a throw, so a statement timeout on a\n // large token delete would discard a rebuild whose projections all wrote successfully. The\n // fallback is the pre-fix steady state: orphan tokens survive, benign, until the next run\n // sweeps them. This matches how the module already treats token-side failures \u2014\n // `batch.ts` records a failed token write via `recordIndexerError` rather than counting it\n // as a write failure, and `indexer.ts` swallows the token delete outside a transaction.\n try {\n if (!(await searchTokensTableExists(db))) return\n\n // Search tokens are written from the same (entity_type, entity_id, tenant_id,\n // organization_id) tuple as the projection, but nothing above ever touched them, so\n // every record this run stopped covering kept a queryable token with no presenter \u2014\n // and token-only orphans left by earlier runs survived every rebuild since.\n let tokens = db.deleteFrom('search_tokens' as any).where('entity_type' as any, '=', entityType)\n // `= / is null` rather than the projection sweep's `is not distinct from`: these are bound\n // constants, so the two spellings are semantically identical here, but only the former can\n // serve as a btree index condition. Without it the delete uses just the `entity_type` prefix\n // of `search_tokens_presence_idx (entity_type, tenant_id, organization_id)` and a\n // tenant-scoped, unpartitioned run reads every tenant's tokens for that entity type \u2014\n // the same planner trap `shared/lib/search/availability.ts` documents for this table (#4723).\n // The anti-join below keeps the null-safe form: it compares columns, not constants.\n if (tenantId !== undefined) {\n tokens = tenantId === null\n ? tokens.where('tenant_id' as any, 'is', null)\n : tokens.where('tenant_id' as any, '=', tenantId)\n }\n if (options.organizationId !== undefined) {\n tokens = options.organizationId === null\n ? tokens.where('organization_id' as any, 'is', null)\n : tokens.where('organization_id' as any, '=', options.organizationId)\n }\n if (partitionIndex != null && partitionCount != null) {\n tokens = tokens.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n // Secondary guard only. `replaceSearchTokensForBatch` returns early for a record whose\n // tokens are unchanged, so a perfectly current record keeps tokens older than this run\n // \u2014 a timestamp-driven sweep would make live records unfindable. What this predicate\n // does earn is the concurrent-writer case: a batch inserts tokens before the matching\n // projection, so a token written during the run can outrun its own presenter.\n tokens = tokens.where('created_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n tokens = tokens.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n // The load-bearing predicate: a token survives while any projection for its own record\n // and scope survives, soft-deleted ones included. `search_tokens` has a second writer \u2014\n // `@open-mercato/search`'s token strategy writes rows for the same records under its own\n // `field` spellings \u2014 and this sweep is safe for those rows only because that strategy\n // draws its record set from the query engine, i.e. from the very projections consulted\n // here. Changing where that strategy gets its records would make this delete eat its rows.\n tokens = tokens.where(sql<boolean>`not exists (\n select 1 from entity_indexes as surviving\n where surviving.entity_type = ${entityType}\n and surviving.entity_id = search_tokens.entity_id\n and surviving.tenant_id is not distinct from search_tokens.tenant_id\n and surviving.organization_id is not distinct from search_tokens.organization_id\n )`)\n await tokens.execute()\n } catch (searchTokenError) {\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:purge-orphans',\n error: searchTokenError,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: options.organizationId ?? null,\n },\n ).catch(() => undefined)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAsB,WAAW;AACjC,SAAS,0BAA0B;AAwBnC,eAAe,wBAAwB,IAAmC;AACxE,QAAM,MAAM,MAAM,GACf,WAAW,2BAAkC,EAC7C,OAAO,OAAe,GAAG,KAAK,CAAQ,EACtC,MAAM,cAAqB,KAAK,eAAe,EAC/C,MAAM,CAAC,EACP,iBAAiB;AACpB,SAAO,CAAC,CAAC;AACX;AAEA,eAAsB,aACpB,IACA,SACe;AACf,QAAM,EAAE,YAAY,UAAU,gBAAgB,gBAAgB,UAAU,IAAI;AAE5E,MAAI,IAAI,GAAG,WAAW,gBAAuB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAC1F,MAAI,aAAa,QAAW;AAC1B,QAAI,EAAE,MAAM,qCAA8C,YAAY,IAAI,EAAE;AAAA,EAC9E;AACA,MAAI,QAAQ,mBAAmB,QAAW;AACxC,QAAI,EAAE,MAAM,2CAAoD,QAAQ,kBAAkB,IAAI,EAAE;AAAA,EAClG;AACA,MAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,QAAI,EAAE,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,EACtG;AACA,MAAI,EAAE,MAAM,cAAqB,KAAK,SAAgB;AACtD,MAAI,QAAQ,kBAAkB,QAAQ;AACpC,QAAI,EAAE,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,EACpE;AACA,QAAM,EAAE,QAAQ;AAYhB,MAAI;AACF,QAAI,CAAE,MAAM,wBAAwB,EAAE,EAAI;AAM1C,QAAI,SAAS,GAAG,WAAW,eAAsB,EAAE,MAAM,eAAsB,KAAK,UAAU;AAQ9F,QAAI,aAAa,QAAW;AAC1B,eAAS,aAAa,OAClB,OAAO,MAAM,aAAoB,MAAM,IAAI,IAC3C,OAAO,MAAM,aAAoB,KAAK,QAAQ;AAAA,IACpD;AACA,QAAI,QAAQ,mBAAmB,QAAW;AACxC,eAAS,QAAQ,mBAAmB,OAChC,OAAO,MAAM,mBAA0B,MAAM,IAAI,IACjD,OAAO,MAAM,mBAA0B,KAAK,QAAQ,cAAc;AAAA,IACxE;AACA,QAAI,kBAAkB,QAAQ,kBAAkB,MAAM;AACpD,eAAS,OAAO,MAAM,0CAAmD,cAAc,OAAO,cAAc,EAAE;AAAA,IAChH;AAMA,aAAS,OAAO,MAAM,cAAqB,KAAK,SAAgB;AAChE,QAAI,QAAQ,kBAAkB,QAAQ;AACpC,eAAS,OAAO,MAAM,aAAoB,UAAU,QAAQ,gBAAgB;AAAA,IAC9E;AAOA,aAAS,OAAO,MAAM;AAAA;AAAA,sCAEY,UAAU;AAAA;AAAA;AAAA;AAAA,MAI1C;AACF,UAAM,OAAO,QAAQ;AAAA,EACvB,SAAS,kBAAkB;AACzB,UAAM;AAAA,MACJ,EAAE,GAAG;AAAA,MACL;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,OAAO;AAAA,QACP;AAAA,QACA,UAAU,YAAY;AAAA,QACtB,gBAAgB,QAAQ,kBAAkB;AAAA,MAC5C;AAAA,IACF,EAAE,MAAM,MAAM,MAAS;AAAA,EACzB;AACF;",
|
|
4
|
+
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\n\ntype PurgeOrphansOptions = {\n entityType: string\n tenantId?: string | null\n organizationId?: string | null\n partitionIndex: number | null\n partitionCount: number | null\n startedAt: Date\n /**\n * Records whose index rows must be preserved even though this run did not refresh\n * them. A row that failed to be written still looks untouched to the `updated_at`\n * predicate below, so without this the purge would delete the very entries the run\n * failed to rebuild.\n */\n excludeRecordIds?: string[]\n}\n\n/**\n * `entity_indexes` ships in an earlier migration than `search_tokens`, so a deployment\n * running this code against a not-yet-migrated database has the projection table and not\n * the token table. The query engine probes the same way before reading the table\n * (`shared/lib/search/availability.ts`).\n */\nasync function searchTokensTableExists(db: Kysely<any>): Promise<boolean> {\n const row = await db\n .selectFrom('information_schema.tables' as any)\n .select(sql<number>`1`.as('one') as any)\n .where('table_name' as any, '=', 'search_tokens')\n .limit(1)\n .executeTakeFirst()\n return !!row\n}\n\nexport async function purgeOrphans(\n db: Kysely<any>,\n options: PurgeOrphansOptions,\n): Promise<void> {\n const { entityType, tenantId, partitionIndex, partitionCount, startedAt } = options\n\n let q = db.deleteFrom('entity_indexes' as any).where('entity_type' as any, '=', entityType)\n if (tenantId !== undefined) {\n q = q.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)\n }\n if (options.organizationId !== undefined) {\n q = q.where(sql<boolean>`organization_id is not distinct from ${options.organizationId ?? null}`)\n }\n if (partitionIndex != null && partitionCount != null) {\n q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n q = q.where('updated_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n await q.execute()\n\n // The token sweep runs as its own statement, after the projection delete has committed, and\n // deliberately cannot fail the run. Its anti-join only needs those deletes to be *visible*,\n // and a committed delete is as visible as an uncommitted one inside a shared transaction \u2014\n // so wrapping the pair buys no correctness, only a new fatal path: `reindexer.ts` does not\n // guard this call and marks the whole job failed on a throw, so a statement timeout on a\n // large token delete would discard a rebuild whose projections all wrote successfully. The\n // fallback is the pre-fix steady state: orphan tokens survive, benign, until the next run\n // sweeps them. This matches how the module already treats token-side failures \u2014\n // `batch.ts` records a failed token write via `recordIndexerError` rather than counting it\n // as a write failure, and `indexer.ts` swallows the token delete outside a transaction.\n try {\n if (!(await searchTokensTableExists(db))) return\n\n // Search tokens are written from the same (entity_type, entity_id, tenant_id,\n // organization_id) tuple as the projection, but nothing above ever touched them, so\n // every record this run stopped covering kept a queryable token with no presenter \u2014\n // and token-only orphans left by earlier runs survived every rebuild since.\n let tokens = db.deleteFrom('search_tokens' as any).where('entity_type' as any, '=', entityType)\n // `= / is null` rather than the projection sweep's `is not distinct from`: these are bound\n // constants, so the two spellings are semantically identical here, but only the former can\n // serve as a btree index condition. Without it the delete uses just the `entity_type` prefix\n // of `search_tokens_presence_idx (entity_type, tenant_id, organization_id)` and a\n // tenant-scoped, unpartitioned run reads every tenant's tokens for that entity type \u2014\n // the same planner trap `shared/lib/search/availability.ts` documents for this table (#4723).\n // The anti-join below keeps the null-safe form: it compares columns, not constants.\n if (tenantId !== undefined) {\n tokens = tenantId === null\n ? tokens.where('tenant_id' as any, 'is', null)\n : tokens.where('tenant_id' as any, '=', tenantId)\n }\n if (options.organizationId !== undefined) {\n tokens = options.organizationId === null\n ? tokens.where('organization_id' as any, 'is', null)\n : tokens.where('organization_id' as any, '=', options.organizationId)\n }\n if (partitionIndex != null && partitionCount != null) {\n tokens = tokens.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)\n }\n // Secondary guard only. `replaceSearchTokensForBatch` returns early for a record whose\n // tokens are unchanged, so a perfectly current record keeps tokens older than this run\n // \u2014 a timestamp-driven sweep would make live records unfindable. What this predicate\n // does earn is the concurrent-writer case: a batch inserts tokens before the matching\n // projection, so a token written during the run can outrun its own presenter.\n tokens = tokens.where('created_at' as any, '<', startedAt as any)\n if (options.excludeRecordIds?.length) {\n tokens = tokens.where('entity_id' as any, 'not in', options.excludeRecordIds)\n }\n // The load-bearing predicate: a token survives while any projection for its own record\n // and scope survives, soft-deleted ones included. `search_tokens` has a second writer \u2014\n // `@open-mercato/search`'s token strategy writes rows for the same records under its own\n // `field` spellings \u2014 and this sweep is safe for those rows only because that strategy\n // draws its record set from the query engine, i.e. from the very projections consulted\n // here. Changing where that strategy gets its records would make this delete eat its rows.\n tokens = tokens.where(sql<boolean>`not exists (\n select 1 from entity_indexes as surviving\n where surviving.entity_type = ${entityType}\n and surviving.entity_id = search_tokens.entity_id\n and surviving.tenant_id is not distinct from search_tokens.tenant_id\n and surviving.organization_id is not distinct from search_tokens.organization_id\n )`)\n await tokens.execute()\n } catch (searchTokenError) {\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:purge-orphans',\n error: searchTokenError,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: options.organizationId ?? null,\n },\n ).catch(() => undefined)\n }\n}\n\ntype RetireScopedCatalogueRowsOptions = {\n entityType: string\n partitionIndex: number | null\n partitionCount: number | null\n /** Records this run failed to write keep their old rows, as in `purgeOrphans`. */\n excludeRecordIds?: string[]\n}\n\ntype CatalogueRetirementDatabase = {\n entity_indexes: { entity_type: string; entity_id: string; tenant_id: string | null; organization_id: string | null }\n search_tokens: { entity_type: string; entity_id: string; tenant_id: string | null; organization_id: string | null }\n}\n\n/**\n * Retire the projections and tokens a declared platform-wide catalogue accumulated before\n * its canonical scope became `tenant_id = NULL, organization_id = NULL`.\n *\n * Earlier sweeps stamped the caller's tenant and organization. `entity_indexes` is unique on\n * (entity_type, entity_id, organization_id_coalesced), so a row stamped with an organization\n * is a different key from the canonical one: the upsert never rewrites it, and\n * `purgeOrphans`, scoped to the canonical row, never selects it - it survives every sweep,\n * even the deletion of its source record. Nothing writes a scoped row for such a type any\n * more (the event path requires a null scope for it, and the sweep now writes one), so every\n * scoped row is residue whichever tenant stamped it. Only call this for an entity type\n * `reindexEntity` writes as a global projection.\n */\nexport async function retireScopedCatalogueRows(\n db: Kysely<CatalogueRetirementDatabase>,\n options: RetireScopedCatalogueRowsOptions,\n): Promise<void> {\n const { entityType, partitionIndex, partitionCount } = options\n const scoped = sql<boolean>`(tenant_id is not null or organization_id is not null)`\n const inPartition = partitionIndex != null && partitionCount != null\n ? sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`\n : null\n\n let projections = db.deleteFrom('entity_indexes').where('entity_type', '=', entityType).where(scoped)\n if (inPartition) projections = projections.where(inPartition)\n if (options.excludeRecordIds?.length) {\n projections = projections.where('entity_id', 'not in', options.excludeRecordIds)\n }\n await projections.execute()\n\n try {\n if (!(await searchTokensTableExists(db))) return\n let tokens = db.deleteFrom('search_tokens').where('entity_type', '=', entityType).where(scoped)\n if (inPartition) tokens = tokens.where(inPartition)\n if (options.excludeRecordIds?.length) {\n tokens = tokens.where('entity_id', 'not in', options.excludeRecordIds)\n }\n tokens = tokens.where(sql<boolean>`not exists (\n select 1 from entity_indexes as surviving\n where surviving.entity_type = ${entityType}\n and surviving.entity_id = search_tokens.entity_id\n and surviving.tenant_id is not distinct from search_tokens.tenant_id\n and surviving.organization_id is not distinct from search_tokens.organization_id\n )`)\n await tokens.execute()\n } catch (searchTokenError) {\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:retire-scoped-catalogue-rows',\n error: searchTokenError,\n entityType,\n tenantId: null,\n organizationId: null,\n },\n ).catch(() => undefined)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -12,17 +12,39 @@ function resolveQueryIndexSourceMetadata(em, entityType) {
|
|
|
12
12
|
if (!registeredEntityIds.includes(entityType)) {
|
|
13
13
|
throw new QueryIndexScopeError(`Query index entity type is not registered: ${entityType}`);
|
|
14
14
|
}
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
17
|
-
|
|
18
|
-
}
|
|
15
|
+
const source = resolveQueryIndexSourceTableMetadata(em, entityType);
|
|
16
|
+
if (!source) throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`);
|
|
17
|
+
const listed = listEntityMetadata(em).some((metadata) => metadata.tableName === (source.tableName ?? source.table) && (metadata.schema && metadata.schema !== "*" ? metadata.schema : void 0) === source.schema);
|
|
18
|
+
if (!listed) throw new QueryIndexScopeError(`[internal] Query index entity metadata was not found for table: ${source.table}`);
|
|
19
|
+
return source;
|
|
20
|
+
}
|
|
21
|
+
function resolveQueryIndexSourceTableMetadata(em, entityType) {
|
|
22
|
+
const rawName = entityType.split(":").pop() ?? "";
|
|
23
|
+
const className = rawName.split(/[_\s]+/).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join("");
|
|
19
24
|
const allMetadata = listEntityMetadata(em);
|
|
20
|
-
|
|
25
|
+
let metadata;
|
|
26
|
+
for (const candidate of [className, `${className}Entity`]) {
|
|
27
|
+
const matches = allMetadata.filter((entry) => entry.className === candidate || entry.name === candidate);
|
|
28
|
+
if (matches.length > 1) throw new QueryIndexScopeError(`[internal] Ambiguous query-index entity metadata: ${entityType}`);
|
|
29
|
+
if (matches.length === 1) {
|
|
30
|
+
metadata = matches[0];
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
21
34
|
if (!metadata) {
|
|
22
|
-
|
|
35
|
+
const table2 = resolveRegisteredEntityTableName(em, entityType);
|
|
36
|
+
if (!table2) return null;
|
|
37
|
+
const matches = allMetadata.filter((entry) => entry.tableName === table2);
|
|
38
|
+
if (matches.length !== 1) throw new QueryIndexScopeError(`[internal] Ambiguous query-index table metadata: ${entityType}`);
|
|
39
|
+
metadata = matches[0];
|
|
23
40
|
}
|
|
41
|
+
const tableName = String(metadata.tableName ?? "");
|
|
42
|
+
if (!tableName) return null;
|
|
43
|
+
const schema = typeof metadata.schema === "string" && metadata.schema !== "*" ? metadata.schema : void 0;
|
|
44
|
+
const table = schema ? `${schema}.${tableName}` : tableName;
|
|
24
45
|
return {
|
|
25
46
|
table,
|
|
47
|
+
...schema ? { schema, tableName } : {},
|
|
26
48
|
organizationColumn: resolveScopeColumn(metadata, "organizationId", table),
|
|
27
49
|
tenantColumn: resolveScopeColumn(metadata, "tenantId", table)
|
|
28
50
|
};
|
|
@@ -121,6 +143,7 @@ export {
|
|
|
121
143
|
loadQueryIndexRowScope,
|
|
122
144
|
resolveQueryIndexRecordScope,
|
|
123
145
|
resolveQueryIndexReindexScope,
|
|
124
|
-
resolveQueryIndexSourceMetadata
|
|
146
|
+
resolveQueryIndexSourceMetadata,
|
|
147
|
+
resolveQueryIndexSourceTableMetadata
|
|
125
148
|
};
|
|
126
149
|
//# sourceMappingURL=subscriber-scope.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/lib/subscriber-scope.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'\n\nexport type QueryIndexScope = {\n tenantId: string | null\n organizationId: string | null\n}\n\nexport type QueryIndexSourceMetadata = {\n table: string\n organizationColumn: string | null\n tenantColumn: string | null\n}\n\nexport type QueryIndexSourceScope =\n | { kind: 'global' }\n | { kind: 'missing' }\n | { kind: 'row'; scope: QueryIndexScope }\n\ntype QueryIndexEntityMetadata = {\n tableName?: unknown\n properties?: Record<string, { fieldNames?: unknown }>\n}\n\nexport class QueryIndexScopeError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'QueryIndexScopeError'\n }\n}\n\ntype ResolveRecordScopeInput = {\n payloadTenantId: string | null | undefined\n payloadOrganizationId: string | null | undefined\n hasPayloadTenantId: boolean\n hasPayloadOrganizationId: boolean\n sourceScope: QueryIndexSourceScope\n}\n\ntype ResolveReindexScopeInput = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n allowAllTenants?: boolean\n}\n\nexport function resolveQueryIndexSourceMetadata(\n em: EntityManager,\n entityType: string,\n): QueryIndexSourceMetadata {\n const registeredEntityIds = Object.values(getEntityIds(false)).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))\n if (!registeredEntityIds.includes(entityType)) {\n throw new QueryIndexScopeError(`Query index entity type is not registered: ${entityType}`)\n }\n\n const
|
|
5
|
-
"mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,wCAAwC;AACjD,SAAS,0BAA0B;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'\nimport { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'\n\nexport type QueryIndexScope = {\n tenantId: string | null\n organizationId: string | null\n}\n\nexport type QueryIndexSourceMetadata = {\n table: string\n schema?: string\n tableName?: string\n organizationColumn: string | null\n tenantColumn: string | null\n}\n\nexport type QueryIndexSourceScope =\n | { kind: 'global' }\n | { kind: 'missing' }\n | { kind: 'row'; scope: QueryIndexScope }\n\ntype QueryIndexEntityMetadata = {\n tableName?: unknown\n className?: unknown\n name?: unknown\n schema?: unknown\n properties?: Record<string, { fieldNames?: unknown }>\n}\n\nexport class QueryIndexScopeError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'QueryIndexScopeError'\n }\n}\n\ntype ResolveRecordScopeInput = {\n payloadTenantId: string | null | undefined\n payloadOrganizationId: string | null | undefined\n hasPayloadTenantId: boolean\n hasPayloadOrganizationId: boolean\n sourceScope: QueryIndexSourceScope\n}\n\ntype ResolveReindexScopeInput = {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n allowAllTenants?: boolean\n}\n\nexport function resolveQueryIndexSourceMetadata(\n em: EntityManager,\n entityType: string,\n): QueryIndexSourceMetadata {\n const registeredEntityIds = Object.values(getEntityIds(false)).flatMap((moduleEntities) => Object.values(moduleEntities ?? {}))\n if (!registeredEntityIds.includes(entityType)) {\n throw new QueryIndexScopeError(`Query index entity type is not registered: ${entityType}`)\n }\n\n const source = resolveQueryIndexSourceTableMetadata(em, entityType)\n if (!source) throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)\n const listed = listEntityMetadata(em).some((metadata) => metadata.tableName === (source.tableName ?? source.table) && (metadata.schema && metadata.schema !== '*' ? metadata.schema : undefined) === source.schema)\n if (!listed) throw new QueryIndexScopeError(`[internal] Query index entity metadata was not found for table: ${source.table}`)\n return source\n}\n\nexport function resolveQueryIndexSourceTableMetadata(\n em: EntityManager,\n entityType: string,\n): QueryIndexSourceMetadata | null {\n const rawName = entityType.split(':').pop() ?? ''\n const className = rawName.split(/[_\\s]+/).filter(Boolean)\n .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join('')\n const allMetadata = listEntityMetadata(em) as unknown as QueryIndexEntityMetadata[]\n let metadata: QueryIndexEntityMetadata | undefined\n for (const candidate of [className, `${className}Entity`]) {\n const matches = allMetadata.filter((entry) => entry.className === candidate || entry.name === candidate)\n if (matches.length > 1) throw new QueryIndexScopeError(`[internal] Ambiguous query-index entity metadata: ${entityType}`)\n if (matches.length === 1) {\n metadata = matches[0]\n break\n }\n }\n if (!metadata) {\n const table = resolveRegisteredEntityTableName(em, entityType)\n if (!table) return null\n const matches = allMetadata.filter((entry) => entry.tableName === table)\n if (matches.length !== 1) throw new QueryIndexScopeError(`[internal] Ambiguous query-index table metadata: ${entityType}`)\n metadata = matches[0]\n }\n const tableName = String(metadata.tableName ?? '')\n if (!tableName) return null\n const schema = typeof metadata.schema === 'string' && metadata.schema !== '*' ? metadata.schema : undefined\n const table = schema ? `${schema}.${tableName}` : tableName\n return {\n table,\n ...(schema ? { schema, tableName } : {}),\n organizationColumn: resolveScopeColumn(metadata, 'organizationId', table),\n tenantColumn: resolveScopeColumn(metadata, 'tenantId', table),\n }\n}\n\nexport async function loadQueryIndexRowScope(\n em: EntityManager,\n source: QueryIndexSourceMetadata,\n recordId: string,\n): Promise<QueryIndexSourceScope> {\n if (!source.organizationColumn && !source.tenantColumn) {\n return { kind: 'global' }\n }\n\n const db = em.getKysely<any>()\n const columns = [source.organizationColumn, source.tenantColumn].filter((column): column is string => column !== null)\n const row = await db\n .selectFrom(source.table as any)\n .select(columns as any)\n .where('id' as any, '=', recordId)\n .executeTakeFirst() as Record<string, string | null | undefined> | undefined\n\n if (!row) {\n return { kind: 'missing' }\n }\n\n return {\n kind: 'row',\n scope: {\n organizationId: source.organizationColumn ? row[source.organizationColumn] ?? null : null,\n tenantId: source.tenantColumn ? row[source.tenantColumn] ?? null : null,\n },\n }\n}\n\nexport function resolveQueryIndexRecordScope(input: ResolveRecordScopeInput): QueryIndexScope {\n const {\n payloadTenantId,\n payloadOrganizationId,\n hasPayloadTenantId,\n hasPayloadOrganizationId,\n sourceScope,\n } = input\n\n if (sourceScope.kind === 'global') {\n if (!hasPayloadTenantId || !hasPayloadOrganizationId || payloadTenantId !== null || payloadOrganizationId !== null) {\n throw new QueryIndexScopeError(\n 'Query index event for a global entity must explicitly provide tenantId and organizationId as null'\n )\n }\n\n return {\n tenantId: null,\n organizationId: null,\n }\n }\n\n if (sourceScope.kind === 'missing') {\n if (!hasPayloadTenantId || !hasPayloadOrganizationId) {\n throw new QueryIndexScopeError(\n 'Query index event is missing tenantId/organizationId and source row scope could not be resolved'\n )\n }\n\n return {\n tenantId: payloadTenantId ?? null,\n organizationId: payloadOrganizationId ?? null,\n }\n }\n\n const rowScope = sourceScope.scope\n\n if (hasPayloadTenantId && !isSameScopeValue(payloadTenantId, rowScope.tenantId)) {\n throw new QueryIndexScopeError(\n `Query index event tenantId does not match source row scope (payload=${String(payloadTenantId ?? null)}, row=${String(rowScope.tenantId)})`\n )\n }\n\n if (hasPayloadOrganizationId && !isSameScopeValue(payloadOrganizationId, rowScope.organizationId)) {\n throw new QueryIndexScopeError(\n `Query index event organizationId does not match source row scope (payload=${String(payloadOrganizationId ?? null)}, row=${String(rowScope.organizationId)})`\n )\n }\n\n return {\n tenantId: hasPayloadTenantId ? (payloadTenantId ?? null) : rowScope.tenantId,\n organizationId: hasPayloadOrganizationId ? (payloadOrganizationId ?? null) : rowScope.organizationId,\n }\n}\n\nexport function resolveQueryIndexReindexScope(input: ResolveReindexScopeInput): {\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n} {\n if (input.tenantId === undefined && input.allowAllTenants !== true) {\n throw new QueryIndexScopeError(\n 'Query index reindex requires tenantId to be set explicitly; all-tenant reindex must opt in with allowAllTenants=true'\n )\n }\n\n return {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n }\n}\n\nfunction isSameScopeValue(left: string | null | undefined, right: string | null): boolean {\n return (left ?? null) === right\n}\n\nfunction resolveScopeColumn(metadata: QueryIndexEntityMetadata, property: string, table: string): string | null {\n const scopeProperty = metadata.properties?.[property]\n if (!scopeProperty) return null\n const fieldNames = scopeProperty.fieldNames\n if (!Array.isArray(fieldNames) || fieldNames.length !== 1 || typeof fieldNames[0] !== 'string' || !fieldNames[0]) {\n throw new QueryIndexScopeError(\n `Query index ${property} metadata must map exactly one physical column for table: ${table}`\n )\n }\n return fieldNames[0]\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,oBAAoB;AAC7B,SAAS,wCAAwC;AACjD,SAAS,0BAA0B;AA4B5B,MAAM,6BAA6B,MAAM;AAAA,EAC9C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAgBO,SAAS,gCACd,IACA,YAC0B;AAC1B,QAAM,sBAAsB,OAAO,OAAO,aAAa,KAAK,CAAC,EAAE,QAAQ,CAAC,mBAAmB,OAAO,OAAO,kBAAkB,CAAC,CAAC,CAAC;AAC9H,MAAI,CAAC,oBAAoB,SAAS,UAAU,GAAG;AAC7C,UAAM,IAAI,qBAAqB,8CAA8C,UAAU,EAAE;AAAA,EAC3F;AAEA,QAAM,SAAS,qCAAqC,IAAI,UAAU;AAClE,MAAI,CAAC,OAAQ,OAAM,IAAI,qBAAqB,wDAAwD,UAAU,EAAE;AAChH,QAAM,SAAS,mBAAmB,EAAE,EAAE,KAAK,CAAC,aAAa,SAAS,eAAe,OAAO,aAAa,OAAO,WAAW,SAAS,UAAU,SAAS,WAAW,MAAM,SAAS,SAAS,YAAe,OAAO,MAAM;AAClN,MAAI,CAAC,OAAQ,OAAM,IAAI,qBAAqB,mEAAmE,OAAO,KAAK,EAAE;AAC7H,SAAO;AACT;AAEO,SAAS,qCACd,IACA,YACiC;AACjC,QAAM,UAAU,WAAW,MAAM,GAAG,EAAE,IAAI,KAAK;AAC/C,QAAM,YAAY,QAAQ,MAAM,QAAQ,EAAE,OAAO,OAAO,EACrD,IAAI,CAAC,YAAY,QAAQ,OAAO,CAAC,EAAE,YAAY,IAAI,QAAQ,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE;AAC/E,QAAM,cAAc,mBAAmB,EAAE;AACzC,MAAI;AACJ,aAAW,aAAa,CAAC,WAAW,GAAG,SAAS,QAAQ,GAAG;AACzD,UAAM,UAAU,YAAY,OAAO,CAAC,UAAU,MAAM,cAAc,aAAa,MAAM,SAAS,SAAS;AACvG,QAAI,QAAQ,SAAS,EAAG,OAAM,IAAI,qBAAqB,qDAAqD,UAAU,EAAE;AACxH,QAAI,QAAQ,WAAW,GAAG;AACxB,iBAAW,QAAQ,CAAC;AACpB;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,UAAU;AACb,UAAMA,SAAQ,iCAAiC,IAAI,UAAU;AAC7D,QAAI,CAACA,OAAO,QAAO;AACnB,UAAM,UAAU,YAAY,OAAO,CAAC,UAAU,MAAM,cAAcA,MAAK;AACvE,QAAI,QAAQ,WAAW,EAAG,OAAM,IAAI,qBAAqB,oDAAoD,UAAU,EAAE;AACzH,eAAW,QAAQ,CAAC;AAAA,EACtB;AACA,QAAM,YAAY,OAAO,SAAS,aAAa,EAAE;AACjD,MAAI,CAAC,UAAW,QAAO;AACvB,QAAM,SAAS,OAAO,SAAS,WAAW,YAAY,SAAS,WAAW,MAAM,SAAS,SAAS;AAClG,QAAM,QAAQ,SAAS,GAAG,MAAM,IAAI,SAAS,KAAK;AAClD,SAAO;AAAA,IACL;AAAA,IACA,GAAI,SAAS,EAAE,QAAQ,UAAU,IAAI,CAAC;AAAA,IACtC,oBAAoB,mBAAmB,UAAU,kBAAkB,KAAK;AAAA,IACxE,cAAc,mBAAmB,UAAU,YAAY,KAAK;AAAA,EAC9D;AACF;AAEA,eAAsB,uBACpB,IACA,QACA,UACgC;AAChC,MAAI,CAAC,OAAO,sBAAsB,CAAC,OAAO,cAAc;AACtD,WAAO,EAAE,MAAM,SAAS;AAAA,EAC1B;AAEA,QAAM,KAAK,GAAG,UAAe;AAC7B,QAAM,UAAU,CAAC,OAAO,oBAAoB,OAAO,YAAY,EAAE,OAAO,CAAC,WAA6B,WAAW,IAAI;AACrH,QAAM,MAAM,MAAM,GACf,WAAW,OAAO,KAAY,EAC9B,OAAO,OAAc,EACrB,MAAM,MAAa,KAAK,QAAQ,EAChC,iBAAiB;AAEpB,MAAI,CAAC,KAAK;AACR,WAAO,EAAE,MAAM,UAAU;AAAA,EAC3B;AAEA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO;AAAA,MACL,gBAAgB,OAAO,qBAAqB,IAAI,OAAO,kBAAkB,KAAK,OAAO;AAAA,MACrF,UAAU,OAAO,eAAe,IAAI,OAAO,YAAY,KAAK,OAAO;AAAA,IACrE;AAAA,EACF;AACF;AAEO,SAAS,6BAA6B,OAAiD;AAC5F,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AAEJ,MAAI,YAAY,SAAS,UAAU;AACjC,QAAI,CAAC,sBAAsB,CAAC,4BAA4B,oBAAoB,QAAQ,0BAA0B,MAAM;AAClH,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,MACL,UAAU;AAAA,MACV,gBAAgB;AAAA,IAClB;AAAA,EACF;AAEA,MAAI,YAAY,SAAS,WAAW;AAClC,QAAI,CAAC,sBAAsB,CAAC,0BAA0B;AACpD,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAEA,WAAO;AAAA,MACL,UAAU,mBAAmB;AAAA,MAC7B,gBAAgB,yBAAyB;AAAA,IAC3C;AAAA,EACF;AAEA,QAAM,WAAW,YAAY;AAE7B,MAAI,sBAAsB,CAAC,iBAAiB,iBAAiB,SAAS,QAAQ,GAAG;AAC/E,UAAM,IAAI;AAAA,MACR,uEAAuE,OAAO,mBAAmB,IAAI,CAAC,SAAS,OAAO,SAAS,QAAQ,CAAC;AAAA,IAC1I;AAAA,EACF;AAEA,MAAI,4BAA4B,CAAC,iBAAiB,uBAAuB,SAAS,cAAc,GAAG;AACjG,UAAM,IAAI;AAAA,MACR,6EAA6E,OAAO,yBAAyB,IAAI,CAAC,SAAS,OAAO,SAAS,cAAc,CAAC;AAAA,IAC5J;AAAA,EACF;AAEA,SAAO;AAAA,IACL,UAAU,qBAAsB,mBAAmB,OAAQ,SAAS;AAAA,IACpE,gBAAgB,2BAA4B,yBAAyB,OAAQ,SAAS;AAAA,EACxF;AACF;AAEO,SAAS,8BAA8B,OAG5C;AACA,MAAI,MAAM,aAAa,UAAa,MAAM,oBAAoB,MAAM;AAClE,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,UAAU,MAAM;AAAA,IAChB,gBAAgB,MAAM;AAAA,EACxB;AACF;AAEA,SAAS,iBAAiB,MAAiC,OAA+B;AACxF,UAAQ,QAAQ,UAAU;AAC5B;AAEA,SAAS,mBAAmB,UAAoC,UAAkB,OAA8B;AAC9G,QAAM,gBAAgB,SAAS,aAAa,QAAQ;AACpD,MAAI,CAAC,cAAe,QAAO;AAC3B,QAAM,aAAa,cAAc;AACjC,MAAI,CAAC,MAAM,QAAQ,UAAU,KAAK,WAAW,WAAW,KAAK,OAAO,WAAW,CAAC,MAAM,YAAY,CAAC,WAAW,CAAC,GAAG;AAChH,UAAM,IAAI;AAAA,MACR,eAAe,QAAQ,6DAA6D,KAAK;AAAA,IAC3F;AAAA,EACF;AACA,SAAO,WAAW,CAAC;AACrB;",
|
|
6
|
+
"names": ["table"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const CORE_TENANT_GLOBAL_ENTITY_TYPES = [
|
|
2
|
+
// No `tenant_id`; `feature_toggle_overrides` carries the per-tenant value.
|
|
3
|
+
// Its `default_value` is `jsonb`, which contributes no tokens.
|
|
4
|
+
"feature_toggles:feature_toggle"
|
|
5
|
+
];
|
|
6
|
+
const TENANT_GLOBAL_ENTITY_TYPES_KEY = /* @__PURE__ */ Symbol.for("@open-mercato/query-index/tenant-global-entity-types");
|
|
7
|
+
function getTenantGlobalEntityTypes() {
|
|
8
|
+
const scope = globalThis;
|
|
9
|
+
if (!scope[TENANT_GLOBAL_ENTITY_TYPES_KEY]) {
|
|
10
|
+
scope[TENANT_GLOBAL_ENTITY_TYPES_KEY] = new Set(CORE_TENANT_GLOBAL_ENTITY_TYPES);
|
|
11
|
+
}
|
|
12
|
+
return scope[TENANT_GLOBAL_ENTITY_TYPES_KEY];
|
|
13
|
+
}
|
|
14
|
+
function normalize(entityType) {
|
|
15
|
+
return String(entityType || "").trim();
|
|
16
|
+
}
|
|
17
|
+
function registerTenantGlobalEntityTypes(...entityTypes) {
|
|
18
|
+
const tenantGlobalEntityTypes = getTenantGlobalEntityTypes();
|
|
19
|
+
for (const entityType of entityTypes) {
|
|
20
|
+
const normalized = normalize(entityType);
|
|
21
|
+
if (normalized) tenantGlobalEntityTypes.add(normalized);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isTenantGlobalEntityType(entityType) {
|
|
25
|
+
return getTenantGlobalEntityTypes().has(normalize(entityType));
|
|
26
|
+
}
|
|
27
|
+
function listTenantGlobalEntityTypes() {
|
|
28
|
+
return Array.from(getTenantGlobalEntityTypes());
|
|
29
|
+
}
|
|
30
|
+
function resetTenantGlobalEntityTypes() {
|
|
31
|
+
const tenantGlobalEntityTypes = getTenantGlobalEntityTypes();
|
|
32
|
+
tenantGlobalEntityTypes.clear();
|
|
33
|
+
for (const entityType of CORE_TENANT_GLOBAL_ENTITY_TYPES) tenantGlobalEntityTypes.add(entityType);
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
isTenantGlobalEntityType,
|
|
37
|
+
listTenantGlobalEntityTypes,
|
|
38
|
+
registerTenantGlobalEntityTypes,
|
|
39
|
+
resetTenantGlobalEntityTypes
|
|
40
|
+
};
|
|
41
|
+
//# sourceMappingURL=tenant-global.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/query_index/lib/tenant-global.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * The narrow exception to the rule `reindexEntity` enforces: entity types whose\n * source table has no `tenant_id` column BECAUSE the rows are one platform-wide\n * catalogue that every tenant is meant to read.\n *\n * A declared type whose table has neither scope column is swept whole and its\n * projection is written once, under `tenant_id = NULL, organization_id = NULL` -\n * where the event path files it too - and every reader widens for exactly this\n * physically verified set (see `search-scope`): `TokenSearchStrategy.search()` on both the tenant and the organization\n * axis, `createPresenterEnricher()`'s `fetchDocsBatch()` on the tenant axis, and\n * `SearchService`'s organization filter. A sweep also retires the rows earlier\n * sweeps stamped with a caller's tenant and organization. Vector work is the one\n * exception: it keeps the caller's scope, because its consumers need a real\n * tenant. The null scope alone is never the signal - it is also how every\n * private table with neither scope column is stored - which is why each branch\n * is gated on this declaration.\n *\n * Everything else with no `tenant_id` column fails closed, INCLUDING entity\n * types nobody has classified yet. That is the point of expressing this as an\n * allowlist over a derived condition rather than as a denylist of known-private\n * tables: a tenant-less entity type introduced by a later release is refused on\n * arrival instead of quietly leaking until someone notices.\n *\n * The bar for admitting an entity type is \"is every tenant MEANT to read every\n * row\", not \"a reindex started refusing it\". Of the fourteen entity types in\n * this repository whose table has no `tenant_id` column, exactly one clears it;\n * the other thirteen are private rows that must fail closed \u2014\n * `directory:tenant` (one row per tenant, `name` tokenised),\n * `auth:user_role`, `auth:session`, `auth:password_reset`,\n * `customers:customer_deal_person_link`, `messages:message_recipient` and the\n * rest of that shape.\n */\nconst CORE_TENANT_GLOBAL_ENTITY_TYPES: readonly string[] = [\n // No `tenant_id`; `feature_toggle_overrides` carries the per-tenant value.\n // Its `default_value` is `jsonb`, which contributes no tokens.\n 'feature_toggles:feature_toggle',\n]\n\nconst TENANT_GLOBAL_ENTITY_TYPES_KEY = Symbol.for('@open-mercato/query-index/tenant-global-entity-types')\n\ntype GlobalWithTenantGlobalEntityTypes = typeof globalThis & {\n [TENANT_GLOBAL_ENTITY_TYPES_KEY]?: Set<string>\n}\n\nfunction getTenantGlobalEntityTypes(): Set<string> {\n const scope = globalThis as GlobalWithTenantGlobalEntityTypes\n if (!scope[TENANT_GLOBAL_ENTITY_TYPES_KEY]) {\n scope[TENANT_GLOBAL_ENTITY_TYPES_KEY] = new Set<string>(CORE_TENANT_GLOBAL_ENTITY_TYPES)\n }\n return scope[TENANT_GLOBAL_ENTITY_TYPES_KEY]\n}\n\nfunction normalize(entityType: string): string {\n return String(entityType || '').trim()\n}\n\n/**\n * Declare that an entity type outside this package is a platform-wide catalogue.\n *\n * Modules shipped as their own package \u2014 and apps with their own tenant-less\n * catalogue tables \u2014 cannot be listed above, so they opt in here. Call it during\n * module registration (a module's `di.ts` is the usual place), before any\n * reindex job runs.\n *\n * Forgetting the call is safe in the only direction that matters: the entity\n * type simply keeps failing closed, and `reindexEntity` logs a warning naming\n * it, so the missing declaration is diagnosable from the log rather than from a\n * cross-tenant search result.\n */\nexport function registerTenantGlobalEntityTypes(...entityTypes: string[]): void {\n const tenantGlobalEntityTypes = getTenantGlobalEntityTypes()\n for (const entityType of entityTypes) {\n const normalized = normalize(entityType)\n if (normalized) tenantGlobalEntityTypes.add(normalized)\n }\n}\n\n/** Declaration intent only; runtime widening also requires physical verification in search-scope. */\nexport function isTenantGlobalEntityType(entityType: string): boolean {\n return getTenantGlobalEntityTypes().has(normalize(entityType))\n}\n\n/** The current allowlist, for diagnostics and tests. */\nexport function listTenantGlobalEntityTypes(): string[] {\n return Array.from(getTenantGlobalEntityTypes())\n}\n\n/** Test-only: drop every registration and restore this package's own entries. */\nexport function resetTenantGlobalEntityTypes(): void {\n const tenantGlobalEntityTypes = getTenantGlobalEntityTypes()\n tenantGlobalEntityTypes.clear()\n for (const entityType of CORE_TENANT_GLOBAL_ENTITY_TYPES) tenantGlobalEntityTypes.add(entityType)\n}\n"],
|
|
5
|
+
"mappings": "AAgCA,MAAM,kCAAqD;AAAA;AAAA;AAAA,EAGzD;AACF;AAEA,MAAM,iCAAiC,uBAAO,IAAI,sDAAsD;AAMxG,SAAS,6BAA0C;AACjD,QAAM,QAAQ;AACd,MAAI,CAAC,MAAM,8BAA8B,GAAG;AAC1C,UAAM,8BAA8B,IAAI,IAAI,IAAY,+BAA+B;AAAA,EACzF;AACA,SAAO,MAAM,8BAA8B;AAC7C;AAEA,SAAS,UAAU,YAA4B;AAC7C,SAAO,OAAO,cAAc,EAAE,EAAE,KAAK;AACvC;AAeO,SAAS,mCAAmC,aAA6B;AAC9E,QAAM,0BAA0B,2BAA2B;AAC3D,aAAW,cAAc,aAAa;AACpC,UAAM,aAAa,UAAU,UAAU;AACvC,QAAI,WAAY,yBAAwB,IAAI,UAAU;AAAA,EACxD;AACF;AAGO,SAAS,yBAAyB,YAA6B;AACpE,SAAO,2BAA2B,EAAE,IAAI,UAAU,UAAU,CAAC;AAC/D;AAGO,SAAS,8BAAwC;AACtD,SAAO,MAAM,KAAK,2BAA2B,CAAC;AAChD;AAGO,SAAS,+BAAqC;AACnD,QAAM,0BAA0B,2BAA2B;AAC3D,0BAAwB,MAAM;AAC9B,aAAW,cAAc,gCAAiC,yBAAwB,IAAI,UAAU;AAClG;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -2,6 +2,11 @@ import { recordIndexerError } from "@open-mercato/shared/lib/indexers/error-log"
|
|
|
2
2
|
import { recordIndexerLog } from "@open-mercato/shared/lib/indexers/status-log";
|
|
3
3
|
import { reindexEntity } from "../lib/reindexer.js";
|
|
4
4
|
import { resolveQueryIndexReindexScope } from "../lib/subscriber-scope.js";
|
|
5
|
+
const REINDEX_REFUSAL_MESSAGES = {
|
|
6
|
+
"no-tenant-column": "the source table has no tenant_id column and the entity type is not declared tenant-global. Declare it with registerTenantGlobalEntityTypes() if it is a global catalogue.",
|
|
7
|
+
"column-probe-failed": "the source table's columns could not be read, so no scope could be proven. This is usually transient - retry the reindex.",
|
|
8
|
+
"declared-global-but-org-scoped": "the entity type is declared tenant-global but its table carries an organization_id column. A global projection requires a table with neither scope column; remove the declaration or the column."
|
|
9
|
+
};
|
|
5
10
|
const metadata = { event: "query_index.reindex", persistent: true };
|
|
6
11
|
function forkReindexEntityManager(em) {
|
|
7
12
|
const fork = em.fork;
|
|
@@ -154,7 +159,8 @@ async function handle(payload, ctx) {
|
|
|
154
159
|
{
|
|
155
160
|
source: "query_index",
|
|
156
161
|
handler: "event:query_index.reindex",
|
|
157
|
-
|
|
162
|
+
level: result.refused ? "warn" : void 0,
|
|
163
|
+
message: result.refused ? `Reindex refused for ${entityType}: ${REINDEX_REFUSAL_MESSAGES[result.refused]}` : `Reindex completed for ${entityType}`,
|
|
158
164
|
entityType,
|
|
159
165
|
tenantId: tenantId ?? null,
|
|
160
166
|
organizationId: organizationId ?? null,
|
|
@@ -162,7 +168,8 @@ async function handle(payload, ctx) {
|
|
|
162
168
|
processed: result.processed,
|
|
163
169
|
total: result.total,
|
|
164
170
|
tenantScopes: result.tenantScopes,
|
|
165
|
-
scopes: result.scopes
|
|
171
|
+
scopes: result.scopes,
|
|
172
|
+
...result.refused ? { refused: result.refused } : {}
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/subscribers/reindex.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { reindexEntity } from '../lib/reindexer'\nimport type { VectorIndexService } from '@open-mercato/search/vector'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { resolveQueryIndexReindexScope } from '../lib/subscriber-scope'\n\nexport const metadata = { event: 'query_index.reindex', persistent: true }\n\nfunction forkReindexEntityManager(em: EntityManager): EntityManager {\n const fork = (em as unknown as { fork?: (options?: Record<string, unknown>) => EntityManager }).fork\n if (typeof fork !== 'function') return em\n return fork.call(em, { clear: true, freshEventManager: true, useContext: false })\n}\n\nexport default async function handle(payload: any, ctx: { resolve: <T=any>(name: string) => T }) {\n const em = forkReindexEntityManager(ctx.resolve<EntityManager>('em'))\n const eventBus = ctx.resolve<any>('eventBus')\n let vectorService: VectorIndexService | null = null\n try {\n vectorService = ctx.resolve<VectorIndexService>('vectorIndexService')\n } catch {\n vectorService = null\n }\n const entityType = String(payload?.entityType || '')\n if (!entityType) return\n let tenantId: string | null | undefined = payload?.tenantId\n let organizationId: string | null | undefined = payload?.organizationId\n const forceFull: boolean = Boolean(payload?.force)\n const batchSize = Number.isFinite(payload?.batchSize) ? Number(payload.batchSize) : undefined\n const partitionCount = Number.isFinite(payload?.partitionCount) ? Math.max(1, Math.trunc(payload.partitionCount)) : undefined\n const partitionIndex = Number.isFinite(payload?.partitionIndex) ? Math.max(0, Math.trunc(payload.partitionIndex)) : undefined\n const resetCoverage = typeof payload?.resetCoverage === 'boolean' ? payload.resetCoverage : undefined\n const requestedByUserId = typeof payload?.requestedByUserId === 'string' ? payload.requestedByUserId : null\n\n const progressTenantId = typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : null\n const progressOrganizationId = typeof organizationId === 'string' && organizationId.length > 0 ? organizationId : null\n const progressPartitionIndex = Number.isFinite(partitionIndex) ? partitionIndex : null\n const progressPartitionCount = Number.isFinite(partitionCount) ? partitionCount : null\n let progressService: ProgressService | null = null\n let progressJobId: string | null = null\n let progressEnabled = false\n try {\n progressService = ctx.resolve<ProgressService>('progressService')\n progressEnabled = progressService != null && progressTenantId != null\n } catch {\n progressService = null\n progressEnabled = false\n }\n\n const updateProgress = async (\n info: { processed: number; total: number },\n options?: { complete?: boolean; failed?: boolean; errorMessage?: string },\n ): Promise<void> => {\n if (!progressEnabled || !progressService || !progressTenantId) return\n const progressCtx = {\n tenantId: progressTenantId,\n organizationId: progressOrganizationId,\n userId: requestedByUserId,\n }\n const totalCount = Number.isFinite(info.total) ? Math.max(0, info.total) : 0\n const processedCount = Number.isFinite(info.processed) ? Math.max(0, info.processed) : 0\n const progressPercent = totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : 0\n try {\n if (!progressJobId) {\n const created = await progressService.createJob({\n jobType: 'query_index.reindex',\n name: `Query index reindex: ${entityType}`,\n description: progressPartitionCount && progressPartitionCount > 1\n ? `Partition ${((progressPartitionIndex ?? 0) + 1).toString()} of ${progressPartitionCount.toString()}`\n : undefined,\n totalCount: totalCount > 0 ? totalCount : undefined,\n cancellable: false,\n meta: {\n entityType,\n partitionIndex: progressPartitionIndex,\n partitionCount: progressPartitionCount,\n },\n partitionIndex: progressPartitionIndex ?? undefined,\n partitionCount: progressPartitionCount ?? undefined,\n }, progressCtx)\n progressJobId = created.id\n await progressService.startJob(progressJobId, progressCtx)\n }\n\n await progressService.updateProgress(\n progressJobId,\n {\n processedCount,\n totalCount: totalCount > 0 ? totalCount : undefined,\n progressPercent,\n },\n progressCtx,\n )\n\n if (options?.complete) {\n await progressService.completeJob(\n progressJobId,\n {\n resultSummary: {\n entityType,\n processed: processedCount,\n total: totalCount,\n },\n },\n progressCtx,\n )\n } else if (options?.failed) {\n await progressService.failJob(\n progressJobId,\n {\n errorMessage: options.errorMessage ?? `Reindex failed for ${entityType}`,\n },\n progressCtx,\n )\n }\n } catch {\n // Never block query_index subscriber execution because of progress tracking.\n }\n }\n\n try {\n const resolvedScope = resolveQueryIndexReindexScope({\n tenantId: payload?.tenantId,\n organizationId: payload?.organizationId,\n allowAllTenants: payload?.allowAllTenants === true,\n })\n tenantId = resolvedScope.tenantId\n organizationId = resolvedScope.organizationId\n\n await recordIndexerLog(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n message: `Reindex started for ${entityType}`,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n details: {\n force: forceFull,\n batchSize: batchSize ?? null,\n partitionCount: partitionCount ?? null,\n partitionIndex: partitionIndex ?? null,\n resetCoverage: resetCoverage ?? null,\n },\n },\n )\n const result = await reindexEntity(em, {\n entityType,\n tenantId,\n organizationId,\n force: forceFull,\n batchSize,\n eventBus,\n emitVectorizeEvents: true,\n partitionCount,\n partitionIndex,\n resetCoverage,\n vectorService,\n onProgress: (info) => {\n void updateProgress(info)\n },\n })\n await updateProgress(\n { processed: result.processed, total: result.total },\n { complete: true },\n )\n await recordIndexerLog(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n message: `Reindex completed for ${entityType}`,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n details: {\n processed: result.processed,\n total: result.total,\n tenantScopes: result.tenantScopes,\n scopes: result.scopes,\n },\n },\n )\n } catch (error) {\n await updateProgress(\n { processed: 0, total: 0 },\n { failed: true, errorMessage: error instanceof Error ? error.message : String(error) },\n )\n await recordIndexerLog(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n level: 'warn',\n message: `Reindex failed for ${entityType}`,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n details: {\n error: error instanceof Error ? error.message : String(error),\n },\n },\n ).catch(() => undefined)\n await recordIndexerError(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n error,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n payload,\n },\n )\n throw error\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,0BAA0B;AACnC,SAAS,wBAAwB;AACjC,SAAS,qBAAqB;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'\nimport { reindexEntity } from '../lib/reindexer'\nimport type { ReindexRefusalReason } from '../lib/reindexer'\nimport type { VectorIndexService } from '@open-mercato/search/vector'\nimport type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'\nimport { resolveQueryIndexReindexScope } from '../lib/subscriber-scope'\n\n/** Operator-facing text for a sweep that deliberately did no work. */\nconst REINDEX_REFUSAL_MESSAGES: Record<ReindexRefusalReason, string> = {\n 'no-tenant-column':\n 'the source table has no tenant_id column and the entity type is not declared tenant-global. ' +\n 'Declare it with registerTenantGlobalEntityTypes() if it is a global catalogue.',\n 'column-probe-failed':\n 'the source table\\'s columns could not be read, so no scope could be proven. This is usually ' +\n 'transient - retry the reindex.',\n 'declared-global-but-org-scoped':\n 'the entity type is declared tenant-global but its table carries an organization_id column. ' +\n 'A global projection requires a table with neither scope column; remove the declaration or ' +\n 'the column.',\n}\n\nexport const metadata = { event: 'query_index.reindex', persistent: true }\n\nfunction forkReindexEntityManager(em: EntityManager): EntityManager {\n const fork = (em as unknown as { fork?: (options?: Record<string, unknown>) => EntityManager }).fork\n if (typeof fork !== 'function') return em\n return fork.call(em, { clear: true, freshEventManager: true, useContext: false })\n}\n\nexport default async function handle(payload: any, ctx: { resolve: <T=any>(name: string) => T }) {\n const em = forkReindexEntityManager(ctx.resolve<EntityManager>('em'))\n const eventBus = ctx.resolve<any>('eventBus')\n let vectorService: VectorIndexService | null = null\n try {\n vectorService = ctx.resolve<VectorIndexService>('vectorIndexService')\n } catch {\n vectorService = null\n }\n const entityType = String(payload?.entityType || '')\n if (!entityType) return\n let tenantId: string | null | undefined = payload?.tenantId\n let organizationId: string | null | undefined = payload?.organizationId\n const forceFull: boolean = Boolean(payload?.force)\n const batchSize = Number.isFinite(payload?.batchSize) ? Number(payload.batchSize) : undefined\n const partitionCount = Number.isFinite(payload?.partitionCount) ? Math.max(1, Math.trunc(payload.partitionCount)) : undefined\n const partitionIndex = Number.isFinite(payload?.partitionIndex) ? Math.max(0, Math.trunc(payload.partitionIndex)) : undefined\n const resetCoverage = typeof payload?.resetCoverage === 'boolean' ? payload.resetCoverage : undefined\n const requestedByUserId = typeof payload?.requestedByUserId === 'string' ? payload.requestedByUserId : null\n\n const progressTenantId = typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : null\n const progressOrganizationId = typeof organizationId === 'string' && organizationId.length > 0 ? organizationId : null\n const progressPartitionIndex = Number.isFinite(partitionIndex) ? partitionIndex : null\n const progressPartitionCount = Number.isFinite(partitionCount) ? partitionCount : null\n let progressService: ProgressService | null = null\n let progressJobId: string | null = null\n let progressEnabled = false\n try {\n progressService = ctx.resolve<ProgressService>('progressService')\n progressEnabled = progressService != null && progressTenantId != null\n } catch {\n progressService = null\n progressEnabled = false\n }\n\n const updateProgress = async (\n info: { processed: number; total: number },\n options?: { complete?: boolean; failed?: boolean; errorMessage?: string },\n ): Promise<void> => {\n if (!progressEnabled || !progressService || !progressTenantId) return\n const progressCtx = {\n tenantId: progressTenantId,\n organizationId: progressOrganizationId,\n userId: requestedByUserId,\n }\n const totalCount = Number.isFinite(info.total) ? Math.max(0, info.total) : 0\n const processedCount = Number.isFinite(info.processed) ? Math.max(0, info.processed) : 0\n const progressPercent = totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : 0\n try {\n if (!progressJobId) {\n const created = await progressService.createJob({\n jobType: 'query_index.reindex',\n name: `Query index reindex: ${entityType}`,\n description: progressPartitionCount && progressPartitionCount > 1\n ? `Partition ${((progressPartitionIndex ?? 0) + 1).toString()} of ${progressPartitionCount.toString()}`\n : undefined,\n totalCount: totalCount > 0 ? totalCount : undefined,\n cancellable: false,\n meta: {\n entityType,\n partitionIndex: progressPartitionIndex,\n partitionCount: progressPartitionCount,\n },\n partitionIndex: progressPartitionIndex ?? undefined,\n partitionCount: progressPartitionCount ?? undefined,\n }, progressCtx)\n progressJobId = created.id\n await progressService.startJob(progressJobId, progressCtx)\n }\n\n await progressService.updateProgress(\n progressJobId,\n {\n processedCount,\n totalCount: totalCount > 0 ? totalCount : undefined,\n progressPercent,\n },\n progressCtx,\n )\n\n if (options?.complete) {\n await progressService.completeJob(\n progressJobId,\n {\n resultSummary: {\n entityType,\n processed: processedCount,\n total: totalCount,\n },\n },\n progressCtx,\n )\n } else if (options?.failed) {\n await progressService.failJob(\n progressJobId,\n {\n errorMessage: options.errorMessage ?? `Reindex failed for ${entityType}`,\n },\n progressCtx,\n )\n }\n } catch {\n // Never block query_index subscriber execution because of progress tracking.\n }\n }\n\n try {\n const resolvedScope = resolveQueryIndexReindexScope({\n tenantId: payload?.tenantId,\n organizationId: payload?.organizationId,\n allowAllTenants: payload?.allowAllTenants === true,\n })\n tenantId = resolvedScope.tenantId\n organizationId = resolvedScope.organizationId\n\n await recordIndexerLog(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n message: `Reindex started for ${entityType}`,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n details: {\n force: forceFull,\n batchSize: batchSize ?? null,\n partitionCount: partitionCount ?? null,\n partitionIndex: partitionIndex ?? null,\n resetCoverage: resetCoverage ?? null,\n },\n },\n )\n const result = await reindexEntity(em, {\n entityType,\n tenantId,\n organizationId,\n force: forceFull,\n batchSize,\n eventBus,\n emitVectorizeEvents: true,\n partitionCount,\n partitionIndex,\n resetCoverage,\n vectorService,\n onProgress: (info) => {\n void updateProgress(info)\n },\n })\n await updateProgress(\n { processed: result.processed, total: result.total },\n { complete: true },\n )\n // A refused sweep is byte-identical to a successful zero-row one, so without this the\n // status page - the one screen built to answer \"why is this entity type not indexed\" -\n // reports a green completion with no stated reason. That undercuts the guard's own\n // safety argument, which is that a missing declaration \"shows up in the log\".\n await recordIndexerLog(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n level: result.refused ? 'warn' : undefined,\n message: result.refused\n ? `Reindex refused for ${entityType}: ${REINDEX_REFUSAL_MESSAGES[result.refused]}`\n : `Reindex completed for ${entityType}`,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n details: {\n processed: result.processed,\n total: result.total,\n tenantScopes: result.tenantScopes,\n scopes: result.scopes,\n ...(result.refused ? { refused: result.refused } : {}),\n },\n },\n )\n } catch (error) {\n await updateProgress(\n { processed: 0, total: 0 },\n { failed: true, errorMessage: error instanceof Error ? error.message : String(error) },\n )\n await recordIndexerLog(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n level: 'warn',\n message: `Reindex failed for ${entityType}`,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n details: {\n error: error instanceof Error ? error.message : String(error),\n },\n },\n ).catch(() => undefined)\n await recordIndexerError(\n { em },\n {\n source: 'query_index',\n handler: 'event:query_index.reindex',\n error,\n entityType,\n tenantId: tenantId ?? null,\n organizationId: organizationId ?? null,\n payload,\n },\n )\n throw error\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -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";
|
|
@@ -114,7 +114,7 @@ function SalesDocumentItemsSection({
|
|
|
114
114
|
const [shippedTotalsResolved, setShippedTotalsResolved] = React.useState(
|
|
115
115
|
kind !== "order"
|
|
116
116
|
);
|
|
117
|
-
const { widgets: allColumnWidgets } = useInjectionDataWidgets(
|
|
117
|
+
const { widgets: allColumnWidgets, isLoading: injectedColumnsLoading, error: injectedColumnsError } = useInjectionDataWidgets(
|
|
118
118
|
extensionPoints.hosts.orderItemColumns.spotId
|
|
119
119
|
);
|
|
120
120
|
const columnWidgets = kind === "order" ? allColumnWidgets : [];
|
|
@@ -500,7 +500,8 @@ function SalesDocumentItemsSection({
|
|
|
500
500
|
return /* @__PURE__ */ jsx("div", { className: "flex h-10 w-10 items-center justify-center rounded border bg-muted text-xs text-muted-foreground", children: "N/A" });
|
|
501
501
|
};
|
|
502
502
|
return /* @__PURE__ */ jsx(OrderItemsInjectionContext.Provider, { value: { documentId, kind }, children: /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
503
|
-
|
|
503
|
+
kind === "order" && injectedColumnsError ? /* @__PURE__ */ jsx(ErrorMessage, { label: t("ui.dataTable.extensions.error", "Some table extensions could not load. Reload the page to retry.") }) : null,
|
|
504
|
+
loading || kind === "order" && injectedColumnsLoading ? /* @__PURE__ */ jsx(
|
|
504
505
|
LoadingMessage,
|
|
505
506
|
{
|
|
506
507
|
label: t("sales.documents.items.loading", "Loading items\u2026"),
|