felt/internal/store/migrations/001_initial_schema.sql
Mikkel Georgsen 17dbfc6dc0 feat(01-02): design initial database schema migration
- 23 tables covering venue settings, chip sets, blind structures, payout structures,
  buy-in configs, tournament templates, tournaments, players, tables, seating,
  transactions, bubble prizes, audit trail, and operators
- All financial columns use INTEGER (int64 cents, never REAL/FLOAT)
- Audit trail append-only enforced by SQLite triggers (reject UPDATE except undone_by, reject DELETE)
- All tournament-specific tables reference tournament_id for multi-tournament support
- Comprehensive indexes on foreign keys and common query patterns
- Players table with UUID PK for cross-venue portability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:31:01 +01:00

439 lines
18 KiB
SQL

-- 001_initial_schema.sql
-- Phase 1: Complete tournament engine schema
-- All financial columns use INTEGER (int64 cents, never REAL/FLOAT)
-- All timestamps use INTEGER (Unix epoch seconds)
-- All UUIDs stored as TEXT
-- =============================================================================
-- Venue & Settings
-- =============================================================================
CREATE TABLE IF NOT EXISTS venue_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), -- singleton row
venue_name TEXT NOT NULL DEFAULT '',
currency_code TEXT NOT NULL DEFAULT 'DKK',
currency_symbol TEXT NOT NULL DEFAULT 'kr',
rounding_denomination INTEGER NOT NULL DEFAULT 5000, -- 50.00 kr in cents
receipt_mode TEXT NOT NULL DEFAULT 'digital' CHECK (receipt_mode IN ('off', 'digital', 'print', 'both')),
timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- =============================================================================
-- Building Blocks (venue-level reusable)
-- =============================================================================
-- Chip Sets
CREATE TABLE IF NOT EXISTS chip_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS chip_denominations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chip_set_id INTEGER NOT NULL REFERENCES chip_sets(id) ON DELETE CASCADE,
value INTEGER NOT NULL, -- cents
color_hex TEXT NOT NULL DEFAULT '#FFFFFF',
label TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE(chip_set_id, value)
);
-- Blind Structures
CREATE TABLE IF NOT EXISTS blind_structures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
game_type_default TEXT NOT NULL DEFAULT 'nlhe',
notes TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS blind_levels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
structure_id INTEGER NOT NULL REFERENCES blind_structures(id) ON DELETE CASCADE,
position INTEGER NOT NULL, -- sort order
level_type TEXT NOT NULL DEFAULT 'round' CHECK (level_type IN ('round', 'break')),
game_type TEXT NOT NULL DEFAULT 'nlhe',
small_blind INTEGER NOT NULL DEFAULT 0,
big_blind INTEGER NOT NULL DEFAULT 0,
ante INTEGER NOT NULL DEFAULT 0,
bb_ante INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER NOT NULL DEFAULT 900, -- 15 min default
chip_up_denomination_value INTEGER, -- nullable, cents
notes TEXT NOT NULL DEFAULT '',
UNIQUE(structure_id, position)
);
-- Payout Structures
CREATE TABLE IF NOT EXISTS payout_structures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS payout_brackets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
structure_id INTEGER NOT NULL REFERENCES payout_structures(id) ON DELETE CASCADE,
min_entries INTEGER NOT NULL,
max_entries INTEGER NOT NULL,
CHECK (min_entries <= max_entries)
);
CREATE TABLE IF NOT EXISTS payout_tiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bracket_id INTEGER NOT NULL REFERENCES payout_brackets(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
percentage_basis_points INTEGER NOT NULL, -- e.g. 5000 = 50.00%
UNIQUE(bracket_id, position)
);
-- Buy-in Configs
CREATE TABLE IF NOT EXISTS buyin_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
buyin_amount INTEGER NOT NULL DEFAULT 0, -- cents
starting_chips INTEGER NOT NULL DEFAULT 0,
rake_total INTEGER NOT NULL DEFAULT 0, -- cents
bounty_amount INTEGER NOT NULL DEFAULT 0, -- cents
bounty_chip INTEGER NOT NULL DEFAULT 0,
rebuy_allowed INTEGER NOT NULL DEFAULT 0,
rebuy_cost INTEGER NOT NULL DEFAULT 0, -- cents
rebuy_chips INTEGER NOT NULL DEFAULT 0,
rebuy_rake INTEGER NOT NULL DEFAULT 0, -- cents
rebuy_limit INTEGER NOT NULL DEFAULT 0, -- 0 = unlimited
rebuy_level_cutoff INTEGER, -- nullable
rebuy_time_cutoff_seconds INTEGER, -- nullable
rebuy_chip_threshold INTEGER, -- nullable
addon_allowed INTEGER NOT NULL DEFAULT 0,
addon_cost INTEGER NOT NULL DEFAULT 0, -- cents
addon_chips INTEGER NOT NULL DEFAULT 0,
addon_rake INTEGER NOT NULL DEFAULT 0, -- cents
addon_level_start INTEGER, -- nullable
addon_level_end INTEGER, -- nullable
reentry_allowed INTEGER NOT NULL DEFAULT 0,
reentry_limit INTEGER NOT NULL DEFAULT 0,
late_reg_level_cutoff INTEGER, -- nullable
late_reg_time_cutoff_seconds INTEGER, -- nullable
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS rake_splits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
buyin_config_id INTEGER NOT NULL REFERENCES buyin_configs(id) ON DELETE CASCADE,
category TEXT NOT NULL CHECK (category IN ('house', 'staff', 'league', 'season_reserve')),
amount INTEGER NOT NULL DEFAULT 0, -- cents
UNIQUE(buyin_config_id, category)
);
-- Points Formulas
CREATE TABLE IF NOT EXISTS points_formulas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
expression TEXT NOT NULL DEFAULT '',
variables TEXT NOT NULL DEFAULT '{}', -- JSON
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- =============================================================================
-- Tournament Templates
-- =============================================================================
CREATE TABLE IF NOT EXISTS tournament_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
chip_set_id INTEGER NOT NULL REFERENCES chip_sets(id),
blind_structure_id INTEGER NOT NULL REFERENCES blind_structures(id),
payout_structure_id INTEGER NOT NULL REFERENCES payout_structures(id),
buyin_config_id INTEGER NOT NULL REFERENCES buyin_configs(id),
points_formula_id INTEGER REFERENCES points_formulas(id), -- nullable
min_players INTEGER NOT NULL DEFAULT 2,
max_players INTEGER, -- nullable
early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0,
early_signup_cutoff TEXT, -- nullable, datetime or player count
punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0,
is_pko INTEGER NOT NULL DEFAULT 0,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- =============================================================================
-- Tournaments (runtime instances)
-- =============================================================================
CREATE TABLE IF NOT EXISTS tournaments (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL,
template_id INTEGER REFERENCES tournament_templates(id), -- nullable, reference only
-- Copied config (local changes don't affect template)
chip_set_id INTEGER NOT NULL REFERENCES chip_sets(id),
blind_structure_id INTEGER NOT NULL REFERENCES blind_structures(id),
payout_structure_id INTEGER NOT NULL REFERENCES payout_structures(id),
buyin_config_id INTEGER NOT NULL REFERENCES buyin_configs(id),
points_formula_id INTEGER REFERENCES points_formulas(id), -- nullable
status TEXT NOT NULL DEFAULT 'created' CHECK (status IN (
'created', 'registering', 'running', 'paused',
'final_table', 'completed', 'cancelled'
)),
min_players INTEGER NOT NULL DEFAULT 2,
max_players INTEGER, -- nullable
early_signup_bonus_chips INTEGER NOT NULL DEFAULT 0,
early_signup_cutoff TEXT, -- nullable
punctuality_bonus_chips INTEGER NOT NULL DEFAULT 0,
is_pko INTEGER NOT NULL DEFAULT 0,
-- Runtime state
current_level INTEGER NOT NULL DEFAULT 0,
clock_state TEXT NOT NULL DEFAULT 'stopped' CHECK (clock_state IN ('stopped', 'running', 'paused')),
clock_remaining_ns INTEGER NOT NULL DEFAULT 0,
total_elapsed_ns INTEGER NOT NULL DEFAULT 0,
hand_for_hand INTEGER NOT NULL DEFAULT 0,
started_at INTEGER, -- nullable
ended_at INTEGER, -- nullable
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- =============================================================================
-- Players
-- =============================================================================
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL,
nickname TEXT,
email TEXT,
phone TEXT,
photo_url TEXT,
notes TEXT,
custom_fields TEXT, -- JSON
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS tournament_players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
player_id TEXT NOT NULL REFERENCES players(id),
status TEXT NOT NULL DEFAULT 'registered' CHECK (status IN (
'registered', 'active', 'busted', 'deal'
)),
seat_table_id INTEGER REFERENCES tables(id), -- nullable
seat_position INTEGER, -- nullable
buy_in_at INTEGER, -- nullable
bust_out_at INTEGER, -- nullable
bust_out_order INTEGER, -- nullable, position when busted
finishing_position INTEGER, -- nullable, final position
current_chips INTEGER NOT NULL DEFAULT 0,
rebuys INTEGER NOT NULL DEFAULT 0,
addons INTEGER NOT NULL DEFAULT 0,
reentries INTEGER NOT NULL DEFAULT 0,
bounty_value INTEGER NOT NULL DEFAULT 0, -- cents, for PKO
bounties_collected INTEGER NOT NULL DEFAULT 0,
prize_amount INTEGER NOT NULL DEFAULT 0, -- cents
points_awarded INTEGER NOT NULL DEFAULT 0,
early_signup_bonus_applied INTEGER NOT NULL DEFAULT 0,
punctuality_bonus_applied INTEGER NOT NULL DEFAULT 0,
hitman_player_id TEXT REFERENCES players(id), -- nullable, who busted them
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(tournament_id, player_id)
);
-- =============================================================================
-- Tables & Seating
-- =============================================================================
CREATE TABLE IF NOT EXISTS tables (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
seat_count INTEGER NOT NULL DEFAULT 9 CHECK (seat_count >= 6 AND seat_count <= 10),
dealer_button_position INTEGER, -- nullable
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS table_blueprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
table_configs TEXT NOT NULL DEFAULT '[]', -- JSON array of {name, seat_count}
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS balance_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'accepted', 'cancelled', 'expired'
)),
from_table_id INTEGER NOT NULL REFERENCES tables(id),
to_table_id INTEGER NOT NULL REFERENCES tables(id),
player_id TEXT REFERENCES players(id), -- nullable
from_seat INTEGER, -- nullable
to_seat INTEGER, -- nullable
reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
resolved_at INTEGER -- nullable
);
-- =============================================================================
-- Financial Transactions
-- =============================================================================
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY, -- UUID
tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
player_id TEXT NOT NULL REFERENCES players(id),
type TEXT NOT NULL CHECK (type IN (
'buyin', 'rebuy', 'addon', 'reentry',
'bounty_collected', 'bounty_paid',
'payout', 'rake', 'chop', 'bubble_prize'
)),
amount INTEGER NOT NULL DEFAULT 0, -- cents
chips INTEGER NOT NULL DEFAULT 0, -- chips given/removed
operator_id TEXT NOT NULL, -- who performed the action
receipt_data TEXT, -- nullable, JSON
undone INTEGER NOT NULL DEFAULT 0,
undone_by TEXT, -- nullable, FK audit_entries.id
metadata TEXT, -- nullable, JSON (bounty chain, chop details, etc)
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS bubble_prizes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tournament_id TEXT NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE,
amount INTEGER NOT NULL DEFAULT 0, -- cents
funded_from TEXT NOT NULL DEFAULT '[]', -- JSON array of {position, reduction_amount}
status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN (
'proposed', 'confirmed', 'cancelled'
)),
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- =============================================================================
-- Audit Trail (append-only)
-- =============================================================================
CREATE TABLE IF NOT EXISTS audit_entries (
id TEXT PRIMARY KEY, -- UUID
tournament_id TEXT, -- nullable, some are venue-level
timestamp INTEGER NOT NULL DEFAULT (unixepoch()), -- UnixNano in Go, epoch seconds in SQL default
operator_id TEXT NOT NULL,
action TEXT NOT NULL, -- e.g. 'player.bust', 'financial.buyin', 'clock.pause', 'seat.move'
target_type TEXT NOT NULL DEFAULT '',
target_id TEXT NOT NULL DEFAULT '',
previous_state TEXT, -- JSON
new_state TEXT, -- JSON
metadata TEXT, -- nullable, JSON
undone_by TEXT -- nullable, references audit_entries.id
-- NO UPDATE OR DELETE -- enforced by triggers below
);
-- Tamper protection: REJECT any UPDATE except setting undone_by on a row where undone_by is currently NULL
CREATE TRIGGER IF NOT EXISTS audit_entries_no_update
BEFORE UPDATE ON audit_entries
WHEN OLD.undone_by IS NOT NULL OR NEW.undone_by IS NULL
OR OLD.id != NEW.id OR OLD.timestamp != NEW.timestamp
OR OLD.operator_id != NEW.operator_id OR OLD.action != NEW.action
BEGIN
SELECT RAISE(ABORT, 'audit_entries is append-only: only undone_by may be set once');
END;
-- Tamper protection: REJECT any DELETE on audit_entries entirely
CREATE TRIGGER IF NOT EXISTS audit_entries_no_delete
BEFORE DELETE ON audit_entries
BEGIN
SELECT RAISE(ABORT, 'audit_entries is append-only: deletion is prohibited');
END;
-- =============================================================================
-- Operators
-- =============================================================================
CREATE TABLE IF NOT EXISTS operators (
id TEXT PRIMARY KEY, -- UUID
name TEXT NOT NULL,
pin_hash TEXT NOT NULL, -- bcrypt
role TEXT NOT NULL DEFAULT 'floor' CHECK (role IN ('admin', 'floor', 'viewer')),
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- =============================================================================
-- Indexes
-- =============================================================================
-- Tournament players: active player lookups and rankings
CREATE INDEX IF NOT EXISTS idx_tournament_players_tournament_status
ON tournament_players(tournament_id, status);
CREATE INDEX IF NOT EXISTS idx_tournament_players_tournament_bust_order
ON tournament_players(tournament_id, bust_out_order);
-- Transactions: financial summaries and player history
CREATE INDEX IF NOT EXISTS idx_transactions_tournament_type
ON transactions(tournament_id, type);
CREATE INDEX IF NOT EXISTS idx_transactions_tournament_player
ON transactions(tournament_id, player_id);
-- Audit entries: log browsing and action filtering
CREATE INDEX IF NOT EXISTS idx_audit_entries_tournament_timestamp
ON audit_entries(tournament_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_entries_action
ON audit_entries(action);
-- Tables: active table lookups per tournament
CREATE INDEX IF NOT EXISTS idx_tables_tournament_active
ON tables(tournament_id, is_active);
-- Foreign key indexes on child tables
CREATE INDEX IF NOT EXISTS idx_chip_denominations_chip_set
ON chip_denominations(chip_set_id);
CREATE INDEX IF NOT EXISTS idx_blind_levels_structure
ON blind_levels(structure_id);
CREATE INDEX IF NOT EXISTS idx_payout_brackets_structure
ON payout_brackets(structure_id);
CREATE INDEX IF NOT EXISTS idx_payout_tiers_bracket
ON payout_tiers(bracket_id);
CREATE INDEX IF NOT EXISTS idx_rake_splits_buyin_config
ON rake_splits(buyin_config_id);
CREATE INDEX IF NOT EXISTS idx_balance_suggestions_tournament
ON balance_suggestions(tournament_id);
CREATE INDEX IF NOT EXISTS idx_bubble_prizes_tournament
ON bubble_prizes(tournament_id);
CREATE INDEX IF NOT EXISTS idx_tournament_players_player
ON tournament_players(player_id);