Skip to content

Commit eef419b

Browse files
committed
fix: make project provisioning asynchronous
1 parent d2094f1 commit eef419b

9 files changed

Lines changed: 608 additions & 34 deletions

File tree

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { NotProvisioned } from './not-provisioned';
4+
5+
const routerRefresh = vi.fn();
6+
const setProject = vi.fn();
7+
8+
vi.mock('next/navigation', () => ({
9+
useRouter: () => ({ refresh: routerRefresh }),
10+
}));
11+
vi.mock('@/lib/route-params', () => ({
12+
useProjectRef: (projectRef: string) => projectRef,
13+
}));
14+
vi.mock('@/lib/session', () => ({
15+
useSession: () => ({
16+
platformKey: 'PLATFORM_KEY',
17+
project: { ref: 'demo', apikey: '', initStatus: 'PENDING_INIT' },
18+
setProject,
19+
}),
20+
}));
21+
vi.mock('@/lib/api', () => ({
22+
apiFetch: vi.fn(),
23+
fetchAllProjects: vi.fn(),
24+
}));
25+
vi.mock('@/lib/project-provisioning', async () => {
26+
const actual = await vi.importActual<
27+
typeof import('@/lib/project-provisioning')
28+
>('@/lib/project-provisioning');
29+
return {
30+
...actual,
31+
pollProjectProvisioning: vi.fn(),
32+
};
33+
});
34+
35+
import { apiFetch, fetchAllProjects } from '@/lib/api';
36+
import {
37+
pollProjectProvisioning,
38+
ProjectProvisioningTimeoutError,
39+
type ProjectProvisioningStatus,
40+
} from '@/lib/project-provisioning';
41+
42+
describe('NotProvisioned', () => {
43+
beforeEach(() => {
44+
routerRefresh.mockReset();
45+
setProject.mockReset();
46+
vi.mocked(apiFetch).mockReset();
47+
vi.mocked(fetchAllProjects).mockReset();
48+
vi.mocked(pollProjectProvisioning).mockReset();
49+
vi.mocked(apiFetch).mockResolvedValue({});
50+
});
51+
52+
it('submits once, waits for terminal status, then refreshes the project context', async () => {
53+
const initialized = status('INITIALIZED', false);
54+
vi.mocked(pollProjectProvisioning).mockImplementation(
55+
async (loadStatus, options) => {
56+
const current = await loadStatus();
57+
options.onStatus?.(current);
58+
return current;
59+
},
60+
);
61+
vi.mocked(apiFetch).mockImplementation(
62+
(path: string, options: any = {}) => {
63+
if (options.method === 'POST')
64+
return Promise.resolve({ submissionState: 'QUEUED' });
65+
if (path.endsWith('/provision')) return Promise.resolve(initialized);
66+
return Promise.resolve({});
67+
},
68+
);
69+
vi.mocked(fetchAllProjects).mockResolvedValue([
70+
{
71+
ref: 'demo',
72+
name: 'Demo',
73+
apikey: 'SERVICE_ROLE_KEY',
74+
initStatus: 'INITIALIZED',
75+
healthStatus: 'HEALTHY',
76+
},
77+
]);
78+
79+
render(<NotProvisioned projectRef="demo" initStatus="PENDING_INIT" />);
80+
81+
await waitFor(() => {
82+
expect(setProject).toHaveBeenCalledWith(
83+
expect.objectContaining({
84+
ref: 'demo',
85+
initStatus: 'INITIALIZED',
86+
}),
87+
);
88+
});
89+
expect(apiFetch).toHaveBeenCalledWith(
90+
'/auth/v1/admin/projects/demo/provision',
91+
expect.objectContaining({ method: 'POST', authScope: 'platform' }),
92+
);
93+
expect(routerRefresh).toHaveBeenCalled();
94+
});
95+
96+
it('describes a monitoring timeout without claiming the database failed', async () => {
97+
const initializing = status('INITIALIZING', true);
98+
vi.mocked(pollProjectProvisioning).mockRejectedValue(
99+
new ProjectProvisioningTimeoutError(initializing),
100+
);
101+
102+
render(<NotProvisioned projectRef="demo" initStatus="PENDING_INIT" />);
103+
104+
expect(
105+
await screen.findByText(
106+
'Database provisioning is taking longer than expected',
107+
),
108+
).toBeInTheDocument();
109+
expect(
110+
screen.getByRole('button', { name: /Check status/ }),
111+
).toBeInTheDocument();
112+
expect(
113+
screen.queryByText('Database provisioning failed'),
114+
).not.toBeInTheDocument();
115+
});
116+
});
117+
118+
function status(
119+
initStatus: string,
120+
running: boolean,
121+
): ProjectProvisioningStatus {
122+
return {
123+
ref: 'demo',
124+
initStatus,
125+
initMessage: null,
126+
enabled: initStatus === 'INITIALIZED',
127+
running,
128+
startedAt: null,
129+
completedAt: null,
130+
};
131+
}

