@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.
Files changed (115) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/bootstrap.js +16 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
  5. package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +2 -1
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +17 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +104 -8
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/data/entities.js +67 -1
  13. package/dist/modules/attachments/data/entities.js.map +2 -2
  14. package/dist/modules/attachments/di.js +7 -1
  15. package/dist/modules/attachments/di.js.map +2 -2
  16. package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
  17. package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
  18. package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
  19. package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
  20. package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
  21. package/dist/modules/attachments/lib/quota-service.js +224 -0
  22. package/dist/modules/attachments/lib/quota-service.js.map +7 -0
  23. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
  24. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
  25. package/dist/modules/attachments/workers/quota-recovery.js +45 -0
  26. package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
  27. package/dist/modules/auth/lib/setup-app.js +16 -2
  28. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  29. package/dist/modules/dashboards/services/widgetDataService.js +3 -4
  30. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  31. package/dist/modules/entities/api/definitions.js +2 -0
  32. package/dist/modules/entities/api/definitions.js.map +2 -2
  33. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  34. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  35. package/dist/modules/entities/cli.js +193 -19
  36. package/dist/modules/entities/cli.js.map +2 -2
  37. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  38. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  39. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  40. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  41. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  42. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  43. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  44. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  45. package/dist/modules/messages/api/[id]/route.js +15 -0
  46. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  47. package/dist/modules/query_index/api/reindex.js +10 -0
  48. package/dist/modules/query_index/api/reindex.js.map +2 -2
  49. package/dist/modules/query_index/cli.js +125 -1
  50. package/dist/modules/query_index/cli.js.map +2 -2
  51. package/dist/modules/query_index/lib/coverage.js +19 -7
  52. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  53. package/dist/modules/query_index/lib/reindexer.js +43 -37
  54. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  55. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  56. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  57. package/dist/modules/sales/commands/documents.js +137 -22
  58. package/dist/modules/sales/commands/documents.js.map +2 -2
  59. package/dist/modules/sales/data/validators.js +2 -0
  60. package/dist/modules/sales/data/validators.js.map +2 -2
  61. package/dist/modules/sales/workflows.js +10 -7
  62. package/dist/modules/sales/workflows.js.map +2 -2
  63. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  64. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  65. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  66. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  67. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  68. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  69. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  70. package/generated/entities.ids.generated.ts +2 -1
  71. package/generated/entity-fields-registry.ts +17 -0
  72. package/package.json +7 -7
  73. package/src/bootstrap.ts +17 -2
  74. package/src/modules/attachments/api/route.ts +114 -7
  75. package/src/modules/attachments/data/entities.ts +59 -0
  76. package/src/modules/attachments/di.ts +9 -1
  77. package/src/modules/attachments/i18n/de.json +3 -0
  78. package/src/modules/attachments/i18n/en.json +3 -0
  79. package/src/modules/attachments/i18n/es.json +3 -0
  80. package/src/modules/attachments/i18n/pl.json +3 -0
  81. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  82. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  83. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  84. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  85. package/src/modules/attachments/lib/quota-service.ts +442 -0
  86. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  87. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  88. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  89. package/src/modules/auth/lib/setup-app.ts +20 -2
  90. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  91. package/src/modules/entities/api/definitions.ts +4 -0
  92. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  93. package/src/modules/entities/cli.ts +238 -21
  94. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  95. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  96. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  97. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  98. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  99. package/src/modules/messages/api/[id]/route.ts +21 -0
  100. package/src/modules/query_index/api/reindex.ts +15 -0
  101. package/src/modules/query_index/cli.ts +180 -1
  102. package/src/modules/query_index/lib/coverage.ts +38 -8
  103. package/src/modules/query_index/lib/reindexer.ts +62 -40
  104. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  105. package/src/modules/sales/commands/documents.ts +237 -29
  106. package/src/modules/sales/data/validators.ts +2 -0
  107. package/src/modules/sales/workflows.ts +10 -7
  108. package/src/modules/workflows/i18n/de.json +1 -0
  109. package/src/modules/workflows/i18n/en.json +1 -0
  110. package/src/modules/workflows/i18n/es.json +1 -0
  111. package/src/modules/workflows/i18n/ko.json +1 -0
  112. package/src/modules/workflows/i18n/pl.json +1 -0
  113. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  114. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  115. 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<void> {
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
- if (organizationId !== null && !hasOrg) return
399
- if (tenantId !== null && !hasTenant) return
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 (organizationId !== null && hasOrg) baseQuery = baseQuery.where('b.organization_id' as any, '=', organizationId)
405
- if (tenantId !== null && hasTenant) baseQuery = baseQuery.where('b.tenant_id' as any, '=', tenantId)
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 (organizationId !== null) indexQuery = indexQuery.where('ei.organization_id' as any, '=', organizationId)
413
- if (tenantId !== null) indexQuery = indexQuery.where('ei.tenant_id' as any, '=', tenantId)
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
- if (resetCoverage) {
327
- if (force) {
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
- let purgeQuery = db
330
- .deleteFrom('entity_indexes' as any)
331
- .where('entity_type' as any, '=', entityType)
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: error instanceof Error ? error.message : error,
379
+ error: err instanceof Error ? err.message : err,
345
380
  })
346
381
  }
347
-
348
- if (emitVectorize && eventBus) {
349
- if (tenantId !== undefined) {
350
- const payload: Record<string, unknown> = {
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 registry = em.getMetadata?.()
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}`)