diff --git a/app/api/lessons/[slug]/prepare/route.ts b/app/api/lessons/[slug]/prepare/route.ts new file mode 100644 index 0000000..718fd3c --- /dev/null +++ b/app/api/lessons/[slug]/prepare/route.ts @@ -0,0 +1,101 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import { auth } from "@/lib/auth"; +import { getLesson } from "@/lib/lessons"; +import { + ensureBranchForLesson, + resetBranchForLesson, +} from "@/lib/branch-manager"; +import { RateLimitError } from "@/lib/rate-limit"; + +type Ctx = { params: Promise<{ slug: string }> }; + +/** + * Prepare (or reset) the sandbox branch for a lesson, streaming progress as + * newline-delimited JSON so the client can show the distinct branching and + * seeding stages live. Each line is one event: + * { "phase": "branching" } + * { "phase": "seeding" } + * { "phase": "ready", "branch": { "name": "…" } } + * { "phase": "error", "message": "…", "retryAfterSeconds"?: number } + */ +export async function POST(req: Request, ctx: Ctx) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { slug } = await ctx.params; + const lesson = await getLesson(slug); + if (!lesson) { + return NextResponse.json({ error: "Lesson not found" }, { status: 404 }); + } + + if ( + !process.env.XATA_API_KEY || + !process.env.XATA_ORG_ID || + !process.env.XATA_PROJECT_ID + ) { + return NextResponse.json( + { error: "Sandbox is not configured." }, + { status: 503 }, + ); + } + + let body: { reset?: unknown } = {}; + try { + body = await req.json(); + } catch { + // No body is fine — treat as a non-reset prepare. + } + const reset = body?.reset === true; + const userId = session.user.id; + + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + const send = (event: Record) => { + controller.enqueue(encoder.encode(JSON.stringify(event) + "\n")); + }; + try { + const onPhase = (phase: "branching" | "seeding") => + send({ phase }); + const row = reset + ? await resetBranchForLesson(userId, lesson, onPhase) + : await ensureBranchForLesson(userId, lesson, onPhase); + + // The lesson page reads the branch row in a server component, so the + // RSC payload needs invalidating before the client's router.refresh() + // will pick up the new branch. Guard it: a failure here shouldn't turn + // an already-successful prepare into an error event. + try { + revalidatePath(`/lessons/${slug}`); + } catch { + // best-effort + } + send({ phase: "ready", branch: { name: row.xataBranchName } }); + } catch (err) { + if (err instanceof RateLimitError) { + send({ + phase: "error", + message: err.message, + retryAfterSeconds: err.retryAfterSeconds, + }); + } else { + send({ phase: "error", message: (err as Error).message }); + } + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "application/x-ndjson; charset=utf-8", + "Cache-Control": "no-store, no-transform", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/lessons/[slug]/reset/route.ts b/app/api/lessons/[slug]/reset/route.ts deleted file mode 100644 index 977042d..0000000 --- a/app/api/lessons/[slug]/reset/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { headers } from "next/headers"; -import { NextResponse } from "next/server"; -import { revalidatePath } from "next/cache"; -import { auth } from "@/lib/auth"; -import { getLesson } from "@/lib/lessons"; -import { resetBranchForLesson } from "@/lib/branch-manager"; -import { RateLimitError } from "@/lib/rate-limit"; - -type Ctx = { params: Promise<{ slug: string }> }; - -export async function POST(_req: Request, ctx: Ctx) { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } - - const { slug } = await ctx.params; - const lesson = await getLesson(slug); - if (!lesson) { - return NextResponse.json({ error: "Lesson not found" }, { status: 404 }); - } - - try { - const row = await resetBranchForLesson(session.user.id, lesson); - // The lesson page reads the branch row in a server component, so the RSC - // payload needs invalidating before router.refresh() will pick up the new - // branch name. - revalidatePath(`/lessons/${slug}`); - return NextResponse.json({ - ok: true, - branch: { id: row.xataBranchId, name: row.xataBranchName }, - }); - } catch (err) { - if (err instanceof RateLimitError) { - return NextResponse.json( - { error: err.message }, - { - status: 429, - headers: { "Retry-After": String(err.retryAfterSeconds) }, - }, - ); - } - return NextResponse.json( - { error: (err as Error).message }, - { status: 500 }, - ); - } -} diff --git a/components/lesson/SandboxBootstrap.tsx b/components/lesson/SandboxBootstrap.tsx new file mode 100644 index 0000000..13e7c0c --- /dev/null +++ b/components/lesson/SandboxBootstrap.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { usePrepareStream } from "./usePrepareStream"; +import { SandboxPrepProgress } from "./SandboxPrepProgress"; + +/** + * Rendered the first time a lesson is opened (no ready branch yet). Drives the + * streaming /prepare endpoint so the user watches the branch get forked and + * seeded, then refreshes the server tree to swap in the interactive panel. + */ +export function SandboxBootstrap({ lessonSlug }: { lessonSlug: string }) { + const router = useRouter(); + const { phase, error, run } = usePrepareStream(lessonSlug); + const started = useRef(false); + + useEffect(() => { + if (started.current) return; + started.current = true; + run().then((res) => { + if (res.ok) router.refresh(); + }); + }, [run, router]); + + const onRetry = () => { + run().then((res) => { + if (res.ok) router.refresh(); + }); + }; + + return ( + <> +
+ + + + preparing sandbox… + + +
+
+
+ {phase === "error" ? ( +
+
+ Couldn't prepare your sandbox +
+

+ {error} +

+ +
+ ) : ( +
+ +
+ )} +
+
+ + ); +} diff --git a/components/lesson/SandboxLoading.tsx b/components/lesson/SandboxLoading.tsx index b587d85..a184c97 100644 --- a/components/lesson/SandboxLoading.tsx +++ b/components/lesson/SandboxLoading.tsx @@ -16,7 +16,7 @@ export function SandboxLoading() {
- Forking a fresh Postgres branch… + Loading sandbox…
diff --git a/components/lesson/SandboxPanel.tsx b/components/lesson/SandboxPanel.tsx index 153f83b..e0305cf 100644 --- a/components/lesson/SandboxPanel.tsx +++ b/components/lesson/SandboxPanel.tsx @@ -1,8 +1,10 @@ "use client"; -import { useState, useTransition } from "react"; +import { useTransition } from "react"; import { useRouter } from "next/navigation"; import { Terminal } from "@/components/shell/Terminal"; +import { usePrepareStream } from "./usePrepareStream"; +import { SandboxPrepProgress } from "./SandboxPrepProgress"; type Props = { lessonSlug: string; @@ -11,39 +13,24 @@ type Props = { export function SandboxPanel({ lessonSlug, branchName }: Props) { const router = useRouter(); - const [fetching, setFetching] = useState(false); + const { phase, error, run, clear } = usePrepareStream(lessonSlug); const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); - // fetching: the POST is in flight. - // isPending: router.refresh() is re-rendering the server tree. - // Either way the user sees the busy state. - const busy = fetching || isPending; + // While the stream runs, `phase` is set (branching/seeding/ready). Once it's + // ready we clear it and hand off to isPending, which stays true until the + // refreshed RSC payload — with the new branch — has rendered. + const streaming = phase !== null && phase !== "error"; + const busy = streaming || isPending; const onClearShell = () => { window.dispatchEvent(new CustomEvent("learn:clear-shell")); }; const onReset = async () => { - setError(null); - setFetching(true); - try { - const res = await fetch( - `/api/lessons/${encodeURIComponent(lessonSlug)}/reset`, - { method: "POST" }, - ); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - setError(body.error ?? `Reset failed (${res.status})`); - return; - } - // Hand off the busy state to isPending so the spinner stays up until - // the refreshed RSC payload has rendered. + const res = await run({ reset: true }); + if (res.ok) { + clear(); startTransition(() => router.refresh()); - } catch (err) { - setError((err as Error).message); - } finally { - setFetching(false); } }; @@ -89,9 +76,15 @@ export function SandboxPanel({ lessonSlug, branchName }: Props) { {busy && (
-
- - Resetting sandbox… +
+ {streaming ? ( + + ) : ( + + + Resetting sandbox… + + )}
)} diff --git a/components/lesson/SandboxPrepProgress.tsx b/components/lesson/SandboxPrepProgress.tsx new file mode 100644 index 0000000..b77ce52 --- /dev/null +++ b/components/lesson/SandboxPrepProgress.tsx @@ -0,0 +1,110 @@ +import type { PrepPhase } from "./usePrepareStream"; + +type StepStatus = "pending" | "active" | "done"; + +function branchStatus(phase: PrepPhase): StepStatus { + if (phase === "branching") return "active"; + if (phase === "seeding" || phase === "ready") return "done"; + return "pending"; +} + +function seedStatus(phase: PrepPhase): StepStatus { + if (phase === "seeding") return "active"; + if (phase === "ready") return "done"; + return "pending"; +} + +/** + * Two-step checklist showing the live branching → seeding progress while a + * sandbox is being prepared. Drive it with the `phase` from usePrepareStream. + */ +export function SandboxPrepProgress({ phase }: { phase: PrepPhase }) { + return ( +
    + + +
+ ); +} + +function Step({ + status, + label, + activeLabel, +}: { + status: StepStatus; + label: string; + activeLabel: string; +}) { + return ( +
  • + + {status === "active" ? activeLabel : label} +
  • + ); +} + +function StatusIcon({ status }: { status: StepStatus }) { + if (status === "done") { + return ( + + + + + ); + } + if (status === "active") { + return ( + + + + + ); + } + return ( + + ); +} diff --git a/components/lesson/SandboxSection.tsx b/components/lesson/SandboxSection.tsx index db5c15b..4489d05 100644 --- a/components/lesson/SandboxSection.tsx +++ b/components/lesson/SandboxSection.tsx @@ -1,16 +1,15 @@ -import { - ensureBranchForLesson, - type UserBranchRow, -} from "@/lib/branch-manager"; +import { getReadyBranch } from "@/lib/branch-manager"; import type { Lesson } from "@/lib/lessons"; import { BranchPanel } from "./BranchPanel"; +import { SandboxBootstrap } from "./SandboxBootstrap"; import { SandboxPanel } from "./SandboxPanel"; /** - * Server component that does the slow Xata work for a lesson sandbox. Rendered - * inside a boundary so the prose can stream first. We catch the - * error here (instead of bubbling to a route-level error boundary) so that - * the rest of the lesson stays readable when Xata is unhappy. + * Server component for a lesson sandbox. It only does the *fast* work — check + * whether a ready branch already exists — so it can stream in quickly behind + * its boundary. If a branch is ready, the interactive panel renders + * immediately. Otherwise it hands off to the client bootstrapper, which drives + * the streaming /prepare endpoint and shows the branching/seeding stages live. */ export async function SandboxSection({ userId, @@ -27,16 +26,21 @@ export async function SandboxSection({ return ; } - let row: UserBranchRow; + let ready; try { - row = await ensureBranchForLesson(userId, lesson); + ready = await getReadyBranch(userId, lesson.meta.slug); } catch (err) { return ; } - return ( - - ); + + if (ready) { + return ( + + ); + } + + return ; } diff --git a/components/lesson/usePrepareStream.ts b/components/lesson/usePrepareStream.ts new file mode 100644 index 0000000..cdfbb76 --- /dev/null +++ b/components/lesson/usePrepareStream.ts @@ -0,0 +1,98 @@ +"use client"; + +import { useCallback, useState } from "react"; + +export type PrepPhase = "branching" | "seeding" | "ready" | "error"; + +type PrepEvent = + | { phase: "branching" } + | { phase: "seeding" } + | { phase: "ready"; branch: { name: string } } + | { phase: "error"; message: string; retryAfterSeconds?: number }; + +type RunResult = + | { ok: true; branchName: string } + | { ok: false; error: string }; + +/** + * Drive the streaming /prepare endpoint, surfacing the live branching/seeding + * phases. `run` POSTs, reads the NDJSON stream, and resolves once the branch is + * ready (or errored). `phase` reflects the current stage for the progress UI; + * `clear` resets it back to idle after the caller has handled the result. + */ +export function usePrepareStream(lessonSlug: string) { + const [phase, setPhase] = useState(null); + const [error, setError] = useState(null); + + const run = useCallback( + async (opts?: { reset?: boolean }): Promise => { + setError(null); + setPhase("branching"); + try { + const res = await fetch( + `/api/lessons/${encodeURIComponent(lessonSlug)}/prepare`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reset: opts?.reset === true }), + }, + ); + if (!res.ok || !res.body) { + const failed = await res.json().catch(() => ({})); + throw new Error(failed.error ?? `Prepare failed (${res.status})`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let branchName: string | null = null; + let streamError: string | null = null; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + let event: PrepEvent; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (event.phase === "branching" || event.phase === "seeding") { + setPhase(event.phase); + } else if (event.phase === "ready") { + branchName = event.branch.name; + } else if (event.phase === "error") { + streamError = event.message; + } + } + } + + if (streamError) throw new Error(streamError); + if (!branchName) { + throw new Error("Sandbox preparation ended unexpectedly."); + } + setPhase("ready"); + return { ok: true, branchName }; + } catch (err) { + const message = (err as Error).message; + setPhase("error"); + setError(message); + return { ok: false, error: message }; + } + }, + [lessonSlug], + ); + + const clear = useCallback(() => { + setPhase(null); + setError(null); + }, []); + + return { phase, error, run, clear }; +} diff --git a/lib/branch-manager.ts b/lib/branch-manager.ts index c8a42cd..cf9205c 100644 --- a/lib/branch-manager.ts +++ b/lib/branch-manager.ts @@ -20,6 +20,15 @@ import { enforceRate } from "@/lib/rate-limit"; export type UserBranchRow = typeof userBranch.$inferSelect; +/** + * The two user-visible stages of preparing a fresh branch. `branching` covers + * provisioning the Xata branch and waiting for its connection string; + * `seeding` covers running the lesson's seed SQL. Reported via the optional + * `onPhase` callback so the UI can show what's happening behind the scenes. + */ +export type BranchPhase = "branching" | "seeding"; +type PhaseCallback = (phase: BranchPhase) => void; + const XATA_NAME_MAX = 63; const DEFAULT_MAX_BRANCHES_PER_USER = 5; const DEFAULT_BRANCH_CREATE_LIMIT = 5; @@ -379,6 +388,7 @@ function maskDsn(dsn: string): string { export async function ensureBranchForLesson( userId: string, lesson: Lesson, + onPhase?: PhaseCallback, ): Promise { let existing = await findExisting(userId, lesson.meta.slug); @@ -415,6 +425,7 @@ export async function ensureBranchForLesson( await enforceBranchQuota(userId); + onPhase?.("branching"); const parentId = await getParentBranchId(); const name = buildBranchName(userId, lesson.meta.slug); let branch; @@ -455,6 +466,7 @@ export async function ensureBranchForLesson( throw err; } + onPhase?.("seeding"); try { await runSeed(dsn, lesson.seedSql); } catch (err) { @@ -517,9 +529,28 @@ export async function dropBranchForLesson( export async function resetBranchForLesson( userId: string, lesson: Lesson, + onPhase?: PhaseCallback, ): Promise { await dropBranchForLesson(userId, lesson.meta.slug); - return ensureBranchForLesson(userId, lesson); + return ensureBranchForLesson(userId, lesson, onPhase); +} + +/** + * Return the user's branch for a lesson only if it's fully ready to use (has a + * connection string). Used by the server component to decide whether to render + * the interactive panel immediately or hand off to the client bootstrapper + * that creates + seeds a fresh branch with live progress. Returns null for a + * missing or half-provisioned row, so the bootstrap path (which heals or + * recreates via ensureBranchForLesson) takes over. + */ +export async function getReadyBranch( + userId: string, + lessonSlug: string, +): Promise { + const row = await findExisting(userId, lessonSlug); + if (!row || !row.connectionString) return null; + await touchLastUsed(userId, lessonSlug); + return row; } export type CleanupResult = {