18 KiB
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:
- Unit Tests: Pure functions, utilities, schema validation
- E2E Tests: Full user flows with happydom (vitest)
- 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 correctlyschema.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 querymigrations-e2e.test.ts: Fresh DB → run all migrations → verify schema
User Tests:
- ✅ Run
pnpm db:migratesuccessfully - ✅ 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 invalidauth-config.test.ts: Validate NextAuth config shape
E2E Tests:
middleware.test.ts: First request sets guest cookie, subsequent requests preserve itauth-session.test.ts: Guest session creation, upgrade flow simulationjwt-callbacks.test.ts: JWT callback carries guestId on upgrade
User Tests:
- ✅ Open app in browser, verify
__Host-guestcookie 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:
userstable (guest + future full users)playerstable (with userId foreign key)user_statstable (with userId foreign key)
- Generate and run migrations
Unit Tests:
schema/users.test.ts: Validate users table constraints, defaultsschema/players.test.ts: Validate players table, foreign key behaviorschema/user-stats.test.ts: Validate stats table, cascading deletesschema-relations.test.ts: Join queries work correctly
E2E Tests:
schema-crud.test.ts: Insert/update/delete for all tablesschema-constraints.test.ts: Foreign key violations throw, unique constraints workseed-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/playersendpoints (GET, POST, PUT, DELETE) - Create
/api/user-statsendpoints (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 requestsapi/user-stats.e2e.test.ts: Update stats, verify persistenceapi/auth-rejection.e2e.test.ts: Requests without session return 401api/data-isolation.e2e.test.ts: User A can't access User B's data
User Tests:
- ✅
curl -X GET /api/playerswith valid session → 200 + data - ✅
curl -X GET /api/playerswithout 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 structurehooks/useUserStats.test.ts: Stats hook returns correct datahooks/mutations.test.ts: Mutation hooks have correct types, invalidation keys
E2E Tests:
hooks/players-query.e2e.test.ts: Hook fetches from API, updates on mutationhooks/stats-query.e2e.test.ts: Stats updates persist and refetchhooks/cache-invalidation.e2e.test.ts: Mutations invalidate correct querieshooks/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
GameModeContextto use React Query hooks only - Rewrite
UserProfileContextto use React Query hooks only
Unit Tests:
contexts/GameModeContext.test.tsx: Context provides correct valuescontexts/UserProfileContext.test.tsx: Context provides correct valuesno-localstorage.test.ts: Grep codebase, fail if localStorage found
E2E Tests:
contexts/game-mode-flow.e2e.test.tsx: Add/remove/activate players via contextcontexts/user-profile-flow.e2e.test.tsx: Update stats via contextcontexts/multi-user.e2e.test.tsx: Multiple sessions maintain separate datacontexts/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.tsentirely - Remove all localStorage constants and references
- Remove all
typeof window !== 'undefined'localStorage checks - Remove
isInitializedstate pattern from contexts
Unit Tests:
dead-code-detection.test.ts: Grep for localStorage, fail if founddead-code-detection.test.ts: Grep for playerMigration imports, fail if founddead-code-detection.test.ts: Grep for STORAGE_KEY constants, fail if found
E2E Tests:
full-app-flow.e2e.test.tsx: Complete user journey without localStorageregression.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 activetype-guards.test.ts: Session type guards work correctly
E2E Tests:
safeguards.e2e.test.ts: Attempting API call without session throws expected errorerror-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 correctrollback.test.ts: Failed mutations rollback correctly
E2E Tests:
optimistic-ui.e2e.test.tsx: UI updates immediately on mutationoptimistic-rollback.e2e.test.tsx: Network failure triggers rollbackloading-states.e2e.test.tsx: Loading indicators appear/disappear correctlyerror-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 keysquery-performance.test.ts: Query execution time is acceptable
E2E Tests:
prefetching.e2e.test.tsx: Prefetched queries load instantlybundle-size.e2e.test.ts: Bundle size within acceptable limitsperformance.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)
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:
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:
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:
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)
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):
pnpm drizzle-kit generate:sqlite
This diffs src/db/schema/*.ts against existing migrations and generates new SQL in drizzle/.
2. Apply Migration (development):
pnpm drizzle-kit push:sqlite
Or use the migration runner:
pnpm db:migrate
3. Migration Runner (src/db/migrate.ts):
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:
{
"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:
- Modify schema files in
src/db/schema/ - Run
pnpm db:generate→ creates migration SQL - Review generated SQL in
drizzle/NNNN_*.sql - Run
pnpm db:migrate→ applies migration - Test with
pnpm db:studio(visual DB browser)
Production Flow:
- Migrations are committed to git
- On deploy, run
pnpm db:migratebefore starting server - 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:
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:
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:
- Middleware creates guest cookie on first visit
- NextAuth JWT contains guest ID
- All API routes verify session and scope to userId
- 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