Files
petal/internal/db/db.go
prosolis 9e141e4169 Phase 10: organization & polish — cross-doc search, tags, touch, warm failures
Cross-document FTS5 search (trigram tokenizer for EN + space-free CJK, kept in
sync by triggers, back-filled from existing docs). GET /api/search uses the FTS
index for queries >=3 runes and a LIKE fallback for 1-2 (so 2-char Chinese words
resolve); snippets are built in Go with rune-aware boundaries and sentinel
highlights.

Tags: user-scoped tags + document_tags join (both cascade), idempotent
create/assign, per-tag doc counts. Doc list and search carry each doc's tags
(one tagsByDoc query). Frontend: useTags, TagChip/TagPicker/SearchBox, rewritten
DocList with chips + filter bar + search.

Tablet/touch: responsive sidebar drawer (hamburger + scrim <768px), coarse-
pointer tap targets, tap-to-open + outside-pointerdown-close for suggestion
cards.

Warm LLM-down state: useCheckpoint llmDown flag drives a gentle bilingual
StatusBar note (writing still saves locally).

Migration 0004 (tags + FTS). Tests: tags lifecycle, search EN/CJK/LIKE/update-
reindex. go build/vet/test, tsc, vite all clean; verified live on deployment host.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 06:29:56 -07:00

278 lines
9.0 KiB
Go

// 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);
`,
},
{
// Per-document tone: guides the grammar-checkpoint LLM so advice fits
// the writer's target register (academic essay vs casual journal).
// 'general' means no specific tone steering.
name: "0002_document_tone",
stmt: `ALTER TABLE documents ADD COLUMN tone TEXT NOT NULL DEFAULT 'general';`,
},
{
// Version history: point-in-time snapshots of a document's body so a
// bad edit or LLM mishap is always recoverable. `kind` distinguishes
// throttled background snapshots ('auto'), explicit user restore
// points ('manual'), and the safety copy taken right before a restore
// ('pre_restore') so restoring is itself undoable. Snapshots cascade
// with the document. Stored fully (content + content_text) so a
// restore is a plain copy with no re-derivation.
name: "0003_document_versions",
stmt: `
CREATE TABLE document_versions (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
content_text TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
kind TEXT NOT NULL DEFAULT 'auto'
CHECK(kind IN ('auto','manual','pre_restore')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_versions_doc_id ON document_versions(doc_id, created_at DESC);
`,
},
{
// Organization & search (Phase 10). Two parts:
//
// 1. Tags. A small, user-scoped label set; `color` holds a palette key
// (rose/mint/peach/lavender/sky/honey) the frontend maps to CSS.
// document_tags is the many-to-many join; both sides cascade so
// deleting a doc or a tag cleans up its assignments.
//
// 2. Full-text search. A standalone FTS5 virtual table over title +
// content_text using the `trigram` tokenizer so search works for
// both English and space-free Chinese (the default tokenizer treats a
// CJK run as one token). It carries an UNINDEXED doc_id to map hits
// back to documents, kept in sync by AFTER INSERT/UPDATE/DELETE
// triggers, and is back-filled from the existing documents here.
// (Trigram needs ≥3 chars to MATCH; the search handler falls back to
// LIKE for shorter queries — common for 2-character Chinese words.)
name: "0004_tags_and_search",
stmt: `
CREATE TABLE tags (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT 'rose',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, name)
);
CREATE TABLE document_tags (
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (doc_id, tag_id)
);
CREATE INDEX idx_document_tags_tag ON document_tags(tag_id);
CREATE VIRTUAL TABLE documents_fts USING fts5(
doc_id UNINDEXED,
title,
content_text,
tokenize='trigram'
);
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN
INSERT INTO documents_fts (doc_id, title, content_text)
VALUES (new.id, new.title, new.content_text);
END;
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
DELETE FROM documents_fts WHERE doc_id = old.id;
END;
CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN
UPDATE documents_fts
SET title = new.title, content_text = new.content_text
WHERE doc_id = old.id;
END;
INSERT INTO documents_fts (doc_id, title, content_text)
SELECT id, title, content_text FROM documents;
`,
},
}
}
// 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
}