@open-mercato/onboarding 0.6.6-develop.5551.1.95625486b2 → 0.6.6-develop.5586.1.c9ed1d68a8

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.
@@ -4,9 +4,6 @@ import { getEntityIds } from "@open-mercato/shared/lib/encryption/entityIds";
4
4
  import { getModules } from "@open-mercato/shared/lib/modules/registry";
5
5
  import { OnboardingService } from "@open-mercato/onboarding/modules/onboarding/lib/service";
6
6
  import { sendWorkspaceReadyEmail } from "@open-mercato/onboarding/modules/onboarding/lib/ready-email";
7
- import { reindexEntity } from "@open-mercato/core/modules/query_index/lib/reindexer";
8
- import { purgeIndexScope } from "@open-mercato/core/modules/query_index/lib/purge";
9
- import { refreshCoverageSnapshot } from "@open-mercato/core/modules/query_index/lib/coverage";
10
7
  import { isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
11
8
  import { PREPARATION_CLAIM_STALE_MS } from "@open-mercato/onboarding/modules/onboarding/lib/preparation-claim";
12
9
  const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5e3;
@@ -83,61 +80,30 @@ async function enqueueVectorReindex(args) {
83
80
  createTimeoutPromise("vector reindex enqueue", VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS)
84
81
  ]);
85
82
  }
86
- async function rebuildTenantQueryIndexes(args) {
87
- const coverageRefreshKeys = /* @__PURE__ */ new Set();
83
+ async function enqueueQueryIndexRebuild(args) {
84
+ let eventBus = null;
88
85
  try {
89
- const allEntities = getEntityIds();
90
- const entityIds = flattenSystemEntityIds(allEntities);
91
- for (const entityType of entityIds) {
92
- try {
93
- await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId });
94
- } catch (error) {
95
- console.error("[onboarding.verify] failed to purge query index scope", {
96
- entityType,
97
- tenantId: args.tenantId,
98
- error
99
- });
100
- }
101
- try {
102
- await reindexEntity(args.em, {
103
- entityType,
104
- tenantId: args.tenantId,
105
- force: true,
106
- emitVectorizeEvents: false,
107
- vectorService: null
108
- });
109
- } catch (error) {
110
- console.error("[onboarding.verify] failed to reindex entity", {
111
- entityType,
112
- tenantId: args.tenantId,
113
- error
114
- });
115
- }
116
- coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`);
117
- coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`);
118
- }
119
- } catch (error) {
120
- console.error("[onboarding.verify] failed to rebuild query indexes", { tenantId: args.tenantId, error });
86
+ eventBus = args.container.resolve("eventBus");
87
+ } catch {
88
+ eventBus = null;
121
89
  }
