feat(db): Migration 0060 backfills onboardingStep + flips to NOT NULL

Migration 0057 shipped `onboarding_step INTEGER DEFAULT 0` as nullable so
the additive DDL avoided a long table rewrite during the W14b deploy.
The wizard mount code and the POST route then carried defensive
null-handling branches (read-side `?? 0`, write-side `OR step IS NULL`)
to keep legacy pre-W14b rows working. That left a tri-state surface the
route had to widen on every step-0 advance.

Backfill every NULL row to 0 (the same value the read-path already
substitutes), then `ALTER COLUMN ... SET NOT NULL`. schema.prisma drops
the `?` so the Prisma type-surface mirrors the DB. The conditional
update WHERE in `/api/onboarding/step` collapses to the strict-equality
form on every step — the legacy null branch becomes unreachable code
and is removed.

Fix-O / senior-M1.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:35:57 +02:00
1 parent a238ae48d1
commit 3f7a8ce6a6
4 files changed
+46 -32

No files matched your search

@@ -0,0 +1,25 @@
-- v1.4.25 W21 Fix-O — Backfill `onboarding_step` and flip column to NOT NULL.
--
-- Migration 0057 shipped `onboarding_step INTEGER DEFAULT 0` — nullable on
-- purpose so the additive DDL didn't risk a long table-rewrite during the
-- W14b deploy. The wizard mount code (`src/app/onboarding/page.tsx`) treats
-- `null` as `0`, the POST route (`src/app/api/onboarding/step/route.ts`)
-- widens its conditional-update WHERE to `OR onboardingStep IS NULL` when
-- the caller is at step 0 — all defensive against the tri-state surface.
--
-- This migration closes that tri-state: every legacy pre-W14b row that
-- still carries a NULL is backfilled to 0 (the same value the read-path
-- already substitutes), then the column is flipped to NOT NULL. New
-- inserts continue to land at the DEFAULT 0 — schema.prisma now mirrors
-- the DB shape: `onboardingStep Int @default(0)` without the `?`.
--
-- Idempotent — re-running on a database that's already been flipped is a
-- no-op for the UPDATE (no rows match) and the ALTER COLUMN tolerates
-- SET NOT NULL on an already-NOT NULL column.
UPDATE "users"
SET "onboarding_step" = 0
WHERE "onboarding_step" IS NULL;
ALTER TABLE "users"
ALTER COLUMN "onboarding_step" SET NOT NULL;
+1 -1
View File
@@ -98,7 +98,7 @@ model User {
// 4 = done (triggers `onboardingCompletedAt` flip on the same
// round-trip — the column stays at 4 for completed users)
// Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
onboardingStep Int? @default(0) @map("onboarding_step")
onboardingStep Int @default(0) @map("onboarding_step")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
+9 -13
View File
@@ -208,18 +208,19 @@ describe("POST /api/onboarding/step — concurrent write race", () => {
id: "user-1",
onboardingCompletedAt: null,
});
// Either the literal { onboardingStep: 2 } form (current > 0) or
// the legacy null-OR-zero form when current === 0. Here current
// is 2 so we expect the strict-equality form.
// Strict-equality precondition — Migration 0060 dropped the
// legacy nullable path so every advance uses `{ in: [current] }`.
expect(whereArg.onboardingStep).toEqual({ in: [2] });
});
it("widens the precondition to null OR 0 when the row is a legacy pre-W14b user", async () => {
// Legacy accounts may have `onboarding_step IS NULL`. The
// conditional update must still claim them on step 1.
it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
// Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
// flipped `onboarding_step` to NOT NULL, so the conditional
// update always uses the strict-equality form — there is no
// legacy null branch left for the route to widen.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: null,
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
@@ -232,12 +233,7 @@ describe("POST /api/onboarding/step — concurrent write race", () => {
expect(res.status).toBe(200);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg.OR).toEqual(
expect.arrayContaining([
{ onboardingStep: 0 },
{ onboardingStep: null },
]),
);
expect(whereArg.onboardingStep).toEqual({ in: [0] });
});
});
+11 -18
View File
@@ -96,7 +96,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Onboarding already completed", 409);
}
const current = fresh.onboardingStep ?? 0;
const current = fresh.onboardingStep;
if (step !== current + 1) {
annotate({
action: { name: "onboarding.step" },
@@ -116,24 +116,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
// both reading `onboardingStep = 2` and both POSTing `step: 3`
// would otherwise both succeed (the read-then-write race); with
// this guard exactly one update sees `count = 1`, the second sees
// `count = 0` and returns 409. Legacy pre-W14b accounts may have
// `onboarding_step IS NULL`, so the `current === 0` branch widens
// the match to null OR 0 — the wizard treats both as "not
// started". `onboardingCompletedAt: null` also catches a race with
// `POST /api/onboarding/complete`.
// `count = 0` and returns 409. `onboardingCompletedAt: null` also
// catches a race with `POST /api/onboarding/complete`. Migration
// 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column
// to NOT NULL, so the legacy null branch this code carried before
// is dropped — every row now matches the strict-equality form.
const claimed = await prisma.user.updateMany({
where:
current === 0
? {
id: user.id,
onboardingCompletedAt: null,
OR: [{ onboardingStep: 0 }, { onboardingStep: null }],
}
: {
id: user.id,
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
where: {
id: user.id,
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
data: {
onboardingStep: step,
...(completing ? { onboardingCompletedAt: new Date() } : {}),