@open-mercato/onboarding 0.6.6-develop.5594.1.30cd738303 → 0.6.6-develop.5612.1.d382eb2f33

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.
@@ -38,11 +38,12 @@ async function readPreparationState(requestId) {
38
38
  return result.rows[0];
39
39
  });
40
40
  }
41
- async function waitForPreparationComplete(requestId) {
41
+ async function waitForPreparationComplete(request, tenant) {
42
42
  const deadline = Date.now() + 6e4;
43
43
  let lastState = null;
44
44
  while (Date.now() < deadline) {
45
- lastState = await readPreparationState(requestId);
45
+ await pollOnboardingStatus(request, tenant.tenantId).catch(() => void 0);
46
+ lastState = await readPreparationState(tenant.requestId);
46
47
  if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;
47
48
  await new Promise((resolve) => setTimeout(resolve, 500));
48
49
  }
@@ -143,7 +144,7 @@ test.describe("TC-ONB-002: multi-tenant onboarding parallel login", () => {
143
144
  for (const response of statusResponses) {
144
145
  expect(response.status(), "status polling should not exhaust the app DB pool").toBe(200);
145
146
  }
146
- await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId)));
147
+ await Promise.all(tenants.map((tenant) => waitForPreparationComplete(request, tenant)));
147
148
  await Promise.all(
148
149
  tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant))