122
- if (!coverageRefreshKeys.size) return;
123
- for (const entry of coverageRefreshKeys) {
124
- const [entityType, tenantKey, orgKey] = entry.split("|");
125
- const orgScope = orgKey === "__null__" ? null : orgKey;
90
+ if (!eventBus) return;
91
+ const entityIds = flattenSystemEntityIds(getEntityIds());
92
+ for (const entityType of entityIds) {
126
93
  try {
127
- await refreshCoverageSnapshot(
128
- args.em,
94
+ await eventBus.emitEvent(
95
+ "query_index.reindex",
129
96
  {
130
97
  entityType,
131
- tenantId: tenantKey,
132
- organizationId: orgScope,
133
- withDeleted: false
134
- }
98
+ tenantId: args.tenantId,
99
+ force: true
100
+ },
101
+ { persistent: true }
135
102
  );
136
103
  } catch (error) {
137
- console.error("[onboarding.verify] failed to refresh coverage snapshot", {
104
+ console.error("[onboarding.verify] failed to enqueue query index rebuild", {
138
105
  entityType,
139
- tenantId: tenantKey,
140
- organizationId: orgScope,
106
+ tenantId: args.tenantId,
141
107
  error
142
108
  });
143
109
  }
@@ -194,12 +160,9 @@ async function runDeferredProvisioning(args) {
194
160
  }
195
161
  }
196
162
  }
197
- await service.renewPreparation(args.requestId, /* @__PURE__ */ new Date()).catch(() => {
198
- });
199
- await rebuildTenantQueryIndexes({
200
- em,
201
- tenantId: args.tenantId,
202
- organizationId: args.organizationId
163
+ await enqueueQueryIndexRebuild({
164
+ container,
165
+ tenantId: args.tenantId
203
166
  });
204
167
  await markWorkspaceReady({
205
168
  requestId: args.requestId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/lib/deferred-provisioning.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SearchIndexer } from '@open-mercato/search'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'\nimport { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'\nimport { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'\n\nconst VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000\nconst SEED_EXAMPLES_TIMEOUT_MS = 15_000\n\nexport function resolveProvisioningIds(request: OnboardingRequest) {\n if (!request.tenantId || !request.organizationId || !request.userId) return null\n return {\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n userId: request.userId,\n }\n}\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n console.info('[onboarding.verify] module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n console.info('[onboarding.verify] module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n // Deferred provisioning is re-triggered on every preparing-page status\n // poll until preparationCompletedAt is set. seedExamples is not fully\n // idempotent (e.g. catalog product handles are unique-scoped), so a\n // re-run that lands before completion collides on an already-seeded row.\n // The workspace is already provisioned and the collision is expected and\n // harmless \u2014 log at info so genuine failures still stand out.\n console.info('[onboarding.verify] module hook skipped (already seeded)', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } else {\n console.error('[onboarding.verify] module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n error,\n })\n }\n throw error\n }\n}\n\nasync function markWorkspaceReady(args: {\n requestId: string\n service: OnboardingService\n}) {\n const request = await args.service.findById(args.requestId)\n // The status guard protects a request that was re-submitted (and reset to\n // pending) while this chain was still running \u2014 completing it would let the\n // new flow skip its own deferred provisioning.\n if (!request || request.preparationCompletedAt || request.status !== 'completed') return\n await args.service.markPreparationCompleted(request, new Date())\n}\n\nasync function enqueueVectorReindex(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n organizationId: string\n}) {\n let searchIndexer: SearchIndexer | null = null\n try {\n searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchIndexer = null\n }\n if (!searchIndexer) return\n\n await Promise.race([\n searchIndexer.reindexAllToVector({\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n purgeFirst: true,\n useQueue: true,\n }),\n createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),\n ])\n}\n\nasync function rebuildTenantQueryIndexes(args: {\n em: EntityManager\n tenantId: string\n organizationId: string\n}) {\n const coverageRefreshKeys = new Set<string>()\n try {\n const allEntities = getEntityIds()\n const entityIds = flattenSystemEntityIds(allEntities)\n for (const entityType of entityIds) {\n try {\n await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })\n } catch (error) {\n console.error('[onboarding.verify] failed to purge query index scope', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n try {\n await reindexEntity(args.em, {\n entityType,\n tenantId: args.tenantId,\n force: true,\n emitVectorizeEvents: false,\n vectorService: null,\n })\n } catch (error) {\n console.error('[onboarding.verify] failed to reindex entity', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)\n coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)\n }\n } catch (error) {\n console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })\n }\n\n if (!coverageRefreshKeys.size) return\n\n for (const entry of coverageRefreshKeys) {\n const [entityType, tenantKey, orgKey] = entry.split('|')\n const orgScope = orgKey === '__null__' ? null : orgKey\n try {\n await refreshCoverageSnapshot(\n args.em,\n {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n withDeleted: false,\n },\n )\n } catch (error) {\n console.error('[onboarding.verify] failed to refresh coverage snapshot', {\n entityType,\n tenantId: tenantKey,\n organizationId: orgScope,\n error,\n })\n }\n }\n}\n\nexport async function runDeferredProvisioning(args: {\n requestId: string\n tenantId: string\n organizationId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n\n // Single-flight guard: the preparing page polls the status endpoint every\n // second and each poll (plus the verify handler) schedules this chain. The\n // atomic claim collapses those triggers into one run per request \u2014 without\n // it, dozens of concurrent seed + full-reindex chains exhaust the PG\n // connection pool (2026-06-11 demo outage). A stale claim (crashed runner)\n // becomes reclaimable after PREPARATION_CLAIM_STALE_MS.\n const claimedAt = new Date()\n const claimed = await service.claimPreparation(\n args.requestId,\n claimedAt,\n new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),\n )\n if (!claimed) {\n console.info('[onboarding.verify] deferred provisioning skipped (already claimed or completed)', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n })\n return\n }\n\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n // Heartbeat: keep the lease fresh while legitimately working so a slow run\n // (many modules \u00D7 15s seed timeout + rebuild) can never look stale and get\n // double-claimed by a later poll.\n await service.renewPreparation(args.requestId, new Date()).catch(() => {})\n try {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedExamples',\n timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,\n run: () => mod.setup!.seedExamples!({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n container,\n }),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n } else {\n console.error('[onboarding.verify] deferred seedExamples failed', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n }\n }\n }\n\n // The rebuild runs BEFORE the completion flag: preparationCompletedAt is the\n // terminal gate for both the status-route scheduling and claimPreparation,\n // so a runner that dies mid-rebuild must leave the flag unset \u2014 the stale\n // claim then makes the whole chain reclaimable and the rebuild self-heals.\n // rebuildTenantQueryIndexes never throws (it logs per-entity failures).\n await service.renewPreparation(args.requestId, new Date()).catch(() => {})\n await rebuildTenantQueryIndexes({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n\n await markWorkspaceReady({\n requestId: args.requestId,\n service,\n })\n\n // Non-fatal (#2954 contract): an email failure must not abort the chain.\n // The status endpoint retries the ready email on later polls while\n // readyEmailSentAt is unset.\n await sendWorkspaceReadyEmail({\n requestId: args.requestId,\n tenantId: args.tenantId,\n }).catch((error) => {\n console.error('[onboarding.verify] ready email failed', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n })\n\n await enqueueVectorReindex({\n container,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n }).catch((error) => {\n console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n reason: error instanceof Error ? error.message : String(error),\n })\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { SearchIndexer } from '@open-mercato/search'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { flattenSystemEntityIds } from '@open-mercato/shared/lib/entities/system-entities'\nimport { getEntityIds } from '@open-mercato/shared/lib/encryption/entityIds'\nimport { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'\nimport { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'\nimport { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'\nimport { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'\n\nconst VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000\nconst SEED_EXAMPLES_TIMEOUT_MS = 15_000\n\nexport function resolveProvisioningIds(request: OnboardingRequest) {\n if (!request.tenantId || !request.organizationId || !request.userId) return null\n return {\n tenantId: request.tenantId,\n organizationId: request.organizationId,\n userId: request.userId,\n }\n}\n\nfunction createTimeoutPromise(label: string, timeoutMs: number): Promise<never> {\n return new Promise((_, reject) => {\n setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs)\n })\n}\n\nasync function runModuleSetupHook(args: {\n moduleId: string\n phase: 'seedExamples'\n timeoutMs: number\n run: () => Promise<void>\n}) {\n const startedAt = Date.now()\n console.info('[onboarding.verify] module hook started', {\n moduleId: args.moduleId,\n phase: args.phase,\n timeoutMs: args.timeoutMs,\n })\n try {\n await Promise.race([\n args.run(),\n createTimeoutPromise(`module ${args.moduleId} ${args.phase}`, args.timeoutMs),\n ])\n console.info('[onboarding.verify] module hook completed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n // Deferred provisioning is re-triggered on every preparing-page status\n // poll until preparationCompletedAt is set. seedExamples is not fully\n // idempotent (e.g. catalog product handles are unique-scoped), so a\n // re-run that lands before completion collides on an already-seeded row.\n // The workspace is already provisioned and the collision is expected and\n // harmless \u2014 log at info so genuine failures still stand out.\n console.info('[onboarding.verify] module hook skipped (already seeded)', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n })\n } else {\n console.error('[onboarding.verify] module hook failed', {\n moduleId: args.moduleId,\n phase: args.phase,\n durationMs: Math.max(0, Date.now() - startedAt),\n timeoutMs: args.timeoutMs,\n error,\n })\n }\n throw error\n }\n}\n\nasync function markWorkspaceReady(args: {\n requestId: string\n service: OnboardingService\n}) {\n const request = await args.service.findById(args.requestId)\n // The status guard protects a request that was re-submitted (and reset to\n // pending) while this chain was still running \u2014 completing it would let the\n // new flow skip its own deferred provisioning.\n if (!request || request.preparationCompletedAt || request.status !== 'completed') return\n await args.service.markPreparationCompleted(request, new Date())\n}\n\nasync function enqueueVectorReindex(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n organizationId: string\n}) {\n let searchIndexer: SearchIndexer | null = null\n try {\n searchIndexer = args.container.resolve<SearchIndexer>('searchIndexer')\n } catch {\n searchIndexer = null\n }\n if (!searchIndexer) return\n\n await Promise.race([\n searchIndexer.reindexAllToVector({\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n purgeFirst: true,\n useQueue: true,\n }),\n createTimeoutPromise('vector reindex enqueue', VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS),\n ])\n}\n\ntype PersistentEventBus = {\n emitEvent: (event: string, payload: unknown, options?: { persistent?: boolean }) => Promise<void>\n}\n\nasync function enqueueQueryIndexRebuild(args: {\n container: { resolve: <T = unknown>(name: string) => T }\n tenantId: string\n}) {\n // Hand the heavy query-index rebuild to the durable queue instead of running\n // a multi-minute force purge+reindex of every system entity inline \u2014 that\n // inline rebuild was what stalled the preparing page and exhausted the PG\n // pool. Each entity becomes a persistent `query_index.reindex` job, so it\n // survives a worker/process restart and is retried independently of\n // onboarding. reindexEntity({ force: true }) purges the scope and refreshes\n // coverage internally, so no explicit purge/coverage sweep is needed here.\n //\n // Scope is the whole tenant (no organizationId): the previous inline rebuild\n // reindexed tenant-wide, which also covers organization_id IS NULL rows and\n // entities whose org is derived from the row (e.g. directory:organization).\n // Narrowing to a single org would silently drop those.\n let eventBus: PersistentEventBus | null = null\n try {\n eventBus = args.container.resolve<PersistentEventBus>('eventBus')\n } catch {\n eventBus = null\n }\n if (!eventBus) return\n\n const entityIds = flattenSystemEntityIds(getEntityIds())\n for (const entityType of entityIds) {\n try {\n await eventBus.emitEvent(\n 'query_index.reindex',\n {\n entityType,\n tenantId: args.tenantId,\n force: true,\n },\n { persistent: true },\n )\n } catch (error) {\n console.error('[onboarding.verify] failed to enqueue query index rebuild', {\n entityType,\n tenantId: args.tenantId,\n error,\n })\n }\n }\n}\n\nexport async function runDeferredProvisioning(args: {\n requestId: string\n tenantId: string\n organizationId: string\n}) {\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n const service = new OnboardingService(em)\n\n // Single-flight guard: the preparing page polls the status endpoint every\n // second and each poll (plus the verify handler) schedules this chain. The\n // atomic claim collapses those triggers into one run per request \u2014 without\n // it, dozens of concurrent seed + full-reindex chains exhaust the PG\n // connection pool (2026-06-11 demo outage). A stale claim (crashed runner)\n // becomes reclaimable after PREPARATION_CLAIM_STALE_MS.\n const claimedAt = new Date()\n const claimed = await service.claimPreparation(\n args.requestId,\n claimedAt,\n new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),\n )\n if (!claimed) {\n console.info('[onboarding.verify] deferred provisioning skipped (already claimed or completed)', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n })\n return\n }\n\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n // Heartbeat: keep the lease fresh while legitimately working so a slow run\n // (many modules \u00D7 15s seed timeout + rebuild) can never look stale and get\n // double-claimed by a later poll.\n await service.renewPreparation(args.requestId, new Date()).catch(() => {})\n try {\n await runModuleSetupHook({\n moduleId: mod.id,\n phase: 'seedExamples',\n timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,\n run: () => mod.setup!.seedExamples!({\n em,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n container,\n }),\n })\n } catch (error) {\n if (isUniqueViolation(error)) {\n console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n })\n } else {\n console.error('[onboarding.verify] deferred seedExamples failed', {\n moduleId: mod.id,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n }\n }\n }\n\n // The query-index rebuild is ENQUEUED before the completion flag, not run\n // inline: preparationCompletedAt is the terminal gate for both the\n // status-route scheduling and claimPreparation, so a runner that dies before\n // the jobs are queued must leave the flag unset \u2014 the stale claim then makes\n // the chain reclaimable and re-enqueues (a repeated force reindex is\n // harmless). Enqueuing is fast, so the workspace is marked ready in seconds\n // while the actual reindex runs in the background workers.\n await enqueueQueryIndexRebuild({\n container,\n tenantId: args.tenantId,\n })\n\n await markWorkspaceReady({\n requestId: args.requestId,\n service,\n })\n\n // Non-fatal (#2954 contract): an email failure must not abort the chain.\n // The status endpoint retries the ready email on later polls while\n // readyEmailSentAt is unset.\n await sendWorkspaceReadyEmail({\n requestId: args.requestId,\n tenantId: args.tenantId,\n }).catch((error) => {\n console.error('[onboarding.verify] ready email failed', {\n requestId: args.requestId,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n error,\n })\n })\n\n await enqueueVectorReindex({\n container,\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n }).catch((error) => {\n console.warn('[onboarding.verify] vector reindex enqueue did not complete promptly', {\n tenantId: args.tenantId,\n organizationId: args.organizationId,\n reason: error instanceof Error ? error.message : String(error),\n })\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.6.6-develop.5551.1.95625486b2",
3
+ "version": "0.6.6-develop.5586.1.c9ed1d68a8",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -69,10 +69,10 @@
69
69
  }
70
70
  },
71
71
  "peerDependencies": {
72
- "@open-mercato/shared": "0.6.6-develop.5551.1.95625486b2"
72
+ "@open-mercato/shared": "0.6.6-develop.5586.1.c9ed1d68a8"
73
73
  },
74
74
  "devDependencies": {
75
- "@open-mercato/shared": "0.6.6-develop.5551.1.95625486b2",
75
+ "@open-mercato/shared": "0.6.6-develop.5586.1.c9ed1d68a8",
76
76
  "@types/jest": "^30.0.0",
77
77
  "jest": "^30.4.2",
78
78
  "ts-jest": "^29.4.11"
@@ -1,10 +1,9 @@
1
1
  const seedExamples = jest.fn(async () => {
2
2
  await new Promise((resolve) => setImmediate(resolve))
3
3
  })
4
- const purgeIndexScope = jest.fn(async () => {})
5
- const reindexEntity = jest.fn(async () => {})
6
- const refreshCoverageSnapshot = jest.fn(async () => {})
4
+ const flattenSystemEntityIds = jest.fn(() => ['catalog:product'])
7
5
  const sendWorkspaceReadyEmail = jest.fn(async () => true)
6
+ const emitEvent = jest.fn(async () => {})
8
7
 
9
8
  type ClaimState = {
10
9
  status: string
@@ -51,6 +50,7 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({
51
50
  createRequestContainer: jest.fn(async () => ({
52
51
  resolve: (name: string) => {
53
52
  if (name === 'em') return {}
53
+ if (name === 'eventBus') return { emitEvent }
54
54
  throw new Error(`unexpected resolve(${name})`)
55
55
  },
56
56
  })),
@@ -61,25 +61,13 @@ jest.mock('@open-mercato/shared/lib/modules/registry', () => ({
61
61
  }))
62
62
 
63
63
  jest.mock('@open-mercato/shared/lib/entities/system-entities', () => ({
64
- flattenSystemEntityIds: () => ['catalog:product'],
64
+ flattenSystemEntityIds: (...args: unknown[]) => flattenSystemEntityIds(...(args as [])),
65
65
  }))
66
66
 
67
67
  jest.mock('@open-mercato/shared/lib/encryption/entityIds', () => ({
68
68
  getEntityIds: () => ({}),
69
69
  }))
70
70
 
71
- jest.mock('@open-mercato/core/modules/query_index/lib/reindexer', () => ({
72
- reindexEntity: (...args: unknown[]) => reindexEntity(...(args as [])),
73
- }))
74
-
75
- jest.mock('@open-mercato/core/modules/query_index/lib/purge', () => ({
76
- purgeIndexScope: (...args: unknown[]) => purgeIndexScope(...(args as [])),
77
- }))
78
-
79
- jest.mock('@open-mercato/core/modules/query_index/lib/coverage', () => ({
80
- refreshCoverageSnapshot: (...args: unknown[]) => refreshCoverageSnapshot(...(args as [])),
81
- }))
82
-
83
71
  jest.mock('@open-mercato/onboarding/modules/onboarding/lib/ready-email', () => ({
84
72
  sendWorkspaceReadyEmail: (...args: unknown[]) => sendWorkspaceReadyEmail(...(args as [])),
85
73
  }))
@@ -101,6 +89,8 @@ const RUN_ARGS = {
101
89
  organizationId: '33333333-3333-4333-8333-333333333333',
102
90
  }
103
91
 
92
+ const REINDEX_ENTITIES = ['catalog:product', 'sales:order', 'customers:customer']
93
+
104
94
  describe('runDeferredProvisioning single-flight claim', () => {
105
95
  beforeEach(() => {
106
96
  jest.useFakeTimers({ doNotFake: ['setImmediate'] })
@@ -118,11 +108,8 @@ describe('runDeferredProvisioning single-flight claim', () => {
118
108
  it('runs the provisioning chain only once when triggered concurrently by status polls (demo pool-exhaustion repro)', async () => {
119
109
  // Repro for the 2026-06-11 demo outage: the preparing page polls
120
110
  // /onboarding/status every ~1s and each poll scheduled a FULL
121
- // runDeferredProvisioning chain (seedExamples for every module + a forced
122
- // purge/reindex of every system entity) until preparationCompletedAt was
123
- // flushed. Dozens of concurrent chains exhausted the 20-connection PG pool,
124
- // the completion flag write itself timed out, and the loop never
125
- // terminated. Concurrent triggers must collapse into exactly one run.
111
+ // runDeferredProvisioning chain until preparationCompletedAt was flushed.
112
+ // Concurrent triggers must collapse into exactly one run.
126
113
  await Promise.all([
127
114
  runDeferredProvisioning(RUN_ARGS),
128
115
  runDeferredProvisioning(RUN_ARGS),
@@ -131,8 +118,7 @@ describe('runDeferredProvisioning single-flight claim', () => {
131
118
 
132
119
  expect(seedExamples).toHaveBeenCalledTimes(1)
133
120
  expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
134
- expect(purgeIndexScope).toHaveBeenCalledTimes(1)
135
- expect(reindexEntity).toHaveBeenCalledTimes(1)
121
+ expect(emitEvent).toHaveBeenCalledTimes(1)
136
122
  expect(sendWorkspaceReadyEmail).toHaveBeenCalledTimes(1)
137
123
  })
138
124
 
@@ -142,8 +128,7 @@ describe('runDeferredProvisioning single-flight claim', () => {
142
128
  await runDeferredProvisioning(RUN_ARGS)
143
129
 
144
130
  expect(seedExamples).not.toHaveBeenCalled()
145
- expect(purgeIndexScope).not.toHaveBeenCalled()
146
- expect(reindexEntity).not.toHaveBeenCalled()
131
+ expect(emitEvent).not.toHaveBeenCalled()
147
132
  expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
148
133
  })
149
134
 
@@ -180,31 +165,51 @@ describe('runDeferredProvisioning single-flight claim', () => {
180
165
  expect(markPreparationCompleted).not.toHaveBeenCalled()
181
166
  })
182
167
 
183
- it('marks preparation completed only after the query-index rebuild (death mid-rebuild stays recoverable)', async () => {
184
- // preparationCompletedAt is the terminal gate for both the status-route
185
- // scheduling and claimPreparation. If it were written before the rebuild,
186
- // a runner dying mid-rebuild would leave the tenant permanently without
187
- // index rows nothing would ever reclaim the run.
168
+ it('enqueues the query-index rebuild as durable per-entity jobs BEFORE marking ready (no inline reindex)', async () => {
169
+ // The workspace is marked ready as soon as the rebuild is QUEUED rather than
170
+ // after a multi-minute inline force reindex (the demo stall). Each entity is
171
+ // a persistent query_index.reindex job so it survives a worker/process
172
+ // restart, and enqueuing before the completion gate keeps a death-before-
173
+ // queueing recoverable (preparationCompletedAt stays unset → stale reclaim
174
+ // re-enqueues; a repeated force reindex is harmless).
175
+ flattenSystemEntityIds.mockReturnValueOnce(REINDEX_ENTITIES)
176
+
188
177
  await runDeferredProvisioning(RUN_ARGS)
189
178
 
190
- expect(reindexEntity).toHaveBeenCalled()
191
- expect(markPreparationCompleted).toHaveBeenCalled()
192
- expect(reindexEntity.mock.invocationCallOrder[0]).toBeLessThan(
179
+ expect(emitEvent).toHaveBeenCalledTimes(REINDEX_ENTITIES.length)
180
+ // Tenant-wide rebuild — NO organizationId. toContainEqual is a deep match,
181
+ // so an accidental org-narrowing of the payload fails here: org-scoping
182
+ // would silently drop organization_id IS NULL rows and org-derived
183
+ // entities (e.g. directory:organization) that the prior inline rebuild
184
+ // covered.
185
+ const emittedPayloads = emitEvent.mock.calls.map((call) => call[1])
186
+ for (const entityType of REINDEX_ENTITIES) {
187
+ expect(emittedPayloads).toContainEqual({
188
+ entityType,
189
+ tenantId: RUN_ARGS.tenantId,
190
+ force: true,
191
+ })
192
+ expect(emitEvent).toHaveBeenCalledWith(
193
+ 'query_index.reindex',
194
+ expect.objectContaining({ entityType }),
195
+ { persistent: true },
196
+ )
197
+ }
198
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
199
+ expect(emitEvent.mock.invocationCallOrder[0]).toBeLessThan(
193
200
  markPreparationCompleted.mock.invocationCallOrder[0],
194
201
  )
195
202
  })
196
203
 
197
- it('still rebuilds query indexes when the ready email fails', async () => {
198
- // #2954's contract: post-provisioning steps are non-fatal. A transient
199
- // SMTP failure must not abort the chain before the query-index rebuild,
200
- // otherwise the tenant is left permanently without index rows (the
201
- // completion flag is already set, so nothing ever retries the rebuild).
204
+ it('enqueues the rebuild and marks ready even when the ready email fails', async () => {
205
+ // #2954's contract: post-provisioning steps are non-fatal. The email is sent
206
+ // AFTER the rebuild is queued and the workspace is marked ready, so a
207
+ // transient SMTP failure can never abort provisioning.
202
208
  sendWorkspaceReadyEmail.mockRejectedValue(new Error('smtp down'))
203
209
 
204
210
  await expect(runDeferredProvisioning(RUN_ARGS)).resolves.toBeUndefined()
205
211
 
206
212
  expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
207
- expect(purgeIndexScope).toHaveBeenCalledTimes(1)
208
- expect(reindexEntity).toHaveBeenCalledTimes(1)
213
+ expect(emitEvent).toHaveBeenCalledTimes(1)
209
214
  })
210
215
  })
@@ -7,9 +7,6 @@ import { getModules } from '@open-mercato/shared/lib/modules/registry'
7
7
  import type { OnboardingRequest } from '@open-mercato/onboarding/modules/onboarding/data/entities'
8
8
  import { OnboardingService } from '@open-mercato/onboarding/modules/onboarding/lib/service'
9
9
  import { sendWorkspaceReadyEmail } from '@open-mercato/onboarding/modules/onboarding/lib/ready-email'
10
- import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reindexer'
11
- import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
12
- import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
13
10
  import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
14
11
  import { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
15
12
 
@@ -115,67 +112,50 @@ async function enqueueVectorReindex(args: {
115
112
  ])
116
113
  }
117
114
 
118
- async function rebuildTenantQueryIndexes(args: {
119
- em: EntityManager
115
+ type PersistentEventBus = {
116
+ emitEvent: (event: string, payload: unknown, options?: { persistent?: boolean }) => Promise<void>
117
+ }
118
+
119
+ async function enqueueQueryIndexRebuild(args: {
120
+ container: { resolve: <T = unknown>(name: string) => T }
120
121
  tenantId: string
121
- organizationId: string
122
122
  }) {
123
- const coverageRefreshKeys = new Set<string>()
123
+ // Hand the heavy query-index rebuild to the durable queue instead of running
124
+ // a multi-minute force purge+reindex of every system entity inline — that
125
+ // inline rebuild was what stalled the preparing page and exhausted the PG
126
+ // pool. Each entity becomes a persistent `query_index.reindex` job, so it
127
+ // survives a worker/process restart and is retried independently of
128
+ // onboarding. reindexEntity({ force: true }) purges the scope and refreshes
129
+ // coverage internally, so no explicit purge/coverage sweep is needed here.
130
+ //
131
+ // Scope is the whole tenant (no organizationId): the previous inline rebuild
132
+ // reindexed tenant-wide, which also covers organization_id IS NULL rows and
133
+ // entities whose org is derived from the row (e.g. directory:organization).
134
+ // Narrowing to a single org would silently drop those.
135
+ let eventBus: PersistentEventBus | null = null
124
136
  try {
125
- const allEntities = getEntityIds()
126
- const entityIds = flattenSystemEntityIds(allEntities)
127
- for (const entityType of entityIds) {
128
- try {
129
- await purgeIndexScope(args.em, { entityType, tenantId: args.tenantId })
130
- } catch (error) {
131
- console.error('[onboarding.verify] failed to purge query index scope', {
132
- entityType,
133
- tenantId: args.tenantId,
134
- error,
135
- })
136
- }
137
- try {
138
- await reindexEntity(args.em, {
139
- entityType,
140
- tenantId: args.tenantId,
141
- force: true,
142
- emitVectorizeEvents: false,
143
- vectorService: null,
144
- })
145
- } catch (error) {
146
- console.error('[onboarding.verify] failed to reindex entity', {
147
- entityType,
148
- tenantId: args.tenantId,
149
- error,
150
- })
151
- }
152
- coverageRefreshKeys.add(`${entityType}|${args.tenantId}|__null__`)
153
- coverageRefreshKeys.add(`${entityType}|${args.tenantId}|${args.organizationId}`)
154
- }
155
- } catch (error) {
156
- console.error('[onboarding.verify] failed to rebuild query indexes', { tenantId: args.tenantId, error })
137
+ eventBus = args.container.resolve<PersistentEventBus>('eventBus')
138
+ } catch {
139
+ eventBus = null
157
140
  }
141
+ if (!eventBus) return
158
142
 
159
- if (!coverageRefreshKeys.size) return
160
-
161
- for (const entry of coverageRefreshKeys) {
162
- const [entityType, tenantKey, orgKey] = entry.split('|')
163
- const orgScope = orgKey === '__null__' ? null : orgKey
143
+ const entityIds = flattenSystemEntityIds(getEntityIds())
144
+ for (const entityType of entityIds) {
164
145
  try {
165
- await refreshCoverageSnapshot(
166
- args.em,
146
+ await eventBus.emitEvent(
147
+ 'query_index.reindex',
167
148
  {
168
149
  entityType,
169
- tenantId: tenantKey,
170
- organizationId: orgScope,
171
- withDeleted: false,
150
+ tenantId: args.tenantId,
151
+ force: true,
172
152
  },
153
+ { persistent: true },
173
154
  )
174
155
  } catch (error) {
175
- console.error('[onboarding.verify] failed to refresh coverage snapshot', {
156
+ console.error('[onboarding.verify] failed to enqueue query index rebuild', {
176
157
  entityType,
177
- tenantId: tenantKey,
178
- organizationId: orgScope,
158
+ tenantId: args.tenantId,
179
159
  error,
180
160
  })
181
161
  }
@@ -249,16 +229,16 @@ export async function runDeferredProvisioning(args: {
249
229
  }
250
230
  }
251
231
 
252
- // The rebuild runs BEFORE the completion flag: preparationCompletedAt is the
253
- // terminal gate for both the status-route scheduling and claimPreparation,
254
- // so a runner that dies mid-rebuild must leave the flag unset — the stale
255
- // claim then makes the whole chain reclaimable and the rebuild self-heals.
256
- // rebuildTenantQueryIndexes never throws (it logs per-entity failures).
257
- await service.renewPreparation(args.requestId, new Date()).catch(() => {})
258
- await rebuildTenantQueryIndexes({
259
- em,
232
+ // The query-index rebuild is ENQUEUED before the completion flag, not run
233
+ // inline: preparationCompletedAt is the terminal gate for both the
234
+ // status-route scheduling and claimPreparation, so a runner that dies before
235
+ // the jobs are queued must leave the flag unset the stale claim then makes
236
+ // the chain reclaimable and re-enqueues (a repeated force reindex is
237
+ // harmless). Enqueuing is fast, so the workspace is marked ready in seconds
238
+ // while the actual reindex runs in the background workers.
239
+ await enqueueQueryIndexRebuild({
240
+ container,
260
241
  tenantId: args.tenantId,
261
- organizationId: args.organizationId,
262
242
  })
263
243
 
264
244
  await markWorkspaceReady({