fix: harden API, DB schema, and CLI safety

- Add 'reminder' to glyph CHECK constraint (was accepted by parser but
  rejected by DB)
- Default serve bind to 127.0.0.1, add --host flag for LAN access
- Validate card_data as JSON in Store.Create/Update/Promote
- Return pagination envelope {data,total,limit,offset} from list endpoint
- Append absorb breadcrumb to source entity before soft-delete
- Add Levenshtein fuzzy match to catch command typos before routing to add
- Replace DDL string-matching migrations with versioned schema_version table
- Update web UI and API tests for envelope response format
This commit is contained in:
2026-05-19 18:30:17 -04:00
parent babf1d6620
commit e09919b679
9 changed files with 243 additions and 89 deletions
+96 -53
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
@@ -16,6 +15,7 @@ var (
ErrAlreadyPromoted = errors.New("invalid_promote")
ErrAlreadyFluid = errors.New("invalid_demote")
ErrTargetCrystallized = errors.New("invalid_absorb")
ErrInvalidCardData = errors.New("invalid_card_data")
)
type Store struct {
@@ -51,64 +51,65 @@ 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
);
const currentSchema = 3
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)
);
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 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 {
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
}
},
s.db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
s.db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
// v2: add title and description columns
func(db *sql.DB) error {
db.Exec(`ALTER TABLE entities ADD COLUMN title TEXT`)
db.Exec(`ALTER TABLE entities ADD COLUMN description TEXT`)
return nil
},
// 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()
// 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)
}
@@ -118,7 +119,7 @@ func (s *Store) migrate() error {
modified_at TEXT NOT NULL,
body TEXT NOT NULL,
glyph TEXT NOT NULL
CHECK (glyph IN ('todo', 'event', 'note')),
CHECK (glyph IN ('todo', 'event', 'note', 'reminder')),
time_anchor TEXT,
completed_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
@@ -140,12 +141,54 @@ func (s *Store) migrate() error {
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)
// 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()
},
}
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)
}
}
return nil
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) {