docs: add server persistence migration plan
Add comprehensive plan for migrating from localStorage to server-side database with NextAuth guest sessions. Key features: - SQLite + Drizzle ORM with type-safe schema definitions - NextAuth v5 with JWT strategy for stateless guest sessions - React Query for client-side data fetching and caching - Comprehensive testing strategy (unit, e2e, manual) - Fast-failure approach with no backwards compatibility - Detailed Drizzle migration setup and workflow - 5 phases with 10 checkpoints, each with specific tests Strategy: greenfield approach with hard cutover at each checkpoint, no localStorage fallbacks, no gradual migration.
This commit is contained in:
334
guest-auth-plan.md
Normal file
334
guest-auth-plan.md
Normal file
@@ -0,0 +1,334 @@
|
||||
for apps/web I would like to get away from using local storage for persisting avatar emojis / names / isActive and persisting this on a per guest session basis in a database. i want to use drizzle as orm and also for schema migrations. for instances where server components are impractical, I want to use tanstack query to interact with the api and for caching / invalidations and also mutations. i want to use nextauth + jwt for stateless and self contained sessions below I have pasted a generic guide that you can use as a pattern. i want to implement this step by step, testing thoroughly at high resolution checkpoints to ensure that at every step along the path we have a rock solid base. have a deep look at the source and propose a plan. keep in mind that this is a greenfield project with no users. we are aiming for excellent implementation and do not have to worry at all about preserving existign data or
|
||||
backwards compatibility
|
||||
|
||||
|
||||
=== generic guide for implementing nextauth + jwt for guest session manageement with a clean path for upgrade to full user account ===
|
||||
|
||||
below is a guic guide for impda
|
||||
|
||||
* **Stateless** on the server (no DB writes just to identify a visitor).
|
||||
* **Self‑contained** in the browser (HttpOnly cookies only — **no localStorage**).
|
||||
* **Easy upgrade** from “guest” → full NextAuth user.
|
||||
|
||||
It uses **two layers of identity**:
|
||||
|
||||
1. a tiny **guest token** (JWT) in an HttpOnly cookie you control;
|
||||
2. a normal **NextAuth session** (JWT strategy). On upgrade, the guest ID is carried into the NextAuth token so you can merge state.
|
||||
|
||||
---
|
||||
|
||||
## Why this works
|
||||
|
||||
* NextAuth’s default **session strategy is `"jwt"`**, stored in a cookie as an **encrypted JWE**; this is stateless and requires no DB lookups. ([Auth.js][1])
|
||||
* JWT cookies are size‑limited (~4 KB), so we store **only a small guest ID** and timestamps, not carts/settings. (If you need more than a few kilobytes, persist to your backend at upgrade time.) ([NextAuth][2])
|
||||
* With **lazy initialization**, NextAuth v5 lets you access the request inside your config (e.g., to read the guest cookie during callbacks). ([Auth.js][1])
|
||||
* Middleware can **create/rotate HttpOnly cookies** at the edge before your app runs. ([Next.js][3])
|
||||
|
||||
---
|
||||
|
||||
## The pattern (Next.js App Router + NextAuth v5)
|
||||
|
||||
### 0) Install & set secrets
|
||||
|
||||
```sh
|
||||
npm i next-auth@beta jose
|
||||
# .env
|
||||
AUTH_SECRET=your-strong-random-secret
|
||||
```
|
||||
|
||||
> Auth.js v5 accepts `AUTH_SECRET` (you can even rotate by passing an array of secrets). ([Auth.js][1])
|
||||
|
||||
---
|
||||
|
||||
### 1) A tiny, signed “guest token” cookie
|
||||
|
||||
Create `lib/guest-token.ts`:
|
||||
|
||||
```ts
|
||||
// lib/guest-token.ts
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
const GUEST_COOKIE = "__Host-guest"; // secure + path=/, no Domain
|
||||
export const GUEST_COOKIE_NAME = GUEST_COOKIE;
|
||||
|
||||
function getKey() {
|
||||
const secret = process.env.AUTH_SECRET!;
|
||||
return new TextEncoder().encode(secret);
|
||||
}
|
||||
|
||||
export async function createGuestToken(sid: string, maxAgeSec = 60 * 60 * 24 * 30) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return await new SignJWT({ typ: "guest", sid, iat: now })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + maxAgeSec)
|
||||
.sign(getKey());
|
||||
}
|
||||
|
||||
export async function verifyGuestToken(token: string) {
|
||||
const { payload } = await jwtVerify(token, getKey());
|
||||
if (payload.typ !== "guest" || typeof payload.sid !== "string") throw new Error("bad guest token");
|
||||
return { sid: payload.sid as string, iat: payload.iat as number, exp: payload.exp as number };
|
||||
}
|
||||
```
|
||||
|
||||
> We keep this payload tiny (just a stable **sid**) to respect cookie size limits. ([NextAuth][2])
|
||||
|
||||
---
|
||||
|
||||
### 2) Middleware: ensure every visitor gets a guest token
|
||||
|
||||
Create `middleware.ts` (Edge‑compatible):
|
||||
|
||||
```ts
|
||||
// middleware.ts
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createGuestToken, GUEST_COOKIE_NAME } from "./lib/guest-token";
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const res = NextResponse.next();
|
||||
const existing = req.cookies.get(GUEST_COOKIE_NAME)?.value;
|
||||
|
||||
if (!existing) {
|
||||
const sid = crypto.randomUUID();
|
||||
const token = await createGuestToken(sid);
|
||||
res.cookies.set({
|
||||
name: GUEST_COOKIE_NAME,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
path: "/", // required for "__Host-" prefix
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Run on all HTML/app routes; skip static assets as you prefer.
|
||||
export const config = {
|
||||
matcher: ["/((?!_next|api|.*\\..*).*)"],
|
||||
};
|
||||
```
|
||||
|
||||
> Reading/setting cookies in Middleware is supported; it runs before your route code. ([Next.js][3])
|
||||
|
||||
---
|
||||
|
||||
### 3) NextAuth v5 config: carry guest → session
|
||||
|
||||
Create `auth.ts` (v5 “lazy init” so you can read the request/cookies):
|
||||
|
||||
```ts
|
||||
// auth.ts
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
// Add your real providers, e.g. GitHub/Google/Email
|
||||
// import GitHub from "next-auth/providers/github";
|
||||
import { verifyGuestToken, GUEST_COOKIE_NAME } from "@/lib/guest-token";
|
||||
|
||||
export type Role = "guest" | "user";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: { id: string; name?: string | null; email?: string | null; image?: string | null };
|
||||
isGuest?: boolean;
|
||||
guestId?: string | null;
|
||||
}
|
||||
}
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
role?: Role;
|
||||
guestId?: string | null; // previous guest sid for migrations
|
||||
}
|
||||
}
|
||||
|
||||
// This provider lets you treat guests as "signed-in" if you want a single code path.
|
||||
// If you prefer to keep guests unauthenticated, you can omit this provider and just use the cookie.
|
||||
const GuestProvider = Credentials({
|
||||
id: "guest",
|
||||
name: "Guest",
|
||||
credentials: {},
|
||||
async authorize() {
|
||||
// Create a synthetic user ID. We still rely on the guest cookie as the stable ID.
|
||||
return { id: `guest:${crypto.randomUUID()}`, name: "Guest" } as any;
|
||||
},
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth((req) => ({
|
||||
session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 30 },
|
||||
// Include GuestProvider if you want `useSession()` to be "authenticated" for guests too.
|
||||
providers: [GuestProvider /*, GitHub(), Google(), Email() ... */],
|
||||
callbacks: {
|
||||
// 1) When a user signs in (guest or real), shape the token
|
||||
async jwt({ token, user, account, trigger }) {
|
||||
if (trigger === "signIn" && account?.provider === "guest" && user) {
|
||||
token.sub = user.id;
|
||||
token.role = "guest";
|
||||
// We’ll expose the actual guest sid through the cookie → session below
|
||||
}
|
||||
|
||||
// On upgrade to a real account, capture the guest sid for migration
|
||||
if (trigger === "signIn" && account && account.provider !== "guest") {
|
||||
const raw = req?.cookies.get(GUEST_COOKIE_NAME)?.value;
|
||||
if (raw) {
|
||||
try {
|
||||
const { sid } = await verifyGuestToken(raw);
|
||||
token.guestId = sid; // carry through to session for merge step
|
||||
} catch {}
|
||||
}
|
||||
token.role = "user";
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
// 2) What the client gets via useSession()/getSession()
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.sub) session.user.id = token.sub;
|
||||
session.isGuest = token.role === "guest";
|
||||
|
||||
// Expose the stable guest sid from the cookie (if present)
|
||||
const raw = req?.cookies.get(GUEST_COOKIE_NAME)?.value;
|
||||
session.guestId = null;
|
||||
if (raw) {
|
||||
try {
|
||||
const { sid } = await verifyGuestToken(raw);
|
||||
session.guestId = sid;
|
||||
} catch {}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
// 3) Optional: gate admin sections, etc., in middleware via `authorized`
|
||||
authorized({ auth }) {
|
||||
// Example: allow all visitors through; you can add role checks here.
|
||||
return true;
|
||||
},
|
||||
},
|
||||
|
||||
// Cookie + JWT behavior (JWT/JWE cookie by default)
|
||||
// - default JWE encryption; uses AUTH_SECRET
|
||||
// - default cookie maxAge/rotation handled by NextAuth
|
||||
}));
|
||||
```
|
||||
|
||||
**Notes**
|
||||
|
||||
* NextAuth encrypts the JWT session cookie (JWE) using your `AUTH_SECRET`; JWT is the default session strategy. ([Auth.js][1])
|
||||
* We **read the guest cookie inside callbacks** thanks to v5 lazy initialization (`NextAuth(req => ({ ... }))`). ([Auth.js][1])
|
||||
* If you don’t want guests to appear “authenticated” in `useSession()`, **remove the `GuestProvider`**. You’ll still have `session === null` for guests *but* you can read the `guestId` via a tiny helper (below).
|
||||
|
||||
---
|
||||
|
||||
### 4) Unified “viewer” helper (server)
|
||||
|
||||
If you prefer not to include the `GuestProvider`, use this on the server:
|
||||
|
||||
```ts
|
||||
// lib/viewer.ts
|
||||
import { auth } from "@/auth";
|
||||
import { cookies } from "next/headers";
|
||||
import { verifyGuestToken, GUEST_COOKIE_NAME } from "@/lib/guest-token";
|
||||
|
||||
export async function getViewer() {
|
||||
const session = await auth(); // NextAuth session or null
|
||||
if (session) return { kind: "user" as const, session };
|
||||
|
||||
const raw = cookies().get(GUEST_COOKIE_NAME)?.value;
|
||||
if (!raw) return { kind: "unknown" as const };
|
||||
|
||||
const { sid } = await verifyGuestToken(raw);
|
||||
return { kind: "guest" as const, guestId: sid };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5) The upgrade path
|
||||
|
||||
When the guest authenticates with any real provider:
|
||||
|
||||
1. **JWT callback** (above) copies the `guestId` from the cookie into the token.
|
||||
2. Handle the actual merge in an **event** or the first request after sign‑in:
|
||||
|
||||
```ts
|
||||
// in auth.ts (inside NextAuth config)
|
||||
events: {
|
||||
async signIn(msg) {
|
||||
// For credentials providers, msg.user is the raw user.
|
||||
// For OAuth/email, you'll have an adapter user in msg.user (if using an adapter)
|
||||
// If you're JWT-only, you can read the guest cookie again here.
|
||||
// Example (pseudocode):
|
||||
// const guestId = ...read from cookie or msg...
|
||||
// await mergeGuestDataIntoUser(guestId, msg.user.id)
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Events are the right hook for one‑time merge/audit work. ([Auth.js][1])
|
||||
|
||||
**What to merge?**
|
||||
|
||||
* Browser‑only state (e.g., a cart in another cookie/IndexedDB) → POST it to your API immediately after login, keyed by `session.guestId`.
|
||||
* Server‑side, tie any anonymous trail you kept (e.g., telemetry keyed by `guestId`) to the new `userId`.
|
||||
|
||||
---
|
||||
|
||||
## Client usage examples
|
||||
|
||||
* **Guest‑aware UI** (works both with and without `GuestProvider`):
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export function UserBadge() {
|
||||
const { data } = useSession();
|
||||
if (data?.isGuest) return <>Browsing as guest • id {data.guestId?.slice(0, 8)}…</>;
|
||||
if (data?.user?.id) return <>Signed in • {data.user.name ?? data.user.id}</>;
|
||||
return <>Loading…</>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security & pitfalls
|
||||
|
||||
* **Keep the guest token tiny.** Cookies have practical limits; NextAuth does cookie chunking, but you still pay in bandwidth; don’t stuff carts/preferences into JWTs. ([NextAuth][2])
|
||||
* Use the `__Host-` prefix + `Secure` + `HttpOnly` + `SameSite=Lax` to harden your cookie (as shown).
|
||||
* **Don’t rely on cookie names** for NextAuth’s own session; use the framework helpers (`auth()`, `getSession()`, `getToken()`), since cookie names can differ across versions/configs. NextAuth stores the session token in its **Session Token cookie** (JWE) and lets you customize names if needed. ([NextAuth][4])
|
||||
* **Role‑guard your APIs**: treat `session.isGuest`/`token.role === "guest"` as read‑only access.
|
||||
|
||||
---
|
||||
|
||||
## Variants you can choose
|
||||
|
||||
**A) “Guest as real session”** (single code path)
|
||||
|
||||
* Keep `GuestProvider` in `providers`. On first visit, call `await signIn("guest", { redirect: false })` client‑side to mint the NextAuth cookie (still no localStorage).
|
||||
* Everything reads from `useSession()`; check `session.isGuest`.
|
||||
* Upgrade with any provider; the `jwt` callback carries `guestId` across.
|
||||
|
||||
**B) “Guest as separate cookie” (leaner)**
|
||||
|
||||
* Omit `GuestProvider`.
|
||||
* Use `getViewer()` server‑side (or a small `/api/viewer` endpoint) to get either `{ kind: "guest", guestId }` or a full session.
|
||||
* Upgrade flow is the same; NextAuth’s callbacks can still read the guest cookie at sign‑in time.
|
||||
|
||||
Both variants remain **stateless** and **browser‑contained** (HttpOnly cookies only). NextAuth’s JWT cookies are **encrypted by default** and rotated/extended by its helpers. ([Auth.js][1])
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
* Stable **guest identity** from first pixel paint (middleware sets the cookie).
|
||||
* A clean, **reliable upgrade path**: guest → user without losing context.
|
||||
* No localStorage; everything is **HttpOnly** cookies + NextAuth JWT.
|
||||
|
||||
If you want, tell me whether you’d prefer Variant A or B for your app (and which auth providers you’ll use). I can tailor the config and add a tiny client hook for fetching `viewer` on the client without exposing tokens.
|
||||
|
||||
[1]: https://authjs.dev/reference/nextjs "Auth.js | Nextjs"
|
||||
[2]: https://next-auth.js.org/faq "Frequently Asked Questions | NextAuth.js"
|
||||
[3]: https://nextjs.org/docs/app/guides/authentication?utm_source=chatgpt.com "Guides: Authentication"
|
||||
[4]: https://next-auth.js.org/configuration/options?utm_source=chatgpt.com "Options | NextAuth.js"
|
||||
548
server-persistence-plan.md
Normal file
548
server-persistence-plan.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# Server Persistence Migration Plan
|
||||
|
||||
**Greenfield Strategy**: Fast failure, no fallbacks, no backwards compatibility.
|
||||
|
||||
## Current localStorage Data (TO BE DELETED)
|
||||
|
||||
**1. Player Data** (`soroban-players-v2`):
|
||||
- Multiple player profiles with UUID-based IDs
|
||||
- Name, emoji, color, creation timestamp
|
||||
- Active/inactive status
|
||||
- Activation order
|
||||
|
||||
**2. User Stats** (`soroban-user-stats`):
|
||||
- Games played, total wins
|
||||
- Favorite game type
|
||||
- Best time, highest accuracy
|
||||
|
||||
**3. Legacy V1 Data** (`soroban-memory-pairs-profile`):
|
||||
- Old indexed player system
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
**Three-Layer Approach:**
|
||||
1. **Unit Tests**: Pure functions, utilities, schema validation
|
||||
2. **E2E Tests**: Full user flows with happydom (vitest)
|
||||
3. **User Tests**: Manual verification of critical paths
|
||||
|
||||
**Fast Failure**: Tests fail immediately on any localStorage usage, missing session, or schema violation.
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan: localStorage → Server Database
|
||||
|
||||
### Phase 1: Foundation Setup
|
||||
**Checkpoint 1.1: Database & Auth Infrastructure**
|
||||
- Install dependencies (drizzle-orm, drizzle-kit, better-sqlite3, next-auth@beta, jose)
|
||||
- Configure Drizzle with SQLite
|
||||
- Set up schema file structure
|
||||
- Create initial migration for guest sessions table
|
||||
|
||||
**Unit Tests**:
|
||||
- `drizzle.config.test.ts`: Validate config loads correctly
|
||||
- `schema.test.ts`: Validate schema definitions (types, constraints)
|
||||
- `migrations.test.ts`: Can run/rollback migrations
|
||||
|
||||
**E2E Tests**:
|
||||
- `database-connection.test.ts`: Connect to DB, run simple query
|
||||
- `migrations-e2e.test.ts`: Fresh DB → run all migrations → verify schema
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Run `pnpm db:migrate` successfully
|
||||
- ✅ Inspect DB file with sqlite3 CLI, verify tables exist
|
||||
- ✅ Run migration twice (should be idempotent)
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint 1.2: Guest Session System**
|
||||
- Implement guest token middleware (HttpOnly cookies)
|
||||
- Set up NextAuth v5 with JWT strategy
|
||||
- Create guest provider
|
||||
- Add session type extensions
|
||||
|
||||
**Unit Tests**:
|
||||
- `guest-token.test.ts`: Create/verify tokens, handle expiry, reject invalid
|
||||
- `auth-config.test.ts`: Validate NextAuth config shape
|
||||
|
||||
**E2E Tests**:
|
||||
- `middleware.test.ts`: First request sets guest cookie, subsequent requests preserve it
|
||||
- `auth-session.test.ts`: Guest session creation, upgrade flow simulation
|
||||
- `jwt-callbacks.test.ts`: JWT callback carries guestId on upgrade
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Open app in browser, verify `__Host-guest` cookie exists (DevTools)
|
||||
- ✅ Cookie is HttpOnly, Secure, SameSite=Lax
|
||||
- ✅ Refresh page, same cookie value persists
|
||||
- ✅ Clear cookies, new guest token generated on next visit
|
||||
|
||||
### Phase 2: Schema & API Design
|
||||
**Checkpoint 2.1: Database Schema**
|
||||
- Create schema for:
|
||||
- `users` table (guest + future full users)
|
||||
- `players` table (with userId foreign key)
|
||||
- `user_stats` table (with userId foreign key)
|
||||
- Generate and run migrations
|
||||
|
||||
**Unit Tests**:
|
||||
- `schema/users.test.ts`: Validate users table constraints, defaults
|
||||
- `schema/players.test.ts`: Validate players table, foreign key behavior
|
||||
- `schema/user-stats.test.ts`: Validate stats table, cascading deletes
|
||||
- `schema-relations.test.ts`: Join queries work correctly
|
||||
|
||||
**E2E Tests**:
|
||||
- `schema-crud.test.ts`: Insert/update/delete for all tables
|
||||
- `schema-constraints.test.ts`: Foreign key violations throw, unique constraints work
|
||||
- `seed-data.test.ts`: Can seed realistic test data
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Insert test user, verify with sqlite3 CLI
|
||||
- ✅ Insert player with invalid userId, verify FK constraint fails
|
||||
- ✅ Delete user, verify cascade deletes players/stats
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint 2.2: API Routes**
|
||||
- Create `/api/players` endpoints (GET, POST, PUT, DELETE)
|
||||
- Create `/api/user-stats` endpoints (GET, PUT)
|
||||
- Create `/api/players/[id]` endpoints
|
||||
- Add auth middleware to verify guest tokens
|
||||
|
||||
**Unit Tests**:
|
||||
- `api/players/route.test.ts`: Handler logic (GET, POST, PUT, DELETE)
|
||||
- `api/user-stats/route.test.ts`: Handler logic (GET, PUT)
|
||||
- `middleware/auth.test.ts`: Auth middleware extracts userId correctly, rejects invalid tokens
|
||||
|
||||
**E2E Tests**:
|
||||
- `api/players.e2e.test.ts`: Full CRUD flow with authenticated requests
|
||||
- `api/user-stats.e2e.test.ts`: Update stats, verify persistence
|
||||
- `api/auth-rejection.e2e.test.ts`: Requests without session return 401
|
||||
- `api/data-isolation.e2e.test.ts`: User A can't access User B's data
|
||||
|
||||
**User Tests**:
|
||||
- ✅ `curl -X GET /api/players` with valid session → 200 + data
|
||||
- ✅ `curl -X GET /api/players` without session → 401
|
||||
- ✅ `curl -X POST /api/players` → creates player, verify in DB
|
||||
- ✅ `curl -X DELETE /api/players/[id]` → deletes player
|
||||
- ✅ Open two browsers (different sessions), verify data isolation
|
||||
|
||||
### Phase 3: React Query Integration
|
||||
**Checkpoint 3.1: Query Hooks**
|
||||
- Create `useUserPlayers()` query hook
|
||||
- Create `useUserStats()` query hook
|
||||
- Create player mutation hooks (add/update/remove/setActive)
|
||||
- Create stats mutation hooks
|
||||
|
||||
**Unit Tests**:
|
||||
- `hooks/useUserPlayers.test.ts`: Query hook returns correct data structure
|
||||
- `hooks/useUserStats.test.ts`: Stats hook returns correct data
|
||||
- `hooks/mutations.test.ts`: Mutation hooks have correct types, invalidation keys
|
||||
|
||||
**E2E Tests**:
|
||||
- `hooks/players-query.e2e.test.ts`: Hook fetches from API, updates on mutation
|
||||
- `hooks/stats-query.e2e.test.ts`: Stats updates persist and refetch
|
||||
- `hooks/cache-invalidation.e2e.test.ts`: Mutations invalidate correct queries
|
||||
- `hooks/error-handling.e2e.test.ts`: Network errors surface correctly
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Open React DevTools, verify queries show in cache
|
||||
- ✅ Trigger mutation, verify loading state → success → cache update
|
||||
- ✅ Go offline, verify queries use cached data
|
||||
- ✅ Trigger mutation offline, verify error handling
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint 3.2: Context Rewrite**
|
||||
- **DELETE** all localStorage read/write code from contexts
|
||||
- **DELETE** `loadPlayerStorage()`, `savePlayerStorage()` and migration utilities
|
||||
- **DELETE** V1 compatibility code
|
||||
- Rewrite `GameModeContext` to use React Query hooks only
|
||||
- Rewrite `UserProfileContext` to use React Query hooks only
|
||||
|
||||
**Unit Tests**:
|
||||
- `contexts/GameModeContext.test.tsx`: Context provides correct values
|
||||
- `contexts/UserProfileContext.test.tsx`: Context provides correct values
|
||||
- `no-localstorage.test.ts`: Grep codebase, fail if localStorage found
|
||||
|
||||
**E2E Tests**:
|
||||
- `contexts/game-mode-flow.e2e.test.tsx`: Add/remove/activate players via context
|
||||
- `contexts/user-profile-flow.e2e.test.tsx`: Update stats via context
|
||||
- `contexts/multi-user.e2e.test.tsx`: Multiple sessions maintain separate data
|
||||
- `contexts/missing-session.e2e.test.tsx`: Throws clear error if no session
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Create new player in UI, verify in DB immediately
|
||||
- ✅ Update player name, verify persistence
|
||||
- ✅ Activate/deactivate players, verify state in DB
|
||||
- ✅ Play game, verify stats update in DB
|
||||
- ✅ Open DevTools Storage, verify NO localStorage entries
|
||||
- ✅ Refresh page, verify all state loads from server
|
||||
|
||||
### Phase 4: Final Cleanup
|
||||
**Checkpoint 4.1: Remove Dead Code**
|
||||
- Delete `src/lib/playerMigration.ts` entirely
|
||||
- Remove all localStorage constants and references
|
||||
- Remove all `typeof window !== 'undefined'` localStorage checks
|
||||
- Remove `isInitialized` state pattern from contexts
|
||||
|
||||
**Unit Tests**:
|
||||
- `dead-code-detection.test.ts`: Grep for localStorage, fail if found
|
||||
- `dead-code-detection.test.ts`: Grep for playerMigration imports, fail if found
|
||||
- `dead-code-detection.test.ts`: Grep for STORAGE_KEY constants, fail if found
|
||||
|
||||
**E2E Tests**:
|
||||
- `full-app-flow.e2e.test.tsx`: Complete user journey without localStorage
|
||||
- `regression.e2e.test.tsx`: All existing test scenarios still pass
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Run `git grep localStorage src/` → no results
|
||||
- ✅ Run `git grep playerMigration src/` → no results
|
||||
- ✅ Run full test suite → all green
|
||||
- ✅ Build production bundle, verify no localStorage in output
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint 4.2: Add Safeguards**
|
||||
- Add ESLint rule to prevent localStorage usage
|
||||
- Add type guards to ensure session exists before API calls
|
||||
- Throw clear errors if session is missing
|
||||
|
||||
**Unit Tests**:
|
||||
- `eslint-config.test.ts`: Verify localStorage rule is active
|
||||
- `type-guards.test.ts`: Session type guards work correctly
|
||||
|
||||
**E2E Tests**:
|
||||
- `safeguards.e2e.test.ts`: Attempting API call without session throws expected error
|
||||
- `error-messages.e2e.test.ts`: Error messages are clear and actionable
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Add `localStorage.setItem()` to code → ESLint error appears
|
||||
- ✅ Try to bypass type guards → TypeScript compilation fails
|
||||
- ✅ Run type check → passes
|
||||
- ✅ Trigger API error → verify error message is helpful
|
||||
|
||||
### Phase 5: Polish & Optimization
|
||||
**Checkpoint 5.1: Optimistic Updates**
|
||||
- Add optimistic updates to all mutations
|
||||
- Proper error handling and rollback
|
||||
- Loading states
|
||||
|
||||
**Unit Tests**:
|
||||
- `optimistic-updates.test.ts`: Optimistic update logic is correct
|
||||
- `rollback.test.ts`: Failed mutations rollback correctly
|
||||
|
||||
**E2E Tests**:
|
||||
- `optimistic-ui.e2e.test.tsx`: UI updates immediately on mutation
|
||||
- `optimistic-rollback.e2e.test.tsx`: Network failure triggers rollback
|
||||
- `loading-states.e2e.test.tsx`: Loading indicators appear/disappear correctly
|
||||
- `error-recovery.e2e.test.tsx`: User can retry failed operations
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Click "Add Player", verify instant UI update
|
||||
- ✅ Simulate network failure (DevTools), verify rollback
|
||||
- ✅ Verify loading spinners appear during async operations
|
||||
- ✅ Trigger error, verify error message and retry button
|
||||
|
||||
---
|
||||
|
||||
**Checkpoint 5.2: Performance**
|
||||
- Add database indexes
|
||||
- Implement query prefetching where beneficial
|
||||
- Optimize bundle size
|
||||
|
||||
**Unit Tests**:
|
||||
- `indexes.test.ts`: Verify indexes exist on foreign keys
|
||||
- `query-performance.test.ts`: Query execution time is acceptable
|
||||
|
||||
**E2E Tests**:
|
||||
- `prefetching.e2e.test.tsx`: Prefetched queries load instantly
|
||||
- `bundle-size.e2e.test.ts`: Bundle size within acceptable limits
|
||||
- `performance.e2e.test.tsx`: Time to interactive < 2s
|
||||
|
||||
**User Tests**:
|
||||
- ✅ Run Lighthouse audit → Performance score > 90
|
||||
- ✅ Test with slow 3G → app remains usable
|
||||
- ✅ Verify bundle size < 500kb (gzipped)
|
||||
- ✅ Profile DB queries, verify no N+1 issues
|
||||
- ✅ Test with 100+ players, verify no slowdown
|
||||
|
||||
## Drizzle Schema & Migration Setup
|
||||
|
||||
### File Structure
|
||||
```
|
||||
apps/web/
|
||||
├── drizzle.config.ts # Drizzle Kit configuration
|
||||
├── drizzle/ # Generated migrations directory
|
||||
│ ├── 0000_initial_schema.sql
|
||||
│ ├── 0001_add_players.sql
|
||||
│ └── meta/ # Migration metadata
|
||||
├── src/
|
||||
│ ├── db/
|
||||
│ │ ├── index.ts # Database client & connection
|
||||
│ │ ├── schema/ # Schema definitions (source of truth)
|
||||
│ │ │ ├── users.ts
|
||||
│ │ │ ├── players.ts
|
||||
│ │ │ ├── user-stats.ts
|
||||
│ │ │ └── index.ts # Re-export all schemas
|
||||
│ │ └── migrate.ts # Migration runner for production
|
||||
│ └── lib/
|
||||
│ └── db.ts # Singleton db instance for app
|
||||
```
|
||||
|
||||
### Drizzle Configuration (`drizzle.config.ts`)
|
||||
```typescript
|
||||
import type { Config } from 'drizzle-kit'
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema/index.ts', // Source of truth
|
||||
out: './drizzle', // Migration output directory
|
||||
driver: 'better-sqlite3',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || './data/sqlite.db',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
} satisfies Config
|
||||
```
|
||||
|
||||
### Schema Definition Pattern
|
||||
Each table gets its own file in `src/db/schema/`:
|
||||
|
||||
**users.ts**:
|
||||
```typescript
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
guestId: text('guest_id').notNull().unique(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
upgradedAt: integer('upgraded_at', { mode: 'timestamp' }),
|
||||
email: text('email').unique(),
|
||||
name: text('name'),
|
||||
})
|
||||
```
|
||||
|
||||
**players.ts**:
|
||||
```typescript
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||
import { users } from './users'
|
||||
import { createId } from '@paralleldrive/cuid2'
|
||||
|
||||
export const players = sqliteTable('players', {
|
||||
id: text('id').primaryKey().$defaultFn(() => createId()),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
emoji: text('emoji').notNull(),
|
||||
color: text('color').notNull(),
|
||||
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
|
||||
})
|
||||
|
||||
// Index for fast lookups by userId
|
||||
export const playersByUserIdIdx = index('players_user_id_idx').on(players.userId)
|
||||
```
|
||||
|
||||
**user-stats.ts**:
|
||||
```typescript
|
||||
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
|
||||
import { users } from './users'
|
||||
|
||||
export const userStats = sqliteTable('user_stats', {
|
||||
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||
gamesPlayed: integer('games_played').notNull().default(0),
|
||||
totalWins: integer('total_wins').notNull().default(0),
|
||||
favoriteGameType: text('favorite_game_type', { enum: ['abacus-numeral', 'complement-pairs'] }),
|
||||
bestTime: integer('best_time'),
|
||||
highestAccuracy: real('highest_accuracy').notNull().default(0),
|
||||
})
|
||||
```
|
||||
|
||||
### Database Client Setup (`src/db/index.ts`)
|
||||
```typescript
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from './schema'
|
||||
|
||||
const sqlite = new Database(process.env.DATABASE_URL || './data/sqlite.db')
|
||||
|
||||
// Enable foreign keys (SQLite requires explicit enable)
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
export { schema }
|
||||
```
|
||||
|
||||
### Migration Workflow
|
||||
|
||||
**1. Generate Migration** (after schema changes):
|
||||
```bash
|
||||
pnpm drizzle-kit generate:sqlite
|
||||
```
|
||||
This diffs `src/db/schema/*.ts` against existing migrations and generates new SQL in `drizzle/`.
|
||||
|
||||
**2. Apply Migration** (development):
|
||||
```bash
|
||||
pnpm drizzle-kit push:sqlite
|
||||
```
|
||||
Or use the migration runner:
|
||||
```bash
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
**3. Migration Runner** (`src/db/migrate.ts`):
|
||||
```typescript
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import { db } from './index'
|
||||
|
||||
// Run all pending migrations
|
||||
migrate(db, { migrationsFolder: './drizzle' })
|
||||
|
||||
console.log('✅ Migrations complete')
|
||||
```
|
||||
|
||||
**4. Package.json Scripts**:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"db:generate": "drizzle-kit generate:sqlite",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push:sqlite",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:drop": "drizzle-kit drop"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
**Development Flow**:
|
||||
1. Modify schema files in `src/db/schema/`
|
||||
2. Run `pnpm db:generate` → creates migration SQL
|
||||
3. Review generated SQL in `drizzle/NNNN_*.sql`
|
||||
4. Run `pnpm db:migrate` → applies migration
|
||||
5. Test with `pnpm db:studio` (visual DB browser)
|
||||
|
||||
**Production Flow**:
|
||||
1. Migrations are committed to git
|
||||
2. On deploy, run `pnpm db:migrate` before starting server
|
||||
3. Application code never runs before migrations complete
|
||||
|
||||
**Rollback Strategy**:
|
||||
- Drizzle doesn't auto-generate down migrations
|
||||
- For critical rollbacks: manually write inverse SQL
|
||||
- Better approach: forward-only migrations with careful planning
|
||||
- Test migrations in staging before production
|
||||
|
||||
### Testing Migrations
|
||||
|
||||
**Unit Tests** verify schema correctness:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { users, players, userStats } from '@/db/schema'
|
||||
|
||||
describe('Schema validation', () => {
|
||||
it('users table has correct structure', () => {
|
||||
expect(users.id).toBeDefined()
|
||||
expect(users.guestId).toBeDefined()
|
||||
})
|
||||
|
||||
it('players table has foreign key to users', () => {
|
||||
expect(players.userId.references).toBeDefined()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**E2E Tests** verify migrations work:
|
||||
```typescript
|
||||
import { describe, it, beforeEach, expect } from 'vitest'
|
||||
import Database from 'better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
|
||||
describe('Migrations', () => {
|
||||
beforeEach(() => {
|
||||
// Fresh in-memory DB for each test
|
||||
const sqlite = new Database(':memory:')
|
||||
const db = drizzle(sqlite)
|
||||
migrate(db, { migrationsFolder: './drizzle' })
|
||||
})
|
||||
|
||||
it('applies all migrations successfully', () => {
|
||||
// If we get here, migrations worked
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('creates all expected tables', () => {
|
||||
const tables = sqlite.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).all()
|
||||
|
||||
expect(tables).toContainEqual({ name: 'users' })
|
||||
expect(tables).toContainEqual({ name: 'players' })
|
||||
expect(tables).toContainEqual({ name: 'user_stats' })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Why This Setup
|
||||
|
||||
**Drizzle Kit Benefits**:
|
||||
- Type-safe schema definitions
|
||||
- Automatic migration generation
|
||||
- Diffs are smart (only generates what changed)
|
||||
- SQL is readable and reviewable
|
||||
- Studio for visual DB inspection
|
||||
|
||||
**SQLite Benefits**:
|
||||
- Single file database (easy backup/restore)
|
||||
- Zero configuration
|
||||
- Fast for read-heavy workloads
|
||||
- Perfect for < 100k users
|
||||
- Easy to migrate to Turso/LibSQL later if needed
|
||||
|
||||
**Migration Philosophy**:
|
||||
- Schema files are source of truth
|
||||
- Generated SQL is committed to git
|
||||
- Migrations are immutable once deployed
|
||||
- Always forward (no down migrations)
|
||||
- Test migrations before deploy
|
||||
|
||||
---
|
||||
|
||||
## Key Architecture Decisions
|
||||
|
||||
**Database**: SQLite via better-sqlite3 (simple, file-based, perfect for this use case)
|
||||
|
||||
**Schema Pattern**:
|
||||
```
|
||||
users (id, guestId, createdAt, upgradedAt?)
|
||||
↓ 1:many
|
||||
players (id, userId, name, emoji, color, isActive, createdAt)
|
||||
|
||||
users
|
||||
↓ 1:1
|
||||
user_stats (userId, gamesPlayed, totalWins, ...)
|
||||
```
|
||||
|
||||
**Session Flow**:
|
||||
1. Middleware creates guest cookie on first visit
|
||||
2. NextAuth JWT contains guest ID
|
||||
3. All API routes verify session and scope to userId
|
||||
4. Future: Guest can upgrade to full account, data migrates automatically
|
||||
|
||||
**Fast Failure Philosophy**:
|
||||
- No localStorage fallbacks
|
||||
- No gradual migration
|
||||
- No V1 compatibility
|
||||
- Hard cutover at each checkpoint
|
||||
- TypeScript ensures session is always checked
|
||||
- Clear error messages if auth fails
|
||||
- ESLint prevents localStorage usage
|
||||
|
||||
**Why This Approach**:
|
||||
- No localStorage = works across devices/browsers when upgraded
|
||||
- Stateless sessions = no session DB writes
|
||||
- Guest-first = zero friction, can play immediately
|
||||
- Clean upgrade path = preserve data when user creates account
|
||||
- React Query = optimal caching, mutations, invalidation
|
||||
- Fast failure = issues surface immediately during development
|
||||
Reference in New Issue
Block a user