@open-mercato/onboarding 0.6.6-develop.5431.1.384a97c7a2 → 0.6.6-develop.5483.1.a1129165ea

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js +158 -0
  3. package/dist/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.js.map +7 -0
  4. package/dist/modules/onboarding/api/get/onboarding/status.js +2 -1
  5. package/dist/modules/onboarding/api/get/onboarding/status.js.map +2 -2
  6. package/dist/modules/onboarding/data/entities.js +3 -0
  7. package/dist/modules/onboarding/data/entities.js.map +2 -2
  8. package/dist/modules/onboarding/lib/deferred-provisioning.js +29 -13
  9. package/dist/modules/onboarding/lib/deferred-provisioning.js.map +2 -2
  10. package/dist/modules/onboarding/lib/preparation-claim.js +10 -0
  11. package/dist/modules/onboarding/lib/preparation-claim.js.map +7 -0
  12. package/dist/modules/onboarding/lib/service.js +31 -0
  13. package/dist/modules/onboarding/lib/service.js.map +2 -2
  14. package/dist/modules/onboarding/migrations/Migration20260611120000.js +13 -0
  15. package/dist/modules/onboarding/migrations/Migration20260611120000.js.map +7 -0
  16. package/generated/entities/onboarding_request/index.ts +1 -0
  17. package/generated/entity-fields-registry.ts +1 -0
  18. package/package.json +3 -3
  19. package/src/__tests__/deferred-provisioning.test.ts +210 -0
  20. package/src/__tests__/status-endpoint-auth.test.ts +38 -0
  21. package/src/modules/onboarding/__integration__/TC-ONB-002-multi-tenant-parallel-login.spec.ts +198 -0
  22. package/src/modules/onboarding/api/get/onboarding/status.ts +11 -1
  23. package/src/modules/onboarding/data/entities.ts +3 -0
  24. package/src/modules/onboarding/lib/deferred-provisioning.ts +50 -13
  25. package/src/modules/onboarding/lib/preparation-claim.ts +6 -0
  26. package/src/modules/onboarding/lib/service.ts +33 -0
  27. package/src/modules/onboarding/migrations/.snapshot-open-mercato.json +10 -0
  28. package/src/modules/onboarding/migrations/Migration20260611120000.ts +13 -0
@@ -16,6 +16,7 @@ export const tenant_id = "tenant_id";
16
16
  export const organization_id = "organization_id";
17
17
  export const user_id = "user_id";
18
18
  export const last_email_sent_at = "last_email_sent_at";
19
+ export const preparation_started_at = "preparation_started_at";
19
20
  export const preparation_completed_at = "preparation_completed_at";
20
21
  export const ready_email_sent_at = "ready_email_sent_at";
21
22
  export const created_at = "created_at";
@@ -20,6 +20,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
20
20
  "organization_id": "organization_id",
21
21
  "user_id": "user_id",
22
22
  "last_email_sent_at": "last_email_sent_at",
23
+ "preparation_started_at": "preparation_started_at",
23
24
  "preparation_completed_at": "preparation_completed_at",
24
25
  "ready_email_sent_at": "ready_email_sent_at",
25
26
  "created_at": "created_at",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/onboarding",
3
- "version": "0.6.6-develop.5431.1.384a97c7a2",
3
+ "version": "0.6.6-develop.5483.1.a1129165ea",
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.5431.1.384a97c7a2"
72
+ "@open-mercato/shared": "0.6.6-develop.5483.1.a1129165ea"
73
73
  },
