aed38433ae
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.
541 lines
11 KiB
Go
541 lines
11 KiB
Go
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
|
|
}
|