@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a
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 +2 -2
- package/dist/bootstrap.js +16 -1
- package/dist/bootstrap.js.map +2 -2
- package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
- package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +17 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/route.js +104 -8
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +67 -1
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/attachments/di.js +7 -1
- package/dist/modules/attachments/di.js.map +2 -2
- package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
- package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
- package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
- package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
- package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
- package/dist/modules/attachments/lib/quota-service.js +224 -0
- package/dist/modules/attachments/lib/quota-service.js.map +7 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
- package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
- package/dist/modules/attachments/workers/quota-recovery.js +45 -0
- package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
- package/dist/modules/auth/lib/setup-app.js +16 -2
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/dashboards/services/widgetDataService.js +3 -4
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +2 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/entities/cli.js +193 -19
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
- package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
- package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
- package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +15 -0
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +10 -0
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/cli.js +125 -1
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/lib/coverage.js +19 -7
- package/dist/modules/query_index/lib/coverage.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +43 -37
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +137 -22
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +2 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/workflows.js +10 -7
- package/dist/modules/sales/workflows.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +27 -0
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
- package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
- package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
- package/generated/entities/attachment_quota_reservation/index.ts +15 -0
- package/generated/entities.ids.generated.ts +2 -1
- package/generated/entity-fields-registry.ts +17 -0
- package/package.json +7 -7
- package/src/bootstrap.ts +17 -2
- package/src/modules/attachments/api/route.ts +114 -7
- package/src/modules/attachments/data/entities.ts +59 -0
- package/src/modules/attachments/di.ts +9 -1
- package/src/modules/attachments/i18n/de.json +3 -0
- package/src/modules/attachments/i18n/en.json +3 -0
- package/src/modules/attachments/i18n/es.json +3 -0
- package/src/modules/attachments/i18n/pl.json +3 -0
- package/src/modules/attachments/lib/drivers/index.ts +1 -1
- package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
- package/src/modules/attachments/lib/drivers/types.ts +5 -0
- package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
- package/src/modules/attachments/lib/quota-service.ts +442 -0
- package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
- package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
- package/src/modules/attachments/workers/quota-recovery.ts +68 -0
- package/src/modules/auth/lib/setup-app.ts +20 -2
- package/src/modules/dashboards/services/widgetDataService.ts +3 -6
- package/src/modules/entities/api/definitions.ts +4 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
- package/src/modules/entities/cli.ts +238 -21
- package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
- package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
- package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
- package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
- package/src/modules/messages/api/[id]/route.ts +21 -0
- package/src/modules/query_index/api/reindex.ts +15 -0
- package/src/modules/query_index/cli.ts +180 -1
- package/src/modules/query_index/lib/coverage.ts +38 -8
- package/src/modules/query_index/lib/reindexer.ts +62 -40
- package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
- package/src/modules/sales/commands/documents.ts +237 -29
- package/src/modules/sales/data/validators.ts +2 -0
- package/src/modules/sales/workflows.ts +10 -7
- package/src/modules/workflows/i18n/de.json +1 -0
- package/src/modules/workflows/i18n/en.json +1 -0
- package/src/modules/workflows/i18n/es.json +1 -0
- package/src/modules/workflows/i18n/ko.json +1 -0
- package/src/modules/workflows/i18n/pl.json +1 -0
- package/src/modules/workflows/lib/activity-executor.ts +40 -1
- package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
- package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
|
@@ -11,7 +11,7 @@ type ProgressBarHandle = {
|
|
|
11
11
|
update(completed: number): void
|
|
12
12
|
complete(): void
|
|
13
13
|
}
|
|
14
|
-
import { resolveEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
14
|
+
import { resolveEntityTableName, resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
15
15
|
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
16
16
|
import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
|
|
17
17
|
import {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from './lib/batch'
|
|
24
24
|
import { reindexEntity, DEFAULT_REINDEX_PARTITIONS } from './lib/reindexer'
|
|
25
25
|
import { purgeIndexScope } from './lib/purge'
|
|
26
|
+
import { refreshCoverageSnapshot } from './lib/coverage'
|
|
26
27
|
import { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'
|
|
27
28
|
import type { VectorIndexService } from '@open-mercato/search/vector'
|
|
28
29
|
|
|
@@ -340,6 +341,168 @@ function describeScope(scope: ScopeDescriptor): string {
|
|
|
340
341
|
return parts.length ? ` (${parts.join(' ')})` : ''
|
|
341
342
|
}
|
|
342
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Above this many missing records the anti-join stops being the cheaper option and the
|
|
346
|
+
* repair falls back to a full single-partition rebuild of the entity.
|
|
347
|
+
*/
|
|
348
|
+
const MAX_TARGETED_REPAIR_RECORDS = 5000
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* List the base records in scope that have no live index row, so a repair rebuilds only
|
|
352
|
+
* what is actually missing instead of re-upserting the whole entity for a one-row gap.
|
|
353
|
+
*/
|
|
354
|
+
async function findUnindexedRecordIds(
|
|
355
|
+
db: Kysely<any>,
|
|
356
|
+
options: {
|
|
357
|
+
entityType: string
|
|
358
|
+
tableName: string
|
|
359
|
+
tenantId?: string | null
|
|
360
|
+
organizationId?: string | null
|
|
361
|
+
limit: number
|
|
362
|
+
},
|
|
363
|
+
): Promise<string[]> {
|
|
364
|
+
const columns = await getColumnSet(db, options.tableName)
|
|
365
|
+
let query = db
|
|
366
|
+
.selectFrom(`${options.tableName} as b` as any)
|
|
367
|
+
.select('b.id' as any)
|
|
368
|
+
.where(({ not, exists, selectFrom }: any) =>
|
|
369
|
+
not(exists(
|
|
370
|
+
selectFrom('entity_indexes as ei' as any)
|
|
371
|
+
.select(sql`1`.as('one'))
|
|
372
|
+
.whereRef('ei.entity_id' as any, '=', sql`b.id::text`)
|
|
373
|
+
.where('ei.entity_type' as any, '=', options.entityType)
|
|
374
|
+
.where('ei.deleted_at' as any, 'is', null as any),
|
|
375
|
+
)),
|
|
376
|
+
)
|
|
377
|
+
.limit(options.limit)
|
|
378
|
+
if (columns.has('deleted_at')) query = query.where('b.deleted_at' as any, 'is', null as any)
|
|
379
|
+
if (options.tenantId != null && columns.has('tenant_id')) {
|
|
380
|
+
query = query.where('b.tenant_id' as any, '=', options.tenantId)
|
|
381
|
+
}
|
|
382
|
+
if (options.organizationId != null && columns.has('organization_id')) {
|
|
383
|
+
query = query.where('b.organization_id' as any, '=', options.organizationId)
|
|
384
|
+
}
|
|
385
|
+
const rows = await query.execute() as Array<{ id: unknown }>
|
|
386
|
+
return rows.map((row) => String(row.id))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Recount the scope from the database after a reindex and rebuild whatever is still
|
|
391
|
+
* missing. Runs unconditionally so `mercato init` (and any scripted reindex) cannot finish
|
|
392
|
+
* leaving a gap that a human has to notice and repair by hand from the Query Indexes
|
|
393
|
+
* screen. When counts already agree this is two COUNT queries; when they do not, the
|
|
394
|
+
* repair is scoped to the records the anti-join says are actually missing.
|
|
395
|
+
*/
|
|
396
|
+
async function verifyAndRepairIndexCoverage(
|
|
397
|
+
em: EntityManager,
|
|
398
|
+
options: {
|
|
399
|
+
entityType: string
|
|
400
|
+
tenantId?: string | null
|
|
401
|
+
organizationId?: string | null
|
|
402
|
+
batchSize?: number
|
|
403
|
+
},
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const scope = {
|
|
406
|
+
entityType: options.entityType,
|
|
407
|
+
tenantId: options.tenantId ?? null,
|
|
408
|
+
organizationId: options.organizationId ?? null,
|
|
409
|
+
withDeleted: false,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let counts: { baseCount: number; indexedCount: number } | null = null
|
|
413
|
+
try {
|
|
414
|
+
counts = await refreshCoverageSnapshot(em, scope)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.warn(
|
|
417
|
+
` -> ${options.entityType}: coverage verification skipped (${error instanceof Error ? error.message : String(error)})`,
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
if (!counts) return
|
|
422
|
+
if (counts.indexedCount > counts.baseCount) {
|
|
423
|
+
// Legitimate for entities whose base table cannot express the scope, since a per-tenant
|
|
424
|
+
// reindex stamps its own scope onto every row. Surfaced rather than repaired: a rebuild
|
|
425
|
+
// cannot remove the surplus, and the over-count would otherwise mask a real shortfall.
|
|
426
|
+
console.warn(
|
|
427
|
+
` -> ${options.entityType}: ${counts.indexedCount} index rows for ${counts.baseCount} records — not repairable by reindexing`,
|
|
428
|
+
)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
if (counts.indexedCount === counts.baseCount) return
|
|
432
|
+
|
|
433
|
+
const db = (em as any).getKysely() as Kysely<any>
|
|
434
|
+
let tableName: string | null = null
|
|
435
|
+
try {
|
|
436
|
+
tableName = resolveRegisteredEntityTableName(em as any, options.entityType)
|
|
437
|
+
} catch {
|
|
438
|
+
tableName = null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const missingIds = tableName
|
|
442
|
+
? await findUnindexedRecordIds(db, {
|
|
443
|
+
entityType: options.entityType,
|
|
444
|
+
tableName,
|
|
445
|
+
tenantId: options.tenantId ?? null,
|
|
446
|
+
organizationId: options.organizationId ?? null,
|
|
447
|
+
limit: MAX_TARGETED_REPAIR_RECORDS + 1,
|
|
448
|
+
}).catch(() => null)
|
|
449
|
+
: null
|
|
450
|
+
|
|
451
|
+
const targeted = missingIds && missingIds.length > 0 && missingIds.length <= MAX_TARGETED_REPAIR_RECORDS
|
|
452
|
+
console.log(
|
|
453
|
+
` -> ${options.entityType}: ${counts.indexedCount}/${counts.baseCount} indexed after reindex — repairing`
|
|
454
|
+
+ `${targeted ? ` ${missingIds!.length} missing record(s)` : ' the whole entity'}...`,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
if (targeted) {
|
|
459
|
+
const columns = await getColumnSet(db, tableName!)
|
|
460
|
+
for (const recordId of missingIds!) {
|
|
461
|
+
await rebuildEntityIndexes({
|
|
462
|
+
em,
|
|
463
|
+
db,
|
|
464
|
+
entityType: options.entityType,
|
|
465
|
+
tableName: tableName!,
|
|
466
|
+
orgOverride: options.organizationId ?? undefined,
|
|
467
|
+
tenantOverride: options.tenantId ?? undefined,
|
|
468
|
+
global: false,
|
|
469
|
+
includeDeleted: false,
|
|
470
|
+
offset: 0,
|
|
471
|
+
recordId,
|
|
472
|
+
batchSize: 1,
|
|
473
|
+
supportsOrgFilter: columns.has('organization_id'),
|
|
474
|
+
supportsTenantFilter: columns.has('tenant_id'),
|
|
475
|
+
supportsDeletedFilter: columns.has('deleted_at'),
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
await reindexEntity(em, {
|
|
480
|
+
entityType: options.entityType,
|
|
481
|
+
tenantId: options.tenantId,
|
|
482
|
+
organizationId: options.organizationId,
|
|
483
|
+
force: false,
|
|
484
|
+
batchSize: options.batchSize,
|
|
485
|
+
emitVectorizeEvents: false,
|
|
486
|
+
resetCoverage: false,
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
console.warn(
|
|
491
|
+
` -> ${options.entityType}: repair pass failed (${error instanceof Error ? error.message : String(error)})`,
|
|
492
|
+
)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const after = await refreshCoverageSnapshot(em, scope).catch(() => null)
|
|
497
|
+
if (after && after.indexedCount < after.baseCount) {
|
|
498
|
+
console.warn(
|
|
499
|
+
` -> ${options.entityType}: still ${after.indexedCount}/${after.baseCount} indexed after repair`,
|
|
500
|
+
)
|
|
501
|
+
} else {
|
|
502
|
+
console.log(` -> ${options.entityType}: coverage repaired`)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
343
506
|
const rebuild: ModuleCli = {
|
|
344
507
|
command: 'rebuild',
|
|
345
508
|
async run(rest) {
|
|
@@ -710,6 +873,14 @@ const reindex: ModuleCli = {
|
|
|
710
873
|
groupedProgress?.complete()
|
|
711
874
|
const totalProcessed = stats.reduce((acc, value) => acc + value, 0)
|
|
712
875
|
console.log(`Finished ${entity}: processed ${totalProcessed} row(s) across ${partitionTargets.length} partition(s)`)
|
|
876
|
+
if (partitionIndexOption === undefined) {
|
|
877
|
+
await verifyAndRepairIndexCoverage(baseEm, {
|
|
878
|
+
entityType: entity,
|
|
879
|
+
tenantId,
|
|
880
|
+
organizationId: orgId,
|
|
881
|
+
batchSize,
|
|
882
|
+
})
|
|
883
|
+
}
|
|
713
884
|
await recordIndexerLog(
|
|
714
885
|
{ em: baseEm },
|
|
715
886
|
{
|
|
@@ -848,6 +1019,14 @@ const reindex: ModuleCli = {
|
|
|
848
1019
|
groupedProgress?.complete()
|
|
849
1020
|
const totalProcessed = partitionResults.reduce((acc, value) => acc + value, 0)
|
|
850
1021
|
console.log(` -> ${id} complete: processed ${totalProcessed} row(s) across ${partitionTargets.length} partition(s)`)
|
|
1022
|
+
if (partitionIndexOption === undefined) {
|
|
1023
|
+
await verifyAndRepairIndexCoverage(baseEm, {
|
|
1024
|
+
entityType: id,
|
|
1025
|
+
tenantId,
|
|
1026
|
+
organizationId: orgId,
|
|
1027
|
+
batchSize,
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
851
1030
|
await recordIndexerLog(
|
|
852
1031
|
{ em: baseEm },
|
|
853
1032
|
{
|
|
@@ -294,6 +294,19 @@ export async function deleteCoverageForEntity(db: Kysely<any>, entityType: strin
|
|
|
294
294
|
.execute()
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
async function deleteCoverageScope(db: Kysely<any>, scope: CoverageScope): Promise<void> {
|
|
298
|
+
const { entityType, tenantId, organizationId, withDeleted } = scope
|
|
299
|
+
if (!entityType) return
|
|
300
|
+
let query = db
|
|
301
|
+
.deleteFrom('entity_index_coverage' as any)
|
|
302
|
+
.where('entity_type' as any, '=', entityType)
|
|
303
|
+
.where('with_deleted' as any, '=', withDeleted === true)
|
|
304
|
+
query = tenantId == null
|
|
305
|
+
? query.where('tenant_id' as any, 'is', null as any)
|
|
306
|
+
: query.where('tenant_id' as any, '=', tenantId)
|
|
307
|
+
await applyOrganizationCondition(query as any, 'organization_id', organizationId ?? null).execute()
|
|
308
|
+
}
|
|
309
|
+
|
|
297
310
|
async function tableHasColumn(db: Kysely<any>, table: string, column: string): Promise<boolean> {
|
|
298
311
|
const key = `${table}.${column}`
|
|
299
312
|
if (COLUMN_CACHE.has(key)) return COLUMN_CACHE.get(key)!
|
|
@@ -381,9 +394,9 @@ export async function primeColumnCache(db: Kysely<any>, checks: ColumnCheck[]):
|
|
|
381
394
|
export async function refreshCoverageSnapshot(
|
|
382
395
|
em: EntityManager,
|
|
383
396
|
scope: CoverageScope,
|
|
384
|
-
): Promise<
|
|
397
|
+
): Promise<{ baseCount: number; indexedCount: number } | null> {
|
|
385
398
|
const entityType = String(scope.entityType || '')
|
|
386
|
-
if (!entityType) return
|
|
399
|
+
if (!entityType) return null
|
|
387
400
|
const tenantId = scope.tenantId ?? null
|
|
388
401
|
const organizationId = scope.organizationId ?? null
|
|
389
402
|
const withDeleted = scope.withDeleted === true
|
|
@@ -395,22 +408,37 @@ export async function refreshCoverageSnapshot(
|
|
|
395
408
|
const hasTenant = await tableHasColumn(db, baseTable, 'tenant_id')
|
|
396
409
|
const hasDeleted = await tableHasColumn(db, baseTable, 'deleted_at')
|
|
397
410
|
|
|
398
|
-
|
|
399
|
-
|
|
411
|
+
// A scope the base table cannot express must not narrow the index side either. Index rows
|
|
412
|
+
// can carry an organization the base table has no column for — `organizations` has no
|
|
413
|
+
// `organization_id` yet its index rows derive one from the record id. Filtering only the
|
|
414
|
+
// index side compares two different populations and reports a gap no reindex can close;
|
|
415
|
+
// returning early instead of recounting left the previous snapshot frozen, which is what
|
|
416
|
+
// surfaced as a permanent "out of sync" row for `directory:organization`.
|
|
417
|
+
//
|
|
418
|
+
// Tenant is different: a base table without `tenant_id` (`user_roles`) cannot be counted
|
|
419
|
+
// per tenant at all, and writing the cross-tenant total into one tenant's row would leak
|
|
420
|
+
// another tenant's volume into it. Drop the unusable scoped row instead, leaving only the
|
|
421
|
+
// global row — which is the one that can be true — rather than freezing a stale count.
|
|
422
|
+
const scopeOrg = organizationId !== null && hasOrg
|
|
423
|
+
if (tenantId !== null && !hasTenant) {
|
|
424
|
+
await deleteCoverageScope(db, { entityType, tenantId, organizationId, withDeleted })
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
const scopeTenant = tenantId !== null && hasTenant
|
|
400
428
|
|
|
401
429
|
let baseQuery = db
|
|
402
430
|
.selectFrom(`${baseTable} as b` as any)
|
|
403
431
|
.select(sql`count(*)`.as('count'))
|
|
404
|
-
if (
|
|
405
|
-
if (
|
|
432
|
+
if (scopeOrg) baseQuery = baseQuery.where('b.organization_id' as any, '=', organizationId)
|
|
433
|
+
if (scopeTenant) baseQuery = baseQuery.where('b.tenant_id' as any, '=', tenantId)
|
|
406
434
|
if (!withDeleted && hasDeleted) baseQuery = baseQuery.where('b.deleted_at' as any, 'is', null as any)
|
|
407
435
|
|
|
408
436
|
let indexQuery = db
|
|
409
437
|
.selectFrom('entity_indexes as ei' as any)
|
|
410
438
|
.select(sql`count(*)`.as('count'))
|
|
411
439
|
.where('ei.entity_type' as any, '=', entityType)
|
|
412
|
-
if (
|
|
413
|
-
if (
|
|
440
|
+
if (scopeOrg) indexQuery = indexQuery.where('ei.organization_id' as any, '=', organizationId)
|
|
441
|
+
if (scopeTenant) indexQuery = indexQuery.where('ei.tenant_id' as any, '=', tenantId)
|
|
414
442
|
if (!withDeleted) indexQuery = indexQuery.where('ei.deleted_at' as any, 'is', null as any)
|
|
415
443
|
|
|
416
444
|
const vectorCountPromise = (async (): Promise<number | undefined> => {
|
|
@@ -453,6 +481,8 @@ export async function refreshCoverageSnapshot(
|
|
|
453
481
|
indexedCount: indexCount,
|
|
454
482
|
vectorCount,
|
|
455
483
|
})
|
|
484
|
+
|
|
485
|
+
return { baseCount, indexedCount: indexCount }
|
|
456
486
|
}
|
|
457
487
|
|
|
458
488
|
export async function writeCoverageCounts(
|
|
@@ -323,55 +323,77 @@ export async function reindexEntity(
|
|
|
323
323
|
|
|
324
324
|
options?.onProgress?.({ processed, total, chunkSize: 0 })
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
// Partitions run concurrently, so a scope-wide purge here would delete rows a sibling
|
|
327
|
+
// partition has already written and never rebuild them (the run still reports success
|
|
328
|
+
// because progress counts attempted writes). Each partition therefore purges only the
|
|
329
|
+
// slice it is about to rebuild, and every partition purges — restricting the purge to
|
|
330
|
+
// the coverage-resetting partition would otherwise leave the other slices' stale rows
|
|
331
|
+
// behind on a forced rebuild.
|
|
332
|
+
if (force && (resetCoverage || usingPartitions)) {
|
|
333
|
+
try {
|
|
334
|
+
let purgeQuery = db
|
|
335
|
+
.deleteFrom('entity_indexes' as any)
|
|
336
|
+
.where('entity_type' as any, '=', entityType)
|
|
337
|
+
if (tenantId !== undefined) {
|
|
338
|
+
purgeQuery = purgeQuery.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)
|
|
339
|
+
}
|
|
340
|
+
if (organizationId !== undefined) {
|
|
341
|
+
purgeQuery = purgeQuery.where(sql<boolean>`organization_id is not distinct from ${organizationId ?? null}`)
|
|
342
|
+
}
|
|
343
|
+
if (usingPartitions && partitionIndex !== null) {
|
|
344
|
+
purgeQuery = purgeQuery.where(
|
|
345
|
+
sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCountRaw}) = ${partitionIndex}`,
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
await purgeQuery.execute()
|
|
349
|
+
} catch (error) {
|
|
350
|
+
logger.warn('Failed to purge index rows before force reindex', {
|
|
351
|
+
entityType,
|
|
352
|
+
tenantId: tenantId ?? null,
|
|
353
|
+
organizationId: organizationId ?? null,
|
|
354
|
+
partitionIndex: usingPartitions ? partitionIndex : null,
|
|
355
|
+
error: error instanceof Error ? error.message : error,
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The vector purge is scope-wide and cannot be partitioned — the queued job deletes every
|
|
361
|
+
// vector for the scope. Emitting it from one partition while siblings are already queueing
|
|
362
|
+
// per-record vectorize jobs is the same race the row purge just had, so a partitioned run
|
|
363
|
+
// leaves it to the caller that owns the fan-out (`api/reindex.ts` queues it once, before
|
|
364
|
+
// dispatching any partition).
|
|
365
|
+
if (force && resetCoverage && !usingPartitions && emitVectorize && eventBus) {
|
|
366
|
+
if (tenantId !== undefined) {
|
|
367
|
+
const payload: Record<string, unknown> = {
|
|
368
|
+
entityType,
|
|
369
|
+
tenantId: tenantId ?? null,
|
|
370
|
+
}
|
|
371
|
+
if (organizationId !== undefined) payload.organizationId = organizationId ?? null
|
|
328
372
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (tenantId !== undefined) {
|
|
333
|
-
purgeQuery = purgeQuery.where(sql<boolean>`tenant_id is not distinct from ${tenantId ?? null}`)
|
|
334
|
-
}
|
|
335
|
-
if (organizationId !== undefined) {
|
|
336
|
-
purgeQuery = purgeQuery.where(sql<boolean>`organization_id is not distinct from ${organizationId ?? null}`)
|
|
337
|
-
}
|
|
338
|
-
await purgeQuery.execute()
|
|
339
|
-
} catch (error) {
|
|
340
|
-
logger.warn('Failed to purge index rows before force reindex', {
|
|
373
|
+
await eventBus.emitEvent('query_index.vectorize_purge', payload)
|
|
374
|
+
} catch (err) {
|
|
375
|
+
logger.warn('Failed to queue vector purge before force reindex', {
|
|
341
376
|
entityType,
|
|
342
377
|
tenantId: tenantId ?? null,
|
|
343
378
|
organizationId: organizationId ?? null,
|
|
344
|
-
error:
|
|
379
|
+
error: err instanceof Error ? err.message : err,
|
|
345
380
|
})
|
|
346
381
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
entityType,
|
|
352
|
-
tenantId: tenantId ?? null,
|
|
353
|
-
}
|
|
354
|
-
if (organizationId !== undefined) payload.organizationId = organizationId ?? null
|
|
355
|
-
try {
|
|
356
|
-
await eventBus.emitEvent('query_index.vectorize_purge', payload)
|
|
357
|
-
} catch (err) {
|
|
358
|
-
logger.warn('Failed to queue vector purge before force reindex', {
|
|
359
|
-
entityType,
|
|
360
|
-
tenantId: tenantId ?? null,
|
|
361
|
-
organizationId: organizationId ?? null,
|
|
362
|
-
error: err instanceof Error ? err.message : err,
|
|
363
|
-
})
|
|
364
|
-
}
|
|
365
|
-
} else {
|
|
366
|
-
logger.warn('Skipping vector purge for force reindex without tenant scope', {
|
|
367
|
-
entityType,
|
|
368
|
-
})
|
|
369
|
-
}
|
|
370
|
-
}
|
|
382
|
+
} else {
|
|
383
|
+
logger.warn('Skipping vector purge for force reindex without tenant scope', {
|
|
384
|
+
entityType,
|
|
385
|
+
})
|
|
371
386
|
}
|
|
387
|
+
}
|
|
372
388
|
|
|
389
|
+
if (resetCoverage) {
|
|
390
|
+
// Only meaningful for an unpartitioned run: `baseCounts` holds this partition's slice,
|
|
391
|
+
// so writing it as the scope's base count under-reports by the partition factor, and
|
|
392
|
+
// zeroing indexed_count scope-wide discards deltas siblings have already applied. The
|
|
393
|
+
// authoritative `refreshCoverageSnapshot` at the end of every partition owns the counts
|
|
394
|
+
// for partitioned runs.
|
|
373
395
|
const nowTs = Date.now()
|
|
374
|
-
for (const scope of baseCounts.values()) {
|
|
396
|
+
for (const scope of usingPartitions ? [] : baseCounts.values()) {
|
|
375
397
|
const key = `${entityType}|${scopeKey(scope.tenantId, scope.organizationId)}`
|
|
376
398
|
const last = lastCoverageReset.get(key) ?? 0
|
|
377
399
|
if (force || nowTs - last >= COVERAGE_REFRESH_THROTTLE_MS) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
2
|
import { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'
|
|
3
3
|
import { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
|
|
4
|
+
import { listEntityMetadata } from '@open-mercato/shared/lib/db/entityMetadata'
|
|
4
5
|
|
|
5
6
|
export type QueryIndexScope = {
|
|
6
7
|
tenantId: string | null
|
|
@@ -58,13 +59,7 @@ export function resolveQueryIndexSourceMetadata(
|
|
|
58
59
|
throw new QueryIndexScopeError(`Query index entity type has no registered ORM table: ${entityType}`)
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
const
|
|
62
|
-
const allMetadataRaw = typeof registry?.getAll === 'function' ? registry.getAll() : null
|
|
63
|
-
const allMetadata: QueryIndexEntityMetadata[] = Array.isArray(allMetadataRaw)
|
|
64
|
-
? allMetadataRaw as QueryIndexEntityMetadata[]
|
|
65
|
-
: allMetadataRaw instanceof Map
|
|
66
|
-
? Array.from(allMetadataRaw.values()) as QueryIndexEntityMetadata[]
|
|
67
|
-
: Object.values(allMetadataRaw ?? {}) as QueryIndexEntityMetadata[]
|
|
62
|
+
const allMetadata = listEntityMetadata(em) as unknown as QueryIndexEntityMetadata[]
|
|
68
63
|
const metadata = allMetadata.find((candidate) => String(candidate.tableName ?? '') === table)
|
|
69
64
|
if (!metadata) {
|
|
70
65
|
throw new QueryIndexScopeError(`Query index entity metadata was not found for table: ${table}`)
|