diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..e96509f --- /dev/null +++ b/internal/db/db.go @@ -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 +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..a3e24df --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,80 @@ +package db + +import ( + "os" + "path/filepath" + "testing" +) + +func testStore(t *testing.T) *Store { + t.Helper() + path := filepath.Join(t.TempDir(), "test.db") + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestOpen_CreatesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatal("database file not created") + } +} + +func TestOpen_WALMode(t *testing.T) { + s := testStore(t) + var mode string + if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil { + t.Fatal(err) + } + if mode != "wal" { + t.Errorf("expected wal, got %s", mode) + } +} + +func TestOpen_ForeignKeys(t *testing.T) { + s := testStore(t) + var fk int + if err := s.db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil { + t.Fatal(err) + } + if fk != 1 { + t.Errorf("expected foreign_keys=1, got %d", fk) + } +} + +func TestOpen_MigrateIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + s1, err := Open(path) + if err != nil { + t.Fatalf("first Open: %v", err) + } + s1.Close() + + s2, err := Open(path) + if err != nil { + t.Fatalf("second Open: %v", err) + } + s2.Close() +} + +func TestDefaultPath_EnvOverride(t *testing.T) { + want := "/tmp/custom-nib.db" + t.Setenv("NIB_DB", want) + got, err := DefaultPath() + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("expected %s, got %s", want, got) + } +} diff --git a/internal/db/entities.go b/internal/db/entities.go new file mode 100644 index 0000000..152d30f --- /dev/null +++ b/internal/db/entities.go @@ -0,0 +1,540 @@ +package db + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + nibulid "github.com/lerko/nib/internal/ulid" +) + +type Glyph string + +const ( + GlyphNote Glyph = "note" + GlyphTodo Glyph = "todo" + GlyphEvent Glyph = "event" +) + +type CardType string + +const ( + CardSnippet CardType = "snippet" + CardTemplate CardType = "template" + CardChecklist CardType = "checklist" + CardDecision CardType = "decision" + CardLink CardType = "link" +) + +func ValidGlyph(s string) bool { + switch Glyph(s) { + case GlyphNote, GlyphTodo, GlyphEvent: + return true + } + return false +} + +func ValidCardType(s string) bool { + switch CardType(s) { + case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink: + return true + } + return false +} + +type Entity struct { + ID string + CreatedAt time.Time + ModifiedAt time.Time + Body string + Glyph Glyph + TimeAnchor *string + CompletedAt *time.Time + Pinned bool + DeletedAt *time.Time + CardType *CardType + CardData *string + UseCount int + LastUsedAt *time.Time + Tags []string +} + +type ListParams struct { + Tag *string + Date *string + Since *time.Time + CardsOnly bool + IncludeDeleted bool + CardTypeFilter *CardType + Sort string + Order string + Limit int + Offset int +} + +func DefaultListParams() ListParams { + return ListParams{ + Sort: "created", + Order: "desc", + Limit: 50, + } +} + +type EntityUpdate struct { + Body *string + Glyph *Glyph + TimeAnchor *string + ClearTime bool + Pinned *bool + CardType *CardType + CardData *string + Tags *[]string +} + +func (s *Store) Create(e *Entity) error { + now := time.Now().UTC() + e.ID = nibulid.New() + e.CreatedAt = now + e.ModifiedAt = now + if e.Tags == nil { + e.Tags = []string{} + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + _, err = tx.Exec(` + INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor, + completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.ID, + e.CreatedAt.Format(time.RFC3339), + e.ModifiedAt.Format(time.RFC3339), + e.Body, + string(e.Glyph), + e.TimeAnchor, + formatTimePtr(e.CompletedAt), + boolToInt(e.Pinned), + formatTimePtr(e.DeletedAt), + cardTypePtr(e.CardType), + e.CardData, + e.UseCount, + formatTimePtr(e.LastUsedAt), + ) + if err != nil { + return err + } + + if err := insertTags(tx, e.ID, e.Tags); err != nil { + return err + } + + return tx.Commit() +} + +func (s *Store) Get(id string) (*Entity, error) { + e := &Entity{} + var createdAt, modifiedAt string + var completedAt, deletedAt, lastUsedAt sql.NullString + var timeAnchor, cardType, cardData sql.NullString + var pinned int + + err := s.db.QueryRow(` + SELECT id, created_at, modified_at, body, glyph, time_anchor, + completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at + FROM entities WHERE id = ?`, id).Scan( + &e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, + &completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt, + ) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) + e.TimeAnchor = nullToPtr(timeAnchor) + e.CompletedAt = parseTimePtr(completedAt) + e.Pinned = pinned != 0 + e.DeletedAt = parseTimePtr(deletedAt) + e.CardType = nullToCardType(cardType) + e.CardData = nullToPtr(cardData) + e.LastUsedAt = parseTimePtr(lastUsedAt) + + tags, err := s.loadTags(id) + if err != nil { + return nil, err + } + e.Tags = tags + + return e, nil +} + +func (s *Store) List(params ListParams) ([]*Entity, error) { + var where []string + var args []any + + if !params.IncludeDeleted { + where = append(where, "e.deleted_at IS NULL") + } + if params.Tag != nil { + where = append(where, "e.id IN (SELECT entity_id FROM entity_tags WHERE tag = ?)") + args = append(args, *params.Tag) + } + if params.Date != nil { + where = append(where, "date(e.created_at) = ?") + args = append(args, *params.Date) + } + if params.Since != nil { + where = append(where, "e.created_at >= ?") + args = append(args, params.Since.Format(time.RFC3339)) + } + if params.CardsOnly { + where = append(where, "e.card_type IS NOT NULL") + } + if params.CardTypeFilter != nil { + where = append(where, "e.card_type = ?") + args = append(args, string(*params.CardTypeFilter)) + } + + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + + orderCol := "e.created_at" + if params.Sort == "use_count" { + orderCol = "e.use_count" + } + orderDir := "DESC" + if strings.EqualFold(params.Order, "asc") { + orderDir = "ASC" + } + + limit := params.Limit + if limit <= 0 { + limit = 50 + } + + query := fmt.Sprintf(` + SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor, + e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data, + e.use_count, e.last_used_at + FROM entities e + %s + ORDER BY %s %s + LIMIT ? OFFSET ?`, whereClause, orderCol, orderDir) + + args = append(args, limit, params.Offset) + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var entities []*Entity + for rows.Next() { + e := &Entity{} + var createdAt, modifiedAt string + var completedAt, deletedAt, lastUsedAt sql.NullString + var timeAnchor, cardType, cardData sql.NullString + var pinned int + + if err := rows.Scan( + &e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor, + &completedAt, &pinned, &deletedAt, &cardType, &cardData, + &e.UseCount, &lastUsedAt, + ); err != nil { + return nil, err + } + + e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt) + e.TimeAnchor = nullToPtr(timeAnchor) + e.CompletedAt = parseTimePtr(completedAt) + e.Pinned = pinned != 0 + e.DeletedAt = parseTimePtr(deletedAt) + e.CardType = nullToCardType(cardType) + e.CardData = nullToPtr(cardData) + e.LastUsedAt = parseTimePtr(lastUsedAt) + + entities = append(entities, e) + } + + for _, e := range entities { + tags, err := s.loadTags(e.ID) + if err != nil { + return nil, err + } + e.Tags = tags + } + + return entities, nil +} + +func (s *Store) Update(id string, u *EntityUpdate) error { + existing, err := s.Get(id) + if err != nil { + return err + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + var sets []string + var args []any + + sets = append(sets, "modified_at = ?") + args = append(args, time.Now().UTC().Format(time.RFC3339)) + + if u.Body != nil { + sets = append(sets, "body = ?") + args = append(args, *u.Body) + } + if u.Glyph != nil { + sets = append(sets, "glyph = ?") + args = append(args, string(*u.Glyph)) + } + if u.ClearTime { + sets = append(sets, "time_anchor = NULL") + } else if u.TimeAnchor != nil { + sets = append(sets, "time_anchor = ?") + args = append(args, *u.TimeAnchor) + } + if u.Pinned != nil { + sets = append(sets, "pinned = ?") + args = append(args, boolToInt(*u.Pinned)) + } + if u.CardType != nil { + sets = append(sets, "card_type = ?") + args = append(args, string(*u.CardType)) + } + if u.CardData != nil { + sets = append(sets, "card_data = ?") + args = append(args, *u.CardData) + } + + args = append(args, existing.ID) + query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", ")) + + if _, err := tx.Exec(query, args...); err != nil { + return err + } + + if u.Tags != nil { + if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil { + return err + } + if err := insertTags(tx, existing.ID, *u.Tags); err != nil { + return err + } + } + + return tx.Commit() +} + +func (s *Store) Promote(id string, cardType CardType, cardData *string) error { + e, err := s.Get(id) + if err != nil { + return err + } + if e.CardType != nil { + return ErrAlreadyPromoted + } + + dataVal := "{}" + if cardData != nil { + dataVal = *cardData + } + + _, err = s.db.Exec(` + UPDATE entities SET card_type = ?, card_data = ?, modified_at = ? + WHERE id = ?`, + string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id) + return err +} + +func (s *Store) Demote(id string) error { + e, err := s.Get(id) + if err != nil { + return err + } + if e.CardType == nil { + return ErrAlreadyFluid + } + + _, err = s.db.Exec(` + UPDATE entities SET card_type = NULL, card_data = NULL, + use_count = 0, last_used_at = NULL, modified_at = ? + WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), id) + return err +} + +type DeleteResult int + +const ( + DeletedSoft DeleteResult = iota + DeletedHard +) + +func (s *Store) SoftDelete(id string) (DeleteResult, error) { + var deletedAt sql.NullString + err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt) + if err == sql.ErrNoRows { + return 0, ErrNotFound + } + if err != nil { + return 0, err + } + + if deletedAt.Valid { + _, err = s.db.Exec("DELETE FROM entities WHERE id = ?", id) + return DeletedHard, err + } + + _, err = s.db.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?", + time.Now().UTC().Format(time.RFC3339), id) + return DeletedSoft, err +} + +func (s *Store) IncrementUse(id string) error { + res, err := s.db.Exec(` + UPDATE entities SET use_count = use_count + 1, last_used_at = ? + WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) Resolve(prefix string) (string, error) { + rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%") + if err != nil { + return "", err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return "", err + } + ids = append(ids, id) + } + + switch len(ids) { + case 0: + return "", ErrNotFound + case 1: + return ids[0], nil + default: + return "", fmt.Errorf("ambiguous id prefix %q matches %d entities", prefix, len(ids)) + } +} + +// helpers + +func (s *Store) loadTags(entityID string) ([]string, error) { + rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + tags = append(tags, tag) + } + if tags == nil { + tags = []string{} + } + return tags, nil +} + +func insertTags(tx *sql.Tx, entityID string, tags []string) error { + for _, tag := range tags { + if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)", + entityID, tag); err != nil { + return err + } + } + return nil +} + +func formatTimePtr(t *time.Time) interface{} { + if t == nil { + return nil + } + return t.Format(time.RFC3339) +} + +func parseTimePtr(ns sql.NullString) *time.Time { + if !ns.Valid { + return nil + } + t, err := time.Parse(time.RFC3339, ns.String) + if err != nil { + return nil + } + return &t +} + +func nullToPtr(ns sql.NullString) *string { + if !ns.Valid { + return nil + } + return &ns.String +} + +func nullToCardType(ns sql.NullString) *CardType { + if !ns.Valid { + return nil + } + ct := CardType(ns.String) + return &ct +} + +func cardTypePtr(ct *CardType) interface{} { + if ct == nil { + return nil + } + return string(*ct) +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func (e *Entity) CardDataJSON() map[string]interface{} { + if e.CardData == nil { + return nil + } + var m map[string]interface{} + json.Unmarshal([]byte(*e.CardData), &m) + return m +} diff --git a/internal/db/entities_test.go b/internal/db/entities_test.go new file mode 100644 index 0000000..19ef9e2 --- /dev/null +++ b/internal/db/entities_test.go @@ -0,0 +1,443 @@ +package db + +import ( + "testing" + "time" +) + +func ptr[T any](v T) *T { + return &v +} + +func TestCreate_Note(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "hello world", Glyph: GlyphNote} + if err := s.Create(e); err != nil { + t.Fatal(err) + } + if e.ID == "" { + t.Fatal("ID not set") + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatal(err) + } + if got.Body != "hello world" { + t.Errorf("body: got %q", got.Body) + } + if got.Glyph != GlyphNote { + t.Errorf("glyph: got %q", got.Glyph) + } +} + +func TestCreate_TodoWithTimeAnchor(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")} + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatal(err) + } + if got.TimeAnchor == nil || *got.TimeAnchor != "14:00" { + t.Errorf("time_anchor: got %v", got.TimeAnchor) + } +} + +func TestCreate_WithTags(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}} + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatal(err) + } + if len(got.Tags) != 2 { + t.Fatalf("expected 2 tags, got %d", len(got.Tags)) + } +} + +func TestCreate_WithCardType(t *testing.T) { + s := testStore(t) + ct := CardSnippet + e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct} + if err := s.Create(e); err != nil { + t.Fatal(err) + } + + got, err := s.Get(e.ID) + if err != nil { + t.Fatal(err) + } + if got.CardType == nil || *got.CardType != CardSnippet { + t.Errorf("card_type: got %v", got.CardType) + } +} + +func TestGet_NotFound(t *testing.T) { + s := testStore(t) + _, err := s.Get("01NONEXISTENT0000000000000") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestList_DefaultParams(t *testing.T) { + s := testStore(t) + for i := 0; i < 3; i++ { + s.Create(&Entity{Body: "note", Glyph: GlyphNote}) + } + + entities, err := s.List(DefaultListParams()) + if err != nil { + t.Fatal(err) + } + if len(entities) != 3 { + t.Fatalf("expected 3, got %d", len(entities)) + } + // desc order: newest first + if entities[0].CreatedAt.Before(entities[2].CreatedAt) { + t.Error("expected newest first") + } +} + +func TestList_FilterByTag(t *testing.T) { + s := testStore(t) + s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}}) + s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}}) + s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}}) + + p := DefaultListParams() + tag := "ops" + p.Tag = &tag + + entities, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if len(entities) != 2 { + t.Errorf("expected 2 entities with tag ops, got %d", len(entities)) + } +} + +func TestList_FilterByDate(t *testing.T) { + s := testStore(t) + s.Create(&Entity{Body: "today", Glyph: GlyphNote}) + + p := DefaultListParams() + date := time.Now().UTC().Format("2006-01-02") + p.Date = &date + + entities, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if len(entities) != 1 { + t.Errorf("expected 1, got %d", len(entities)) + } + + otherDate := "2020-01-01" + p.Date = &otherDate + entities, err = s.List(p) + if err != nil { + t.Fatal(err) + } + if len(entities) != 0 { + t.Errorf("expected 0 for past date, got %d", len(entities)) + } +} + +func TestList_CardsOnly(t *testing.T) { + s := testStore(t) + s.Create(&Entity{Body: "fluid", Glyph: GlyphNote}) + ct := CardSnippet + s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct}) + + p := DefaultListParams() + p.CardsOnly = true + entities, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if len(entities) != 1 { + t.Fatalf("expected 1 card, got %d", len(entities)) + } + if entities[0].Body != "card" { + t.Errorf("expected card entity, got %q", entities[0].Body) + } +} + +func TestList_IncludeDeleted(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "doomed", Glyph: GlyphNote} + s.Create(e) + s.SoftDelete(e.ID) + + p := DefaultListParams() + entities, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if len(entities) != 0 { + t.Error("deleted entity should be excluded by default") + } + + p.IncludeDeleted = true + entities, err = s.List(p) + if err != nil { + t.Fatal(err) + } + if len(entities) != 1 { + t.Error("deleted entity should appear with include_deleted") + } +} + +func TestList_SortByUseCount(t *testing.T) { + s := testStore(t) + ct := CardSnippet + e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct} + e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct} + s.Create(e1) + s.Create(e2) + s.IncrementUse(e2.ID) + s.IncrementUse(e2.ID) + + p := DefaultListParams() + p.Sort = "use_count" + entities, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if entities[0].Body != "high" { + t.Errorf("expected highest use first, got %q", entities[0].Body) + } +} + +func TestList_Pagination(t *testing.T) { + s := testStore(t) + for i := 0; i < 10; i++ { + s.Create(&Entity{Body: "note", Glyph: GlyphNote}) + } + + p := DefaultListParams() + p.Limit = 3 + p.Offset = 0 + page1, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if len(page1) != 3 { + t.Fatalf("expected 3, got %d", len(page1)) + } + + p.Offset = 3 + page2, err := s.List(p) + if err != nil { + t.Fatal(err) + } + if len(page2) != 3 { + t.Fatalf("expected 3, got %d", len(page2)) + } + if page1[0].ID == page2[0].ID { + t.Error("pages should not overlap") + } +} + +func TestUpdate_Body(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "old", Glyph: GlyphNote} + s.Create(e) + + time.Sleep(1100 * time.Millisecond) + newBody := "new" + if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.Body != "new" { + t.Errorf("body not updated: %q", got.Body) + } + if !got.ModifiedAt.After(e.ModifiedAt) { + t.Error("modified_at not updated") + } +} + +func TestUpdate_Tags(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}} + s.Create(e) + + newTags := []string{"new1", "new2"} + if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if len(got.Tags) != 2 { + t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags) + } +} + +func TestPromote_Success(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "trick", Glyph: GlyphNote} + s.Create(e) + + if err := s.Promote(e.ID, CardSnippet, nil); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.CardType == nil || *got.CardType != CardSnippet { + t.Errorf("expected snippet, got %v", got.CardType) + } +} + +func TestPromote_AlreadyPromoted(t *testing.T) { + s := testStore(t) + ct := CardSnippet + e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct} + s.Create(e) + + if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted { + t.Errorf("expected ErrAlreadyPromoted, got %v", err) + } +} + +func TestDemote_Success(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "trick", Glyph: GlyphNote} + s.Create(e) + s.Promote(e.ID, CardSnippet, nil) + + if err := s.Demote(e.ID); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.CardType != nil { + t.Errorf("expected nil card_type, got %v", got.CardType) + } + if got.UseCount != 0 { + t.Errorf("expected use_count reset to 0, got %d", got.UseCount) + } +} + +func TestDemote_AlreadyFluid(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "trick", Glyph: GlyphNote} + s.Create(e) + + if err := s.Demote(e.ID); err != ErrAlreadyFluid { + t.Errorf("expected ErrAlreadyFluid, got %v", err) + } +} + +func TestSoftDelete_First(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "doomed", Glyph: GlyphNote} + s.Create(e) + + result, err := s.SoftDelete(e.ID) + if err != nil { + t.Fatal(err) + } + if result != DeletedSoft { + t.Errorf("expected DeletedSoft, got %d", result) + } + + got, _ := s.Get(e.ID) + if got.DeletedAt == nil { + t.Error("expected deleted_at to be set") + } +} + +func TestSoftDelete_Second(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "doomed", Glyph: GlyphNote} + s.Create(e) + + s.SoftDelete(e.ID) + result, err := s.SoftDelete(e.ID) + if err != nil { + t.Fatal(err) + } + if result != DeletedHard { + t.Errorf("expected DeletedHard, got %d", result) + } + + _, err = s.Get(e.ID) + if err != ErrNotFound { + t.Errorf("expected ErrNotFound after hard delete, got %v", err) + } +} + +func TestSoftDelete_NotFound(t *testing.T) { + s := testStore(t) + _, err := s.SoftDelete("01NONEXISTENT0000000000000") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestIncrementUse(t *testing.T) { + s := testStore(t) + ct := CardSnippet + e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct} + s.Create(e) + + if err := s.IncrementUse(e.ID); err != nil { + t.Fatal(err) + } + + got, _ := s.Get(e.ID) + if got.UseCount != 1 { + t.Errorf("expected use_count=1, got %d", got.UseCount) + } + if got.LastUsedAt == nil { + t.Error("expected last_used_at to be set") + } +} + +func TestResolve_FullID(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "test", Glyph: GlyphNote} + s.Create(e) + + got, err := s.Resolve(e.ID) + if err != nil { + t.Fatal(err) + } + if got != e.ID { + t.Errorf("expected %s, got %s", e.ID, got) + } +} + +func TestResolve_Prefix(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "test", Glyph: GlyphNote} + s.Create(e) + + got, err := s.Resolve(e.ID[:6]) + if err != nil { + t.Fatal(err) + } + if got != e.ID { + t.Errorf("expected %s, got %s", e.ID, got) + } +} + +func TestResolve_NotFound(t *testing.T) { + s := testStore(t) + _, err := s.Resolve("ZZZZZZZZZ") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound, got %v", err) + } +} diff --git a/internal/db/tags.go b/internal/db/tags.go new file mode 100644 index 0000000..29b2787 --- /dev/null +++ b/internal/db/tags.go @@ -0,0 +1,33 @@ +package db + +type TagCount struct { + Tag string + Count int +} + +func (s *Store) ListTags() ([]TagCount, error) { + rows, err := s.db.Query(` + SELECT t.tag, COUNT(*) as cnt + FROM entity_tags t + JOIN entities e ON t.entity_id = e.id + WHERE e.deleted_at IS NULL + GROUP BY t.tag + ORDER BY t.tag`) + if err != nil { + return nil, err + } + defer rows.Close() + + var tags []TagCount + for rows.Next() { + var tc TagCount + if err := rows.Scan(&tc.Tag, &tc.Count); err != nil { + return nil, err + } + tags = append(tags, tc) + } + if tags == nil { + tags = []TagCount{} + } + return tags, nil +} diff --git a/internal/db/tags_test.go b/internal/db/tags_test.go new file mode 100644 index 0000000..9cd3dfe --- /dev/null +++ b/internal/db/tags_test.go @@ -0,0 +1,63 @@ +package db + +import "testing" + +func TestListTags_Empty(t *testing.T) { + s := testStore(t) + tags, err := s.ListTags() + if err != nil { + t.Fatal(err) + } + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(tags)) + } +} + +func TestListTags_Counts(t *testing.T) { + s := testStore(t) + s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}) + s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}}) + s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}}) + + tags, err := s.ListTags() + if err != nil { + t.Fatal(err) + } + if len(tags) != 3 { + t.Fatalf("expected 3 distinct tags, got %d", len(tags)) + } + + counts := map[string]int{} + for _, tc := range tags { + counts[tc.Tag] = tc.Count + } + if counts["ops"] != 2 { + t.Errorf("ops count: expected 2, got %d", counts["ops"]) + } + if counts["nginx"] != 1 { + t.Errorf("nginx count: expected 1, got %d", counts["nginx"]) + } + if counts["home"] != 1 { + t.Errorf("home count: expected 1, got %d", counts["home"]) + } +} + +func TestListTags_ExcludesDeleted(t *testing.T) { + s := testStore(t) + e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}} + s.Create(e) + s.SoftDelete(e.ID) + + s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}}) + + tags, err := s.ListTags() + if err != nil { + t.Fatal(err) + } + if len(tags) != 1 { + t.Fatalf("expected 1 tag, got %d", len(tags)) + } + if tags[0].Tag != "here" { + t.Errorf("expected 'here', got %q", tags[0].Tag) + } +} diff --git a/internal/ulid/ulid.go b/internal/ulid/ulid.go new file mode 100644 index 0000000..9ec14d2 --- /dev/null +++ b/internal/ulid/ulid.go @@ -0,0 +1,20 @@ +package ulid + +import ( + "crypto/rand" + "sync" + + "github.com/oklog/ulid/v2" +) + +var ( + entropy *ulid.MonotonicEntropy + entropyOnce sync.Once +) + +func New() string { + entropyOnce.Do(func() { + entropy = ulid.Monotonic(rand.Reader, 0) + }) + return ulid.MustNew(ulid.Now(), entropy).String() +} diff --git a/internal/ulid/ulid_test.go b/internal/ulid/ulid_test.go new file mode 100644 index 0000000..04553b0 --- /dev/null +++ b/internal/ulid/ulid_test.go @@ -0,0 +1,28 @@ +package ulid + +import ( + "testing" +) + +func TestNew_ReturnsValidULID(t *testing.T) { + id := New() + if len(id) != 26 { + t.Errorf("expected 26 chars, got %d: %s", len(id), id) + } +} + +func TestNew_Unique(t *testing.T) { + a := New() + b := New() + if a == b { + t.Errorf("two calls returned same ULID: %s", a) + } +} + +func TestNew_Sortable(t *testing.T) { + a := New() + b := New() + if b < a { + t.Errorf("expected b >= a for sequential calls: a=%s b=%s", a, b) + } +}