Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 60f977d50c Restrict dev secret permissions and state exact Node.js ranges in README
CI / ci (push) Successful in 23s
CI / ci (pull_request) Successful in 24s
Independent review Approved — 0 high findings (#7)
The quick start now sets the dev data directory to 700 and the secret key
file to 600, so other local users cannot read the key. The prerequisites
state the exact supported Node.js ranges from the lockfile's jsdom engine
(^22.22.2 || ^24.15.0 || >=26.0.0) instead of "22.22.2 or newer".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:24:07 -04:00
dev_agentandClaude Opus 5.5 bed51ed2bd Fix architecture and README accuracy findings from review
CI / ci (push) Successful in 23s
CI / ci (pull_request) Successful in 23s
Independent review Not signed off — 0 high, 1 medium, 1 low (#7)
- Topology: show the port mapping host 8090 -> container 8080, and note
  that searxng is internal to the compose network with no published port.
- Backend: logging is log/slog JSON.
- Scheduler: only the household-configurable times live in
  household_settings; week_rollover and the ai_runs purge are fixed.
- README: state the Node.js floor required by the committed lockfile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:20:30 -04:00
dev_agentandClaude Opus 5.5 37f6200250 Correct architecture doc to match the CTXD architecture section
CI / ci (pull_request) Successful in 24s
CI / ci (push) Successful in 23s
Independent review Not signed off — 0 high, 3 medium, 2 low (#7)
Replace the data model table with the CTXD tables and columns (recipes
are fetched recipe pages; dinners hold the meal record and lifecycle),
fix the dinner lifecycle (Pass leads to archived; tried can return to
quest_log), add the session cookie attributes and expiry, require the
session cookie and CSRF header on /api/v1, make the ai_runs purge daily,
use the handoff routes plus the CTXD settings routes, and remove details
not supported by CTXD, the requirements or the UI handoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:16:58 -04:00
dev_agentandClaude Opus 5.5 998898c549 Add AGENTS.md, CLAUDE.md, README and architecture docs
AGENTS.md is the authoritative agent guide: house rules, approved
dependencies, quality floor, repository layout, branch and merge
conventions, where context and work tracking live, GLOSSARY vocabulary
and the rule never to use tea. CLAUDE.md imports it. README gives the
purpose and a make dev quick start. docs/ARCHITECTURE.md mirrors the
CTXD CONTEXT.MD architecture section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:15:12 -04:00
10 changed files with 39 additions and 611 deletions
+37 -9
View File
@@ -1,11 +1,6 @@
# 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]
@@ -21,12 +16,45 @@ 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: make test
run: make test
- name: npm ci
working-directory: web
run: npm ci
- name: make build
run: make build
- 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
-15
View File
@@ -15,8 +15,6 @@ 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>".
@@ -40,19 +38,6 @@ 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
+2 -17
View File
@@ -1,20 +1,5 @@
module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25.0
go 1.25
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
)
require github.com/go-chi/chi/v5 v5.3.1
-50
View File
@@ -1,52 +1,2 @@
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=
-105
View File
@@ -1,105 +0,0 @@
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()
}
-206
View File
@@ -1,206 +0,0 @@
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")
}
}
-79
View File
@@ -1,79 +0,0 @@
// 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
}
-116
View File
@@ -1,116 +0,0 @@
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")
}
}
-4
View File
@@ -1,4 +0,0 @@
-- 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).
-10
View File
@@ -1,10 +0,0 @@
// 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