@open-mercato/onboarding 0.6.6-develop.5588.1.a8f6c51d1f → 0.6.6-develop.5598.1.5e7d48d297

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 {
@@ -98,7 +99,7 @@ async function enqueueQueryIndexRebuild(args) {
98
99
  tenantId: args.tenantId,
99
100
  force: true
100
101
  },
101
- { persistent: true }
102
+ { persistent: true, deliverInline: false }
102
103
  );
103
104
  } catch (error) {
104
105
  console.error("[onboarding.verify] failed to enqueue query index rebuild", {
@@ -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: (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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",
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.5588.1.a8f6c51d1f",
3
+ "version": "0.6.6-develop.5598.1.5e7d48d297",
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.5588.1.a8f6c51d1f"
72
+ "@open-mercato/shared": "0.6.6-develop.5598.1.5e7d48d297"
73
73
  },
74
74
  "devDependencies": {
75
- "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f",
75
+ "@open-mercato/shared": "0.6.6-develop.5598.1.5e7d48d297",
76
76
  "@types/jest": "^30.0.0",
77
77
  "jest": "^30.4.2",
78
78
  "ts-jest": "^29.4.11"
@@ -189,10 +189,14 @@ describe('runDeferredProvisioning single-flight claim', () => {
189
189
  tenantId: RUN_ARGS.tenantId,
190
190
  force: true,
191
191
  })
192
+ // deliverInline: false makes the persistent emit enqueue-only — the heavy
193
+ // reindex runs solely in the events worker, never inline in this request.
194
+ // A bare { persistent: true } would dual-dispatch and re-introduce the
195
+ // onboarding stall, so the exact options shape is asserted here.
192
196
  expect(emitEvent).toHaveBeenCalledWith(
193
197
  'query_index.reindex',
194
198
  expect.objectContaining({ entityType }),
195
- { persistent: true },
199
+ { persistent: true, deliverInline: false },
196
200
  )
197
201
  }
198
202
  expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
@@ -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
@@ -113,7 +117,11 @@ async function enqueueVectorReindex(args: {
113
117
  }
114
118
 
115
119
  type PersistentEventBus = {
116
- emitEvent: (event: string, payload: unknown, options?: { persistent?: boolean }) => Promise<void>
120
+ emitEvent: (
121
+ event: string,
122
+ payload: unknown,
123
+ options?: { persistent?: boolean; deliverInline?: boolean },
124
+ ) => Promise<void>
117
125
  }
118
126
 
119
127
  async function enqueueQueryIndexRebuild(args: {
@@ -143,6 +151,13 @@ async function enqueueQueryIndexRebuild(args: {
143
151
  const entityIds = flattenSystemEntityIds(getEntityIds())
144
152
  for (const entityType of entityIds) {
145
153
  try {
154
+ // deliverInline: false is load-bearing here. A bare `{ persistent: true }`
155
+ // emit dual-dispatches: the events worker drains the queued job AND the
156
+ // subscriber runs inline in THIS request — so the multi-minute force
157
+ // reindex of every system entity would still block onboarding (and reuse
158
+ // the request's already-committed em, spamming "Transaction is already
159
+ // committed" from the vector-purge status-log prune). Enqueue-only hands
160
+ // the rebuild to the worker and returns immediately.
146
161
  await eventBus.emitEvent(
147
162
  'query_index.reindex',
148
163
  {
@@ -150,7 +165,7 @@ async function enqueueQueryIndexRebuild(args: {
150
165
  tenantId: args.tenantId,
151
166
  force: true,
152
167
  },
153
- { persistent: true },
168
+ { persistent: true, deliverInline: false },
154
169
  )
155
170
  } catch (error) {
156
171
  console.error('[onboarding.verify] failed to enqueue query index rebuild', {
@@ -191,60 +206,82 @@ export async function runDeferredProvisioning(args: {
191
206
  return
192
207
  }
193
208
 
194
- 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()
195
225
 
196
- for (const mod of modules) {
197
- if (!mod.setup?.seedExamples) continue
198
- // Heartbeat: keep the lease fresh while legitimately working so a slow run
199
- // (many modules × 15s seed timeout + rebuild) can never look stale and get
200
- // double-claimed by a later poll.
201
- await service.renewPreparation(args.requestId, new Date()).catch(() => {})
202
- try {
203
- await runModuleSetupHook({
204
- moduleId: mod.id,
205
- phase: 'seedExamples',
206
- timeoutMs: SEED_EXAMPLES_TIMEOUT_MS,
207
- run: () => mod.setup!.seedExamples!({
208
- em,
209
- tenantId: args.tenantId,
210
- organizationId: args.organizationId,
211
- container,
212
- }),
213
- })
214
- } catch (error) {
215
- if (isUniqueViolation(error)) {
216
- console.info('[onboarding.verify] deferred seedExamples skipped (already applied)', {
217
- moduleId: mod.id,
218
- tenantId: args.tenantId,
219
- organizationId: args.organizationId,
220
- })
221
- } else {
222
- 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({
223
236
  moduleId: mod.id,
224
- tenantId: args.tenantId,
225
- organizationId: args.organizationId,
226
- 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
+ }),
227
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
+ }
228
261
  }
229
262
  }
230
- }
231
263
 
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,
241
- tenantId: args.tenantId,
242
- })
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
+ })
243
275
 
244
- await markWorkspaceReady({
245
- requestId: args.requestId,
246
- service,
247
- })
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
+ }
248
285
 
249
286
  // Non-fatal (#2954 contract): an email failure must not abort the chain.
250
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