diff --git a/cmd/server/main.go b/cmd/server/main.go index bbf388b..ec9f141 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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=". @@ -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 diff --git a/go.mod b/go.mod index 37e681e..3c014a7 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ 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 + 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 +) diff --git a/go.sum b/go.sum index bdb0eaa..1d524f4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,52 @@ +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/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= diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..2ac5dec --- /dev/null +++ b/internal/store/migrate.go @@ -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() +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go new file mode 100644 index 0000000..35a0824 --- /dev/null +++ b/internal/store/migrate_test.go @@ -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") + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e3748d2 --- /dev/null +++ b/internal/store/store.go @@ -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 /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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1125ce0 --- /dev/null +++ b/internal/store/store_test.go @@ -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") + } +} diff --git a/migrations/0000_bootstrap.sql b/migrations/0000_bootstrap.sql new file mode 100644 index 0000000..a21b38f --- /dev/null +++ b/migrations/0000_bootstrap.sql @@ -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). diff --git a/migrations/embed.go b/migrations/embed.go new file mode 100644 index 0000000..a9cb022 --- /dev/null +++ b/migrations/embed.go @@ -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