frontend/apps/studio/src/components/not-provisioned.tsx

Lines changed: 107 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { useRouter } from 'next/navigation';
66
import { CloudOff, Loader2, Zap } from 'lucide-react';
77
import { Button, Card, CardContent } from '@nubase/ui';
88
import { apiFetch, fetchAllProjects, type ApiError } from '@/lib/api';
9+
import {
10+
pollProjectProvisioning,
11+
ProjectProvisioningFailedError,
12+
ProjectProvisioningTimeoutError,
13+
type ProjectProvisioningStatus,
14+
} from '@/lib/project-provisioning';
915
import { useSession, type ProjectContext } from '@/lib/session';
1016
import { useProjectRef } from '@/lib/route-params';
1117

@@ -14,28 +20,69 @@ interface NotProvisionedProps {
1420
initStatus?: string | null;
1521
}
1622

23+
type ProvisioningFailureKind = 'database' | 'monitoring' | 'request';
24+
25+
interface ProvisioningFailure {
26+
kind: ProvisioningFailureKind;
27+
message: string;
28+
}
29+
1730
/**
1831
* Shown on data pages while the project's database isn't initialised yet. The user
1932
* shouldn't have to click anything: provisioning starts automatically on mount and
2033
* this just reports progress, falling back to a manual retry if it fails.
2134
*/
22-
export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps) {
35+
export function NotProvisioned({
36+
projectRef,
37+
initStatus,
38+
}: NotProvisionedProps) {
2339
const router = useRouter();
2440
const { platformKey, project, setProject } = useSession();
2541
const resolvedProjectRef = useProjectRef(projectRef);
2642
const [running, setRunning] = useState(false);
27-
const [error, setError] = useState<string | null>(null);
28-
const autoStarted = useRef(false);
43+
const [observedStatus, setObservedStatus] = useState(initStatus ?? null);
44+
const [failure, setFailure] = useState<ProvisioningFailure | null>(null);
45+
const controllerRef = useRef<AbortController | null>(null);
46+
const runIdRef = useRef(0);
2947

3048
async function provision() {
3149
if (!platformKey || !resolvedProjectRef) return;
50+
const runId = ++runIdRef.current;
51+
controllerRef.current?.abort();
52+
const controller = new AbortController();
53+
controllerRef.current = controller;
3254
setRunning(true);
33-
setError(null);
55+
setFailure(null);
3456
try {
35-
await apiFetch(`/auth/v1/admin/projects/${encodeURIComponent(resolvedProjectRef)}/provision`, {
36-
method: 'POST',
37-
apikey: platformKey,
38-
});
57+
await apiFetch(
58+
`/auth/v1/admin/projects/${encodeURIComponent(resolvedProjectRef)}/provision`,
59+
{
60+
method: 'POST',
61+
apikey: platformKey,
62+
authScope: 'platform',
63+
signal: controller.signal,
64+
},
65+
);
66+
await pollProjectProvisioning(
67+
() =>
68+
apiFetch<ProjectProvisioningStatus>(
69+
`/auth/v1/admin/projects/${encodeURIComponent(resolvedProjectRef)}/provision`,
70+
{
71+
apikey: platformKey,
72+
authScope: 'platform',
73+
signal: controller.signal,
74+
},
75+
),
76+
{
77+
signal: controller.signal,
78+
onStatus: (status) => {
79+
if (runId === runIdRef.current) {
80+
setObservedStatus(status.initStatus);
81+
}
82+
},
83+
},
84+
);
85+
if (runId !== runIdRef.current) return;
3986
const refreshed = await fetchProject(platformKey, resolvedProjectRef);
4087
if (refreshed) {
4188
setProject(refreshed);
@@ -44,23 +91,51 @@ export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps)
4491
}
4592
router.refresh();
4693
} catch (err) {
47-
setError((err as ApiError).message ?? 'Provision failed.');
94+
if (controller.signal.aborted || runId !== runIdRef.current) return;
95+
if (err instanceof ProjectProvisioningFailedError) {
96+
setObservedStatus(err.status.initStatus);
97+
setFailure({ kind: 'database', message: err.message });
98+
} else if (err instanceof ProjectProvisioningTimeoutError) {
99+
setObservedStatus(err.lastStatus.initStatus);
100+
setFailure({ kind: 'monitoring', message: err.message });
101+
} else {
102+
setFailure({
103+
kind: 'request',
104+
message:
105+
(err as ApiError).message ??
106+
'Unable to start or monitor provisioning.',
107+
});
108+
}
48109
} finally {
49-
setRunning(false);
110+
if (runId === runIdRef.current) {
111+
setRunning(false);
112+
}
50113
}
51114
}
52115

53-
// Kick off provisioning automatically the moment this lands — no manual click.
54-
// Fires once per mount; on failure the manual retry button below takes over.
116+
// Strict Mode mounts this effect twice in development. Aborting the previous run and
117+
// relying on backend per-project deduplication keeps both the browser and worker safe.
55118
useEffect(() => {
56-
if (autoStarted.current) return;
57119
if (!platformKey || !resolvedProjectRef) return;
58-
autoStarted.current = true;
59120
void provision();
121+
return () => {
122+
controllerRef.current?.abort();
123+
};
60124
// eslint-disable-next-line react-hooks/exhaustive-deps
61125
}, [platformKey, resolvedProjectRef]);
62126

63-
const failed = !!error && !running;
127+
const failed = !!failure && !running;
128+
const currentStatus = observedStatus ?? initStatus ?? 'unknown';
129+
const failureTitle =
130+
failure?.kind === 'database'
131+
? 'Database provisioning failed'
132+
: failure?.kind === 'monitoring'
133+
? 'Database provisioning is taking longer than expected'
134+
: 'Unable to monitor database provisioning';
135+
const failureDescription =
136+
failure?.kind === 'database'
137+
? 'Postgres reported an initialization failure. Retry after resolving the persisted error below.'
138+
: 'The backend may still be provisioning this project. Checking again is safe and will not start a duplicate worker.';
64139

65140
return (
66141
<div className="p-8">
@@ -69,16 +144,19 @@ export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps)
69144
{failed ? (
70145
<>
71146
<CloudOff className="h-8 w-8 text-muted-foreground" />
72-
<h2 className="text-lg font-semibold">Database provisioning failed</h2>
147+
<h2 className="text-lg font-semibold">{failureTitle}</h2>
73148
<p className="max-w-md text-sm text-muted-foreground">
74149
This project is in state{' '}
75-
<code className="font-mono text-xs">{initStatus ?? 'unknown'}</code>. The underlying Postgres
76-
database couldn&apos;t be initialised.
150+
<code className="font-mono text-xs">{currentStatus}</code>.{' '}
151+
{failureDescription}
152+
</p>
153+
<p className="max-w-md text-xs text-destructive">
154+
{failure?.message}
77155
</p>
78-
<p className="max-w-md text-xs text-destructive">{error}</p>
79156
<div className="flex gap-2 pt-2">
80157
<Button size="sm" onClick={provision} disabled={running}>
81-
<Zap className="h-3.5 w-3.5" /> Retry
158+
<Zap className="h-3.5 w-3.5" />
159+
{failure?.kind === 'database' ? 'Retry' : 'Check status'}
82160
</Button>
83161
<Link href={`/project/${resolvedProjectRef}/settings`}>
84162
<Button variant="outline" size="sm">
@@ -92,9 +170,11 @@ export function NotProvisioned({ projectRef, initStatus }: NotProvisionedProps)
92170
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
93171
<h2 className="text-lg font-semibold">Initializing database…</h2>
94172
<p className="max-w-md text-sm text-muted-foreground">
95-
Provisioning the Postgres database and running the auth/storage schema for{' '}
96-
<code className="font-mono text-xs">{resolvedProjectRef}</code>. This takes ~30 seconds — the
97-
Database, Auth and Storage pages will populate automatically once it&apos;s done.
173+
Provisioning the Postgres database and running the auth/storage
174+
schema for{' '}
175+
<code className="font-mono text-xs">{resolvedProjectRef}</code>.
176+
This can take a few minutes — the Database, Auth and Storage
177+
pages will populate automatically once it&apos;s done.
98178
</p>
99179
</>
100180
)}
@@ -112,7 +192,10 @@ interface ProjectSummary {
112192
apikey?: string | null;
113193
}
114194

115-
async function fetchProject(platformKey: string, projectRef: string): Promise<ProjectContext | null> {
195+
async function fetchProject(
196+
platformKey: string,
197+
projectRef: string,
198+
): Promise<ProjectContext | null> {
116199
const projects = await fetchAllProjects<ProjectSummary>(platformKey);
117200
const project = projects.find((p) => p.ref === projectRef);
118201
if (!project) return null;

0 commit comments

Comments
 (0)