# Plan B: Database Schema + Migrations
---
wave: 1
depends_on: []
files_modified:
- internal/store/migrations/001_initial_schema.sql
- internal/store/migrations/002_fts_indexes.sql
- internal/store/migrate.go
- internal/store/db.go
autonomous: true
requirements: [ARCH-03, ARCH-08, PLYR-01, PLYR-07, SEAT-01, SEAT-02]
---
## Goal
The complete LibSQL database schema for Phase 1 is defined in migration files and auto-applied on startup. Every table, index, and FTS5 virtual table needed by the tournament engine exists. All financial columns use INTEGER (int64 cents, never float/real). The migration system is simple (sequential numbered SQL files, applied once, tracked in a migrations table).
## Context
- **LibSQL is SQLite-compatible** — standard SQL DDL, PRAGMA, triggers, FTS5 all work
- **All financial values are int64 cents** (ARCH-03) — every money column is INTEGER, never REAL
- **Append-only audit trail** (ARCH-08) — audit_entries table is INSERT only, never UPDATE/DELETE
- **Player database persistent on Leaf** (PLYR-01) — players table with UUID PK
- **FTS5 for typeahead search** (PLYR-02) — virtual table on player names
- **Tables and seats** (SEAT-01, SEAT-02) — tables, seats, blueprints
- Schema must support multi-tournament (all tournament-specific tables reference tournament_id)
## User Decisions (from CONTEXT.md)
- **Templates are compositions of reusable building blocks** — chip_sets, blind_structures, payout_structures, buyin_configs, points_formulas are all independent entities
- **Entry count = unique entries only** — not rebuys or add-ons (affects how we count entries for payout bracket selection)
- **Receipts configurable per venue** — venue_settings table needed
- **Chip bonuses** — early signup bonus, punctuality bonus fields in tournament config
- **Minimum player threshold** — field in tournament metadata
## Tasks
Create `internal/store/migrations/001_initial_schema.sql` containing all Phase 1 tables. Use `IF NOT EXISTS` on all CREATE statements. Every table gets `created_at` and `updated_at` timestamps (INTEGER, Unix epoch seconds).
**Venue & Settings:**
```sql
-- venue_settings: singleton row for venue-level config
-- Fields: id, venue_name, currency_code, currency_symbol, rounding_denomination (INTEGER cents),
-- receipt_mode (off/digital/print/both), timezone, created_at, updated_at
```
**Building Blocks (venue-level reusable):**
```sql
-- chip_sets: id, name, is_builtin, created_at, updated_at
-- chip_denominations: id, chip_set_id FK, value (INTEGER cents), color_hex, label, sort_order
-- blind_structures: id, name, is_builtin, game_type_default, notes, created_at, updated_at
-- blind_levels: id, structure_id FK, position (sort order), level_type (round/break),
-- game_type, small_blind (INTEGER), big_blind (INTEGER), ante (INTEGER), bb_ante (INTEGER),
-- duration_seconds, chip_up_denomination_value (INTEGER, nullable), notes
-- payout_structures: id, name, is_builtin, created_at, updated_at
-- payout_brackets: id, structure_id FK, min_entries, max_entries
-- payout_tiers: id, bracket_id FK, position, percentage_basis_points (INTEGER, e.g. 5000 = 50.00%)
-- buyin_configs: id, name, buyin_amount (INTEGER cents), starting_chips (INTEGER),
-- rake_total (INTEGER cents), bounty_amount (INTEGER cents), bounty_chip (INTEGER),
-- rebuy_allowed BOOLEAN, rebuy_cost (INTEGER), rebuy_chips (INTEGER), rebuy_rake (INTEGER),
-- rebuy_limit (INTEGER, 0=unlimited), rebuy_level_cutoff (INTEGER nullable),
-- rebuy_time_cutoff_seconds (INTEGER nullable), rebuy_chip_threshold (INTEGER nullable),
-- addon_allowed BOOLEAN, addon_cost (INTEGER), addon_chips (INTEGER), addon_rake (INTEGER),
-- addon_level_start (INTEGER nullable), addon_level_end (INTEGER nullable),
-- reentry_allowed BOOLEAN, reentry_limit (INTEGER),
-- late_reg_level_cutoff (INTEGER nullable), late_reg_time_cutoff_seconds (INTEGER nullable),
-- created_at, updated_at
-- rake_splits: id, buyin_config_id FK, category (house/staff/league/season_reserve), amount (INTEGER cents)
-- points_formulas: id, name, expression TEXT, variables TEXT (JSON), is_builtin, created_at, updated_at
```
**Tournament Templates:**
```sql
-- tournament_templates: id, name, description, chip_set_id FK, blind_structure_id FK,
-- payout_structure_id FK, buyin_config_id FK, points_formula_id FK (nullable),
-- min_players (INTEGER), max_players (INTEGER nullable),
-- early_signup_bonus_chips (INTEGER, default 0), early_signup_cutoff TEXT (nullable, datetime or player count),
-- punctuality_bonus_chips (INTEGER, default 0),
-- is_pko BOOLEAN DEFAULT FALSE,
-- is_builtin BOOLEAN DEFAULT FALSE,
-- created_at, updated_at
```
**Tournaments (runtime):**
```sql
-- tournaments: id (UUID), name, template_id FK (nullable, reference only),
-- status (created/registering/running/paused/final_table/completed/cancelled),
-- -- Copied config (local changes don't affect template):
-- chip_set_id FK, blind_structure_id FK, payout_structure_id FK, buyin_config_id FK,
-- points_formula_id FK (nullable),
-- min_players, max_players,
-- early_signup_bonus_chips, early_signup_cutoff,
-- punctuality_bonus_chips,
-- is_pko BOOLEAN,
-- -- Runtime state:
-- current_level INTEGER DEFAULT 0,
-- clock_state TEXT DEFAULT 'stopped', -- stopped/running/paused
-- clock_remaining_ns INTEGER DEFAULT 0,
-- total_elapsed_ns INTEGER DEFAULT 0,
-- hand_for_hand BOOLEAN DEFAULT FALSE,
-- started_at INTEGER (nullable), ended_at INTEGER (nullable),
-- created_at, updated_at
```
**Players:**
```sql
-- players: id (UUID), name, nickname (nullable), email (nullable), phone (nullable),
-- photo_url (nullable), notes TEXT (nullable), custom_fields TEXT (nullable, JSON),
-- created_at, updated_at
-- tournament_players: id, tournament_id FK, player_id FK, status (registered/active/busted/deal),
-- seat_table_id FK (nullable), seat_position INTEGER (nullable),
-- buy_in_at INTEGER (nullable), bust_out_at INTEGER (nullable),
-- bust_out_order INTEGER (nullable), -- position when busted (derived from bust order, not stored permanently)
-- finishing_position INTEGER (nullable), -- final position (set at tournament end or deal)
-- current_chips INTEGER DEFAULT 0,
-- rebuys INTEGER DEFAULT 0, addons INTEGER DEFAULT 0, reentries INTEGER DEFAULT 0,
-- bounty_value (INTEGER cents, for PKO — starts at half of bounty_amount),
-- bounties_collected INTEGER DEFAULT 0,
-- prize_amount (INTEGER cents, default 0),
-- points_awarded INTEGER DEFAULT 0,
-- early_signup_bonus_applied BOOLEAN DEFAULT FALSE,
-- punctuality_bonus_applied BOOLEAN DEFAULT FALSE,
-- hitman_player_id (nullable, FK players — who busted them),
-- created_at, updated_at
-- UNIQUE(tournament_id, player_id) -- one entry per player per tournament.
-- Re-entry reactivates same row: status → 'active', reentries += 1,
-- bust_out_at/bust_out_order/finishing_position/hitman_player_id are CLEARED (previous values preserved in audit trail).
-- Only the final bust matters for ranking purposes.
```
**Tables & Seating:**
```sql
-- tables: id, tournament_id FK, name TEXT, seat_count INTEGER (6-10),
-- dealer_button_position INTEGER (nullable), is_active BOOLEAN DEFAULT TRUE,
-- created_at, updated_at
-- table_blueprints: id, name, table_configs TEXT (JSON array of {name, seat_count}),
-- created_at, updated_at
-- balance_suggestions: id, tournament_id FK, status (pending/accepted/cancelled/expired),
-- from_table_id FK, to_table_id FK,
-- player_id FK (nullable — set when suggestion specifies a player),
-- from_seat INTEGER (nullable), to_seat INTEGER (nullable),
-- reason TEXT, created_at, resolved_at INTEGER (nullable)
```
**Financial Transactions:**
```sql
-- transactions: id (UUID), tournament_id FK, player_id FK,
-- type (buyin/rebuy/addon/reentry/bounty_collected/bounty_paid/payout/rake/chop/bubble_prize),
-- amount (INTEGER cents), chips (INTEGER, chips given/removed),
-- operator_id TEXT, -- who performed the action
-- receipt_data TEXT (nullable, JSON),
-- undone BOOLEAN DEFAULT FALSE, undone_by TEXT (nullable, FK audit_entries.id),
-- metadata TEXT (nullable, JSON — for bounty chain info, chop details, etc),
-- created_at
-- bubble_prizes: id, tournament_id FK, amount (INTEGER cents),
-- funded_from TEXT (JSON — array of {position, reduction_amount}),
-- status (proposed/confirmed/cancelled), created_at
```
**Audit Trail:**
```sql
-- audit_entries: id (UUID), tournament_id TEXT (nullable — some are venue-level),
-- timestamp INTEGER (UnixNano), operator_id TEXT,
-- action TEXT (e.g. 'player.bust', 'financial.buyin', 'clock.pause', 'seat.move'),
-- target_type TEXT, target_id TEXT,
-- previous_state TEXT (JSON), new_state TEXT (JSON),
-- metadata TEXT (nullable, JSON),
-- undone_by TEXT (nullable, references audit_entries.id)
-- -- NO UPDATE OR DELETE — append only
```
**Operators:**
```sql
-- operators: id (UUID), name TEXT, pin_hash TEXT (bcrypt), role TEXT (admin/floor/viewer),
-- created_at, updated_at
```
Add indexes on all foreign keys and common query patterns:
- `tournament_players(tournament_id, status)` for active player lookups
- `tournament_players(tournament_id, bust_out_order)` for rankings
- `transactions(tournament_id, type)` for financial summaries
- `transactions(tournament_id, player_id)` for player transaction history
- `audit_entries(tournament_id, timestamp)` for audit log browsing
- `audit_entries(action)` for action filtering
- `tables(tournament_id, is_active)` for active table lookups
**Verification:** The SQL file is valid and can be executed against a fresh LibSQL database without errors.
**1. FTS5 Migration** (`internal/store/migrations/002_fts_indexes.sql`):
Create FTS5 virtual table for player search:
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS players_fts USING fts5(
name, nickname, email,
content='players',
content_rowid='rowid'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER IF NOT EXISTS players_ai AFTER INSERT ON players BEGIN
INSERT INTO players_fts(rowid, name, nickname, email)
VALUES (new.rowid, new.name, new.nickname, new.email);
END;
CREATE TRIGGER IF NOT EXISTS players_ad AFTER DELETE ON players BEGIN
INSERT INTO players_fts(players_fts, rowid, name, nickname, email)
VALUES ('delete', old.rowid, old.name, old.nickname, old.email);
END;
CREATE TRIGGER IF NOT EXISTS players_au AFTER UPDATE ON players BEGIN
INSERT INTO players_fts(players_fts, rowid, name, nickname, email)
VALUES ('delete', old.rowid, old.name, old.nickname, old.email);
INSERT INTO players_fts(rowid, name, nickname, email)
VALUES (new.rowid, new.name, new.nickname, new.email);
END;
```
**2. Migration Runner** (`internal/store/migrate.go`):
- Create a `_migrations` table to track applied migrations: `(id INTEGER PRIMARY KEY, name TEXT, applied_at INTEGER)`
- On startup, read all `*.sql` files from the embedded migrations directory (use `//go:embed migrations/*.sql`)
- Sort by filename (numeric prefix ensures order)
- For each migration not yet applied: execute within a transaction, record in _migrations table
- Log each migration applied
- Return error if any migration fails (don't apply partial migrations)
**3. Wire into db.go:**
- After opening the database and setting PRAGMAs, call `RunMigrations(db)` automatically
- Add a `//go:embed migrations/*.sql` directive in migrate.go to embed migration SQL files
**4. Seed data:**
Create `internal/store/migrations/003_seed_data.sql`:
- Insert a default admin operator (name: "Admin", PIN hash for "1234", role: "admin")
- Insert a default venue_settings row (currency: DKK, symbol: kr, rounding: 5000 = 50 kr, receipt_mode: digital)
- Insert a default chip set ("Standard") with common denominations:
- 25 (white, #FFFFFF), 100 (red, #FF0000), 500 (green, #00AA00), 1000 (black, #000000), 5000 (blue, #0000FF)
- Insert a second chip set ("Copenhagen") with DKK-friendly denominations:
- 100 (white), 500 (red), 1000 (green), 5000 (black), 10000 (blue)
**Verification:**
- `make run` starts the binary and auto-applies all 3 migrations
- Second startup skips already-applied migrations (idempotent)
- `SELECT * FROM _migrations` shows all 3 rows
- `SELECT * FROM operators` shows the default admin
- `SELECT * FROM chip_denominations` shows 5 denominations
- FTS5 search works: insert a player, query `SELECT * FROM players_fts WHERE players_fts MATCH 'searchterm'`
## Verification Criteria
1. All migration SQL files are syntactically valid
2. Migrations auto-apply on first startup, skip on subsequent startups
3. All financial columns use INTEGER type (grep for REAL/FLOAT returns zero hits in schema)
4. FTS5 virtual table syncs with players table via triggers
5. Default seed data (admin operator, venue settings, chip set) exists after first startup
6. Foreign key constraints are enforced (test by inserting invalid FK)
7. Schema supports multi-tournament (tournament_id FK on all tournament-specific tables)
## Must-Haves (Goal-Backward)
- [ ] Every money column is INTEGER (int64 cents) — zero float64 in the schema
- [ ] Audit trail table is append-only by design (no UPDATE trigger needed — enforce in application code)
- [ ] Player table exists with UUID PK for cross-venue portability
- [ ] FTS5 enables typeahead search on player names
- [ ] All tournament-specific tables reference tournament_id for multi-tournament support
- [ ] Migration system is embedded and runs automatically on startup