// Package kg implements the knowledge graph pillar — notes, cells, facts, // and graph edges that connect ideas and reference artifacts. package kg import ( "context" "database/sql" "errors" "fmt" "time" "git.wntrmute.dev/kyle/exo/core" "git.wntrmute.dev/kyle/exo/db" ) // NodeType distinguishes notes from artifact links in the graph. type NodeType string const ( NodeTypeNote NodeType = "note" NodeTypeArtifactLink NodeType = "artifact_link" ) // Node is an entity in the knowledge graph. type Node struct { ID string ParentID string // parent node ID (empty for root nodes) Name string // human-readable name (C2 wiki style) Type NodeType Created time.Time Modified time.Time Children []string // child node IDs (populated by Get) Tags map[string]bool Categories map[string]bool } // NewNode creates a Node with a new UUID and current timestamps. func NewNode(name string, nodeType NodeType) *Node { now := time.Now().UTC() return &Node{ ID: core.NewUUID(), Name: name, Type: nodeType, Created: now, Modified: now, Tags: map[string]bool{}, Categories: map[string]bool{}, } } // Store persists a Node to the database. func (n *Node) Store(ctx context.Context, tx *sql.Tx) error { parentID := sql.NullString{String: n.ParentID, Valid: n.ParentID != ""} _, err := tx.ExecContext(ctx, `INSERT INTO nodes (id, parent_id, name, type, created, modified) VALUES (?, ?, ?, ?, ?, ?)`, n.ID, parentID, n.Name, string(n.Type), db.ToDBTime(n.Created), db.ToDBTime(n.Modified)) if err != nil { return fmt.Errorf("kg: failed to store node: %w", err) } // Link tags. for tag := range n.Tags { var tagID string row := tx.QueryRowContext(ctx, `SELECT id FROM tags WHERE tag=?`, tag) if err := row.Scan(&tagID); err != nil { return fmt.Errorf("kg: unknown tag %q: %w", tag, err) } _, err := tx.ExecContext(ctx, `INSERT INTO node_tags (node_id, tag_id) VALUES (?, ?)`, n.ID, tagID) if err != nil { return fmt.Errorf("kg: failed to link node tag: %w", err) } } // Link categories. for cat := range n.Categories { var catID string row := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE category=?`, cat) if err := row.Scan(&catID); err != nil { return fmt.Errorf("kg: unknown category %q: %w", cat, err) } _, err := tx.ExecContext(ctx, `INSERT INTO node_categories (node_id, category_id) VALUES (?, ?)`, n.ID, catID) if err != nil { return fmt.Errorf("kg: failed to link node category: %w", err) } } return nil } // Get retrieves a Node by its ID, including children, tags, and categories. func (n *Node) Get(ctx context.Context, tx *sql.Tx) error { if n.ID == "" { return fmt.Errorf("kg: node missing ID: %w", core.ErrNoID) } var parentID sql.NullString var created, modified, nodeType string row := tx.QueryRowContext(ctx, `SELECT parent_id, name, type, created, modified FROM nodes WHERE id=?`, n.ID) if err := row.Scan(&parentID, &n.Name, &nodeType, &created, &modified); err != nil { return fmt.Errorf("kg: failed to retrieve node: %w", err) } n.Type = NodeType(nodeType) if parentID.Valid { n.ParentID = parentID.String } var err error n.Created, err = db.FromDBTime(created, nil) if err != nil { return err } n.Modified, err = db.FromDBTime(modified, nil) if err != nil { return err } // Load children. n.Children = nil childRows, err := tx.QueryContext(ctx, `SELECT id FROM nodes WHERE parent_id=?`, n.ID) if err != nil { return fmt.Errorf("kg: failed to load node children: %w", err) } defer func() { _ = childRows.Close() }() for childRows.Next() { var childID string if err := childRows.Scan(&childID); err != nil { return err } n.Children = append(n.Children, childID) } if err := childRows.Err(); err != nil { return err } // Load tags. n.Tags = map[string]bool{} tagRows, err := tx.QueryContext(ctx, `SELECT t.tag FROM node_tags nt JOIN tags t ON nt.tag_id = t.id WHERE nt.node_id=?`, n.ID) if err != nil { return fmt.Errorf("kg: failed to load node tags: %w", err) } defer func() { _ = tagRows.Close() }() for tagRows.Next() { var tag string if err := tagRows.Scan(&tag); err != nil { return err } n.Tags[tag] = true } if err := tagRows.Err(); err != nil { return err } // Load categories. n.Categories = map[string]bool{} catRows, err := tx.QueryContext(ctx, `SELECT c.category FROM node_categories nc JOIN categories c ON nc.category_id = c.id WHERE nc.node_id=?`, n.ID) if err != nil { return fmt.Errorf("kg: failed to load node categories: %w", err) } defer func() { _ = catRows.Close() }() for catRows.Next() { var cat string if err := catRows.Scan(&cat); err != nil { return err } n.Categories[cat] = true } return catRows.Err() } // GetNodeByName retrieves a Node by its name. func GetNodeByName(ctx context.Context, tx *sql.Tx, name string) (*Node, error) { var id string row := tx.QueryRowContext(ctx, `SELECT id FROM nodes WHERE name=?`, name) if err := row.Scan(&id); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("kg: failed to look up node by name: %w", err) } n := &Node{ID: id} if err := n.Get(ctx, tx); err != nil { return nil, err } return n, nil }