Phase 1: data layer (SQLite, migrations, models, seed)
This commit is contained in:
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