@open-mercato/onboarding 0.6.6-develop.5558.1.748adcd5fc → 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
|
|
87
|
-
|
|
83
|
+
async function enqueueQueryIndexRebuild(args) {
|
|
84
|
+
let eventBus = null;
|
|
88
85
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (!
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
128
|
-
|
|
94
|
+
await eventBus.emitEvent(
|
|
95
|
+
"query_index.reindex",
|
|
129
96
|
{
|
|
130
97
|
entityType,
|
|
131
|
-
tenantId:
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
104
|
+
console.error("[onboarding.verify] failed to enqueue query index rebuild", {
|
|
138
105
|
entityType,
|
|
139
|
-
tenantId:
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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": "AAEA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAE3B,SAAS,yBAAyB;AAClC,SAAS,+BAA+B;AACxC,SAAS,
|
|
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.
|
|
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.
|
|
72
|
+
"@open-mercato/shared": "0.6.6-develop.5586.1.c9ed1d68a8"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
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
|
|
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: () => [
|
|
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
|
|
122
|
-
//
|
|
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(
|
|
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(
|
|
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('
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// a
|
|
187
|
-
//
|
|
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(
|
|
191
|
-
|
|
192
|
-
|
|
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('
|
|
198
|
-
// #2954's contract: post-provisioning steps are non-fatal.
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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(
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
146
|
+
await eventBus.emitEvent(
|
|
147
|
+
'query_index.reindex',
|
|
167
148
|
{
|
|
168
149
|
entityType,
|
|
169
|
-
tenantId:
|
|
170
|
-
|
|
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
|
|
156
|
+
console.error('[onboarding.verify] failed to enqueue query index rebuild', {
|
|
176
157
|
entityType,
|
|
177
|
-
tenantId:
|
|
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
|
|
253
|
-
// terminal gate for both the
|
|
254
|
-
// so a runner that dies
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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({
|