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
107 lines
3.9 KiB
Go
107 lines
3.9 KiB
Go
// Package store provides pansy's durable persistence layer: a pure-Go SQLite
|
|
// database (modernc.org/sqlite — no cgo, so the binary stays static) with a
|
|
// tiny embedded migration runner. Entity query methods are added per feature
|
|
// issue; this file only handles opening and the connection pragmas.
|
|
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
_ "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.
|
|
type DB struct {
|
|
sql *sql.DB
|
|
}
|
|
|
|
// 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,
|
|
// 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)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: open %q: %w", path, err)
|
|
}
|
|
if memory {
|
|
// Each new connection to a bare ":memory:" DSN gets its OWN empty database,
|
|
// so a pooled second connection wouldn't see the migrated schema. Pin the
|
|
// pool to one connection to keep an in-memory DB coherent.
|
|
sqldb.SetMaxOpenConns(1)
|
|
}
|
|
if err := sqldb.Ping(); err != nil {
|
|
sqldb.Close()
|
|
return nil, fmt.Errorf("store: ping %q: %w", path, err)
|
|
}
|
|
return &DB{sql: sqldb}, nil
|
|
}
|
|
|
|
// 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) {
|
|
body := strings.TrimPrefix(path, "file:")
|
|
|
|
filePart, rawQuery, _ := strings.Cut(body, "?")
|
|
query := url.Values{}
|
|
if q, err := url.ParseQuery(rawQuery); err == nil {
|
|
query = q
|
|
}
|
|
|
|
// 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.
|
|
func (d *DB) Close() error { return d.sql.Close() }
|
|
|
|
// SQL exposes the underlying *sql.DB for the per-entity query files.
|
|
func (d *DB) SQL() *sql.DB { return d.sql }
|