Phase 1: data layer (SQLite, migrations, models, seed)

This commit is contained in:
prosolis
2026-06-25 20:25:14 -07:00
parent e72d48d64e
commit 9c98e97030
7 changed files with 403 additions and 7 deletions

184
internal/db/db.go Normal file
View 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
View 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
View 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"
)