diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 4820fd4..9b9caba 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -21,11 +21,12 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - [x] Dev workflow documented in README; `.env.example` added - [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback -### Phase 1 — Data layer -- [ ] SQLite (modernc) init + migrations (`internal/db/db.go`) -- [ ] Models: User, Document, Suggestion (`internal/db/models.go`) -- [ ] Seed hardcoded `local` user -- [ ] Schema includes `voice` in suggestions type CHECK +### Phase 1 — Data layer ✅ +- [x] SQLite (modernc) init + migrations (`internal/db/db.go`) — versioned `schema_migrations` runner, WAL + foreign keys, single writer conn +- [x] Models: User, Document, Suggestion (`internal/db/models.go`) — + type/status constants +- [x] Seed hardcoded `local` user (idempotent on startup) +- [x] Schema includes `voice` in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration) +- [x] `db.Open` wired into `cmd/server/main.go`; `db_test.go` covers migrate/seed idempotency, CHECK constraint, FK cascade ### Phase 2 — Document CRUD + auto-save ← first "it works" milestone - [ ] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`) @@ -70,3 +71,4 @@ Multi-session build. **Source of truth for what's done and what's next.** Update ## Session log - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created. - 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user. +- 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone. diff --git a/cmd/server/main.go b/cmd/server/main.go index 038ab66..40a306d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,12 +11,20 @@ import ( "github.com/go-chi/chi/v5/middleware" "gitea.parodia.dev/drwily/petal/internal/config" + "gitea.parodia.dev/drwily/petal/internal/db" "gitea.parodia.dev/drwily/petal/web" ) func main() { cfg := config.Load() + database, err := db.Open(cfg.DatabasePath) + if err != nil { + log.Fatalf("database: %v", err) + } + defer database.Close() + log.Printf("database ready at %s", cfg.DatabasePath) + r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) diff --git a/go.mod b/go.mod index d15a5ac..0935db1 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,20 @@ module gitea.parodia.dev/drwily/petal -go 1.24.4 +go 1.25.0 -require github.com/go-chi/chi/v5 v5.3.0 +require ( + github.com/go-chi/chi/v5 v5.3.0 + modernc.org/sqlite v1.53.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.20 // 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.44.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum index cf7adc4..860a569 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,53 @@ +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.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +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/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..1904f8b --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,184 @@ +// Package db owns the SQLite connection, schema migrations, and the core data +// models. It uses modernc.org/sqlite (pure Go, no cgo) so the app stays a +// single static binary. +package db + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// LocalUserID is the id of the single hardcoded user the app runs as while auth +// is deferred. The seeded row keeps foreign keys valid; real auth replaces it +// later without a schema change. +const LocalUserID = "local" + +// DB wraps the SQL handle. It's a thin alias today, leaving room for prepared +// statements or helpers later without churning call sites. +type DB struct { + *sql.DB +} + +// Open initialises the database at path: it ensures the parent directory +// exists, opens the connection with foreign keys and WAL enabled, runs all +// pending migrations, and seeds the local user. +func Open(path string) (*DB, error) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + } + + sqlDB, err := sql.Open("sqlite", dsn(path)) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + // SQLite is a single writer; one connection avoids "database is locked" + // churn while keeping WAL's concurrent readers. + sqlDB.SetMaxOpenConns(1) + + if err := sqlDB.Ping(); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + d := &DB{sqlDB} + if err := d.migrate(); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + if err := d.seed(); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("seed: %w", err) + } + return d, nil +} + +// dsn builds the modernc.org/sqlite connection string with the pragmas we want +// applied to every connection. +func dsn(path string) string { + q := url.Values{} + q.Add("_pragma", "foreign_keys(1)") + q.Add("_pragma", "busy_timeout(5000)") + q.Add("_pragma", "journal_mode(WAL)") + return "file:" + path + "?" + q.Encode() +} + +// migration is one ordered, idempotent schema step. Append new migrations to the +// slice in migrate(); never edit or reorder an already-shipped one. +type migration struct { + name string + stmt string +} + +// migrate runs every migration not yet recorded in schema_migrations, inside a +// transaction each, in order. +func (d *DB) migrate() error { + if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + return err + } + + for _, m := range migrations() { + var exists bool + if err := d.QueryRow( + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE name = ?)`, m.name, + ).Scan(&exists); err != nil { + return err + } + if exists { + continue + } + + tx, err := d.Begin() + if err != nil { + return err + } + if _, err := tx.Exec(m.stmt); err != nil { + _ = tx.Rollback() + return fmt.Errorf("migration %q: %w", m.name, err) + } + if _, err := tx.Exec(`INSERT INTO schema_migrations (name) VALUES (?)`, m.name); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record migration %q: %w", m.name, err) + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +// migrations returns the ordered schema history. The initial migration mirrors +// the schema in petal-spec.md. +func migrations() []migration { + return []migration{ + { + name: "0001_initial_schema", + stmt: ` +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + display_name TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE documents ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + user_id TEXT NOT NULL REFERENCES users(id), + title TEXT NOT NULL DEFAULT 'Untitled', + content TEXT NOT NULL DEFAULT '{}', + content_text TEXT NOT NULL DEFAULT '', + word_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE suggestions ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + from_pos INTEGER NOT NULL, + to_pos INTEGER NOT NULL, + original TEXT NOT NULL, + replacement TEXT NOT NULL, + explanation TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('grammar','phrasing','idiom','clarity','voice')), + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE plagiarism_reports ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + backend TEXT NOT NULL DEFAULT 'copyleaks', + similarity_pct REAL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','complete','error')), + result_json TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id); +CREATE INDEX idx_plagiarism_doc_id ON plagiarism_reports(doc_id); +`, + }, + } +} + +// seed inserts the hardcoded local user if it doesn't already exist. It's +// idempotent, so it runs safely on every startup. +func (d *DB) seed() error { + _, err := d.Exec( + `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?) + ON CONFLICT(id) DO NOTHING`, + LocalUserID, "local@petal.local", "Writer", + ) + return err +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..df7fd09 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,77 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func TestOpenMigratesAndSeeds(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + + d, err := Open(path) + if err != nil { + t.Fatalf("first open: %v", err) + } + + // Local user is seeded. + var email string + if err := d.QueryRow(`SELECT email FROM users WHERE id = ?`, LocalUserID).Scan(&email); err != nil { + t.Fatalf("local user not seeded: %v", err) + } + + // All expected tables exist. + for _, table := range []string{"users", "documents", "suggestions", "plagiarism_reports", "schema_migrations"} { + var name string + err := d.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name) + if err != nil { + t.Errorf("table %q missing: %v", table, err) + } + } + + // The voice suggestion type is permitted by the CHECK constraint. + if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil { + t.Fatalf("insert document: %v", err) + } + if _, err := d.Exec( + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) + VALUES ('d1', 0, 4, 'teh', '', 'voice flag', 'voice')`, + ); err != nil { + t.Fatalf("insert voice suggestion: %v", err) + } + + // An invalid type is rejected. + if _, err := d.Exec( + `INSERT INTO suggestions (doc_id, from_pos, to_pos, original, replacement, explanation, type) + VALUES ('d1', 0, 4, 'teh', 'the', 'x', 'nonsense')`, + ); err == nil { + t.Error("expected CHECK constraint to reject invalid suggestion type") + } + + // Cascade delete removes child suggestions (foreign keys enabled). + if _, err := d.Exec(`DELETE FROM documents WHERE id = 'd1'`); err != nil { + t.Fatalf("delete document: %v", err) + } + var n int + if err := d.QueryRow(`SELECT COUNT(*) FROM suggestions WHERE doc_id = 'd1'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("expected cascade delete, got %d orphan suggestions", n) + } + d.Close() + + // Re-opening is idempotent: migrations and seed don't double-apply or error. + d2, err := Open(path) + if err != nil { + t.Fatalf("second open: %v", err) + } + defer d2.Close() + + var users int + if err := d2.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, LocalUserID).Scan(&users); err != nil { + t.Fatal(err) + } + if users != 1 { + t.Errorf("expected exactly 1 local user after reopen, got %d", users) + } +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..2b57480 --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,59 @@ +package db + +import "time" + +// User is an account. With auth deferred, the app runs as a single hardcoded +// `local` user (see LocalUserID); the user_id columns and this type exist so +// real auth can drop in later without a schema migration. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + CreatedAt time.Time `json:"created_at"` +} + +// Document is a single piece of writing. `Content` is the Tiptap JSON document +// (source of truth for the editor); `ContentText` is the flattened plain text +// kept in sync on every save and fed to the LLM. +type Document struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` // Tiptap JSON + ContentText string `json:"content_text"` // plain text for the LLM + WordCount int `json:"word_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Suggestion is a single LLM-proposed edit anchored to a span of the document. +// +// FromPos/ToPos are plaintext offsets into ContentText for server-side use only; +// the frontend re-anchors by matching the `Original` string in ProseMirror +// coordinates at render time (spec Note #6). `Replacement` is empty for `voice` +// flags — those are awareness-only, with no correction to apply. +type Suggestion struct { + ID string `json:"id"` + DocID string `json:"doc_id"` + FromPos int `json:"from_pos"` + ToPos int `json:"to_pos"` + Original string `json:"original"` + Replacement string `json:"replacement"` + Explanation string `json:"explanation"` + Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice + Status string `json:"status"` // pending | accepted | rejected + CreatedAt time.Time `json:"created_at"` +} + +// Suggestion type and status values, mirrored from the schema CHECK constraints. +const ( + SuggestionTypeGrammar = "grammar" + SuggestionTypePhrasing = "phrasing" + SuggestionTypeIdiom = "idiom" + SuggestionTypeClarity = "clarity" + SuggestionTypeVoice = "voice" + + SuggestionStatusPending = "pending" + SuggestionStatusAccepted = "accepted" + SuggestionStatusRejected = "rejected" +)