diff --git a/service/node_service.go b/service/node_service.go index c20230c..46eaeca 100644 --- a/service/node_service.go +++ b/service/node_service.go @@ -1,6 +1,9 @@ package service -import "axolotl/models" +import ( + "axolotl/models" + "axolotl/store" +) type NodeService interface { Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) @@ -13,15 +16,15 @@ type NodeService interface { } func InitNodeService(path string) error { - return InitSqliteDB(path) + return store.InitSQLiteStore(path) } func GetNodeService(cfg Config) (NodeService, error) { - db, err := GetSqliteDB(cfg) + st, err := store.FindAndOpenSQLiteStore() if err != nil { return nil, err } - return &sqliteNodeService{db: db, userID: cfg.GetUser()}, nil + return &nodeServiceImpl{store: st, userID: cfg.GetUser()}, nil } type listFilter struct { diff --git a/service/node_service_impl.go b/service/node_service_impl.go new file mode 100644 index 0000000..023b8bb --- /dev/null +++ b/service/node_service_impl.go @@ -0,0 +1,361 @@ +package service + +import ( + "axolotl/models" + "axolotl/store" + "maps" + "regexp" + "slices" + "time" +) + +type nodeServiceImpl struct { + store store.Store + userID string +} + +var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) + +func mentions(t string) []string { + seen := make(map[string]bool) + for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { + seen[m[1]] = true + } + return slices.Collect(maps.Keys(seen)) +} + +func (s *nodeServiceImpl) GetByID(id string) (*models.Node, error) { + return s.store.GetNode(id) +} + +func (s *nodeServiceImpl) Exists(id string) (bool, error) { + return s.store.NodeExists(id) +} + +func (s *nodeServiceImpl) Delete(id string) error { + return s.store.DeleteNode(id) +} + +func (s *nodeServiceImpl) CanClose(id string) (bool, []string, error) { + node, err := s.store.GetNode(id) + if err != nil { + return false, nil, err + } + blockerIDs := node.Relations()[string(models.RelBlocks)] + var blocking []string + for _, bID := range blockerIDs { + blocker, err := s.store.GetNode(bID) + if err != nil { + return false, nil, err + } + if blocker.GetProperty("status") == "open" { + blocking = append(blocking, bID) + } + } + return len(blocking) == 0, blocking, nil +} + +func (s *nodeServiceImpl) List(opts ...ListOption) ([]*models.Node, error) { + f := &listFilter{} + for _, opt := range opts { + opt(f) + } + + // Resolve rel filter targets from names to node IDs (read-only, no auto-creation). + resolvedRels := make([]*models.Rel, 0, len(f.relPrefixes)) + for _, rel := range f.relPrefixes { + resolvedID, ok := s.lookupRelTarget(rel.Type, rel.Target) + if !ok { + return nil, nil // target doesn't exist; no nodes can match + } + resolvedRels = append(resolvedRels, &models.Rel{Type: rel.Type, Target: resolvedID}) + } + + return s.store.FindNodes(f.tagPrefixes, resolvedRels) +} + +// lookupRelTarget resolves a rel filter target (name or ID) to a node ID without +// creating anything. Returns (id, false) when the target doesn't exist in the store. +func (s *nodeServiceImpl) lookupRelTarget(relType models.RelType, target string) (string, bool) { + if exists, _ := s.store.NodeExists(target); exists { + return target, true + } + var nodeType string + switch relType { + case models.RelAssignee, models.RelCreated, models.RelMentions: + nodeType = "user" + case models.RelInNamespace: + nodeType = "namespace" + default: + return target, true // other rel types expect a raw node ID + } + id, err := s.resolveIDByNameAndType(s.store, target, nodeType) + if err != nil || id == "" { + return "", false + } + return id, true +} + +func (s *nodeServiceImpl) Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) { + id, err := s.store.GenerateID() + if err != nil { + return nil, err + } + + err = s.store.Transaction(func(st store.Store) error { + now := time.Now().UTC().Format(time.RFC3339) + if err := st.AddNode(id, title, content, dueDate, now, now); err != nil { + return err + } + + for _, m := range mentions(title + " " + content) { + userID, err := s.resolveUserRef(st, m) + if err != nil { + return err + } + if err := st.AddEdge(id, userID, models.RelMentions); err != nil { + return err + } + } + + for _, t := range tags { + if err := st.AddTag(id, t); err != nil { + return err + } + } + + hasCreated := false + for rt, tgts := range rels { + for _, tgt := range tgts { + if rt == models.RelCreated { + hasCreated = true + } + if rt == models.RelAssignee || rt == models.RelCreated { + var err error + if tgt, err = s.resolveUserRef(st, tgt); err != nil { + return err + } + } + if rt == models.RelInNamespace { + var err error + if tgt, err = s.resolveNamespaceRef(st, tgt); err != nil { + return err + } + } + if err := st.AddEdge(id, tgt, rt); err != nil { + return err + } + } + } + + if !hasCreated { + userID, err := s.resolveUserRef(st, s.userID) + if err != nil { + return err + } + if err := st.AddEdge(id, userID, models.RelCreated); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return nil, err + } + return s.store.GetNode(id) +} + +func (s *nodeServiceImpl) Update(node *models.Node) error { + current, err := s.store.GetNode(node.ID) + if err != nil { + return err + } + + return s.store.Transaction(func(st store.Store) error { + // Update scalar fields if changed. + if node.Title != current.Title || node.Content != current.Content || node.DueDate != current.DueDate { + updatedAt := time.Now().UTC().Format(time.RFC3339) + if err := st.UpdateNode(node.ID, node.Title, node.Content, node.DueDate, updatedAt); err != nil { + return err + } + } + + // Sync mention edges when title or content changed. + if node.Title != current.Title || node.Content != current.Content { + newMentions := mentions(node.Title + " " + node.Content) + + existingMentionIDs := make(map[string]bool) + for _, uid := range current.Relations()[string(models.RelMentions)] { + existingMentionIDs[uid] = true + } + + mentionedUserIDs := make(map[string]bool) + for _, m := range newMentions { + userID, err := s.resolveUserRef(st, m) + if err != nil { + return err + } + mentionedUserIDs[userID] = true + if !existingMentionIDs[userID] { + if err := st.AddEdge(node.ID, userID, models.RelMentions); err != nil { + return err + } + } + } + for uid := range existingMentionIDs { + if !mentionedUserIDs[uid] { + if err := st.RemoveEdge(node.ID, uid, models.RelMentions); err != nil { + return err + } + } + } + } + + // Sync tags. + currentTags := current.Tags() + nodeTags := node.Tags() + for _, t := range currentTags { + if !slices.Contains(nodeTags, t) { + if err := st.RemoveTag(node.ID, t); err != nil { + return err + } + } + } + for _, t := range nodeTags { + if !slices.Contains(currentTags, t) { + if err := st.AddTag(node.ID, t); err != nil { + return err + } + } + } + + // Sync edges (excluding mention edges, already handled above). + currentRels := current.Relations() + nodeRels := node.Relations() + for rt, tgts := range currentRels { + if rt == string(models.RelMentions) { + continue + } + for _, tgt := range tgts { + if nodeRels[rt] == nil || !slices.Contains(nodeRels[rt], tgt) { + if err := st.RemoveEdge(node.ID, tgt, models.RelType(rt)); err != nil { + return err + } + } + } + } + for rt, tgts := range nodeRels { + if rt == string(models.RelMentions) { + continue + } + for _, tgt := range tgts { + if currentRels[rt] == nil || !slices.Contains(currentRels[rt], tgt) { + resolvedTgt := tgt + if models.RelType(rt) == models.RelAssignee || models.RelType(rt) == models.RelCreated { + var err error + if resolvedTgt, err = s.resolveUserRef(st, tgt); err != nil { + return err + } + } + if models.RelType(rt) == models.RelInNamespace { + var err error + if resolvedTgt, err = s.resolveNamespaceRef(st, tgt); err != nil { + return err + } + } + if err := st.AddEdge(node.ID, resolvedTgt, models.RelType(rt)); err != nil { + return err + } + } + } + } + + return nil + }) +} + +// resolveIDByNameAndType finds a node by title and _type tag. +// Loads all nodes of that type and filters in-memory (few users/namespaces expected). +func (s *nodeServiceImpl) resolveIDByNameAndType(st store.Store, title, nodeType string) (string, error) { + nodes, err := st.FindNodes([]string{"_type::" + nodeType}, nil) + if err != nil { + return "", err + } + for _, n := range nodes { + if n.Title == title { + return n.ID, nil + } + } + return "", nil +} + +func (s *nodeServiceImpl) resolveUserRef(st store.Store, ref string) (string, error) { + if exists, _ := st.NodeExists(ref); exists { + return ref, nil + } + return s.ensureUser(st, ref) +} + +func (s *nodeServiceImpl) ensureUser(st store.Store, username string) (string, error) { + userID, err := s.resolveIDByNameAndType(st, username, "user") + if err != nil { + return "", err + } + if userID != "" { + return userID, nil + } + + id, err := st.GenerateID() + if err != nil { + return "", err + } + now := time.Now().UTC().Format(time.RFC3339) + if err := st.AddNode(id, username, "", "", now, now); err != nil { + return "", err + } + if err := st.AddTag(id, "_type::user"); err != nil { + return "", err + } + return id, nil +} + +func (s *nodeServiceImpl) resolveNamespaceRef(st store.Store, ref string) (string, error) { + if exists, _ := st.NodeExists(ref); exists { + return ref, nil + } + return s.ensureNamespace(st, ref) +} + +func (s *nodeServiceImpl) ensureNamespace(st store.Store, name string) (string, error) { + nsID, err := s.resolveIDByNameAndType(st, name, "namespace") + if err != nil { + return "", err + } + if nsID != "" { + return nsID, nil + } + + id, err := st.GenerateID() + if err != nil { + return "", err + } + now := time.Now().UTC().Format(time.RFC3339) + if err := st.AddNode(id, name, "", "", now, now); err != nil { + return "", err + } + if err := st.AddTag(id, "_type::namespace"); err != nil { + return "", err + } + if err := st.AddEdge(id, id, models.RelInNamespace); err != nil { + return "", err + } + userID, err := s.resolveUserRef(st, s.userID) + if err != nil { + return "", err + } + if err := st.AddEdge(id, userID, models.RelCreated); err != nil { + return "", err + } + return id, nil +} diff --git a/service/node_service_sqlite.go b/service/node_service_sqlite.go deleted file mode 100644 index f17fb48..0000000 --- a/service/node_service_sqlite.go +++ /dev/null @@ -1,520 +0,0 @@ -package service - -import ( - "axolotl/models" - "database/sql" - "errors" - "fmt" - "maps" - "math/rand" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "time" - - _ "modernc.org/sqlite" -) - -type sqliteNodeService struct { - db *sql.DB - userID string -} - -var migrations = []string{ - `CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, - `CREATE TABLE IF NOT EXISTS tags (node_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (node_id, tag), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE)`, - `CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, to_id TEXT NOT NULL, rel_type TEXT NOT NULL, PRIMARY KEY (from_id, to_id, rel_type), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE)`, - `CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`, -} - -var mentionRegex = regexp.MustCompile(`@([a-z0-9_]+)`) - -func mentions(t string) []string { - seen := make(map[string]bool) - for _, m := range mentionRegex.FindAllStringSubmatch(t, -1) { - seen[m[1]] = true - } - return slices.Collect(maps.Keys(seen)) -} - -func InitSqliteDB(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - var err error - db, err := sql.Open("sqlite", path) - if err != nil { - return err - } - for _, q := range append([]string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}, migrations...) { - if _, err := db.Exec(q); err != nil { - return err - } - } - return err -} - -func GetSqliteDB(cfg Config) (*sql.DB, error) { - dir, err := filepath.Abs(".") - if err != nil { - return nil, err - } - for { - dbpath := filepath.Join(dir, ".ax.db") - if _, err := os.Stat(dbpath); err == nil { - db, err := sql.Open("sqlite", dbpath) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - return db, nil - } - if parent := filepath.Dir(dir); parent == dir { - return nil, errors.New("no .ax.db found (run 'ax init' first)") - } else { - dir = parent - } - } -} - -func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) { - n := models.NewNode() - q := s.db.QueryRow("SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id) - if err := q.Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt); err != nil { - return nil, err - } - - if rows, err := s.db.Query("SELECT tag FROM tags WHERE node_id = ?", id); err == nil { - defer rows.Close() - for rows.Next() { - var tag string - rows.Scan(&tag) - n.AddTag(tag) - } - } else { - return nil, err - } - - if rows, err := s.db.Query("SELECT to_id, rel_type FROM rels WHERE from_id = ?", id); err == nil { - defer rows.Close() - for rows.Next() { - var toID, relType string - rows.Scan(&toID, &relType) - n.AddRelation(models.RelType(relType), toID) - } - } else { - return nil, err - } - return n, nil -} - -func (s *sqliteNodeService) Exists(id string) (bool, error) { - var e bool - err := s.db.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&e) - return e, err -} - -func (s *sqliteNodeService) Delete(id string) error { - _, err := s.db.Exec("DELETE FROM nodes WHERE id = ?", id) - return err -} - -func (s *sqliteNodeService) CanClose(id string) (bool, []string, error) { - rows, err := s.db.Query("SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", id, models.RelBlocks) - if err != nil { - return false, nil, err - } - defer rows.Close() - var blocking []string - for rows.Next() { - var bID, tag string - if err := rows.Scan(&bID); err != nil { - return false, nil, err - } - if err := s.db.QueryRow("SELECT tag FROM tags WHERE node_id = ? AND tag LIKE '_status::%'", bID).Scan(&tag); err == sql.ErrNoRows { - continue - } else if err != nil { - return false, nil, err - } - if strings.HasSuffix(tag, "::open") { - blocking = append(blocking, bID) - } - } - return len(blocking) == 0, blocking, nil -} - -func genID() string { - b := make([]byte, 5) - for i := range b { - b[i] = "abcdefghijklmnopqrstuvwxyz"[rand.Intn(26)] - } - return string(b) -} - -func (s *sqliteNodeService) generateUniqueID() string { - for { - id := genID() - if exists, _ := s.Exists(id); !exists { - return id - } - } -} - -func (s *sqliteNodeService) Create(title, content, dueDate string, tags []string, rels map[models.RelType][]string) (*models.Node, error) { - tx, err := s.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - - now, id := time.Now().UTC().Format(time.RFC3339), s.generateUniqueID() - if _, err := tx.Exec("INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - id, title, content, dueDate, now, now); err != nil { - return nil, err - } - - for _, m := range mentions(title + " " + content) { - userID, err := s.resolveUserRef(tx, m) - if err != nil { - return nil, err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, userID, models.RelMentions); err != nil { - return nil, err - } - } - - for _, t := range tags { - if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, ?)", id, t); err != nil { - return nil, err - } - } - - hasCreated := false - for rt, tgts := range rels { - for _, tgt := range tgts { - if rt == models.RelCreated { - hasCreated = true - } - if rt == models.RelAssignee || rt == models.RelCreated { - var err error - if tgt, err = s.resolveUserRef(tx, tgt); err != nil { - return nil, err - } - } - if rt == models.RelInNamespace { - var err error - if tgt, err = s.resolveNamespaceRef(tx, tgt); err != nil { - return nil, err - } - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, tgt, rt); err != nil { - return nil, err - } - } - } - if !hasCreated { - userID, err := s.resolveUserRef(tx, s.userID) - if err != nil { - return nil, err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", id, userID, models.RelCreated); err != nil { - return nil, err - } - } - - if err := tx.Commit(); err != nil { - return nil, err - } - return s.GetByID(id) -} - -func (s *sqliteNodeService) Update(node *models.Node) error { - current, err := s.GetByID(node.ID) - if err != nil { - return err - } - - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - upd := func(col, val string) error { - _, err := tx.Exec("UPDATE nodes SET "+col+" = ? WHERE id = ?", val, node.ID) - return err - } - - newTitle, newContent := current.Title, current.Content - if node.Title != current.Title { - if err := upd("title", node.Title); err != nil { - return err - } - newTitle = node.Title - } - if node.Content != current.Content { - if err := upd("content", node.Content); err != nil { - return err - } - newContent = node.Content - } - if node.DueDate != current.DueDate { - if node.DueDate == "" { - if _, err := tx.Exec("UPDATE nodes SET due_date = NULL WHERE id = ?", node.ID); err != nil { - return err - } - } else { - if err := upd("due_date", node.DueDate); err != nil { - return err - } - } - } - - if node.Title != current.Title || node.Content != current.Content { - newMentions := mentions(newTitle + " " + newContent) - rows, err := tx.Query("SELECT to_id FROM rels WHERE from_id = ? AND rel_type = ?", node.ID, models.RelMentions) - if err != nil { - return err - } - existingMentionIDs := make(map[string]bool) - for rows.Next() { - var uid string - if err := rows.Scan(&uid); err != nil { - rows.Close() - return err - } - existingMentionIDs[uid] = true - } - rows.Close() - - mentionedUserIDs := make(map[string]bool) - for _, m := range newMentions { - userID, err := s.resolveUserRef(tx, m) - if err != nil { - return err - } - mentionedUserIDs[userID] = true - if !existingMentionIDs[userID] { - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", node.ID, userID, models.RelMentions); err != nil { - return err - } - } - } - - for uid := range existingMentionIDs { - if !mentionedUserIDs[uid] { - if _, err := tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", node.ID, uid, models.RelMentions); err != nil { - return err - } - } - } - } - - currentTags := current.Tags() - nodeTags := node.Tags() - for _, t := range currentTags { - if !slices.Contains(nodeTags, t) { - tx.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", node.ID, t) - } - } - for _, t := range nodeTags { - if !slices.Contains(currentTags, t) { - tx.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", node.ID, t) - } - } - - currentRels := current.Relations() - nodeRels := node.Relations() - for rt, tgts := range currentRels { - for _, tgt := range tgts { - if nodeRels[rt] == nil || !slices.Contains(nodeRels[rt], tgt) { - tx.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", node.ID, tgt, rt) - } - } - } - for rt, tgts := range nodeRels { - for _, tgt := range tgts { - if currentRels[rt] == nil || !slices.Contains(currentRels[rt], tgt) { - resolvedTgt := tgt - if models.RelType(rt) == models.RelAssignee || models.RelType(rt) == models.RelCreated { - var err error - if resolvedTgt, err = s.resolveUserRef(tx, tgt); err != nil { - return err - } - } - if models.RelType(rt) == models.RelInNamespace { - var err error - if resolvedTgt, err = s.resolveNamespaceRef(tx, tgt); err != nil { - return err - } - } - tx.Exec("INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", node.ID, resolvedTgt, rt) - } - } - } - - if _, err := tx.Exec("UPDATE nodes SET updated_at = ? WHERE id = ?", time.Now().UTC().Format(time.RFC3339), node.ID); err != nil { - return err - } - return tx.Commit() -} - -func (s *sqliteNodeService) resolveIDByNameAndTypeTx(tx *sql.Tx, title, nodeType string) (string, error) { - var id string - query := ` - SELECT n.id FROM nodes n - JOIN tags t ON n.id = t.node_id - WHERE n.title = ? AND t.tag = ? - LIMIT 1 - ` - tag := "_type::" + nodeType - var err error - if tx != nil { - err = tx.QueryRow(query, title, tag).Scan(&id) - } else { - err = s.db.QueryRow(query, title, tag).Scan(&id) - } - if err == sql.ErrNoRows { - return "", nil - } - return id, err -} - -func (s *sqliteNodeService) resolveUserIDByNameTx(tx *sql.Tx, username string) (string, error) { - return s.resolveIDByNameAndTypeTx(tx, username, "user") -} - -func (s *sqliteNodeService) resolveUserIDByName(username string) (string, error) { - return s.resolveUserIDByNameTx(nil, username) -} - -func (s *sqliteNodeService) resolveNamespaceIDByNameTx(tx *sql.Tx, name string) (string, error) { - return s.resolveIDByNameAndTypeTx(tx, name, "namespace") -} - -func (s *sqliteNodeService) resolveNamespaceIDByName(name string) (string, error) { - return s.resolveNamespaceIDByNameTx(nil, name) -} - -func (s *sqliteNodeService) List(opts ...ListOption) ([]*models.Node, error) { - f := &listFilter{} - for _, opt := range opts { - opt(f) - } - - q, joins, whereConds, havingConds := "SELECT DISTINCT n.id FROM nodes n", []string{}, []string{}, []string{} - var whereArgs, havingArgs []any - if len(f.tagPrefixes) == 0 { - f.tagPrefixes = append(f.tagPrefixes, "") - } - - joins = append(joins, "JOIN tags t_tag ON n.id = t_tag.node_id") - cond := "" - for _, t := range f.tagPrefixes { - cond += "t_tag.tag LIKE ? || '%' OR " - havingArgs = append(havingArgs, t) - } - havingConds = append(havingConds, "SUM(CASE WHEN "+cond[:len(cond)-4]+" THEN 1 ELSE 0 END) >= ?") - havingArgs = append(havingArgs, len(f.tagPrefixes)) - - if len(joins) > 0 { - q += " " + strings.Join(joins, " ") + " " - } - if len(whereConds) > 0 { - q += " WHERE " + strings.Join(whereConds, " AND ") - } - q += " GROUP BY n.id" - if len(havingConds) > 0 { - q += " HAVING " + strings.Join(havingConds, " AND ") - } - - args := append(whereArgs, havingArgs...) - rows, err := s.db.Query(q+" ORDER BY n.created_at DESC", args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var nodes []*models.Node - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - if n, err := s.GetByID(id); err == nil { - nodes = append(nodes, n) - } else { - return nil, err - } - } - return nodes, nil -} - -func (s *sqliteNodeService) resolveUserRef(tx *sql.Tx, ref string) (string, error) { - if exists, _ := s.Exists(ref); exists { - return ref, nil - } - return s.ensureUser(tx, ref) -} - -func (s *sqliteNodeService) ensureUser(tx *sql.Tx, username string) (string, error) { - userID, err := s.resolveUserIDByNameTx(tx, username) - if err != nil { - return "", err - } - if userID != "" { - return userID, nil - } - - id := s.generateUniqueID() - now := time.Now().UTC().Format(time.RFC3339) - if _, err := tx.Exec("INSERT INTO nodes (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", - id, username, now, now); err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, '_type::user')", id); err != nil { - return "", err - } - return id, nil -} - -func (s *sqliteNodeService) resolveNamespaceRef(tx *sql.Tx, ref string) (string, error) { - if exists, _ := s.Exists(ref); exists { - return ref, nil - } - return s.ensureNamespace(tx, ref) -} - -func (s *sqliteNodeService) ensureNamespace(tx *sql.Tx, name string) (string, error) { - nsID, err := s.resolveNamespaceIDByNameTx(tx, name) - if err != nil { - return "", err - } - if nsID != "" { - return nsID, nil - } - - id := s.generateUniqueID() - now := time.Now().UTC().Format(time.RFC3339) - if _, err := tx.Exec("INSERT INTO nodes (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", - id, name, now, now); err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO tags (node_id, tag) VALUES (?, '_type::namespace')", id); err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", - id, id, models.RelInNamespace); err != nil { - return "", err - } - userID, err := s.resolveUserRef(tx, s.userID) - if err != nil { - return "", err - } - if _, err := tx.Exec("INSERT INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", - id, userID, models.RelCreated); err != nil { - return "", err - } - return id, nil -} diff --git a/store/sqlite.go b/store/sqlite.go new file mode 100644 index 0000000..fdbe660 --- /dev/null +++ b/store/sqlite.go @@ -0,0 +1,341 @@ +package store + +import ( + "axolotl/models" + "database/sql" + "errors" + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" +) + +var migrations = []string{ + `CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`, + `CREATE TABLE IF NOT EXISTS tags (node_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (node_id, tag), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE)`, + `CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, to_id TEXT NOT NULL, rel_type TEXT NOT NULL, PRIMARY KEY (from_id, to_id, rel_type), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE)`, + `CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, + `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, + `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`, +} + +// querier abstracts *sql.DB and *sql.Tx so SQL helpers work for both. +type querier interface { + Exec(query string, args ...any) (sql.Result, error) + Query(query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row +} + +// SQLiteStore is the top-level Store backed by a SQLite database file. +type SQLiteStore struct { + db *sql.DB +} + +// txStore wraps an active transaction. Its Transaction method is a no-op +// passthrough so nested calls reuse the same transaction. +type txStore struct { + db *sql.DB + tx *sql.Tx +} + +// InitSQLiteStore creates the database file and applies the schema. +// It is idempotent (uses CREATE TABLE IF NOT EXISTS). +func InitSQLiteStore(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return err + } + defer db.Close() + pragmas := []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"} + for _, q := range append(pragmas, migrations...) { + if _, err := db.Exec(q); err != nil { + return err + } + } + return nil +} + +// FindAndOpenSQLiteStore walks up from the current working directory to find +// an .ax.db file and opens it. +func FindAndOpenSQLiteStore() (Store, error) { + dir, err := filepath.Abs(".") + if err != nil { + return nil, err + } + for { + dbpath := filepath.Join(dir, ".ax.db") + if _, err := os.Stat(dbpath); err == nil { + return NewSQLiteStore(dbpath) + } + if parent := filepath.Dir(dir); parent == dir { + return nil, errors.New("no .ax.db found (run 'ax init' first)") + } else { + dir = parent + } + } +} + +// NewSQLiteStore opens a SQLite database at the given path and applies +// per-connection PRAGMAs. foreign_keys must be set on every connection. +func NewSQLiteStore(path string) (Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + for _, q := range []string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"} { + if _, err := db.Exec(q); err != nil { + db.Close() + return nil, err + } + } + return &SQLiteStore{db: db}, nil +} + +// --- Transaction --- + +func (s *SQLiteStore) Transaction(fn func(Store) error) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := fn(&txStore{db: s.db, tx: tx}); err != nil { + return err + } + return tx.Commit() +} + +func (s *txStore) Transaction(fn func(Store) error) error { + return fn(s) // already in a transaction — reuse it +} + +// --- Node operations --- + +func addNode(q querier, id, title, content, dueDate, createdAt, updatedAt string) error { + var dd interface{} + if dueDate != "" { + dd = dueDate + } + _, err := q.Exec( + "INSERT INTO nodes (id, title, content, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + id, title, content, dd, createdAt, updatedAt, + ) + return err +} + +func getNode(q querier, id string) (*models.Node, error) { + n := models.NewNode() + err := q.QueryRow( + "SELECT id, title, COALESCE(content, ''), COALESCE(due_date, ''), created_at, updated_at FROM nodes WHERE id = ?", id, + ).Scan(&n.ID, &n.Title, &n.Content, &n.DueDate, &n.CreatedAt, &n.UpdatedAt) + if err != nil { + return nil, err + } + rows, err := q.Query("SELECT tag FROM tags WHERE node_id = ?", id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var tag string + rows.Scan(&tag) + n.AddTag(tag) + } + rows2, err := q.Query("SELECT to_id, rel_type FROM rels WHERE from_id = ?", id) + if err != nil { + return nil, err + } + defer rows2.Close() + for rows2.Next() { + var toID, relType string + rows2.Scan(&toID, &relType) + n.AddRelation(models.RelType(relType), toID) + } + return n, nil +} + +func updateNode(q querier, id, title, content, dueDate, updatedAt string) error { + var dd interface{} + if dueDate != "" { + dd = dueDate + } + _, err := q.Exec( + "UPDATE nodes SET title = ?, content = ?, due_date = ?, updated_at = ? WHERE id = ?", + title, content, dd, updatedAt, id, + ) + return err +} + +func deleteNode(q querier, id string) error { + _, err := q.Exec("DELETE FROM nodes WHERE id = ?", id) + return err +} + +func nodeExists(q querier, id string) (bool, error) { + var e bool + err := q.QueryRow("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)", id).Scan(&e) + return e, err +} + +func (s *SQLiteStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { + return addNode(s.db, id, title, content, dueDate, createdAt, updatedAt) +} +func (s *SQLiteStore) GetNode(id string) (*models.Node, error) { return getNode(s.db, id) } +func (s *SQLiteStore) UpdateNode(id, title, content, dueDate, updatedAt string) error { + return updateNode(s.db, id, title, content, dueDate, updatedAt) +} +func (s *SQLiteStore) DeleteNode(id string) error { return deleteNode(s.db, id) } +func (s *SQLiteStore) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) } + +func (s *txStore) AddNode(id, title, content, dueDate, createdAt, updatedAt string) error { + return addNode(s.tx, id, title, content, dueDate, createdAt, updatedAt) +} +func (s *txStore) GetNode(id string) (*models.Node, error) { return getNode(s.tx, id) } +func (s *txStore) UpdateNode(id, title, content, dueDate, updatedAt string) error { + return updateNode(s.tx, id, title, content, dueDate, updatedAt) +} +func (s *txStore) DeleteNode(id string) error { return deleteNode(s.tx, id) } +func (s *txStore) NodeExists(id string) (bool, error) { return nodeExists(s.db, id) } + +// --- ID generation --- + +func genID() string { + b := make([]byte, 5) + for i := range b { + b[i] = "abcdefghijklmnopqrstuvwxyz"[rand.Intn(26)] + } + return string(b) +} + +func generateID(q querier) (string, error) { + for { + id := genID() + exists, err := nodeExists(q, id) + if err != nil { + return "", err + } + if !exists { + return id, nil + } + } +} + +func (s *SQLiteStore) GenerateID() (string, error) { return generateID(s.db) } +func (s *txStore) GenerateID() (string, error) { return generateID(s.db) } + +// --- Tag operations --- + +func addTag(q querier, nodeID, tag string) error { + _, err := q.Exec("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)", nodeID, tag) + return err +} + +func removeTag(q querier, nodeID, tag string) error { + _, err := q.Exec("DELETE FROM tags WHERE node_id = ? AND tag = ?", nodeID, tag) + return err +} + +func (s *SQLiteStore) AddTag(nodeID, tag string) error { return addTag(s.db, nodeID, tag) } +func (s *SQLiteStore) RemoveTag(nodeID, tag string) error { return removeTag(s.db, nodeID, tag) } +func (s *txStore) AddTag(nodeID, tag string) error { return addTag(s.tx, nodeID, tag) } +func (s *txStore) RemoveTag(nodeID, tag string) error { return removeTag(s.tx, nodeID, tag) } + +// --- Edge operations --- + +func addEdge(q querier, fromID, toID string, relType models.RelType) error { + _, err := q.Exec("INSERT OR IGNORE INTO rels (from_id, to_id, rel_type) VALUES (?, ?, ?)", fromID, toID, relType) + return err +} + +func removeEdge(q querier, fromID, toID string, relType models.RelType) error { + _, err := q.Exec("DELETE FROM rels WHERE from_id = ? AND to_id = ? AND rel_type = ?", fromID, toID, relType) + return err +} + +func (s *SQLiteStore) AddEdge(fromID, toID string, relType models.RelType) error { + return addEdge(s.db, fromID, toID, relType) +} +func (s *SQLiteStore) RemoveEdge(fromID, toID string, relType models.RelType) error { + return removeEdge(s.db, fromID, toID, relType) +} +func (s *txStore) AddEdge(fromID, toID string, relType models.RelType) error { + return addEdge(s.tx, fromID, toID, relType) +} +func (s *txStore) RemoveEdge(fromID, toID string, relType models.RelType) error { + return removeEdge(s.tx, fromID, toID, relType) +} + +// --- FindNodes --- + +func findNodes(q querier, tagPrefixes []string, edgeFilters []*models.Rel) ([]*models.Node, error) { + // Edge filter args come first (in JOIN ON clauses), tag args come last (in HAVING). + query := "SELECT DISTINCT n.id FROM nodes n" + var joins []string + var args []any + + // Edge filtering: each filter becomes an inner join with conditions in the ON clause. + for i, ef := range edgeFilters { + alias := fmt.Sprintf("r%d", i) + joins = append(joins, fmt.Sprintf( + "JOIN rels %s ON n.id = %s.from_id AND %s.rel_type = ? AND %s.to_id = ?", + alias, alias, alias, alias, + )) + args = append(args, string(ef.Type), ef.Target) + } + + // Tag filtering: join tags and use HAVING with aggregate to require all prefixes. + joins = append(joins, "JOIN tags t_tag ON n.id = t_tag.node_id") + if len(tagPrefixes) == 0 { + tagPrefixes = []string{""} + } + cond := "" + for _, t := range tagPrefixes { + cond += "t_tag.tag LIKE ? || '%' OR " + args = append(args, t) + } + having := "SUM(CASE WHEN " + cond[:len(cond)-4] + " THEN 1 ELSE 0 END) >= ?" + args = append(args, len(tagPrefixes)) + + query += " " + strings.Join(joins, " ") + query += " GROUP BY n.id HAVING " + having + query += " ORDER BY n.created_at DESC" + + rows, err := q.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + + var nodes []*models.Node + for _, id := range ids { + n, err := getNode(q, id) + if err != nil { + return nil, err + } + nodes = append(nodes, n) + } + return nodes, nil +} + +func (s *SQLiteStore) FindNodes(tagPrefixes []string, edgeFilters []*models.Rel) ([]*models.Node, error) { + return findNodes(s.db, tagPrefixes, edgeFilters) +} + +func (s *txStore) FindNodes(tagPrefixes []string, edgeFilters []*models.Rel) ([]*models.Node, error) { + return findNodes(s.tx, tagPrefixes, edgeFilters) +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..da88705 --- /dev/null +++ b/store/store.go @@ -0,0 +1,31 @@ +package store + +import "axolotl/models" + +// Store is a primitive graph persistence interface. It provides basic +// operations for nodes, tags, and directed edges. No business logic lives here. +type Store interface { + // Nodes + AddNode(id, title, content, dueDate, createdAt, updatedAt string) error + GetNode(id string) (*models.Node, error) // returns node with tags and edges populated + UpdateNode(id, title, content, dueDate, updatedAt string) error // empty dueDate stores NULL + DeleteNode(id string) error + NodeExists(id string) (bool, error) + GenerateID() (string, error) // returns a random 5-char ID guaranteed unique in the store + + // Tags + AddTag(nodeID, tag string) error + RemoveTag(nodeID, tag string) error + + // Edges (directed, typed) + AddEdge(fromID, toID string, relType models.RelType) error + RemoveEdge(fromID, toID string, relType models.RelType) error + + // Query returns fully-populated nodes matching all given tag prefixes and edge filters. + FindNodes(tagPrefixes []string, edgeFilters []*models.Rel) ([]*models.Node, error) + + // Transaction runs fn inside an atomic transaction. If fn returns an error + // the transaction is rolled back; otherwise it is committed. + // Calls to Transaction inside fn reuse the same transaction (no nesting). + Transaction(fn func(Store) error) error +}