package db import ( "database/sql" "errors" "fmt" "os" "path/filepath" _ "modernc.org/sqlite" ) var ( ErrNotFound = errors.New("not_found") ErrAlreadyPromoted = errors.New("invalid_promote") ErrAlreadyFluid = errors.New("invalid_demote") ErrTargetCrystallized = errors.New("invalid_absorb") ErrInvalidCardData = errors.New("invalid_card_data") ) type Store struct { db *sql.DB } func Open(path string) (*Store, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } for _, pragma := range []string{ "PRAGMA journal_mode = WAL", "PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000", } { if _, err := db.Exec(pragma); err != nil { db.Close() return nil, err } } s := &Store{db: db} if err := s.migrate(); err != nil { db.Close() return nil, err } return s, nil } func (s *Store) Close() error { return s.db.Close() } const currentSchema = 4 var migrations = []func(db *sql.DB) error{ // v1: initial schema func(db *sql.DB) error { _, err := db.Exec(` CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL, modified_at TEXT NOT NULL, body TEXT NOT NULL, glyph TEXT NOT NULL, time_anchor TEXT, completed_at TEXT, pinned INTEGER NOT NULL DEFAULT 0, deleted_at TEXT, card_type TEXT, card_data TEXT, use_count INTEGER NOT NULL DEFAULT 0, last_used_at TEXT ); CREATE TABLE IF NOT EXISTS entity_tags ( entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY (entity_id, tag) ); CREATE INDEX IF NOT EXISTS idx_entities_created ON entities(created_at DESC) WHERE deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_entities_card_use ON entities(use_count DESC) WHERE card_type IS NOT NULL AND deleted_at IS NULL; CREATE INDEX IF NOT EXISTS idx_entity_tags_tag ON entity_tags(tag); `) return err }, // v2: add title and description columns func(db *sql.DB) error { if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`); err != nil { return fmt.Errorf("add title column: %w", err) } if _, err := db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`); err != nil { return fmt.Errorf("add description column: %w", err) } return nil }, // v3: rebuild table with CHECK constraints (card_type 'note', glyph 'reminder') func(db *sql.DB) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() // Disable FK checks during rebuild to avoid dangling references if _, err := tx.Exec(`PRAGMA foreign_keys = OFF`); err != nil { return fmt.Errorf("migrate fk off: %w", err) } if _, err := tx.Exec(`ALTER TABLE entities RENAME TO _entities_migrate`); err != nil { return fmt.Errorf("migrate rename: %w", err) } if _, err := tx.Exec(`CREATE TABLE entities ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL, modified_at TEXT NOT NULL, body TEXT NOT NULL, glyph TEXT NOT NULL CHECK (glyph IN ('todo', 'event', 'note', 'reminder')), time_anchor TEXT, completed_at TEXT, pinned INTEGER NOT NULL DEFAULT 0, deleted_at TEXT, card_type TEXT CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link', 'note') OR card_type IS NULL), card_data TEXT, use_count INTEGER NOT NULL DEFAULT 0, last_used_at TEXT, title TEXT, description TEXT )`); err != nil { return fmt.Errorf("migrate create: %w", err) } if _, err := tx.Exec(`INSERT INTO entities SELECT * FROM _entities_migrate`); err != nil { return fmt.Errorf("migrate copy: %w", err) } if _, err := tx.Exec(`DROP TABLE _entities_migrate`); err != nil { return fmt.Errorf("migrate drop: %w", err) } // Rebuild entity_tags to point FK at new entities table if _, err := tx.Exec(`ALTER TABLE entity_tags RENAME TO _tags_migrate`); err != nil { return fmt.Errorf("migrate tags rename: %w", err) } if _, err := tx.Exec(`CREATE TABLE entity_tags ( entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY (entity_id, tag) )`); err != nil { return fmt.Errorf("migrate tags create: %w", err) } if _, err := tx.Exec(`INSERT INTO entity_tags SELECT * FROM _tags_migrate`); err != nil { return fmt.Errorf("migrate tags copy: %w", err) } if _, err := tx.Exec(`DROP TABLE _tags_migrate`); err != nil { return fmt.Errorf("migrate tags drop: %w", err) } if _, err := tx.Exec(`PRAGMA foreign_keys = ON`); err != nil { return fmt.Errorf("migrate fk on: %w", err) } return tx.Commit() }, // v4: add indexes for common query filters func(db *sql.DB) error { for _, idx := range []string{ `CREATE INDEX IF NOT EXISTS idx_entities_deleted ON entities(deleted_at)`, `CREATE INDEX IF NOT EXISTS idx_entities_modified ON entities(modified_at DESC) WHERE deleted_at IS NULL`, } { if _, err := db.Exec(idx); err != nil { return fmt.Errorf("create index: %w", err) } } return nil }, } func (s *Store) migrate() error { s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`) var version int err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&version) if err != nil { version = 0 } for i := version; i < len(migrations); i++ { if err := migrations[i](s.db); err != nil { return fmt.Errorf("migration %d: %w", i+1, err) } } if version == 0 { _, err = s.db.Exec(`INSERT INTO schema_version (version) VALUES (?)`, len(migrations)) } else if len(migrations) > version { _, err = s.db.Exec(`UPDATE schema_version SET version = ?`, len(migrations)) } return err } func DefaultPath() (string, error) { if env := os.Getenv("NIB_DB"); env != "" { return env, nil } home, err := os.UserHomeDir() if err != nil { return "", err } dir := filepath.Join(home, ".nib") if err := os.MkdirAll(dir, 0o700); err != nil { return "", err } return filepath.Join(dir, "nib.db"), nil }