Phase 1: data layer (SQLite, migrations, models, seed)
This commit is contained in:
@@ -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] Dev workflow documented in README; `.env.example` added
|
||||||
- [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback
|
- [x] Verified end-to-end: binary serves `/api/health` + embedded SPA + SPA fallback
|
||||||
|
|
||||||
### Phase 1 — Data layer
|
### Phase 1 — Data layer ✅
|
||||||
- [ ] SQLite (modernc) init + migrations (`internal/db/db.go`)
|
- [x] SQLite (modernc) init + migrations (`internal/db/db.go`) — versioned `schema_migrations` runner, WAL + foreign keys, single writer conn
|
||||||
- [ ] Models: User, Document, Suggestion (`internal/db/models.go`)
|
- [x] Models: User, Document, Suggestion (`internal/db/models.go`) — + type/status constants
|
||||||
- [ ] Seed hardcoded `local` user
|
- [x] Seed hardcoded `local` user (idempotent on startup)
|
||||||
- [ ] Schema includes `voice` in suggestions type CHECK
|
- [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
|
### Phase 2 — Document CRUD + auto-save ← first "it works" milestone
|
||||||
- [ ] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`)
|
- [ ] 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
|
## 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: 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 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.
|
||||||
|
|||||||
@@ -11,12 +11,20 @@ import (
|
|||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
"gitea.parodia.dev/drwily/petal/web"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
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 := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
|
|||||||
19
go.mod
19
go.mod
@@ -1,5 +1,20 @@
|
|||||||
module gitea.parodia.dev/drwily/petal
|
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
|
||||||
|
)
|
||||||
|
|||||||
51
go.sum
51
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 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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=
|
||||||
|
|||||||
184
internal/db/db.go
Normal file
184
internal/db/db.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
77
internal/db/db_test.go
Normal file
77
internal/db/db_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
internal/db/models.go
Normal file
59
internal/db/models.go
Normal file
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user