# 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