Address Gadfly review findings on Phase 0

Applied the fixes warranted by the adversarial review of PR #21 (all
findings graded in the gadfly store):

Store (highest impact):
- buildDSN always merges busy_timeout/journal_mode/foreign_keys pragmas
  into the DSN, even for file: URIs or paths with a query string — the
  prior passthrough silently disabled FK enforcement for those PANSY_DB
  values. Also escape '#' in the path and tighten in-memory detection to
  an exact ':memory:' token or mode=memory param (no substring misfire).
- migrate.go: detect duplicate migration versions with a clear error;
  wrap appliedVersions' rows.Err() with the store: prefix.

API:
- SetTrustedProxies failure now falls back to trust-none instead of
  leaving gin's trust-everyone default (X-Forwarded-For spoofing).
- SPA: 404 embedded directories (was a directory listing), 404 bare /api,
  add X-Content-Type-Options: nosniff, cache index.html bytes once at
  startup, single fs.Stat existence check, shared writeAPIError helper.
- Move gin.SetMode out of package init() into New().

Config: validate PANSY_PORT range (fall back to 8080 with a warning).

Web:
- api.ts: serialize the request body before the fetch try so a
  JSON.stringify failure isn't misreported as a network error.
- AppShell: move state-specific/conflicting utilities into
  active/inactiveProps so concatenated Tailwind classes don't collide.

