Files
nib-v1/internal/db/db.go
T
lerko 2b177eeae9 feat(cards): add 'note' card type for readable markdown content
New card type renders body as styled markdown with no copy/fill/run
affordance. Glyph: ¶, color: --note.

Migration uses transaction to safely rebuild table constraint.
Checks both 'note' presence and modified_at column to catch
partial migration state.
2026-05-17 12:49:43 -04:00

165 lines
4.1 KiB
Go

package db
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
var (
ErrNotFound = errors.New("not_found")
ErrAlreadyPromoted = errors.New("invalid_promote")
ErrAlreadyFluid = errors.New("invalid_demote")
ErrTargetCrystallized = errors.New("invalid_absorb")
)
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', 'note')
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);
`)
if err != nil {
return err
}
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
// Migrate CHECK constraint to include 'note' card type
var needsMigrate bool
row := s.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='entities'`)
var ddl string
if row.Scan(&ddl) == nil {
hasNote := strings.Contains(ddl, "'link', 'note'")
hasModified := strings.Contains(ddl, "modified_at")
needsMigrate = !hasNote || !hasModified
}
if needsMigrate {
tx, err := s.db.Begin()
if err != nil {
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')),
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)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("migrate commit: %w", err)
}
}
return nil
}
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
}