149
150
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts"],
4
- "sourcesContent": ["// Adapted from PR #3007 (pkarw) \u2014 multi-tenant parallel onboarding repro for the\n// 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at\n// lease column introduced by the single-flight deferred-provisioning fix.\nimport { createHash, randomUUID } from 'node:crypto';\nimport { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingTenant = {\n email: string;\n organizationName: string;\n token: string;\n requestId?: string;\n tenantId?: string;\n};\n\ntype BrowserTenantSession = {\n context: BrowserContext;\n page: Page;\n refreshRequests: string[];\n};\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\nconst ONBOARDING_PASSWORD = 'ParallelPass123!';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email = $1\n returning id`,\n [email, hashToken(token)],\n );\n expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readPreparationState(requestId: string): Promise<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n}> {\n return withClient(async (client) => {\n const result = await client.query<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n }>(\n `select preparation_completed_at, preparation_started_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);\n return result.rows[0];\n });\n}\n\nasync function waitForPreparationComplete(requestId: string): Promise<void> {\n const deadline = Date.now() + 60_000;\n let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;\n while (Date.now() < deadline) {\n lastState = await readPreparationState(requestId);\n if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();\n expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();\n}\n\nasync function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {\n const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {\n data: {\n email: tenant.email,\n firstName: 'Parallel',\n lastName: 'Login',\n organizationName: tenant.organizationName,\n password: ONBOARDING_PASSWORD,\n confirmPassword: ONBOARDING_PASSWORD,\n termsAccepted: true,\n marketingConsent: true,\n locale: 'en',\n },\n });\n expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);\n}\n\nasync function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {\n const context = await browser.newContext({ baseURL: BASE_URL });\n const page = await context.newPage();\n const refreshRequests: string[] = [];\n page.on('request', (browserRequest) => {\n const url = browserRequest.url();\n if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);\n });\n\n await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const tenantId = new URL(page.url()).searchParams.get('tenant');\n expect(tenantId, 'verify redirect should include tenant id').toMatch(\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,\n );\n return { context, page, refreshRequests, tenantId: tenantId! };\n}\n\nasync function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {\n return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {\n headers: {\n Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,\n },\n });\n}\n\nasync function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {\n expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();\n await expect(session.page).toHaveURL(new RegExp(`/login\\\\?tenant=${tenant.tenantId}`));\n await expect(session.page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await session.page.getByLabel('Email').fill(tenant.email);\n await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await session.page.getByRole('button', { name: 'Sign in' }).click();\n await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\\/backend(?:\\/.*)?$/);\n await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);\n\n const profileResult = await session.page.evaluate(async () => {\n const response = await fetch('/api/auth/profile', { credentials: 'include' });\n const body = await response.json().catch(() => null);\n return { status: response.status, body };\n });\n expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);\n const profile = profileResult.body;\n expect(profile.email).toBe(tenant.email);\n expect(profile.roles).toContain('admin');\n\n await session.page.goto('/backend');\n await expect(session.page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);\n}\n\ntest.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {\n test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({\n browser,\n request,\n }) => {\n // Two full onboardings + parallel browser logins + DB-polled preparation\n // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).\n test.setTimeout(120_000);\n const unique = randomUUID().slice(0, 8);\n const tenants: OnboardingTenant[] = [1, 2].map((index) => ({\n email: `qa-onboarding-parallel-${unique}-${index}@example.test`,\n organizationName: `QA Onboarding Parallel ${unique} ${index}`,\n token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,\n }));\n\n await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));\n\n for (const tenant of tenants) {\n tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);\n }\n\n const browserSessions = await Promise.all(\n tenants.map(async (tenant) => {\n const session = await verifyTenantInBrowser(browser, tenant.token);\n tenant.tenantId = session.tenantId;\n return session;\n }),\n );\n\n try {\n const statusResponses = await Promise.all(\n tenants.flatMap((tenant) =>\n Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),\n ),\n );\n for (const response of statusResponses) {\n expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);\n }\n\n await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId!)));\n await Promise.all(\n tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),\n );\n } finally {\n await Promise.all(browserSessions.map((session) => session.context.close()));\n }\n });\n});\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["// Adapted from PR #3007 (pkarw) \u2014 multi-tenant parallel onboarding repro for the\n// 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at\n// lease column introduced by the single-flight deferred-provisioning fix.\nimport { createHash, randomUUID } from 'node:crypto';\nimport { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';\nimport { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';\n\nexport const integrationMeta = {\n dependsOnModules: ['onboarding'],\n requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],\n requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],\n};\n\ntype OnboardingTenant = {\n email: string;\n organizationName: string;\n token: string;\n requestId?: string;\n tenantId?: string;\n};\n\ntype BrowserTenantSession = {\n context: BrowserContext;\n page: Page;\n refreshRequests: string[];\n};\n\nconst BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';\nconst ONBOARDING_PASSWORD = 'ParallelPass123!';\n\nfunction hashToken(token: string): string {\n return createHash('sha256').update(token).digest('hex');\n}\n\nasync function replaceVerificationToken(email: string, token: string): Promise<string> {\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `update onboarding_requests\n set token_hash = $2,\n expires_at = now() + interval '24 hours',\n updated_at = now()\n where email = $1\n returning id`,\n [email, hashToken(token)],\n );\n expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);\n return result.rows[0].id;\n });\n}\n\nasync function readPreparationState(requestId: string): Promise<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n}> {\n return withClient(async (client) => {\n const result = await client.query<{\n preparation_completed_at: Date | null;\n preparation_started_at: Date | null;\n }>(\n `select preparation_completed_at, preparation_started_at\n from onboarding_requests\n where id = $1`,\n [requestId],\n );\n expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);\n return result.rows[0];\n });\n}\n\nasync function waitForPreparationComplete(\n request: APIRequestContext,\n tenant: OnboardingTenant,\n): Promise<void> {\n const deadline = Date.now() + 60_000;\n let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;\n while (Date.now() < deadline) {\n // Drive the same recovery the real preparing page provides: it polls the\n // status endpoint every ~1s, and each poll re-schedules deferred\n // provisioning when no fresh claim is held. Watching only the DB would never\n // recover a deferred runner that crashed after claiming its lease.\n await pollOnboardingStatus(request, tenant.tenantId!).catch(() => undefined);\n lastState = await readPreparationState(tenant.requestId!);\n if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();\n expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();\n}\n\nasync function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {\n const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {\n data: {\n email: tenant.email,\n firstName: 'Parallel',\n lastName: 'Login',\n organizationName: tenant.organizationName,\n password: ONBOARDING_PASSWORD,\n confirmPassword: ONBOARDING_PASSWORD,\n termsAccepted: true,\n marketingConsent: true,\n locale: 'en',\n },\n });\n expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);\n}\n\nasync function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {\n const context = await browser.newContext({ baseURL: BASE_URL });\n const page = await context.newPage();\n const refreshRequests: string[] = [];\n page.on('request', (browserRequest) => {\n const url = browserRequest.url();\n if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);\n });\n\n await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);\n await expect(page).toHaveURL(/\\/onboarding\\/preparing\\?tenant=[0-9a-f-]+/);\n await expect(page.getByText('We are preparing your workspace')).toBeVisible();\n const tenantId = new URL(page.url()).searchParams.get('tenant');\n expect(tenantId, 'verify redirect should include tenant id').toMatch(\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,\n );\n return { context, page, refreshRequests, tenantId: tenantId! };\n}\n\nasync function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {\n return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {\n headers: {\n Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,\n },\n });\n}\n\nasync function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {\n expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();\n await expect(session.page).toHaveURL(new RegExp(`/login\\\\?tenant=${tenant.tenantId}`));\n await expect(session.page.locator('form[data-auth-ready=\"1\"]')).toBeVisible();\n await session.page.getByLabel('Email').fill(tenant.email);\n await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);\n await session.page.getByRole('button', { name: 'Sign in' }).click();\n await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\\/backend(?:\\/.*)?$/);\n await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);\n\n const profileResult = await session.page.evaluate(async () => {\n const response = await fetch('/api/auth/profile', { credentials: 'include' });\n const body = await response.json().catch(() => null);\n return { status: response.status, body };\n });\n expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);\n const profile = profileResult.body;\n expect(profile.email).toBe(tenant.email);\n expect(profile.roles).toContain('admin');\n\n await session.page.goto('/backend');\n await expect(session.page).toHaveURL(/\\/backend(?:\\/.*)?$/);\n expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);\n}\n\ntest.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {\n test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({\n browser,\n request,\n }) => {\n // Two full onboardings + parallel browser logins + DB-polled preparation\n // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).\n test.setTimeout(120_000);\n const unique = randomUUID().slice(0, 8);\n const tenants: OnboardingTenant[] = [1, 2].map((index) => ({\n email: `qa-onboarding-parallel-${unique}-${index}@example.test`,\n organizationName: `QA Onboarding Parallel ${unique} ${index}`,\n token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,\n }));\n\n await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));\n\n for (const tenant of tenants) {\n tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);\n }\n\n const browserSessions = await Promise.all(\n tenants.map(async (tenant) => {\n const session = await verifyTenantInBrowser(browser, tenant.token);\n tenant.tenantId = session.tenantId;\n return session;\n }),\n );\n\n try {\n const statusResponses = await Promise.all(\n tenants.flatMap((tenant) =>\n Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),\n ),\n );\n for (const response of statusResponses) {\n expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);\n }\n\n await Promise.all(tenants.map((tenant) => waitForPreparationComplete(request, tenant)));\n await Promise.all(\n tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),\n );\n } finally {\n await Promise.all(browserSessions.map((session) => session.context.close()));\n }\n });\n});\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -8,6 +8,7 @@ import { isUniqueViolation } from "@open-mercato/shared/lib/crud/errors";
8
8
  import { PREPARATION_CLAIM_STALE_MS } from "@open-mercato/onboarding/modules/onboarding/lib/preparation-claim";
9
9
  const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5e3;
10
10
  const SEED_EXAMPLES_TIMEOUT_MS = 15e3;
11
+ const PREPARATION_HEARTBEAT_MS = 5e3;
11
12
  function resolveProvisioningIds(request) {
12
13
  if (!request.tenantId || !request.organizationId || !request.userId) return null;
13
14
  return {
@@ -126,48 +127,59 @@ async function runDeferredProvisioning(args) {
126
127
  });
127
128
  return;
128
129
  }
129
- const modules = getModules();
130
- for (const mod of modules) {
131
- if (!mod.setup?.seedExamples) continue;
132
- await service.renewPreparation(args.requestId, /* @__PURE__ */ new Date()).catch(() => {
130
+ const heartbeatEm = typeof em.fork === "function" ? em.fork() : em;
131
+ const heartbeatService = new OnboardingService(heartbeatEm);
132
+ const heartbeat = setInterval(() => {
133
+ void heartbeatService.renewPreparation(args.requestId, /* @__PURE__ */ new Date()).catch(() => {
133
134
  });
134
- try {
135
- await runModuleSetupHook({
136
- moduleId: mod.id,
137
- phase: "seedExamples",
138
- timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
139
- run: () => mod.setup.seedExamples({
140
- em,
141
- tenantId: args.tenantId,
142
- organizationId: args.organizationId,
143
- container
144
- })
135
+ }, PREPARATION_HEARTBEAT_MS);
136
+ if (typeof heartbeat.unref === "function") heartbeat.unref();
137
+ try {
138
+ const modules = getModules();
139
+ for (const mod of modules) {
140
+ if (!mod.setup?.seedExamples) continue;
141
+ await service.renewPreparation(args.requestId, /* @__PURE__ */ new Date()).catch(() => {
145
142
  });
146
- } catch (error) {
147
- if (isUniqueViolation(error)) {
148
- console.info("[onboarding.verify] deferred seedExamples skipped (already applied)", {
143
+ try {
144
+ await runModuleSetupHook({
149
145
  moduleId: mod.id,
150
- tenantId: args.tenantId,
151
- organizationId: args.organizationId
152
- });
153
- } else {
154
- console.error("[onboarding.verify] deferred seedExamples failed", {
155
- moduleId: mod.id,
156
- tenantId: args.tenantId,
157
- organizationId: args.organizationId,
158
- error
146
+ phase: "seedExamples",
147
+ timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
148
+ run: () => mod.setup.seedExamples({
149
+ em,
150
+ tenantId: args.tenantId,
151
+ organizationId: args.organizationId,
152
+ container
153
+ })
159
154
  });
155
+ } catch (error) {
156
+ if (isUniqueViolation(error)) {
157
+ console.info("[onboarding.verify] deferred seedExamples skipped (already applied)", {
158
+ moduleId: mod.id,
159
+ tenantId: args.tenantId,
160
+ organizationId: args.organizationId
161
+ });
162
+ } else {
163
+ console.error("[onboarding.verify] deferred seedExamples failed", {
164
+ moduleId: mod.id,
165
+ tenantId: args.tenantId,
166
+ organizationId: args.organizationId,
167
+ error
168
+ });
169
+ }
160
170
  }
161
171
  }
172
+ await enqueueQueryIndexRebuild({
173
+ container,
174
+ tenantId: args.tenantId
175
+ });
176
+ await markWorkspaceReady({
177
+ requestId: args.requestId,
178
+ service
179
+ });
180
+ } finally {
181
+ clearInterval(heartbeat);
162
182
  }
163
- await enqueueQueryIndexRebuild({
164
- container,
165
- tenantId: args.tenantId
166
- });
167
- await markWorkspaceReady({
168
- requestId: args.requestId,
169
- service
170
- });
171
183
  await sendWorkspaceReadyEmail({
172
184
  requestId: args.requestId,
173
185
  tenantId: args.tenantId
@@ -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 { 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: (\n event: string,\n payload: unknown,\n options?: { persistent?: boolean; deliverInline?: boolean },\n ) => 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 // deliverInline: false is load-bearing here. A bare `{ persistent: true }`\n // emit dual-dispatches: the events worker drains the queued job AND the\n // subscriber runs inline in THIS request \u2014 so the multi-minute force\n // reindex of every system entity would still block onboarding (and reuse\n // the request's already-committed em, spamming \"Transaction is already\n // committed\" from the vector-purge status-log prune). Enqueue-only hands\n // the rebuild to the worker and returns immediately.\n await eventBus.emitEvent(\n 'query_index.reindex',\n {\n entityType,\n tenantId: args.tenantId,\n force: true,\n },\n { persistent: true, deliverInline: false },\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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",
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// Steady lease-renewal cadence while the chain works. Must stay well below\n// PREPARATION_CLAIM_STALE_MS so a live runner is never mistaken for a crashed\n// one, regardless of how long any single module's seedExamples takes.\nconst PREPARATION_HEARTBEAT_MS = 5_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: (\n event: string,\n payload: unknown,\n options?: { persistent?: boolean; deliverInline?: boolean },\n ) => 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 // deliverInline: false is load-bearing here. A bare `{ persistent: true }`\n // emit dual-dispatches: the events worker drains the queued job AND the\n // subscriber runs inline in THIS request \u2014 so the multi-minute force\n // reindex of every system entity would still block onboarding (and reuse\n // the request's already-committed em, spamming \"Transaction is already\n // committed\" from the vector-purge status-log prune). Enqueue-only hands\n // the rebuild to the worker and returns immediately.\n await eventBus.emitEvent(\n 'query_index.reindex',\n {\n entityType,\n tenantId: args.tenantId,\n force: true,\n },\n { persistent: true, deliverInline: false },\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 // Steady heartbeat on a SEPARATE EntityManager (own connection) so renewal can\n // never interleave with the chain's own DB work on `em`. This keeps the lease\n // fresh on a fixed cadence independent of how long any single module's\n // seedExamples takes, which is what lets PREPARATION_CLAIM_STALE_MS stay short:\n // a runner that actually crashes stops renewing and is reclaimable in seconds,\n // while a genuinely-working one is never falsely reclaimed. renewPreparation is\n // a no-op once the request is completed, so a late tick can't resurrect a\n // finished lease.\n const heartbeatEm = typeof (em as { fork?: () => EntityManager }).fork === 'function'\n ? em.fork()\n : em\n const heartbeatService = new OnboardingService(heartbeatEm)\n const heartbeat = setInterval(() => {\n void heartbeatService.renewPreparation(args.requestId, new Date()).catch(() => {})\n }, PREPARATION_HEARTBEAT_MS)\n if (typeof heartbeat.unref === 'function') heartbeat.unref()\n\n try {\n const modules = getModules()\n\n for (const mod of modules) {\n if (!mod.setup?.seedExamples) continue\n // Renew immediately before each module too, so even a long-running module\n // starts from a fresh lease in addition to the steady heartbeat above.\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 } finally {\n // Stop renewing as soon as the chain finishes (or throws): a crashed runner\n // must let the lease go stale so a status poll can reclaim it.\n clearInterval(heartbeat)\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
  }
@@ -1,4 +1,4 @@
1
- const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1e3;
1
+ const PREPARATION_CLAIM_STALE_MS = 30 * 1e3;
2
2
  function isPreparationClaimActive(startedAt, now = /* @__PURE__ */ new Date()) {
3
3
  if (!startedAt) return false;
4
4
  return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/onboarding/lib/preparation-claim.ts"],
4
- "sourcesContent": ["export const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1000\n\nexport function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {\n if (!startedAt) return false\n return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS\n}\n"],
5
- "mappings": "AAAO,MAAM,6BAA6B,KAAK,KAAK;AAE7C,SAAS,yBAAyB,WAAoC,MAAY,oBAAI,KAAK,GAAY;AAC5G,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,UAAU,QAAQ,IAAI,IAAI,QAAQ,IAAI;AAC/C;",
4
+ "sourcesContent": ["// A claimed-but-crashed deferred-provisioning runner must become reclaimable\n// fast enough that the preparing page's ~1s status polling can recover it within\n// seconds \u2014 not strand the workspace on \"preparing\" for minutes. A live runner\n// renews its lease on a steady PREPARATION_HEARTBEAT_MS cadence (see\n// deferred-provisioning.ts), so this window only needs comfortable headroom over\n// that heartbeat (here 6\u00D7) to never reclaim a runner that is genuinely working.\nexport const PREPARATION_CLAIM_STALE_MS = 30 * 1000\n\nexport function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {\n if (!startedAt) return false\n return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS\n}\n"],
5
+ "mappings": "AAMO,MAAM,6BAA6B,KAAK;AAExC,SAAS,yBAAyB,WAAoC,MAAY,oBAAI,KAAK,GAAY;AAC5G,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,UAAU,QAAQ,IAAI,IAAI,QAAQ,IAAI;AAC/C;",
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.5594.1.30cd738303",
3
+ "version": "0.6.6-develop.5612.1.d382eb2f33",
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.5594.1.30cd738303"
72
+ "@open-mercato/shared": "0.6.6-develop.5612.1.d382eb2f33"
73
73
  },
74
74
  "devDependencies": {
75
- "@open-mercato/shared": "0.6.6-develop.5594.1.30cd738303",
75
+ "@open-mercato/shared": "0.6.6-develop.5612.1.d382eb2f33",
76
76
  "@types/jest": "^30.0.0",
77
77
  "jest": "^30.4.2",
78
78
  "ts-jest": "^29.4.11"
@@ -0,0 +1,31 @@
1
+ import {
2
+ PREPARATION_CLAIM_STALE_MS,
3
+ isPreparationClaimActive,
4
+ } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
5
+
6
+ describe('preparation claim staleness', () => {
7
+ const now = new Date('2026-06-15T12:00:00.000Z')
8
+
9
+ it('treats a null lease as inactive (reclaimable)', () => {
10
+ expect(isPreparationClaimActive(null, now)).toBe(false)
11
+ expect(isPreparationClaimActive(undefined, now)).toBe(false)
12
+ })
13
+
14
+ it('treats a freshly-renewed lease as active', () => {
15
+ const justRenewed = new Date(now.getTime() - 1_000)
16
+ expect(isPreparationClaimActive(justRenewed, now)).toBe(true)
17
+ })
18
+
19
+ it('treats a lease older than the stale window as reclaimable', () => {
20
+ const crashedRunner = new Date(now.getTime() - PREPARATION_CLAIM_STALE_MS - 1)
21
+ expect(isPreparationClaimActive(crashedRunner, now)).toBe(false)
22
+ })
23
+
24
+ it('keeps the stale window short enough for status-poll recovery', () => {
25
+ // The preparing page polls status every ~1s and recovers a crashed runner by
26
+ // re-scheduling deferred provisioning once the lease goes stale. A multi-
27
+ // minute window (the historical 10 minutes) stranded the workspace on
28
+ // "preparing". Guard against regressing back to a long window.
29
+ expect(PREPARATION_CLAIM_STALE_MS).toBeLessThanOrEqual(60_000);
30
+ })
31
+ })
@@ -67,11 +67,19 @@ async function readPreparationState(requestId: string): Promise<{
67
67
  });
68
68
  }
69
69
 
70
- async function waitForPreparationComplete(requestId: string): Promise<void> {
70
+ async function waitForPreparationComplete(
71
+ request: APIRequestContext,
72
+ tenant: OnboardingTenant,
73
+ ): Promise<void> {
71
74
  const deadline = Date.now() + 60_000;
72
75
  let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;
73
76
  while (Date.now() < deadline) {
74
- lastState = await readPreparationState(requestId);
77
+ // Drive the same recovery the real preparing page provides: it polls the
78
+ // status endpoint every ~1s, and each poll re-schedules deferred
79
+ // provisioning when no fresh claim is held. Watching only the DB would never
80
+ // recover a deferred runner that crashed after claiming its lease.
81
+ await pollOnboardingStatus(request, tenant.tenantId!).catch(() => undefined);
82
+ lastState = await readPreparationState(tenant.requestId!);
75
83
  if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;
76
84
  await new Promise((resolve) => setTimeout(resolve, 500));
77
85
  }
@@ -187,7 +195,7 @@ test.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {
187
195
  expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);
188
196
  }
189
197
 
190
- await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId!)));
198
+ await Promise.all(tenants.map((tenant) => waitForPreparationComplete(request, tenant)));
191
199
  await Promise.all(
192
200
  tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),
193
201
  );
@@ -12,6 +12,10 @@ import { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onb
12
12
 
13
13
  const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000
14
14
  const SEED_EXAMPLES_TIMEOUT_MS = 15_000
15
+ // Steady lease-renewal cadence while the chain works. Must stay well below
16
+ // PREPARATION_CLAIM_STALE_MS so a live runner is never mistaken for a crashed
17
+ // one, regardless of how long any single module's seedExamples takes.
18
+ const PREPARATION_HEARTBEAT_MS = 5_000
15
19
 
16
20
  export function resolveProvisioningIds(request: OnboardingRequest) {
17
21
  if (!request.tenantId || !request.organizationId || !request.userId) return null
@@ -202,60 +206,82 @@ export async function runDeferredProvisioning(args: {
202
206
  return
203
207
  }
204
208
 
205
- const modules = getModules()
209
+ // Steady heartbeat on a SEPARATE EntityManager (own connection) so renewal can
210
+ // never interleave with the chain's own DB work on `em`. This keeps the lease
211
+ // fresh on a fixed cadence independent of how long any single module's
212
+ // seedExamples takes, which is what lets PREPARATION_CLAIM_STALE_MS stay short:
213
+ // a runner that actually crashes stops renewing and is reclaimable in seconds,
214
+ // while a genuinely-working one is never falsely reclaimed. renewPreparation is
215
+ // a no-op once the request is completed, so a late tick can't resurrect a
216
+ // finished lease.
217
+ const heartbeatEm = typeof (em as { fork?: () => EntityManager }).fork === 'function'
218
+ ? em.fork()
219
+ : em
220
+ const heartbeatService = new OnboardingService(heartbeatEm)
221
+ const heartbeat = setInterval(() => {
222
+ void heartbeatService.renewPreparation(args.requestId, new Date()).catch(() => {})
223
+ }, PREPARATION_HEARTBEAT_MS)
224
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
206
225
 
207
- for (const mod of modules) {
208
- if (!mod.setup?.seedExamples) continue
209
- // Heartbeat: keep the lease fresh while legitimately working so a slow run
210
- // (many modules × 15s seed timeout + rebuild) can never look stale and get
211
- // double-claimed by a later poll.
212
- await service.renewPreparation(args.requestId, new Date()).catch(() => {})
213
- try {
214
- await runModuleSetupHook({
215
- moduleId: mod.id,
216
- phase: 'seedExamples',
217
- timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
218
- run: () => mod.setup!.seedExamples!({
219
- em,
220
- tenantId: args.tenantId,
221
- organizationId: args.organizationId,
222
- container,
223
- }),
224
- })
225
- } catch (error) {
226
- if (isUniqueViolation(error)) {
227
- console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {
228
- moduleId: mod.id,
229
- tenantId: args.tenantId,
230
- organizationId: args.organizationId,
231
- })
232
- } else {
233
- console.error('[onboarding.verify] deferred seedExamples failed', {
226
+ try {
227
+ const modules = getModules()
228
+
229
+ for (const mod of modules) {
230
+ if (!mod.setup?.seedExamples) continue
231
+ // Renew immediately before each module too, so even a long-running module
232
+ // starts from a fresh lease in addition to the steady heartbeat above.
233
+ await service.renewPreparation(args.requestId, new Date()).catch(() => {})
234
+ try {
235
+ await runModuleSetupHook({
234
236
  moduleId: mod.id,
235
- tenantId: args.tenantId,
236
- organizationId: args.organizationId,
237
- error,
237
+ phase: 'seedExamples',
238
+ timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
239
+ run: () => mod.setup!.seedExamples!({
240
+ em,
241
+ tenantId: args.tenantId,
242
+ organizationId: args.organizationId,
243
+ container,
244
+ }),
238
245
  })
246
+ } catch (error) {
247
+ if (isUniqueViolation(error)) {
248
+ console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {
249
+ moduleId: mod.id,
250
+ tenantId: args.tenantId,
251
+ organizationId: args.organizationId,
252
+ })
253
+ } else {
254
+ console.error('[onboarding.verify] deferred seedExamples failed', {
255
+ moduleId: mod.id,
256
+ tenantId: args.tenantId,
257
+ organizationId: args.organizationId,
258
+ error,
259
+ })
260
+ }
239
261
  }
240
262
  }
241
- }
242
263
 
243
- // The query-index rebuild is ENQUEUED before the completion flag, not run
244
- // inline: preparationCompletedAt is the terminal gate for both the
245
- // status-route scheduling and claimPreparation, so a runner that dies before
246
- // the jobs are queued must leave the flag unset — the stale claim then makes
247
- // the chain reclaimable and re-enqueues (a repeated force reindex is
248
- // harmless). Enqueuing is fast, so the workspace is marked ready in seconds
249
- // while the actual reindex runs in the background workers.
250
- await enqueueQueryIndexRebuild({
251
- container,
252
- tenantId: args.tenantId,
253
- })
264
+ // The query-index rebuild is ENQUEUED before the completion flag, not run
265
+ // inline: preparationCompletedAt is the terminal gate for both the
266
+ // status-route scheduling and claimPreparation, so a runner that dies before
267
+ // the jobs are queued must leave the flag unset — the stale claim then makes
268
+ // the chain reclaimable and re-enqueues (a repeated force reindex is
269
+ // harmless). Enqueuing is fast, so the workspace is marked ready in seconds
270
+ // while the actual reindex runs in the background workers.
271
+ await enqueueQueryIndexRebuild({
272
+ container,
273
+ tenantId: args.tenantId,
274
+ })
254
275
 
255
- await markWorkspaceReady({
256
- requestId: args.requestId,
257
- service,
258
- })
276
+ await markWorkspaceReady({
277
+ requestId: args.requestId,
278
+ service,
279
+ })
280
+ } finally {
281
+ // Stop renewing as soon as the chain finishes (or throws): a crashed runner
282
+ // must let the lease go stale so a status poll can reclaim it.
283
+ clearInterval(heartbeat)
284
+ }
259
285
 
260
286
  // Non-fatal (#2954 contract): an email failure must not abort the chain.
261
287
  // The status endpoint retries the ready email on later polls while
@@ -1,4 +1,10 @@
1
- export const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1000
1
+ // A claimed-but-crashed deferred-provisioning runner must become reclaimable
2
+ // fast enough that the preparing page's ~1s status polling can recover it within
3
+ // seconds — not strand the workspace on "preparing" for minutes. A live runner
4
+ // renews its lease on a steady PREPARATION_HEARTBEAT_MS cadence (see
5
+ // deferred-provisioning.ts), so this window only needs comfortable headroom over
6
+ // that heartbeat (here 6×) to never reclaim a runner that is genuinely working.
7
+ export const PREPARATION_CLAIM_STALE_MS = 30 * 1000
2
8
 
3
9
  export function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {
4
10
  if (!startedAt) return false