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:
+21
-14
@@ -25,6 +25,20 @@ func testServer(t *testing.T) (*httptest.Server, *db.Store) {
|
||||
return srv, store
|
||||
}
|
||||
|
||||
type listEnvelope struct {
|
||||
Data []EntityResponse `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
func decodeList(t *testing.T, resp *http.Response) []EntityResponse {
|
||||
t.Helper()
|
||||
var env listEnvelope
|
||||
json.NewDecoder(resp.Body).Decode(&env)
|
||||
return env.Data
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
@@ -157,8 +171,7 @@ func TestListEntities_Default(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(entities))
|
||||
}
|
||||
@@ -175,8 +188,7 @@ func TestListEntities_FilterTag(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(entities))
|
||||
}
|
||||
@@ -198,8 +210,7 @@ func TestListEntities_CardsOnly(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1 card, got %d", len(entities))
|
||||
}
|
||||
@@ -215,16 +226,14 @@ func TestListEntities_Pagination(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var page1 []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&page1)
|
||||
page1 := decodeList(t, resp)
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = http.Get(srv.URL + "/api/entities?limit=2&offset=2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var page2 []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&page2)
|
||||
page2 := decodeList(t, resp)
|
||||
resp.Body.Close()
|
||||
|
||||
if len(page1) != 2 || len(page2) != 2 {
|
||||
@@ -517,8 +526,7 @@ func TestAbsorbEntity_Success(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(listResp.Body).Decode(&entities)
|
||||
entities := decodeList(t, listResp)
|
||||
listResp.Body.Close()
|
||||
for _, ent := range entities {
|
||||
if ent.ID == source.ID {
|
||||
@@ -686,8 +694,7 @@ func TestListEntities_TitleInResponse(t *testing.T) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var entities []EntityResponse
|
||||
json.NewDecoder(resp.Body).Decode(&entities)
|
||||
entities := decodeList(t, resp)
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1, got %d", len(entities))
|
||||
}
|
||||
|
||||
@@ -102,6 +102,15 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
||||
}
|
||||
p.Offset = offset
|
||||
}
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = 50
|
||||
}
|
||||
|
||||
total, err := store.Count(p)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
entities, err := store.List(p)
|
||||
if err != nil {
|
||||
@@ -109,11 +118,16 @@ func listEntities(store *db.Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]EntityResponse, len(entities))
|
||||
items := make([]EntityResponse, len(entities))
|
||||
for i, e := range entities {
|
||||
resp[i] = entityToResponse(e)
|
||||
items[i] = entityToResponse(e)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"data": items,
|
||||
"total": total,
|
||||
"limit": p.Limit,
|
||||
"offset": p.Offset,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +175,10 @@ func createEntity(store *db.Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := store.Create(e); err != nil {
|
||||
if err == db.ErrInvalidCardData {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -227,6 +245,10 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||
return
|
||||
}
|
||||
if err == db.ErrInvalidCardData {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -291,6 +313,10 @@ func promoteEntity(store *db.Store) http.HandlerFunc {
|
||||
writeError(w, http.StatusBadRequest, "invalid_promote", "entity is already crystallized")
|
||||
return
|
||||
}
|
||||
if err == db.ErrInvalidCardData {
|
||||
writeError(w, http.StatusBadRequest, "invalid_card_data", "card_data must be valid JSON")
|
||||
return
|
||||
}
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
+96
-53
@@ -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) {
|
||||
|
||||
+28
-5
@@ -104,6 +104,9 @@ type EntityUpdate struct {
|
||||
}
|
||||
|
||||
func (s *Store) Create(e *Entity) error {
|
||||
if e.CardData != nil && !json.Valid([]byte(*e.CardData)) {
|
||||
return ErrInvalidCardData
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
e.ID = nibulid.New()
|
||||
e.CreatedAt = now
|
||||
@@ -179,7 +182,7 @@ func (s *Store) Get(id string) (*Entity, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
func listWhere(params ListParams) (string, []any) {
|
||||
var where []string
|
||||
var args []any
|
||||
|
||||
@@ -214,10 +217,23 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
args = append(args, string(*params.CardTypeFilter))
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||
clause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
return clause, args
|
||||
}
|
||||
|
||||
func (s *Store) Count(params ListParams) (int, error) {
|
||||
whereClause, args := listWhere(params)
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM entities e %s", whereClause)
|
||||
var count int
|
||||
err := s.db.QueryRow(query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
whereClause, args := listWhere(params)
|
||||
|
||||
orderCol := "e.created_at"
|
||||
switch params.Sort {
|
||||
@@ -336,6 +352,9 @@ func (s *Store) Update(id string, u *EntityUpdate) error {
|
||||
args = append(args, string(*u.CardType))
|
||||
}
|
||||
if u.CardData != nil {
|
||||
if !json.Valid([]byte(*u.CardData)) {
|
||||
return ErrInvalidCardData
|
||||
}
|
||||
sets = append(sets, "card_data = ?")
|
||||
args = append(args, *u.CardData)
|
||||
}
|
||||
@@ -370,6 +389,9 @@ func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
||||
|
||||
dataVal := "{}"
|
||||
if cardData != nil {
|
||||
if !json.Valid([]byte(*cardData)) {
|
||||
return ErrInvalidCardData
|
||||
}
|
||||
dataVal = *cardData
|
||||
}
|
||||
|
||||
@@ -473,8 +495,9 @@ func (s *Store) Absorb(targetID, sourceID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
||||
now, sourceID); err != nil {
|
||||
absorbNote := source.Body + "\n\n[absorbed into " + targetID + "]"
|
||||
if _, err := tx.Exec("UPDATE entities SET body = ?, deleted_at = ?, modified_at = ? WHERE id = ?",
|
||||
absorbNote, now, now, sourceID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user