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

+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() } : {}),