Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de7a0df15 | ||
|
|
e99ddec433 | ||
|
|
5304887ce9 | ||
|
|
59c3cf259d | ||
|
|
e9ee4e768a | ||
|
|
65b27a7689 | ||
|
|
645b3c00b2 |
+9
-37
@@ -1,6 +1,11 @@
|
||||
# Runs on every push and pull request (no branch filter) so feature branches,
|
||||
# their pull requests, and dev/main are all checked. Runs directly on the
|
||||
# self-hosted act_runner; no container: key.
|
||||
#
|
||||
# The Makefile is the single build definition: CI runs `make test` and
|
||||
# `make build` so it fails whenever either target would, and the binary
|
||||
# carries the Makefile's -trimpath and version-stamped -ldflags (the
|
||||
# `make build` log shows the -X main.version=... value).
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
@@ -16,45 +21,12 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted="$(gofmt -l .)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test
|
||||
run: go test -race ./...
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: npm ci
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
- name: make test
|
||||
run: make test
|
||||
|
||||
- name: npm run typecheck
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: npm test
|
||||
working-directory: web
|
||||
run: npm test
|
||||
|
||||
- name: npm run build
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
# Embed the real PWA, as `make embed` does, then build the static binary.
|
||||
- name: go build (embedded PWA)
|
||||
run: |
|
||||
rm -rf internal/frontend/dist
|
||||
mkdir -p internal/frontend/dist
|
||||
cp -R web/dist/. internal/frontend/dist/
|
||||
CGO_ENABLED=0 go build -o bin/mealquest ./cmd/server
|
||||
- name: make build
|
||||
run: make build
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// version is set at build time with -ldflags "-X main.version=<version>".
|
||||
@@ -38,6 +40,19 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := store.Open(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("close database", "error", err)
|
||||
}
|
||||
}()
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.3.1
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/oklog/ulid/v2 v2.1.2
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,2 +1,55 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
idMu sync.Mutex
|
||||
idEntropy = ulid.Monotonic(rand.Reader, 0)
|
||||
idLastMS uint64
|
||||
)
|
||||
|
||||
// NewID returns a new ULID as its canonical 26-character string. IDs from
|
||||
// this process sort strictly in creation order, including within one
|
||||
// millisecond and across a backwards wall-clock step. It panics if the
|
||||
// monotonic entropy overflows within a millisecond.
|
||||
func NewID() string {
|
||||
idMu.Lock()
|
||||
defer idMu.Unlock()
|
||||
ms := max(ulid.Timestamp(time.Now()), idLastMS)
|
||||
idLastMS = ms
|
||||
return ulid.MustNew(ms, idEntropy).String()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
func TestNewIDStrictlyIncreasing(t *testing.T) {
|
||||
const n = 10000
|
||||
prev := NewID()
|
||||
for range n {
|
||||
id := NewID()
|
||||
if len(id) != ulid.EncodedSize {
|
||||
t.Fatalf("NewID() = %q, want %d characters", id, ulid.EncodedSize)
|
||||
}
|
||||
if _, err := ulid.ParseStrict(id); err != nil {
|
||||
t.Fatalf("NewID() = %q: %v", id, err)
|
||||
}
|
||||
if id <= prev {
|
||||
t.Fatalf("NewID() = %q, not after previous %q", id, prev)
|
||||
}
|
||||
prev = id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createSchemaMigrations = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`
|
||||
|
||||
// Migrate applies every *.sql file at the root of migrations that is not yet
|
||||
// recorded in schema_migrations, in filename order. Each migration runs in its
|
||||
// own transaction together with the insert that records it, so a failing
|
||||
// migration leaves the database at the previous version. A version is the
|
||||
// file name without its .sql extension.
|
||||
//
|
||||
// The database must not hold a version unknown to migrations: that means it
|
||||
// was migrated by a newer build, and Migrate refuses to continue.
|
||||
func Migrate(ctx context.Context, db *sql.DB, migrations fs.FS) error {
|
||||
files, err := fs.Glob(migrations, "*.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: list migrations: %w", err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("migrate: no migrations found")
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, createSchemaMigrations); err != nil {
|
||||
return fmt.Errorf("migrate: create schema_migrations: %w", err)
|
||||
}
|
||||
applied, err := appliedVersions(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
known := make(map[string]bool, len(files))
|
||||
for _, name := range files {
|
||||
known[strings.TrimSuffix(name, ".sql")] = true
|
||||
}
|
||||
for version := range applied {
|
||||
if !known[version] {
|
||||
return fmt.Errorf("migrate: database has version %q, which this build does not know", version)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range files { // fs.Glob returns names in lexical order
|
||||
version := strings.TrimSuffix(name, ".sql")
|
||||
if applied[version] {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(migrations, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read %s: %w", name, err)
|
||||
}
|
||||
if err := apply(ctx, db, version, string(body)); err != nil {
|
||||
return fmt.Errorf("migrate: apply %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appliedVersions(ctx context.Context, db *sql.DB) (map[string]bool, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
applied := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("migrate: read schema_migrations: %w", err)
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("migrate: read schema_migrations: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func apply(ctx context.Context, db *sql.DB, version, body string) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback() // no-op once committed
|
||||
|
||||
if _, err := tx.ExecContext(ctx, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
|
||||
version, time.Now().UTC().Format(time.RFC3339),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
var testMigrations = fstest.MapFS{
|
||||
"0001_first.sql": {Data: []byte("CREATE TABLE first (id INTEGER PRIMARY KEY);")},
|
||||
"0002_second.sql": {Data: []byte("CREATE TABLE second (id INTEGER PRIMARY KEY);\nCREATE TABLE third (id INTEGER PRIMARY KEY);")},
|
||||
"README.md": {Data: []byte("not a migration")},
|
||||
}
|
||||
|
||||
type migrationRow struct {
|
||||
version string
|
||||
appliedAt string
|
||||
}
|
||||
|
||||
func migrationRows(t *testing.T, s *Store) []migrationRow {
|
||||
t.Helper()
|
||||
rows, err := s.Reader.Query("SELECT version, applied_at FROM schema_migrations ORDER BY version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []migrationRow
|
||||
for rows.Next() {
|
||||
var r migrationRow
|
||||
if err := rows.Scan(&r.version, &r.appliedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func versions(rows []migrationRow) string {
|
||||
vs := make([]string, len(rows))
|
||||
for i, r := range rows {
|
||||
vs[i] = r.version
|
||||
}
|
||||
return strings.Join(vs, ",")
|
||||
}
|
||||
|
||||
func tableExists(t *testing.T, s *Store, name string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := s.Reader.QueryRow("SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?", name).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
|
||||
func TestMigrateFreshDatabaseReachesLatest(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(context.Background(), s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := migrationRows(t, s)
|
||||
if got := versions(rows); got != "0001_first,0002_second" {
|
||||
t.Fatalf("versions = %q", got)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := time.Parse(time.RFC3339, r.appliedAt); err != nil || !strings.HasSuffix(r.appliedAt, "Z") {
|
||||
t.Errorf("applied_at %q is not UTC RFC 3339", r.appliedAt)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"first", "second", "third"} {
|
||||
if !tableExists(t, s, table) {
|
||||
t.Errorf("table %s missing", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSecondRunIsNoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := migrationRows(t, s)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
after := migrationRows(t, s)
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("rows after second run = %v, want %v", after, before)
|
||||
}
|
||||
for i := range before {
|
||||
if after[i] != before[i] {
|
||||
t.Errorf("row %d changed: %v -> %v", i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateBrokenMigrationRollsBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
broken := fstest.MapFS{"0003_broken.sql": {Data: []byte(
|
||||
// The first statement succeeds; the second is invalid, so the
|
||||
// table created by the first must be rolled back with it.
|
||||
"CREATE TABLE fourth (id INTEGER PRIMARY KEY);\nCREATE TABLE oops (;",
|
||||
)}}
|
||||
for name, f := range testMigrations {
|
||||
broken[name] = f
|
||||
}
|
||||
err := Migrate(ctx, s.Writer, broken)
|
||||
if err == nil {
|
||||
t.Fatal("broken migration returned no error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0003_broken.sql") {
|
||||
t.Errorf("error %q does not name the failing migration", err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0001_first,0002_second" {
|
||||
t.Errorf("versions after failure = %q, want previous version intact", got)
|
||||
}
|
||||
if tableExists(t, s, "fourth") {
|
||||
t.Error("table from the failed migration was not rolled back")
|
||||
}
|
||||
for _, table := range []string{"first", "second", "third"} {
|
||||
if !tableExists(t, s, table) {
|
||||
t.Errorf("previous table %s missing after failed migration", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAppliesInFilenameOrder(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
// 0002 depends on 0001; MapFS iteration order is random, so this only
|
||||
// passes if migrations are sorted by name.
|
||||
ordered := fstest.MapFS{
|
||||
"0002_child.sql": {Data: []byte("CREATE TABLE child (parent_id INTEGER REFERENCES parent(id)); INSERT INTO child VALUES (1);")},
|
||||
"0001_parent.sql": {Data: []byte("CREATE TABLE parent (id INTEGER PRIMARY KEY); INSERT INTO parent VALUES (1);")},
|
||||
}
|
||||
if err := Migrate(context.Background(), s.Writer, ordered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateUnknownAppliedVersionFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older := fstest.MapFS{"0001_first.sql": testMigrations["0001_first.sql"]}
|
||||
if err := Migrate(ctx, s.Writer, older); err == nil {
|
||||
t.Fatal("migrating a database newer than the build returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateNoMigrationsFails(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(context.Background(), s.Writer, fstest.MapFS{}); err == nil {
|
||||
t.Fatal("empty migrations returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A later build adds 0001_init.sql alongside the unchanged bootstrap.
|
||||
upgraded := fstest.MapFS{
|
||||
"0000_bootstrap.sql": {Data: bootstrap},
|
||||
"0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")},
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, upgraded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
|
||||
}
|
||||
if !tableExists(t, s, "later") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package store owns the MealQuest SQLite database: one writer connection,
|
||||
// a read pool, and the embedded schema migrations.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite" // registers the "sqlite" database/sql driver
|
||||
)
|
||||
|
||||
// FileName is the database file inside the data directory.
|
||||
const FileName = "mealquest.db"
|
||||
|
||||
// readPoolSize is the maximum number of open read connections.
|
||||
const readPoolSize = 4
|
||||
|
||||
// connPragmas are applied by the driver to every new connection.
|
||||
var connPragmas = []string{
|
||||
"busy_timeout(5000)",
|
||||
"journal_mode(WAL)",
|
||||
"foreign_keys(1)",
|
||||
}
|
||||
|
||||
// Store holds the database handles. All writes go through Writer, which has
|
||||
// exactly one connection; Reader is a pool of read-only connections.
|
||||
type Store struct {
|
||||
Writer *sql.DB
|
||||
Reader *sql.DB
|
||||
}
|
||||
|
||||
// Open opens <dataDir>/mealquest.db, creating it if absent, and verifies both
|
||||
// handles can connect. It does not apply migrations; call Migrate for that.
|
||||
func Open(ctx context.Context, dataDir string) (*Store, error) {
|
||||
path, err := filepath.Abs(filepath.Join(dataDir, FileName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: resolve database path: %w", err)
|
||||
}
|
||||
|
||||
// The writer is opened and pinged first so the file exists and is in WAL
|
||||
// mode before any read-only connection touches it.
|
||||
writer, err := openDB(ctx, path, 1, url.Values{"_txlock": {"immediate"}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open writer: %w", err)
|
||||
}
|
||||
reader, err := openDB(ctx, path, readPoolSize, url.Values{"_pragma": {"query_only(1)"}})
|
||||
if err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("store: open reader: %w", err), writer.Close())
|
||||
}
|
||||
return &Store{Writer: writer, Reader: reader}, nil
|
||||
}
|
||||
|
||||
// Close closes both handles.
|
||||
func (s *Store) Close() error {
|
||||
return errors.Join(s.Reader.Close(), s.Writer.Close())
|
||||
}
|
||||
|
||||
func openDB(ctx context.Context, path string, maxConns int, extra url.Values) (*sql.DB, error) {
|
||||
q := url.Values{"_pragma": connPragmas}
|
||||
for k, vs := range extra {
|
||||
q[k] = append(q[k], vs...)
|
||||
}
|
||||
dsn := (&url.URL{Scheme: "file", Path: path, RawQuery: q.Encode()}).String()
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(maxConns)
|
||||
db.SetMaxIdleConns(maxConns)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, errors.Join(err, db.Close())
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s, err := Open(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := s.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOpenCreatesDatabaseFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(context.Background(), dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if _, err := os.Stat(filepath.Join(dir, FileName)); err != nil {
|
||||
t.Fatalf("database file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMissingDirFails(t *testing.T) {
|
||||
_, err := Open(context.Background(), filepath.Join(t.TempDir(), "missing"))
|
||||
if err == nil {
|
||||
t.Fatal("Open succeeded on a missing data directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolSizes(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if got := s.Writer.Stats().MaxOpenConnections; got != 1 {
|
||||
t.Errorf("writer max open connections = %d, want 1", got)
|
||||
}
|
||||
if got := s.Reader.Stats().MaxOpenConnections; got != readPoolSize {
|
||||
t.Errorf("reader max open connections = %d, want %d", got, readPoolSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPragmasOnEveryConnection holds every reader connection open at once so
|
||||
// each one is a distinct connection, then checks the pragmas on each and on
|
||||
// the writer.
|
||||
func TestPragmasOnEveryConnection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
|
||||
conns := []*sql.Conn{}
|
||||
for range readPoolSize {
|
||||
c, err := s.Reader.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
conns = append(conns, c)
|
||||
}
|
||||
w, err := s.Writer.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer w.Close()
|
||||
conns = append(conns, w)
|
||||
|
||||
for i, c := range conns {
|
||||
var fk, busy int
|
||||
var journal string
|
||||
if err := c.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&journal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fk != 1 || busy != 5000 || journal != "wal" {
|
||||
t.Errorf("connection %d: foreign_keys=%d busy_timeout=%d journal_mode=%q, want 1, 5000, wal", i, fk, busy, journal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderIsReadOnly(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Reader.Exec("CREATE TABLE t (id INTEGER)"); err == nil {
|
||||
t.Fatal("write through the reader succeeded")
|
||||
}
|
||||
if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER)"); err != nil {
|
||||
t.Fatalf("write through the writer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeysEnforced(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Writer.Exec(`
|
||||
CREATE TABLE parent (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE child (parent_id INTEGER NOT NULL REFERENCES parent(id));
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.Exec("INSERT INTO child (parent_id) VALUES (42)"); err == nil {
|
||||
t.Fatal("insert violating a foreign key succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so
|
||||
// stored timestamps sort lexicographically in time order.
|
||||
const timestampLayout = "2006-01-02T15:04:05.000Z"
|
||||
|
||||
// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond
|
||||
// precision. Sub-millisecond precision is dropped on write. Use
|
||||
// sql.Null[Timestamp] for a nullable column.
|
||||
type Timestamp struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// Now returns the current instant as a Timestamp, in UTC at millisecond
|
||||
// precision, so it equals its stored form.
|
||||
func Now() Timestamp {
|
||||
return Timestamp{time.Now().UTC().Truncate(time.Millisecond)}
|
||||
}
|
||||
|
||||
// String returns the stored TEXT form.
|
||||
func (t Timestamp) String() string {
|
||||
return t.UTC().Format(timestampLayout)
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero Timestamp is an error, as is a UTC
|
||||
// year outside 0000–9999, which the four-digit stored form cannot represent.
|
||||
func (t Timestamp) Value() (driver.Value, error) {
|
||||
if t.IsZero() {
|
||||
return nil, errors.New("store: zero Timestamp")
|
||||
}
|
||||
if y := t.UTC().Year(); y < 0 || y > 9999 {
|
||||
return nil, fmt.Errorf("store: Timestamp year %d outside 0000-9999", y)
|
||||
}
|
||||
return t.String(), nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner. It accepts only TEXT in the stored form, and
|
||||
// rejects the zero instant, which Value refuses to write.
|
||||
func (t *Timestamp) Scan(src any) error {
|
||||
s, ok := src.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src)
|
||||
}
|
||||
parsed, err := time.Parse(timestampLayout, s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: scan Timestamp %q: %w", s, err)
|
||||
}
|
||||
if parsed.Format(timestampLayout) != s {
|
||||
return fmt.Errorf("store: scan Timestamp %q: want %s", s, timestampLayout)
|
||||
}
|
||||
if parsed.IsZero() {
|
||||
return fmt.Errorf("store: scan Timestamp %q: zero Timestamp", s)
|
||||
}
|
||||
t.Time = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// localDateLayout is the household-local calendar date form.
|
||||
const localDateLayout = "2006-01-02"
|
||||
|
||||
// LocalDate is a household-local calendar date with no time or zone, stored
|
||||
// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored.
|
||||
type LocalDate struct {
|
||||
year int
|
||||
month time.Month
|
||||
day int
|
||||
}
|
||||
|
||||
// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any
|
||||
// date that does not exist on the calendar.
|
||||
func ParseLocalDate(s string) (LocalDate, error) {
|
||||
t, err := time.Parse(localDateLayout, s)
|
||||
if err != nil {
|
||||
return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err)
|
||||
}
|
||||
if t.Format(localDateLayout) != s {
|
||||
return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s)
|
||||
}
|
||||
return LocalDate{t.Year(), t.Month(), t.Day()}, nil
|
||||
}
|
||||
|
||||
// String returns the YYYY-MM-DD form.
|
||||
func (d LocalDate) String() string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day)
|
||||
}
|
||||
|
||||
// IsZero reports whether d is the zero LocalDate.
|
||||
func (d LocalDate) IsZero() bool {
|
||||
return d == LocalDate{}
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer. The zero LocalDate is an error.
|
||||
func (d LocalDate) Value() (driver.Value, error) {
|
||||
if d.IsZero() {
|
||||
return nil, errors.New("store: zero LocalDate")
|
||||
}
|
||||
return d.String(), nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT.
|
||||
func (d *LocalDate) Scan(src any) error {
|
||||
s, ok := src.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src)
|
||||
}
|
||||
parsed, err := ParseLocalDate(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: scan LocalDate: %w", err)
|
||||
}
|
||||
*d = parsed
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTypesTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Writer.Exec("CREATE TABLE v (val TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// roundTrip writes in through the writer, then reads it back into out and
|
||||
// returns the stored SQLite type and raw text.
|
||||
func roundTrip(t *testing.T, s *Store, in, out any) (typ, raw string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := s.Writer.ExecContext(ctx, "DELETE FROM v"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Writer.ExecContext(ctx, "INSERT INTO v (val) VALUES (?)", in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Reader.QueryRowContext(ctx, "SELECT typeof(val), val, val FROM v").Scan(&typ, &raw, out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return typ, raw
|
||||
}
|
||||
|
||||
func TestTimestampRoundTrip(t *testing.T) {
|
||||
s := openTypesTestStore(t)
|
||||
zone := time.FixedZone("EDT", -4*60*60)
|
||||
in := Timestamp{time.Date(2026, 10, 3, 8, 37, 5, 123456789, zone)}
|
||||
|
||||
var out Timestamp
|
||||
typ, raw := roundTrip(t, s, in, &out)
|
||||
if typ != "text" || raw != "2026-10-03T12:37:05.123Z" {
|
||||
t.Fatalf("stored %s %q, want text %q", typ, raw, "2026-10-03T12:37:05.123Z")
|
||||
}
|
||||
if want := in.Truncate(time.Millisecond); !out.Equal(want) {
|
||||
t.Fatalf("read back %v, want %v", out, want)
|
||||
}
|
||||
if out.Location() != time.UTC {
|
||||
t.Fatalf("read back location %v, want UTC", out.Location())
|
||||
}
|
||||
|
||||
now := Now()
|
||||
roundTrip(t, s, now, &out)
|
||||
if out != now {
|
||||
t.Fatalf("Now() round trip = %#v, want %#v", out, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimestampRejects(t *testing.T) {
|
||||
if _, err := (Timestamp{}).Value(); err == nil {
|
||||
t.Error("zero Timestamp Value succeeded")
|
||||
}
|
||||
for _, tm := range []time.Time{
|
||||
time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(-1, 12, 31, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(9999, 12, 31, 23, 0, 0, 0, time.FixedZone("west", -2*3600)),
|
||||
} {
|
||||
if v, err := (Timestamp{tm}).Value(); err == nil {
|
||||
t.Errorf("Timestamp{%v}.Value() = %v, want error", tm, v)
|
||||
}
|
||||
}
|
||||
for _, src := range []any{
|
||||
nil, int64(0), []byte("2026-10-03T12:37:05.123Z"), "2026-10-03T12:37:05Z",
|
||||
"2026-10-03T08:37:05.123-04:00", "2026-10-03T12:37:05,123Z", "garbage",
|
||||
"0001-01-01T00:00:00.000Z",
|
||||
} {
|
||||
var ts Timestamp
|
||||
if err := ts.Scan(src); err == nil {
|
||||
t.Errorf("Scan(%#v) succeeded", src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRoundTrip(t *testing.T) {
|
||||
s := openTypesTestStore(t)
|
||||
for _, str := range []string{"2026-10-03", "2028-02-29", "0001-01-01", "9999-12-31"} {
|
||||
in, err := ParseLocalDate(str)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLocalDate(%q): %v", str, err)
|
||||
}
|
||||
if in.String() != str {
|
||||
t.Fatalf("String() = %q, want %q", in.String(), str)
|
||||
}
|
||||
var out LocalDate
|
||||
typ, raw := roundTrip(t, s, in, &out)
|
||||
if typ != "text" || raw != str {
|
||||
t.Fatalf("stored %s %q, want text %q", typ, raw, str)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("read back %v, want %v", out, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDateRejectsInvalid(t *testing.T) {
|
||||
for _, str := range []string{
|
||||
"", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00",
|
||||
"2026-10-32", "2026-1-03", "2026-10-3", "26-10-03", "2026/10/03",
|
||||
"2026-10-03T00:00:00Z", " 2026-10-03", "20261003", "not-a-date",
|
||||
} {
|
||||
if d, err := ParseLocalDate(str); err == nil {
|
||||
t.Errorf("ParseLocalDate(%q) = %v, want error", str, d)
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(str); err == nil {
|
||||
t.Errorf("Scan(%q) succeeded", str)
|
||||
}
|
||||
}
|
||||
if _, err := (LocalDate{}).Value(); err == nil {
|
||||
t.Error("zero LocalDate Value succeeded")
|
||||
}
|
||||
var d LocalDate
|
||||
if err := d.Scan(nil); err == nil {
|
||||
t.Error("Scan(nil) succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// WithTx runs fn in a transaction on the writer. It commits when fn returns
|
||||
// nil and rolls back when fn returns an error, panics or exits the goroutine.
|
||||
// A panic is not recovered: it propagates unchanged after the rollback.
|
||||
func (s *Store) WithTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := s.Writer.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin transaction: %w", err)
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
_ = tx.Rollback() // fn panicked; the panic carries on regardless
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
finished = true
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("store: roll back transaction: %w", rbErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
finished = true
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("store: commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func openTxTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s := openTestStore(t)
|
||||
if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, s *Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := s.Reader.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func insertRow(tx *sql.Tx) error {
|
||||
_, err := tx.Exec("INSERT INTO t (id) VALUES (1)")
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWithTxCommitsOnNil(t *testing.T) {
|
||||
s := openTxTestStore(t)
|
||||
if err := s.WithTx(context.Background(), insertRow); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := countRows(t, s); n != 1 {
|
||||
t.Fatalf("rows after commit = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTxRollsBackOnError(t *testing.T) {
|
||||
s := openTxTestStore(t)
|
||||
errBoom := errors.New("boom")
|
||||
err := s.WithTx(context.Background(), func(tx *sql.Tx) error {
|
||||
if err := insertRow(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return errBoom
|
||||
})
|
||||
if !errors.Is(err, errBoom) {
|
||||
t.Fatalf("WithTx error = %v, want %v", err, errBoom)
|
||||
}
|
||||
if n := countRows(t, s); n != 0 {
|
||||
t.Fatalf("rows after rollback = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTxRollsBackOnPanic(t *testing.T) {
|
||||
s := openTxTestStore(t)
|
||||
const value = "kaboom"
|
||||
func() {
|
||||
defer func() {
|
||||
if p := recover(); p != value {
|
||||
t.Fatalf("recovered %v, want panic %q to propagate", p, value)
|
||||
}
|
||||
}()
|
||||
_ = s.WithTx(context.Background(), func(tx *sql.Tx) error {
|
||||
if err := insertRow(tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
panic(value)
|
||||
})
|
||||
t.Fatal("WithTx returned instead of panicking")
|
||||
}()
|
||||
if n := countRows(t, s); n != 0 {
|
||||
t.Fatalf("rows after panic = %d, want 0", n)
|
||||
}
|
||||
// The single writer connection must have been released.
|
||||
if err := s.WithTx(context.Background(), insertRow); err != nil {
|
||||
t.Fatalf("WithTx after panic: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 0000_bootstrap: immutable no-op bootstrap migration. It lets the migrations
|
||||
-- embed compile and proves the runner end to end. Never edit or remove it:
|
||||
-- applied versions are recorded by filename and never re-run. The initial v1
|
||||
-- schema arrives as a new migration, 0001_init.sql (Feature 02 task 3).
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package migrations embeds the SQL schema migrations applied at boot by
|
||||
// internal/store. Files are named NNNN_slug.sql and applied in filename order.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds every *.sql migration in this directory.
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
Reference in New Issue
Block a user