14 plans in 6 waves covering all 68 requirements for the Tournament Engine phase. Includes research (go-libsql, NATS JetStream, Svelte 5 runes, ICM complexity), plan verification (2 iterations), and user feedback (hand-for-hand UX, SEAT-06 reword, re-entry semantics, integration test, DKK defaults, JWT 7-day expiry, clock tap safety). Wave structure: 1: A (scaffold), B (schema) 2: C (auth/audit), D (clock), E (templates), J (frontend scaffold) 3: F (financial), H (seating), M (layout shell) 4: G (player management) 5: I (tournament lifecycle) 6: K (overview/financials), L (players), N (tables/more) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
277 lines
13 KiB
Markdown
277 lines
13 KiB
Markdown
# 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
|
|
|
|
<task id="B1" title="Design and write the initial schema migration">
|
|
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.
|
|
</task>
|
|
|
|
<task id="B2" title="Implement migration runner and FTS5 indexes">
|
|
**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'`
|
|
</task>
|
|
|
|
## 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
|