soroban-abacus-flashcards/server-persistence-plan.md

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:

  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)

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:

  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:

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:

  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