feat(01-01): initialize Go module, dependency tree, and project scaffold
- Go module at github.com/felt-app/felt with go-libsql pinned to commit hash - Full directory structure per research recommendations (cmd/leaf, internal/*, frontend/) - Makefile with build, run, run-dev, test, frontend, all, clean targets - LibSQL database with WAL mode, foreign keys, and embedded migration runner - SvelteKit SPA stub served via go:embed - Package stubs for all internal packages (server, nats, store, auth, clock, etc.) - go build and go vet pass cleanly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
17dbfc6dc0
commit
af13732b2b
47 changed files with 566 additions and 0 deletions
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Binary
|
||||||
|
cmd/leaf/leaf
|
||||||
|
|
||||||
|
# Data directory
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Frontend (SvelteKit build output managed separately)
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/.svelte-kit/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
31
Makefile
Normal file
31
Makefile
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
.PHONY: build run test frontend all clean
|
||||||
|
|
||||||
|
# Default data directory
|
||||||
|
DATA_DIR ?= ./data
|
||||||
|
ADDR ?= :8080
|
||||||
|
BINARY := cmd/leaf/leaf
|
||||||
|
|
||||||
|
build:
|
||||||
|
CGO_ENABLED=1 go build -o $(BINARY) ./cmd/leaf/
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./$(BINARY) --data-dir $(DATA_DIR) --addr $(ADDR)
|
||||||
|
|
||||||
|
run-dev: build
|
||||||
|
./$(BINARY) --data-dir $(DATA_DIR) --addr $(ADDR) --dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
CGO_ENABLED=1 go test ./...
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
@mkdir -p frontend/build
|
||||||
|
@if [ ! -f frontend/build/index.html ]; then \
|
||||||
|
echo '<!DOCTYPE html><html><head><title>Felt</title></head><body><h1>Felt</h1><p>Loading...</p></body></html>' > frontend/build/index.html; \
|
||||||
|
fi
|
||||||
|
@echo "Frontend build complete (stub)"
|
||||||
|
|
||||||
|
all: frontend build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(BINARY)
|
||||||
|
rm -rf data/
|
||||||
51
cmd/leaf/main.go
Normal file
51
cmd/leaf/main.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/felt-app/felt/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := flag.String("data-dir", "./data", "Data directory for database and files")
|
||||||
|
addr := flag.String("addr", ":8080", "HTTP listen address")
|
||||||
|
devMode := flag.Bool("dev", false, "Enable development mode (applies dev seed data)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||||
|
log.SetPrefix("felt: ")
|
||||||
|
|
||||||
|
log.Printf("starting (data-dir=%s, addr=%s, dev=%v)", *dataDir, *addr, *devMode)
|
||||||
|
|
||||||
|
// Open database (runs migrations automatically)
|
||||||
|
db, err := store.Open(*dataDir, *devMode)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to open database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Check first-run setup status
|
||||||
|
needsSetup, err := db.NeedsSetup()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to check setup status: %v", err)
|
||||||
|
}
|
||||||
|
if needsSetup {
|
||||||
|
log.Printf("no operators found - first-run setup required")
|
||||||
|
log.Printf("POST /api/v1/setup to create first admin operator")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database is working
|
||||||
|
var one int
|
||||||
|
if err := db.QueryRow("SELECT 1").Scan(&one); err != nil {
|
||||||
|
log.Fatalf("database health check failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("database ready")
|
||||||
|
|
||||||
|
// Placeholder for HTTP server (will be implemented in Plan A)
|
||||||
|
_ = addr
|
||||||
|
fmt.Fprintf(os.Stderr, "felt: ready (database operational, HTTP server not yet implemented)\n")
|
||||||
|
}
|
||||||
38
frontend/build/index.html
Normal file
38
frontend/build/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Felt</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.loading h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.loading p {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="loading">
|
||||||
|
<h1>Felt</h1>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend/embed.go
Normal file
38
frontend/embed.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:build
|
||||||
|
var files embed.FS
|
||||||
|
|
||||||
|
// Handler returns an http.Handler that serves the embedded SvelteKit SPA.
|
||||||
|
// Unknown paths fall back to index.html for client-side routing.
|
||||||
|
func Handler() http.Handler {
|
||||||
|
fsys, _ := fs.Sub(files, "build")
|
||||||
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try to serve static file first
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" {
|
||||||
|
path = "/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file exists in the embedded filesystem
|
||||||
|
cleanPath := path[1:] // Remove leading slash
|
||||||
|
if cleanPath == "" {
|
||||||
|
cleanPath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fs.Stat(fsys, cleanPath); err != nil {
|
||||||
|
// SPA fallback: serve index.html for client-side routing
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
11
go.mod
Normal file
11
go.mod
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
module github.com/felt-app/felt
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff // no tagged releases — pinned to commit 43644db490ff
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
|
||||||
|
)
|
||||||
16
go.sum
Normal file
16
go.sum
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||||
|
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||||
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
|
||||||
|
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY=
|
||||||
|
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
|
||||||
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
|
||||||
|
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||||
|
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
1
internal/audit/trail.go
Normal file
1
internal/audit/trail.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package audit
|
||||||
1
internal/audit/undo.go
Normal file
1
internal/audit/undo.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package audit
|
||||||
1
internal/auth/jwt.go
Normal file
1
internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package auth
|
||||||
1
internal/auth/pin.go
Normal file
1
internal/auth/pin.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package auth
|
||||||
1
internal/blind/structure.go
Normal file
1
internal/blind/structure.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package blind
|
||||||
1
internal/blind/templates.go
Normal file
1
internal/blind/templates.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package blind
|
||||||
1
internal/blind/wizard.go
Normal file
1
internal/blind/wizard.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package blind
|
||||||
1
internal/clock/engine.go
Normal file
1
internal/clock/engine.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package clock
|
||||||
1
internal/clock/ticker.go
Normal file
1
internal/clock/ticker.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package clock
|
||||||
1
internal/clock/warnings.go
Normal file
1
internal/clock/warnings.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package clock
|
||||||
1
internal/financial/chop.go
Normal file
1
internal/financial/chop.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package financial
|
||||||
1
internal/financial/engine.go
Normal file
1
internal/financial/engine.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package financial
|
||||||
1
internal/financial/icm.go
Normal file
1
internal/financial/icm.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package financial
|
||||||
1
internal/financial/payout.go
Normal file
1
internal/financial/payout.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package financial
|
||||||
1
internal/financial/receipt.go
Normal file
1
internal/financial/receipt.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package financial
|
||||||
1
internal/nats/embedded.go
Normal file
1
internal/nats/embedded.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package nats
|
||||||
1
internal/nats/publisher.go
Normal file
1
internal/nats/publisher.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package nats
|
||||||
1
internal/player/player.go
Normal file
1
internal/player/player.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package player
|
||||||
1
internal/player/qrcode.go
Normal file
1
internal/player/qrcode.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package player
|
||||||
1
internal/player/ranking.go
Normal file
1
internal/player/ranking.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package player
|
||||||
1
internal/seating/balance.go
Normal file
1
internal/seating/balance.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package seating
|
||||||
1
internal/seating/blueprint.go
Normal file
1
internal/seating/blueprint.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package seating
|
||||||
1
internal/seating/breaktable.go
Normal file
1
internal/seating/breaktable.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package seating
|
||||||
1
internal/seating/table.go
Normal file
1
internal/seating/table.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package seating
|
||||||
1
internal/server/middleware/auth.go
Normal file
1
internal/server/middleware/auth.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package middleware
|
||||||
1
internal/server/middleware/role.go
Normal file
1
internal/server/middleware/role.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package middleware
|
||||||
1
internal/server/server.go
Normal file
1
internal/server/server.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package server
|
||||||
1
internal/server/ws/hub.go
Normal file
1
internal/server/ws/hub.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package ws
|
||||||
130
internal/store/db.go
Normal file
130
internal/store/db.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// Package store provides database access for the Felt tournament engine.
|
||||||
|
//
|
||||||
|
// It manages the LibSQL (SQLite-compatible) connection, runs embedded migrations
|
||||||
|
// on startup, and provides the database handle for use by other packages.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "github.com/tursodatabase/go-libsql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB wraps the underlying sql.DB connection with Felt-specific configuration.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
dataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (or creates) a LibSQL database in the given data directory.
|
||||||
|
// It enables WAL mode and foreign key enforcement, then runs any pending
|
||||||
|
// migrations automatically.
|
||||||
|
func Open(dataDir string, devMode bool) (*DB, error) {
|
||||||
|
// Ensure data directory exists
|
||||||
|
if err := os.MkdirAll(dataDir, 0750); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: create data dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(dataDir, "felt.db")
|
||||||
|
sqlDB, err := sql.Open("libsql", "file:"+dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify connection
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("store: ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set PRAGMAs for performance and correctness.
|
||||||
|
// go-libsql: journal_mode returns a row, use QueryRow.
|
||||||
|
// foreign_keys and busy_timeout are set then verified with separate queries.
|
||||||
|
var walMode string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA journal_mode=WAL").Scan(&walMode); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("store: set journal_mode: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("store: journal_mode=%s", walMode)
|
||||||
|
|
||||||
|
// These PRAGMAs are setters that don't return rows in go-libsql.
|
||||||
|
// We set them, then verify with a separate getter query.
|
||||||
|
if _, err := execPragma(sqlDB, "PRAGMA foreign_keys=ON"); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("store: set foreign_keys: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := execPragma(sqlDB, "PRAGMA busy_timeout=5000"); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("store: set busy_timeout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify settings
|
||||||
|
var fk, bt string
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA foreign_keys").Scan(&fk); err == nil {
|
||||||
|
log.Printf("store: foreign_keys=%s", fk)
|
||||||
|
}
|
||||||
|
if err := sqlDB.QueryRow("PRAGMA busy_timeout").Scan(&bt); err == nil {
|
||||||
|
log.Printf("store: busy_timeout=%s", bt)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("store: opened database at %s", dbPath)
|
||||||
|
|
||||||
|
db := &DB{
|
||||||
|
DB: sqlDB,
|
||||||
|
dataDir: dataDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if err := RunMigrations(sqlDB, devMode); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, fmt.Errorf("store: run migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// execPragma tries Exec first; if that fails with "Execute returned rows",
|
||||||
|
// it falls back to QueryRow. go-libsql is inconsistent about which PRAGMAs
|
||||||
|
// return rows vs which don't.
|
||||||
|
func execPragma(db *sql.DB, pragma string) (string, error) {
|
||||||
|
// Try Exec first (works for pure setters)
|
||||||
|
_, err := db.Exec(pragma)
|
||||||
|
if err == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// If Exec fails because it returns rows, use QueryRow
|
||||||
|
var result string
|
||||||
|
if qErr := db.QueryRow(pragma).Scan(&result); qErr != nil {
|
||||||
|
// Return the original error if QueryRow also fails
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataDir returns the data directory path.
|
||||||
|
func (db *DB) DataDir() string {
|
||||||
|
return db.dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// OperatorCount returns the number of operators in the database.
|
||||||
|
// Used by the first-run setup check.
|
||||||
|
func (db *DB) OperatorCount() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM operators").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsSetup returns true if there are zero operators and the system
|
||||||
|
// needs initial admin setup. In dev mode, this is handled by the dev
|
||||||
|
// seed migration, so it always returns false.
|
||||||
|
func (db *DB) NeedsSetup() (bool, error) {
|
||||||
|
count, err := db.OperatorCount()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count == 0, nil
|
||||||
|
}
|
||||||
121
internal/store/migrate.go
Normal file
121
internal/store/migrate.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// devOnlyMigrations lists migration filenames that should only be applied
|
||||||
|
// in development mode (--dev flag).
|
||||||
|
var devOnlyMigrations = map[string]bool{
|
||||||
|
"004_dev_seed.sql": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations applies all pending SQL migrations embedded in the binary.
|
||||||
|
// Migrations are sorted by filename (numeric prefix ensures order) and
|
||||||
|
// executed within individual transactions. Each successful migration is
|
||||||
|
// recorded in the _migrations table to prevent re-application.
|
||||||
|
//
|
||||||
|
// If devMode is false, migrations listed in devOnlyMigrations are skipped.
|
||||||
|
func RunMigrations(db *sql.DB, devMode bool) error {
|
||||||
|
// Create migrations tracking table
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
applied_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create _migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read all migration files from embedded filesystem
|
||||||
|
entries, err := migrationsFS.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by filename to ensure order
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].Name() < entries[j].Name()
|
||||||
|
})
|
||||||
|
|
||||||
|
applied := 0
|
||||||
|
skipped := 0
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := entry.Name()
|
||||||
|
|
||||||
|
// Skip dev-only migrations unless in dev mode
|
||||||
|
if devOnlyMigrations[name] && !devMode {
|
||||||
|
log.Printf("store: skipping dev-only migration %s (not in dev mode)", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already applied
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read migration SQL
|
||||||
|
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute migration within a transaction
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(string(sqlBytes)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("execute migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record migration as applied
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
"INSERT INTO _migrations (name, applied_at) VALUES (?, ?)",
|
||||||
|
name, now,
|
||||||
|
); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied++
|
||||||
|
log.Printf("store: applied migration %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if applied > 0 {
|
||||||
|
log.Printf("store: %d migration(s) applied, %d already up-to-date", applied, skipped)
|
||||||
|
} else if skipped > 0 {
|
||||||
|
log.Printf("store: all %d migration(s) already applied", skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
28
internal/store/migrations/002_fts_indexes.sql
Normal file
28
internal/store/migrations/002_fts_indexes.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
-- 002_fts_indexes.sql
|
||||||
|
-- FTS5 virtual table for player typeahead search
|
||||||
|
-- Synced with players table via triggers
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS players_fts USING fts5(
|
||||||
|
name, nickname, email,
|
||||||
|
content='players',
|
||||||
|
content_rowid='rowid'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sync triggers: keep FTS index up to date with players table
|
||||||
|
|
||||||
|
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;
|
||||||
27
internal/store/migrations/003_seed_data.sql
Normal file
27
internal/store/migrations/003_seed_data.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- 003_seed_data.sql
|
||||||
|
-- Default venue settings and built-in chip sets
|
||||||
|
-- Applied on every fresh database
|
||||||
|
|
||||||
|
-- Default venue settings (DKK, Denmark)
|
||||||
|
INSERT OR IGNORE INTO venue_settings (id, venue_name, currency_code, currency_symbol, rounding_denomination, receipt_mode, timezone)
|
||||||
|
VALUES (1, '', 'DKK', 'kr', 5000, 'digital', 'Europe/Copenhagen');
|
||||||
|
|
||||||
|
-- Standard chip set (universal denominations)
|
||||||
|
INSERT OR IGNORE INTO chip_sets (id, name, is_builtin) VALUES (1, 'Standard', 1);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) VALUES
|
||||||
|
(1, 25, '#FFFFFF', '25', 1),
|
||||||
|
(1, 100, '#FF0000', '100', 2),
|
||||||
|
(1, 500, '#00AA00', '500', 3),
|
||||||
|
(1, 1000, '#000000', '1000', 4),
|
||||||
|
(1, 5000, '#0000FF', '5000', 5);
|
||||||
|
|
||||||
|
-- Copenhagen chip set (DKK-friendly denominations)
|
||||||
|
INSERT OR IGNORE INTO chip_sets (id, name, is_builtin) VALUES (2, 'Copenhagen', 1);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) VALUES
|
||||||
|
(2, 100, '#FFFFFF', '100', 1),
|
||||||
|
(2, 500, '#FF0000', '500', 2),
|
||||||
|
(2, 1000, '#00AA00', '1000', 3),
|
||||||
|
(2, 5000, '#000000', '5000', 4),
|
||||||
|
(2, 10000, '#0000FF', '10000', 5);
|
||||||
12
internal/store/migrations/004_dev_seed.sql
Normal file
12
internal/store/migrations/004_dev_seed.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- 004_dev_seed.sql
|
||||||
|
-- Development-only seed data: default admin operator
|
||||||
|
-- Only applied when --dev flag is set
|
||||||
|
-- PIN: 1234 (bcrypt hash below)
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO operators (id, name, pin_hash, role)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000001',
|
||||||
|
'Admin',
|
||||||
|
'$2a$10$RD/9mCpMunyvJ0Ax1gHvHOrVmu0DL0/ah0NppuVN4rKgtpfuAEvdK',
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
1
internal/template/buyin.go
Normal file
1
internal/template/buyin.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package template
|
||||||
1
internal/template/chipset.go
Normal file
1
internal/template/chipset.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package template
|
||||||
1
internal/template/payout.go
Normal file
1
internal/template/payout.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package template
|
||||||
1
internal/template/tournament.go
Normal file
1
internal/template/tournament.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package template
|
||||||
1
internal/tournament/multi.go
Normal file
1
internal/tournament/multi.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package tournament
|
||||||
1
internal/tournament/state.go
Normal file
1
internal/tournament/state.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package tournament
|
||||||
1
internal/tournament/tournament.go
Normal file
1
internal/tournament/tournament.go
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
package tournament
|
||||||
Loading…
Add table
Reference in a new issue