Tests: added store DSN-pragma/memory-detection cases and SPA
directory-404 case. go build/vet/test clean (CGO off); web tsc + build
clean; re-verified in a browser (SPA, deep links, /assets 404, nav).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-18 15:19:30 -04:00
co-authored by Claude Opus 4.8
parent 91da9ff945
commit 0f3dedab73
9 changed files with 193 additions and 51 deletions
+9 -1
View File
@@ -69,7 +69,10 @@ func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) {
}
applied[v] = true
}
return applied, rows.Err()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate schema_migrations: %w", err)
}
return applied, nil
}
func (d *DB) applyMigration(ctx context.Context, m migration) error {
@@ -99,6 +102,7 @@ func loadMigrations() ([]migration, error) {
}
var migrations []migration
seen := map[int]string{}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
@@ -107,6 +111,10 @@ func loadMigrations() ([]migration, error) {
if err != nil {
return nil, err
}
if prev, dup := seen[version]; dup {
return nil, fmt.Errorf("store: duplicate migration version %d: %q and %q", version, prev, e.Name())
}
seen[version] = e.Name()
body, err := fs.ReadFile(migrationsFS, "migrations/"+e.Name())
if err != nil {
return nil, fmt.Errorf("store: read migration %s: %w", e.Name(), err)
+54 -16
View File
@@ -13,6 +13,16 @@ import (
_ "modernc.org/sqlite" // pure-Go driver, registered as "sqlite"
)
// requiredPragmas are applied to every connection (they are per-connection in
// SQLite): wait rather than fail on a contended write, use WAL journaling, and
// enforce foreign keys. They are attached to the DSN so the whole pool inherits
// them.
var requiredPragmas = []string{
"busy_timeout(5000)",
"journal_mode(WAL)",
"foreign_keys(1)",
}
// DB is a handle to the SQLite database backing pansy. Safe for concurrent use:
// WAL mode allows many readers with one writer, and busy_timeout makes a
// contended writer wait rather than fail with SQLITE_BUSY.
@@ -22,10 +32,11 @@ type DB struct {
// Open opens (creating if absent) the SQLite database at path and returns a DB.
// The pragmas — busy_timeout, WAL journal, and foreign-key enforcement — are
// applied per connection via the DSN so every pooled connection inherits them.
// A path of ":memory:" yields an ephemeral in-memory database (useful in tests);
// the pool is pinned to a single connection so all queries see the same DB.
// The caller owns the returned DB and must Close it.
// applied per connection via the DSN so every pooled connection inherits them,
// regardless of whether path is a bare filename, a path with query parameters,
// or a full file: URI. A path of ":memory:" yields an ephemeral in-memory
// database (useful in tests); the pool is pinned to a single connection so all
// queries see the same DB. The caller owns the returned DB and must Close it.
func Open(path string) (*DB, error) {
dsn, memory := buildDSN(path)
sqldb, err := sql.Open("sqlite", dsn)
@@ -45,20 +56,47 @@ func Open(path string) (*DB, error) {
return &DB{sql: sqldb}, nil
}
// buildDSN turns a filesystem path (or ":memory:") into a modernc DSN carrying
// the pragmas we want on every connection, and reports whether it is in-memory.
// A path that already looks like a DSN (has a scheme or query) is passed through
// untouched.
// buildDSN converts the configured path into a modernc DSN that always carries
// the required pragmas, and reports whether it is an in-memory database. It
// accepts a bare filename, a path with existing query parameters, or a full
// file: URI, and merges the pragmas without clobbering any the user supplied.
func buildDSN(path string) (dsn string, memory bool) {
memory = path == ":memory:" || strings.Contains(path, ":memory:") || strings.Contains(path, "mode=memory")
if strings.HasPrefix(path, "file:") || strings.Contains(path, "?") {
return path, memory
body := strings.TrimPrefix(path, "file:")
filePart, rawQuery, _ := strings.Cut(body, "?")
query := url.Values{}
if q, err := url.ParseQuery(rawQuery); err == nil {
query = q
}
pragmas := url.Values{}
pragmas.Add("_pragma", "busy_timeout(5000)")
pragmas.Add("_pragma", "journal_mode(WAL)")
pragmas.Add("_pragma", "foreign_keys(1)")
return "file:" + path + "?" + pragmas.Encode(), memory
// Detect in-memory precisely: the exact ":memory:" token, or an explicit
// mode=memory parameter. A substring match would misfire on real file paths.
memory = filePart == ":memory:" || query.Get("mode") == "memory"
have := make(map[string]bool, len(query["_pragma"]))
for _, p := range query["_pragma"] {
have[pragmaName(p)] = true
}
for _, p := range requiredPragmas {
if !have[pragmaName(p)] {
query.Add("_pragma", p)
}
}
// A '#' in a filesystem path would otherwise start a URI fragment and drop
// the query string that carries the pragmas.
filePart = strings.ReplaceAll(filePart, "#", "%23")
return "file:" + filePart + "?" + query.Encode(), memory
}
// pragmaName extracts the pragma's name from a "_pragma" value such as
// "busy_timeout(5000)" or "foreign_keys=1", lowercased for case-insensitive
// de-duplication against a user-supplied value.
func pragmaName(p string) string {
if i := strings.IndexAny(p, "(="); i >= 0 {
p = p[:i]
}
return strings.ToLower(strings.TrimSpace(p))
}
// Close closes the underlying database.
+58
View File
@@ -3,6 +3,7 @@ package store
import (
"context"
"path/filepath"
"strings"
"testing"
)
@@ -102,6 +103,63 @@ func TestCheckConstraintRejectsBadEnum(t *testing.T) {
}
}
func TestForeignKeysEnforcedForFileURIDSN(t *testing.T) {
// A file: URI (or any path with query params) must still get foreign_keys(1):
// the earlier passthrough silently dropped the pragmas for these DSNs.
p := filepath.Join(t.TempDir(), "uri.db")
db, err := Open("file:" + p + "?_pragma=synchronous(1)")
if err != nil {
t.Fatalf("Open file: URI: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
_, err = db.SQL().Exec(
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
)
if err == nil {
t.Fatal("expected foreign-key violation for file: URI DSN, got nil")
}
}
func TestBuildDSNAlwaysIncludesPragmas(t *testing.T) {
for _, in := range []string{
"./pansy.db",
"/data/pansy.db",
"file:/data/pansy.db",
"file:/data/pansy.db?cache=shared",
"/tmp/weird?name.db",
} {
dsn, _ := buildDSN(in)
for _, want := range []string{"busy_timeout", "journal_mode", "foreign_keys"} {
if !strings.Contains(dsn, want) {
t.Errorf("buildDSN(%q) = %q, missing %s pragma", in, dsn, want)
}
}
}
}
func TestBuildDSNDoesNotDuplicateUserPragma(t *testing.T) {
// A user-supplied busy_timeout must not be duplicated by our default.
dsn, _ := buildDSN("file:/data/x.db?_pragma=busy_timeout(9000)")
if strings.Count(dsn, "busy_timeout") != 1 {
t.Errorf("buildDSN kept both user and default busy_timeout: %q", dsn)
}
}
func TestBuildDSNMemoryDetection(t *testing.T) {
if _, mem := buildDSN(":memory:"); !mem {
t.Error(":memory: not detected as in-memory")
}
if _, mem := buildDSN("file:x.db?mode=memory"); !mem {
t.Error("mode=memory not detected as in-memory")
}
if _, mem := buildDSN("/var/lib/pansy/pansy.db"); mem {
t.Error("file path wrongly detected as in-memory")
}
}
func TestInMemoryDBIsCoherentAcrossQueries(t *testing.T) {
// A bare ":memory:" DSN gives each connection its own DB; Open must pin the
// pool so the migrated schema is visible to subsequent queries.