74
74
  "devDependencies": {
75
- "@open-mercato/shared": "0.6.6-develop.5431.1.384a97c7a2",
75
+ "@open-mercato/shared": "0.6.6-develop.5483.1.a1129165ea",
76
76
  "@types/jest": "^30.0.0",
77
77
  "jest": "^30.4.2",
78
78
  "ts-jest": "^29.4.11"
@@ -0,0 +1,210 @@
1
+ const seedExamples = jest.fn(async () => {
2
+ await new Promise((resolve) => setImmediate(resolve))
3
+ })
4
+ const purgeIndexScope = jest.fn(async () => {})
5
+ const reindexEntity = jest.fn(async () => {})
6
+ const refreshCoverageSnapshot = jest.fn(async () => {})
7
+ const sendWorkspaceReadyEmail = jest.fn(async () => true)
8
+
9
+ type ClaimState = {
10
+ status: string
11
+ preparationStartedAt: Date | null
12
+ preparationCompletedAt: Date | null
13
+ }
14
+
15
+ const claimState: ClaimState = {
16
+ status: 'completed',
17
+ preparationStartedAt: null,
18
+ preparationCompletedAt: null,
19
+ }
20
+
21
+ const claimPreparation = jest.fn(async (_requestId: string, claimedAt: Date, staleBefore: Date) => {
22
+ if (claimState.status !== 'completed') return false
23
+ if (claimState.preparationCompletedAt) return false
24
+ if (claimState.preparationStartedAt && claimState.preparationStartedAt.getTime() >= staleBefore.getTime()) {
25
+ return false
26
+ }
27
+ claimState.preparationStartedAt = claimedAt
28
+ return true
29
+ })
30
+
31
+ const renewPreparation = jest.fn(async (_requestId: string, renewedAt: Date) => {
32
+ if (claimState.status !== 'completed') return false
33
+ if (claimState.preparationCompletedAt) return false
34
+ if (!claimState.preparationStartedAt) return false
35
+ claimState.preparationStartedAt = renewedAt
36
+ return true
37
+ })
38
+
39
+ const findById = jest.fn(async (id: string) => ({
40
+ id,
41
+ status: claimState.status,
42
+ preparationCompletedAt: claimState.preparationCompletedAt,
43
+ }))
44
+
45
+ const markPreparationCompleted = jest.fn(async (_request: unknown, completedAt: Date) => {
46
+ claimState.preparationCompletedAt = completedAt
47
+ claimState.preparationStartedAt = null
48
+ })
49
+
50
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
51
+ createRequestContainer: jest.fn(async () => ({
52
+ resolve: (name: string) => {
53
+ if (name === 'em') return {}
54
+ throw new Error(`unexpected resolve(${name})`)
55
+ },
56
+ })),
57
+ }))
58
+
59
+ jest.mock('@open-mercato/shared/lib/modules/registry', () => ({
60
+ getModules: () => [{ id: 'catalog', setup: { seedExamples } }],
61
+ }))
62
+
63
+ jest.mock('@open-mercato/shared/lib/entities/system-entities', () => ({
64
+ flattenSystemEntityIds: () => ['catalog:product'],
65
+ }))
66
+
67
+ jest.mock('@open-mercato/shared/lib/encryption/entityIds', () => ({
68
+ getEntityIds: () => ({}),
69
+ }))
70
+
71
+ jest.mock('@open-mercato/core/modules/query_index/lib/reindexer', () => ({
72
+ reindexEntity: (...args: unknown[]) => reindexEntity(...(args as [])),
73
+ }))
74
+
75
+ jest.mock('@open-mercato/core/modules/query_index/lib/purge', () => ({
76
+ purgeIndexScope: (...args: unknown[]) => purgeIndexScope(...(args as [])),
77
+ }))
78
+
79
+ jest.mock('@open-mercato/core/modules/query_index/lib/coverage', () => ({
80
+ refreshCoverageSnapshot: (...args: unknown[]) => refreshCoverageSnapshot(...(args as [])),
81
+ }))
82
+
83
+ jest.mock('@open-mercato/onboarding/modules/onboarding/lib/ready-email', () => ({
84
+ sendWorkspaceReadyEmail: (...args: unknown[]) => sendWorkspaceReadyEmail(...(args as [])),
85
+ }))
86
+
87
+ jest.mock('@open-mercato/onboarding/modules/onboarding/lib/service', () => ({
88
+ OnboardingService: jest.fn().mockImplementation(() => ({
89
+ claimPreparation,
90
+ renewPreparation,
91
+ findById,
92
+ markPreparationCompleted,
93
+ })),
94
+ }))
95
+
96
+ import { runDeferredProvisioning } from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
97
+
98
+ const RUN_ARGS = {
99
+ requestId: 'req-1',
100
+ tenantId: '11111111-1111-4111-8111-111111111111',
101
+ organizationId: '33333333-3333-4333-8333-333333333333',
102
+ }
103
+
104
+ describe('runDeferredProvisioning single-flight claim', () => {
105
+ beforeEach(() => {
106
+ jest.useFakeTimers({ doNotFake: ['setImmediate'] })
107
+ jest.clearAllMocks()
108
+ claimState.status = 'completed'
109
+ claimState.preparationStartedAt = null
110
+ claimState.preparationCompletedAt = null
111
+ sendWorkspaceReadyEmail.mockResolvedValue(true)
112
+ })
113
+
114
+ afterEach(() => {
115
+ jest.useRealTimers()
116
+ })
117
+
118
+ it('runs the provisioning chain only once when triggered concurrently by status polls (demo pool-exhaustion repro)', async () => {
119
+ // Repro for the 2026-06-11 demo outage: the preparing page polls
120
+ // /onboarding/status every ~1s and each poll scheduled a FULL
121
+ // runDeferredProvisioning chain (seedExamples for every module + a forced
122
+ // purge/reindex of every system entity) until preparationCompletedAt was
123
+ // flushed. Dozens of concurrent chains exhausted the 20-connection PG pool,
124
+ // the completion flag write itself timed out, and the loop never
125
+ // terminated. Concurrent triggers must collapse into exactly one run.
126
+ await Promise.all([
127
+ runDeferredProvisioning(RUN_ARGS),
128
+ runDeferredProvisioning(RUN_ARGS),
129
+ runDeferredProvisioning(RUN_ARGS),
130
+ ])
131
+
132
+ expect(seedExamples).toHaveBeenCalledTimes(1)
133
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
134
+ expect(purgeIndexScope).toHaveBeenCalledTimes(1)
135
+ expect(reindexEntity).toHaveBeenCalledTimes(1)
136
+ expect(sendWorkspaceReadyEmail).toHaveBeenCalledTimes(1)
137
+ })
138
+
139
+ it('does not run any heavy work when preparation already completed', async () => {
140
+ claimState.preparationCompletedAt = new Date()
141
+
142
+ await runDeferredProvisioning(RUN_ARGS)
143
+
144
+ expect(seedExamples).not.toHaveBeenCalled()
145
+ expect(purgeIndexScope).not.toHaveBeenCalled()
146
+ expect(reindexEntity).not.toHaveBeenCalled()
147
+ expect(sendWorkspaceReadyEmail).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('re-claims and recovers when a previous claim went stale', async () => {
151
+ claimState.preparationStartedAt = new Date(Date.now() - 20 * 60 * 1000)
152
+
153
+ await runDeferredProvisioning(RUN_ARGS)
154
+
155
+ expect(seedExamples).toHaveBeenCalledTimes(1)
156
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
157
+ })
158
+
159
+ it('renews the lease while working so a slow run never looks stale', async () => {
160
+ await runDeferredProvisioning(RUN_ARGS)
161
+
162
+ expect(renewPreparation).toHaveBeenCalled()
163
+ expect(renewPreparation.mock.invocationCallOrder[0]).toBeLessThan(
164
+ markPreparationCompleted.mock.invocationCallOrder[0],
165
+ )
166
+ })
167
+
168
+ it('does not complete a request that was re-submitted (reset to pending) mid-chain', async () => {
169
+ // A user can re-onboard the same email while an old chain is still running:
170
+ // createOrUpdateRequest resets the request to pending. The stale chain must
171
+ // not mark THAT request prepared — the new flow owns deferred provisioning.
172
+ findById.mockImplementationOnce(async (id: string) => ({
173
+ id,
174
+ status: 'pending',
175
+ preparationCompletedAt: null,
176
+ }))
177
+
178
+ await runDeferredProvisioning(RUN_ARGS)
179
+
180
+ expect(markPreparationCompleted).not.toHaveBeenCalled()
181
+ })
182
+
183
+ it('marks preparation completed only after the query-index rebuild (death mid-rebuild stays recoverable)', async () => {
184
+ // preparationCompletedAt is the terminal gate for both the status-route
185
+ // scheduling and claimPreparation. If it were written before the rebuild,
186
+ // a runner dying mid-rebuild would leave the tenant permanently without
187
+ // index rows — nothing would ever reclaim the run.
188
+ await runDeferredProvisioning(RUN_ARGS)
189
+
190
+ expect(reindexEntity).toHaveBeenCalled()
191
+ expect(markPreparationCompleted).toHaveBeenCalled()
192
+ expect(reindexEntity.mock.invocationCallOrder[0]).toBeLessThan(
193
+ markPreparationCompleted.mock.invocationCallOrder[0],
194
+ )
195
+ })
196
+
197
+ it('still rebuilds query indexes when the ready email fails', async () => {
198
+ // #2954's contract: post-provisioning steps are non-fatal. A transient
199
+ // SMTP failure must not abort the chain before the query-index rebuild,
200
+ // otherwise the tenant is left permanently without index rows (the
201
+ // completion flag is already set, so nothing ever retries the rebuild).
202
+ sendWorkspaceReadyEmail.mockRejectedValue(new Error('smtp down'))
203
+
204
+ await expect(runDeferredProvisioning(RUN_ARGS)).resolves.toBeUndefined()
205
+
206
+ expect(markPreparationCompleted).toHaveBeenCalledTimes(1)
207
+ expect(purgeIndexScope).toHaveBeenCalledTimes(1)
208
+ expect(reindexEntity).toHaveBeenCalledTimes(1)
209
+ })
210
+ })
@@ -170,6 +170,44 @@ describe('onboarding status endpoint authorization', () => {
170
170
  expect(res.status).toBe(404)
171
171
  })
172
172
 
173
+ it('does not schedule deferred provisioning while a fresh preparation claim is active', async () => {
174
+ // Regression for the 2026-06-11 demo outage: every preparing-page poll
175
+ // scheduled another full deferred-provisioning chain until the completion
176
+ // flag landed, exhausting the PG connection pool. While a fresh claim is
177
+ // recorded the poll must not schedule another run.
178
+ findLatestByTenantId.mockResolvedValue(
179
+ makeRequest({
180
+ userId: '55555555-5555-4555-8555-555555555555',
181
+ preparationCompletedAt: null,
182
+ preparationStartedAt: new Date(),
183
+ }),
184
+ )
185
+
186
+ const res = await GET(
187
+ buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
188
+ )
189
+
190
+ expect(res.status).toBe(200)
191
+ expect(runDeferredProvisioning).not.toHaveBeenCalled()
192
+ })
193
+
194
+ it('schedules deferred provisioning again once the preparation claim is stale', async () => {
195
+ findLatestByTenantId.mockResolvedValue(
196
+ makeRequest({
197
+ userId: '55555555-5555-4555-8555-555555555555',
198
+ preparationCompletedAt: null,
199
+ preparationStartedAt: new Date(Date.now() - 20 * 60 * 1000),
200
+ }),
201
+ )
202
+
203
+ const res = await GET(
204
+ buildRequest({ tenantId: TENANT_ID, cookie: `om_login_tenant=${TENANT_ID}` }),
205
+ )
206
+
207
+ expect(res.status).toBe(200)
208
+ expect(runDeferredProvisioning).toHaveBeenCalledTimes(1)
209
+ })
210
+
173
211
  it('recovers an interrupted processing request that already has provisioned ids', async () => {
174
212
  const request = makeRequest({
175
213
  status: 'processing',
@@ -0,0 +1,198 @@
1
+ // Adapted from PR #3007 (pkarw) — multi-tenant parallel onboarding repro for the
2
+ // 2026-06-11 demo pool-exhaustion outage, ported to the preparation_started_at
3
+ // lease column introduced by the single-flight deferred-provisioning fix.
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { expect, test, type APIRequestContext, type Browser, type BrowserContext, type Page } from '@playwright/test';
6
+ import { withClient } from '@open-mercato/core/helpers/integration/dbFixtures';
7
+
8
+ export const integrationMeta = {
9
+ dependsOnModules: ['onboarding'],
10
+ requiredEnvVars: ['SELF_SERVICE_ONBOARDING_ENABLED'],
11
+ requiredAnyEnvVars: ['CONSENT_INTEGRITY_SECRET', 'AUTH_SECRET', 'NEXTAUTH_SECRET', 'JWT_SECRET'],
12
+ };
13
+
14
+ type OnboardingTenant = {
15
+ email: string;
16
+ organizationName: string;
17
+ token: string;
18
+ requestId?: string;
19
+ tenantId?: string;
20
+ };
21
+
22
+ type BrowserTenantSession = {
23
+ context: BrowserContext;
24
+ page: Page;
25
+ refreshRequests: string[];
26
+ };
27
+
28
+ const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000';
29
+ const ONBOARDING_PASSWORD = 'ParallelPass123!';
30
+
31
+ function hashToken(token: string): string {
32
+ return createHash('sha256').update(token).digest('hex');
33
+ }
34
+
35
+ async function replaceVerificationToken(email: string, token: string): Promise<string> {
36
+ return withClient(async (client) => {
37
+ const result = await client.query<{ id: string }>(
38
+ `update onboarding_requests
39
+ set token_hash = $2,
40
+ expires_at = now() + interval '24 hours',
41
+ updated_at = now()
42
+ where email = $1
43
+ returning id`,
44
+ [email, hashToken(token)],
45
+ );
46
+ expect(result.rowCount, `onboarding request should exist for ${email}`).toBe(1);
47
+ return result.rows[0].id;
48
+ });
49
+ }
50
+
51
+ async function readPreparationState(requestId: string): Promise<{
52
+ preparation_completed_at: Date | null;
53
+ preparation_started_at: Date | null;
54
+ }> {
55
+ return withClient(async (client) => {
56
+ const result = await client.query<{
57
+ preparation_completed_at: Date | null;
58
+ preparation_started_at: Date | null;
59
+ }>(
60
+ `select preparation_completed_at, preparation_started_at
61
+ from onboarding_requests
62
+ where id = $1`,
63
+ [requestId],
64
+ );
65
+ expect(result.rowCount, `onboarding request ${requestId} should remain queryable`).toBe(1);
66
+ return result.rows[0];
67
+ });
68
+ }
69
+
70
+ async function waitForPreparationComplete(requestId: string): Promise<void> {
71
+ const deadline = Date.now() + 60_000;
72
+ let lastState: Awaited<ReturnType<typeof readPreparationState>> | null = null;
73
+ while (Date.now() < deadline) {
74
+ lastState = await readPreparationState(requestId);
75
+ if (lastState.preparation_completed_at && !lastState.preparation_started_at) return;
76
+ await new Promise((resolve) => setTimeout(resolve, 500));
77
+ }
78
+ expect(lastState?.preparation_started_at, 'deferred preparation lease should be cleared after completion').toBeNull();
79
+ expect(lastState?.preparation_completed_at, 'workspace preparation should complete').toBeTruthy();
80
+ }
81
+
82
+ async function submitOnboarding(request: APIRequestContext, tenant: OnboardingTenant): Promise<void> {
83
+ const response = await request.post(`${BASE_URL}/api/onboarding/onboarding`, {
84
+ data: {
85
+ email: tenant.email,
86
+ firstName: 'Parallel',
87
+ lastName: 'Login',
88
+ organizationName: tenant.organizationName,
89
+ password: ONBOARDING_PASSWORD,
90
+ confirmPassword: ONBOARDING_PASSWORD,
91
+ termsAccepted: true,
92
+ marketingConsent: true,
93
+ locale: 'en',
94
+ },
95
+ });
96
+ expect(response.status(), `onboarding start should succeed for ${tenant.email}`).toBe(200);
97
+ }
98
+
99
+ async function verifyTenantInBrowser(browser: Browser, token: string): Promise<BrowserTenantSession & { tenantId: string }> {
100
+ const context = await browser.newContext({ baseURL: BASE_URL });
101
+ const page = await context.newPage();
102
+ const refreshRequests: string[] = [];
103
+ page.on('request', (browserRequest) => {
104
+ const url = browserRequest.url();
105
+ if (url.includes('/api/auth/session/refresh')) refreshRequests.push(url);
106
+ });
107
+
108
+ await page.goto(`/api/onboarding/onboarding/verify?token=${encodeURIComponent(token)}`);
109
+ await expect(page).toHaveURL(/\/onboarding\/preparing\?tenant=[0-9a-f-]+/);
110
+ await expect(page.getByText('We are preparing your workspace')).toBeVisible();
111
+ const tenantId = new URL(page.url()).searchParams.get('tenant');
112
+ expect(tenantId, 'verify redirect should include tenant id').toMatch(
113
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
114
+ );
115
+ return { context, page, refreshRequests, tenantId: tenantId! };
116
+ }
117
+
118
+ async function pollOnboardingStatus(request: APIRequestContext, tenantId: string) {
119
+ return request.get(`${BASE_URL}/api/onboarding/onboarding/status?tenantId=${encodeURIComponent(tenantId)}`, {
120
+ headers: {
121
+ Cookie: `om_login_tenant=${encodeURIComponent(tenantId)}`,
122
+ },
123
+ });
124
+ }
125
+
126
+ async function loginAndAssertBackend(session: BrowserTenantSession, tenant: OnboardingTenant): Promise<void> {
127
+ expect(tenant.tenantId, `tenant id should exist for ${tenant.email}`).toBeTruthy();
128
+ await expect(session.page).toHaveURL(new RegExp(`/login\\?tenant=${tenant.tenantId}`));
129
+ await expect(session.page.locator('form[data-auth-ready="1"]')).toBeVisible();
130
+ await session.page.getByLabel('Email').fill(tenant.email);
131
+ await session.page.getByLabel('Password', { exact: true }).fill(ONBOARDING_PASSWORD);
132
+ await session.page.getByRole('button', { name: 'Sign in' }).click();
133
+ await expect(session.page, `browser login should land ${tenant.email} in the backend`).toHaveURL(/\/backend(?:\/.*)?$/);
134
+ await expect(session.page.getByText(/Session expired/i)).toHaveCount(0);
135
+
136
+ const profileResult = await session.page.evaluate(async () => {
137
+ const response = await fetch('/api/auth/profile', { credentials: 'include' });
138
+ const body = await response.json().catch(() => null);
139
+ return { status: response.status, body };
140
+ });
141
+ expect(profileResult.status, `browser page should keep ${tenant.email} authenticated`).toBe(200);
142
+ const profile = profileResult.body;
143
+ expect(profile.email).toBe(tenant.email);
144
+ expect(profile.roles).toContain('admin');
145
+
146
+ await session.page.goto('/backend');
147
+ await expect(session.page).toHaveURL(/\/backend(?:\/.*)?$/);
148
+ expect(session.refreshRequests, `backend should not bounce ${tenant.email} through session refresh`).toHaveLength(0);
149
+ }
150
+
151
+ test.describe('TC-ONB-002: multi-tenant onboarding parallel login', () => {
152
+ test('creates two self-service tenants, handles repeated status polling, and keeps both logins authenticated', async ({
153
+ browser,
154
+ request,
155
+ }) => {
156
+ // Two full onboardings + parallel browser logins + DB-polled preparation
157
+ // far exceed the 20s suite default (house pattern: TC-AI-AGENT-SETTINGS-005).
158
+ test.setTimeout(120_000);
159
+ const unique = randomUUID().slice(0, 8);
160
+ const tenants: OnboardingTenant[] = [1, 2].map((index) => ({
161
+ email: `qa-onboarding-parallel-${unique}-${index}@example.test`,
162
+ organizationName: `QA Onboarding Parallel ${unique} ${index}`,
163
+ token: `integration-parallel-${index}-${randomUUID().replace(/-/g, '')}`,
164
+ }));
165
+
166
+ await Promise.all(tenants.map((tenant) => submitOnboarding(request, tenant)));
167
+
168
+ for (const tenant of tenants) {
169
+ tenant.requestId = await replaceVerificationToken(tenant.email, tenant.token);
170
+ }
171
+
172
+ const browserSessions = await Promise.all(
173
+ tenants.map(async (tenant) => {
174
+ const session = await verifyTenantInBrowser(browser, tenant.token);
175
+ tenant.tenantId = session.tenantId;
176
+ return session;
177
+ }),
178
+ );
179
+
180
+ try {
181
+ const statusResponses = await Promise.all(
182
+ tenants.flatMap((tenant) =>
183
+ Array.from({ length: 6 }, () => pollOnboardingStatus(request, tenant.tenantId!)),
184
+ ),
185
+ );
186
+ for (const response of statusResponses) {
187
+ expect(response.status(), 'status polling should not exhaust the app DB pool').toBe(200);
188
+ }
189
+
190
+ await Promise.all(tenants.map((tenant) => waitForPreparationComplete(tenant.requestId!)));
191
+ await Promise.all(
192
+ tenants.map((tenant, index) => loginAndAssertBackend(browserSessions[index], tenant)),
193
+ );
194
+ } finally {
195
+ await Promise.all(browserSessions.map((session) => session.context.close()));
196
+ }
197
+ });
198
+ });
@@ -9,6 +9,7 @@ import {
9
9
  resolveProvisioningIds,
10
10
  runDeferredProvisioning,
11
11
  } from '@open-mercato/onboarding/modules/onboarding/lib/deferred-provisioning'
12
+ import { isPreparationClaimActive } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
12
13
  import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
13
14
 
14
15
  export const metadata = {
@@ -78,7 +79,16 @@ export async function GET(req: Request) {
78
79
  if (provisioningIds && request.status === 'processing') {
79
80
  await service.markCompleted(request, provisioningIds)
80
81
  }
81
- if (provisioningIds && request.status === 'completed' && !request.preparationCompletedAt) {
82
+ // Schedule deferred provisioning only while no runner holds a fresh claim —
83
+ // otherwise every ~1s poll piles another full seed + reindex chain onto the
84
+ // connection pool. The atomic claim inside runDeferredProvisioning remains
85
+ // the authoritative gate; this check just keeps polls cheap.
86
+ if (
87
+ provisioningIds &&
88
+ request.status === 'completed' &&
89
+ !request.preparationCompletedAt &&
90
+ !isPreparationClaimActive(request.preparationStartedAt)
91
+ ) {
82
92
  after(async () => {
83
93
  await runDeferredProvisioning({
84
94
  requestId: request.id,
@@ -60,6 +60,9 @@ export class OnboardingRequest {
60
60
  @Property({ name: 'last_email_sent_at', type: Date, nullable: true })
61
61
  lastEmailSentAt?: Date | null
62
62
 
63
+ @Property({ name: 'preparation_started_at', type: Date, nullable: true })
64
+ preparationStartedAt?: Date | null
65
+
63
66
  @Property({ name: 'preparation_completed_at', type: Date, nullable: true })
64
67
  preparationCompletedAt?: Date | null
65
68
 
@@ -11,6 +11,7 @@ import { reindexEntity } from '@open-mercato/core/modules/query_index/lib/reinde
11
11
  import { purgeIndexScope } from '@open-mercato/core/modules/query_index/lib/purge'
12
12
  import { refreshCoverageSnapshot } from '@open-mercato/core/modules/query_index/lib/coverage'
13
13
  import { isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
14
+ import { PREPARATION_CLAIM_STALE_MS } from '@open-mercato/onboarding/modules/onboarding/lib/preparation-claim'
14
15
 
15
16
  const VECTOR_REINDEX_ENQUEUE_TIMEOUT_MS = 5_000
16
17
  const SEED_EXAMPLES_TIMEOUT_MS = 15_000
@@ -80,13 +81,14 @@ async function runModuleSetupHook(args: {
80
81
 
81
82
  async function markWorkspaceReady(args: {
82
83
  requestId: string
84
+ service: OnboardingService
83
85
  }) {
84
- const container = await createRequestContainer()
85
- const em = container.resolve('em') as EntityManager
86
- const service = new OnboardingService(em)
87
- const request = await service.findById(args.requestId)
88
- if (!request || request.preparationCompletedAt) return
89
- await service.markPreparationCompleted(request, new Date())
86
+ const request = await args.service.findById(args.requestId)
87
+ // The status guard protects a request that was re-submitted (and reset to
88
+ // pending) while this chain was still running — completing it would let the
89
+ // new flow skip its own deferred provisioning.
90
+ if (!request || request.preparationCompletedAt || request.status !== 'completed') return
91
+ await args.service.markPreparationCompleted(request, new Date())
90
92
  }
91
93
 
92
94
  async function enqueueVectorReindex(args: {
@@ -187,10 +189,36 @@ export async function runDeferredProvisioning(args: {
187
189
  }) {
188
190
  const container = await createRequestContainer()
189
191
  const em = container.resolve('em') as EntityManager
192
+ const service = new OnboardingService(em)
193
+
194
+ // Single-flight guard: the preparing page polls the status endpoint every
195
+ // second and each poll (plus the verify handler) schedules this chain. The
196
+ // atomic claim collapses those triggers into one run per request — without
197
+ // it, dozens of concurrent seed + full-reindex chains exhaust the PG
198
+ // connection pool (2026-06-11 demo outage). A stale claim (crashed runner)
199
+ // becomes reclaimable after PREPARATION_CLAIM_STALE_MS.
200
+ const claimedAt = new Date()
201
+ const claimed = await service.claimPreparation(
202
+ args.requestId,
203
+ claimedAt,
204
+ new Date(claimedAt.getTime() - PREPARATION_CLAIM_STALE_MS),
205
+ )
206
+ if (!claimed) {
207
+ console.info('[onboarding.verify] deferred provisioning skipped (already claimed or completed)', {
208
+ requestId: args.requestId,
209
+ tenantId: args.tenantId,
210
+ })
211
+ return
212
+ }
213
+
190
214
  const modules = getModules()
191
215
 
192
216
  for (const mod of modules) {
193
217
  if (!mod.setup?.seedExamples) continue
218
+ // Heartbeat: keep the lease fresh while legitimately working so a slow run
219
+ // (many modules × 15s seed timeout + rebuild) can never look stale and get
220
+ // double-claimed by a later poll.
221
+ await service.renewPreparation(args.requestId, new Date()).catch(() => {})
194
222
  try {
195
223
  await runModuleSetupHook({
196
224
  moduleId: mod.id,
@@ -221,10 +249,26 @@ export async function runDeferredProvisioning(args: {
221
249
  }
222
250
  }
223
251
 
252
+ // The rebuild runs BEFORE the completion flag: preparationCompletedAt is the
253
+ // terminal gate for both the status-route scheduling and claimPreparation,
254
+ // so a runner that dies mid-rebuild must leave the flag unset — the stale
255
+ // claim then makes the whole chain reclaimable and the rebuild self-heals.
256
+ // rebuildTenantQueryIndexes never throws (it logs per-entity failures).
257
+ await service.renewPreparation(args.requestId, new Date()).catch(() => {})
258
+ await rebuildTenantQueryIndexes({
259
+ em,
260
+ tenantId: args.tenantId,
261
+ organizationId: args.organizationId,
262
+ })
263
+
224
264
  await markWorkspaceReady({
225
265
  requestId: args.requestId,
266
+ service,
226
267
  })
227
268
 
269
+ // Non-fatal (#2954 contract): an email failure must not abort the chain.
270
+ // The status endpoint retries the ready email on later polls while
271
+ // readyEmailSentAt is unset.
228
272
  await sendWorkspaceReadyEmail({
229
273
  requestId: args.requestId,
230
274
  tenantId: args.tenantId,
@@ -235,13 +279,6 @@ export async function runDeferredProvisioning(args: {
235
279
  organizationId: args.organizationId,
236
280
  error,
237
281
  })
238
- throw error
239
- })
240
-
241
- await rebuildTenantQueryIndexes({
242
- em,
243
- tenantId: args.tenantId,
244
- organizationId: args.organizationId,
245
282
  })
246
283
 
247
284
  await enqueueVectorReindex({
@@ -0,0 +1,6 @@
1
+ export const PREPARATION_CLAIM_STALE_MS = 10 * 60 * 1000
2
+
3
+ export function isPreparationClaimActive(startedAt: Date | null | undefined, now: Date = new Date()): boolean {
4
+ if (!startedAt) return false
5
+ return startedAt.getTime() > now.getTime() - PREPARATION_CLAIM_STALE_MS
6
+ }