refactor: adopt portigo infrastructure patterns

Add config package with build-tag-switched dev/prod environments,
structured logging via zerolog, Taskfile for dev workflow, golangci-lint
config, testutil package, and improved DB setup with proper SQLite
pragmas and cleanup. Rename sqlc output package from gen to repository.

Switch to allowlist .gitignore, Alpine+UPX+scratch Dockerfile, and
CI pipeline with test/lint gates before deploy.
This commit is contained in:
Ryan Hamamura
2026-03-02 11:48:47 -10:00
parent 6d4f3eb821
commit 2df20c2840
27 changed files with 694 additions and 143 deletions

View File

@@ -1,33 +1,70 @@
// Package db handles SQLite database setup, pragma configuration, and
// goose migrations.
package db
import (
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrations embed.FS
var MigrationFS embed.FS
var DB *sql.DB
func Init(dbPath string) error {
func Init(dbPath string) (func(), error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return nil, fmt.Errorf("creating data dir: %w", err)
}
// busy_timeout must be first because the connection needs to block on
// busy before WAL mode is set in case it hasn't been set already.
pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
var err error
DB, err = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
DB, err = goose.OpenDBWithDriver("sqlite", dbPath+pragmas)
if err != nil {
return err
return nil, fmt.Errorf("opening database: %w", err)
}
DB.SetMaxOpenConns(1)
goose.SetBaseFS(migrations)
if err := DB.Ping(); err != nil {
return nil, errors.Join(fmt.Errorf("pinging database: %w", err), DB.Close())
}
slog.Info("db connected", "db", dbPath)
sub, err := fs.Sub(MigrationFS, "migrations")
if err != nil {
return nil, errors.Join(fmt.Errorf("migrations sub fs: %w", err), DB.Close())
}
goose.SetBaseFS(sub)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
return nil, errors.Join(fmt.Errorf("setting goose dialect: %w", err), DB.Close())
}
if err := goose.Up(DB, "migrations"); err != nil {
return err
if err := goose.Up(DB, "."); err != nil {
return nil, errors.Join(fmt.Errorf("running migrations: %w", err), DB.Close())
}
return nil
if _, err := DB.Exec("PRAGMA optimize"); err != nil {
return nil, errors.Join(fmt.Errorf("pragma optimize: %w", err), DB.Close())
}
cleanup := func() {
if _, err := DB.Exec("PRAGMA optimize(0x10002)"); err != nil {
slog.Error("pragma optimize at shutdown", "error", err)
}
if err := DB.Close(); err != nil {
slog.Error("closing database", "error", err)
}
}
return cleanup, nil
}

View File

@@ -5,17 +5,17 @@ import (
"database/sql"
"slices"
"github.com/ryanhamamura/c4/db/gen"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
"github.com/ryanhamamura/c4/snake"
"github.com/ryanhamamura/c4/ui"
)
type GamePersister struct {
queries *gen.Queries
queries *repository.Queries
}
func NewGamePersister(q *gen.Queries) *GamePersister {
func NewGamePersister(q *repository.Queries) *GamePersister {
return &GamePersister{queries: q}
}
@@ -24,7 +24,7 @@ func (p *GamePersister) SaveGame(g *game.Game) error {
_, err := p.queries.GetGame(ctx, g.ID)
if err == sql.ErrNoRows {
_, err = p.queries.CreateGame(ctx, gen.CreateGameParams{
_, err = p.queries.CreateGame(ctx, repository.CreateGameParams{
ID: g.ID,
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
@@ -51,7 +51,7 @@ func (p *GamePersister) SaveGame(g *game.Game) error {
rematchGameID = sql.NullString{String: *g.RematchGameID, Valid: true}
}
return p.queries.UpdateGame(ctx, gen.UpdateGameParams{
return p.queries.UpdateGame(ctx, repository.UpdateGameParams{
Board: g.BoardToJSON(),
CurrentTurn: int64(g.CurrentTurn),
Status: int64(g.Status),
@@ -100,7 +100,7 @@ func (p *GamePersister) SaveGamePlayer(gameID string, player *game.Player, slot
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return p.queries.CreateGamePlayer(ctx, gen.CreateGamePlayerParams{
return p.queries.CreateGamePlayer(ctx, repository.CreateGamePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
@@ -144,10 +144,10 @@ func (p *GamePersister) DeleteGame(id string) error {
// SnakePersister implements snake.Persister
type SnakePersister struct {
queries *gen.Queries
queries *repository.Queries
}
func NewSnakePersister(q *gen.Queries) *SnakePersister {
func NewSnakePersister(q *repository.Queries) *SnakePersister {
return &SnakePersister{queries: q}
}
@@ -167,7 +167,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
_, err := p.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = p.queries.CreateSnakeGame(ctx, gen.CreateSnakeGameParams{
_, err = p.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
@@ -192,7 +192,7 @@ func (p *SnakePersister) SaveSnakeGame(sg *snake.SnakeGame) error {
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
}
return p.queries.UpdateSnakeGame(ctx, gen.UpdateSnakeGameParams{
return p.queries.UpdateSnakeGame(ctx, repository.UpdateSnakeGameParams{
Board: boardJSON,
Status: int64(sg.Status),
WinnerUserID: winnerUserID,
@@ -247,7 +247,7 @@ func (p *SnakePersister) SaveSnakePlayer(gameID string, player *snake.Player) er
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
}
return p.queries.CreateSnakePlayer(ctx, gen.CreateSnakePlayerParams{
return p.queries.CreateSnakePlayer(ctx, repository.CreateSnakePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
@@ -290,15 +290,15 @@ func (p *SnakePersister) DeleteSnakeGame(id string) error {
}
type ChatPersister struct {
queries *gen.Queries
queries *repository.Queries
}
func NewChatPersister(q *gen.Queries) *ChatPersister {
func NewChatPersister(q *repository.Queries) *ChatPersister {
return &ChatPersister{queries: q}
}
func (p *ChatPersister) SaveChatMessage(gameID string, msg ui.C4ChatMessage) error {
return p.queries.CreateChatMessage(context.Background(), gen.CreateChatMessageParams{
return p.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: gameID,
Nickname: msg.Nickname,
Color: int64(msg.Color),

View File

@@ -3,7 +3,7 @@
// sqlc v1.30.0
// source: chat.sql
package gen
package repository
import (
"context"

View File

@@ -2,7 +2,7 @@
// versions:
// sqlc v1.30.0
package gen
package repository
import (
"context"

View File

@@ -3,7 +3,7 @@
// sqlc v1.30.0
// source: games.sql
package gen
package repository
import (
"context"

View File

@@ -2,7 +2,7 @@
// versions:
// sqlc v1.30.0
package gen
package repository
import (
"database/sql"

View File

@@ -3,7 +3,7 @@
// sqlc v1.30.0
// source: snake_games.sql
package gen
package repository
import (
"context"

View File

@@ -3,7 +3,7 @@
// sqlc v1.30.0
// source: users.sql
package gen
package repository
import (
"context"

View File

@@ -5,5 +5,9 @@ sql:
schema: "migrations"
gen:
go:
package: "gen"
out: "gen"
package: "repository"
out: "repository"
emit_db_tags: true
emit_json_tags: true
emit_result_struct_pointers: true
emit_pointers_for_null_types: true