e9ecc4c1f7
CI / test (pull_request) Successful in 2m13s
Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader. Move PRAGMA foreign_keys outside transaction in v3 migration where SQLite was silently ignoring it. Escape LIKE wildcards in link resolution to prevent false matches. Add non-localhost binding warning, log writeJSON encoder errors, add ?permanent=true for explicit hard delete, preserve title/description during absorb, use millisecond backup timestamps, add path.Clean to spaHandler. Frontend gains checkedJSON() for resp.ok validation, consistent stopPropagation, and shared renderCardSections() to eliminate duplicate rendering.
246 lines
6.6 KiB
Go
246 lines
6.6 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
|
|
}
|
|
|
|
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 {
|
|
// PRAGMA foreign_keys must be set outside a transaction (SQLite ignores it inside one)
|
|
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
|
return fmt.Errorf("migrate fk off: %w", err)
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
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.Commit(); err != nil {
|
|
db.Exec(`PRAGMA foreign_keys = ON`)
|
|
return err
|
|
}
|
|
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
|
|
return fmt.Errorf("migrate fk on: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
|
|
// 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
|
|
},
|
|
|
|
// v5: add entity_links table for wiki-links
|
|
func(db *sql.DB) error {
|
|
_, err := db.Exec(`
|
|
CREATE TABLE entity_links (
|
|
from_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
|
to_id TEXT REFERENCES entities(id) ON DELETE SET NULL,
|
|
link_text TEXT NOT NULL,
|
|
PRIMARY KEY (from_id, link_text)
|
|
);
|
|
CREATE INDEX idx_entity_links_to ON entity_links(to_id) WHERE to_id IS NOT NULL;
|
|
`)
|
|
return err
|
|
},
|
|
}
|
|
|
|
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
|
|
}
|