package nodes import ( "database/sql" "errors" "fmt" "time" "verstak/internal/core/storage" "verstak/internal/core/util" ) var ( // ErrNotFound is returned when a node cannot be located. ErrNotFound = errors.New("node not found") // ErrDuplicateSlug means slug uniqueness is violated within the parent. ErrDuplicateSlug = errors.New("duplicate slug under the same parent") ) // Repository provides CRUD for the nodes table. type Repository struct { db *storage.DB } // NewRepository wraps an open storage DB. func NewRepository(db *storage.DB) *Repository { return &Repository{db: db} } // DB returns the underlying storage (useful for transactions). func (r *Repository) DB() *storage.DB { return r.db } // tx is a small helper for one-statement anonymous function transactions. func tx(db *storage.DB, fn func(*sql.Tx) error) error { t, err := db.Begin() if err != nil { return err } defer t.Rollback() if err := fn(t); err != nil { return err } return t.Commit() } // now returns an RFC3339 timestamp. func now() string { return time.Now().UTC().Format(time.RFC3339) } // --- CRUD --- // Create inserts a root or child node. // parentID may be empty for root-level nodes. // The id, timestamps, revision and slug are generated if not provided. func (r *Repository) Create(parentID string, typ, title string) (*Node, error) { if !IsValidType(typ) { return nil, fmt.Errorf("invalid node type: %s", typ) } if title == "" { return nil, errors.New("title is required") } n := &Node{ ID: util.UUID7(), Type: typ, Title: title, Slug: Slugify(title), SortOrder: 0, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Revision: 1, } if parentID != "" { n.ParentID = &parentID } err := r.insertNode(n) if err != nil { return nil, err } return n, nil } func (r *Repository) insertNode(n *Node) error { var parent interface{} if n.ParentID != nil { parent = *n.ParentID } _, err := r.db.Exec( `INSERT INTO nodes (id,parent_id,type,title,slug,path,sort_order, created_at,updated_at,deleted_at,revision,device_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, n.ID, parent, n.Type, n.Title, n.Slug, n.Path, n.SortOrder, n.CreatedAt.Format(time.RFC3339), n.UpdatedAt.Format(time.RFC3339), n.DeletedAt, n.Revision, n.DeviceID, ) return err } // Get returns a plain node (even if soft-deleted). func (r *Repository) Get(id string) (*Node, error) { row := r.db.QueryRow( `SELECT id,parent_id,type,title,slug,path,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE id = ?`, id) return scanNode(row) } // GetActive returns ErrNotFound if the node is soft-deleted or missing. func (r *Repository) GetActive(id string) (*Node, error) { n, err := r.Get(id) if err != nil { return nil, err } if n.IsDeleted() { return nil, ErrNotFound } return n, nil } // ListChildren returns direct children ordered by sort_order, then title. // IncludeDeleted lists soft-deleted children too. func (r *Repository) ListChildren(parentID string, includeDeleted bool) ([]Node, error) { q := `SELECT id,parent_id,type,title,slug,path,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE parent_id = ?` if !includeDeleted { q += " AND deleted_at IS NULL" } q += " ORDER BY sort_order, title" rows, err := r.db.Query(q, parentID) if err != nil { return nil, err } defer rows.Close() return scanNodes(rows) } // ListRoots returns nodes with no parent (top-level). func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) { q := `SELECT id,parent_id,type,title,slug,path,sort_order, created_at,updated_at,deleted_at,revision,device_id FROM nodes WHERE parent_id IS NULL` if !includeDeleted { q += " AND deleted_at IS NULL" } q += " ORDER BY sort_order, title" rows, err := r.db.Query(q) if err != nil { return nil, err } defer rows.Close() return scanNodes(rows) } // UpdateTitle changes title (slug is recomputed). func (r *Repository) UpdateTitle(id, title string) error { if title == "" { return errors.New("title required") } slug := Slugify(title) t := now() res, err := r.db.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, title, slug, t, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // Move changes the parent and/or sort order of a node. // parentID="" means move to root. func (r *Repository) Move(id, parentID string, sortOrder int) error { var parent interface{} if parentID != "" { parent = parentID } t := now() res, err := r.db.Exec( `UPDATE nodes SET parent_id=?, sort_order=?, updated_at=?, revision=revision+1 WHERE id=? AND deleted_at IS NULL`, parent, sortOrder, t, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // SoftDelete marks a node as deleted (does not touch its children). func (r *Repository) SoftDelete(id string) error { t := now() res, err := r.db.Exec( `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, t, t, id) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // MetaSet sets or overwrites a key-value pair for a node. func (r *Repository) MetaSet(nodeID, key, value string) error { _, err := r.db.Exec( `INSERT INTO node_meta (node_id,key,value) VALUES (?,?,?) ON CONFLICT(node_id,key) DO UPDATE SET value=excluded.value`, nodeID, key, value) return err } // MetaGet returns a single meta value, ok=false if missing. func (r *Repository) MetaGet(nodeID, key string) (string, bool, error) { var v string err := r.db.QueryRow( `SELECT value FROM node_meta WHERE node_id=? AND key=?`, nodeID, key).Scan(&v) if err == sql.ErrNoRows { return "", false, nil } if err != nil { return "", false, err } return v, true, nil } // MetaList returns all meta for a node. func (r *Repository) MetaList(nodeID string) ([]Meta, error) { rows, err := r.db.Query( `SELECT key,value FROM node_meta WHERE node_id=?`, nodeID) if err != nil { return nil, err } defer rows.Close() var out []Meta for rows.Next() { var m Meta if err := rows.Scan(&m.Key, &m.Value); err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // --- scanning helpers --- type scanner interface { Scan(dest ...interface{}) error } func scanNode(s scanner) (*Node, error) { var n Node var parentID, path, deletedAt, deviceID sql.NullString var createdStr, updatedStr string err := s.Scan( &n.ID, &parentID, &n.Type, &n.Title, &n.Slug, &path, &n.SortOrder, &createdStr, &updatedStr, &deletedAt, &n.Revision, &deviceID, ) if err == sql.ErrNoRows { return nil, ErrNotFound } if err != nil { return nil, err } if parentID.Valid { n.ParentID = &parentID.String } if path.Valid { n.Path = &path.String } if deletedAt.Valid { t, _ := time.Parse(time.RFC3339, deletedAt.String) n.DeletedAt = &t } if deviceID.Valid { n.DeviceID = &deviceID.String } n.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) n.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) return &n, nil } func scanNodes(rows *sql.Rows) ([]Node, error) { var out []Node for rows.Next() { n, err := scanNode(rows) if err != nil { return nil, err } out = append(out, *n) } return out, rows.Err() }