2152baeb4f
- nib export: dump all entities to JSON (stdout or --output file) - nib backup: atomic SQLite backup via VACUUM INTO (WAL-safe) - Store.Backup() method on db layer - Tests for both commands
230 lines
6.1 KiB
Go
230 lines
6.1 KiB
Go
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()
|
|
}
|
|
|
|
func (s *Store) Backup(dst string) error {
|
|
_, err := s.db.Exec("VACUUM INTO ?", dst)
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|