feat(db): add SQLite schema, Store CRUD, ULID generation
Foundation layer: entities table with card support, entity_tags join table, WAL mode, busy_timeout, full CRUD operations including promote/demote lifecycle and soft/hard delete. 33 tests passing.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not_found")
|
||||
ErrAlreadyPromoted = errors.New("invalid_promote")
|
||||
ErrAlreadyFluid = errors.New("invalid_demote")
|
||||
)
|
||||
|
||||
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) migrate() error {
|
||||
_, err := s.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
|
||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
||||
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')
|
||||
OR card_type IS NULL),
|
||||
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
|
||||
}
|
||||
|
||||
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, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "nib.db"), nil
|
||||
}
|
||||
Reference in New Issue
Block a user