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:
+54
-16
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user