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:
Thomas Hallock
2025-10-05 16:55:11 -05:00
parent a3878a8537
commit dd0df8c274
2 changed files with 882 additions and 0 deletions

334
guest-auth-plan.md Normal file
View 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).
* **Selfcontained** 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
* NextAuths 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 sizelimited (~4KB), 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` (Edgecompatible):
```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";
// Well 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 dont want guests to appear “authenticated” in `useSession()`, **remove the `GuestProvider`**. Youll 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 signin:
```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 onetime merge/audit work. ([Auth.js][1])
**What to merge?**
* Browseronly state (e.g., a cart in another cookie/IndexedDB) → POST it to your API immediately after login, keyed by `session.guestId`.
* Serverside, tie any anonymous trail you kept (e.g., telemetry keyed by `guestId`) to the new `userId`.
---
## Client usage examples
* **Guestaware 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; dont stuff carts/preferences into JWTs. ([NextAuth][2])
* Use the `__Host-` prefix + `Secure` + `HttpOnly` + `SameSite=Lax` to harden your cookie (as shown).
* **Dont rely on cookie names** for NextAuths 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])
* **Roleguard your APIs**: treat `session.isGuest`/`token.role === "guest"` as readonly 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 })` clientside 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()` serverside (or a small `/api/viewer` endpoint) to get either `{ kind: "guest", guestId }` or a full session.
* Upgrade flow is the same; NextAuths callbacks can still read the guest cookie at signin time.
Both variants remain **stateless** and **browsercontained** (HttpOnly cookies only). NextAuths 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 youd prefer Variant A or B for your app (and which auth providers youll 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
View 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