Add Phase 4 knowledge graph: nodes, cells, facts, edges, gRPC service
Build the knowledge graph pillar with the kg package: - Node: hierarchical notes with parent/children, C2 wiki-style naming, shared tag/category pool with artifacts - Cell: content units (markdown, code, plain) with ordinal ordering - Fact: EAV tuples with transaction timestamps and retraction support - Edge: directed graph links (child, parent, related, artifact_link) Includes schema migration (002_knowledge_graph.sql), protobuf definitions (kg.proto), full gRPC KnowledgeGraphService implementation, CLI commands (node create/get), and comprehensive test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
kg/cell.go
Normal file
109
kg/cell.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package kg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/exo/core"
|
||||
"git.wntrmute.dev/kyle/exo/db"
|
||||
)
|
||||
|
||||
// CellType distinguishes content types within a note.
|
||||
type CellType string
|
||||
|
||||
const (
|
||||
CellTypeMarkdown CellType = "markdown"
|
||||
CellTypeCode CellType = "code"
|
||||
CellTypePlain CellType = "plain"
|
||||
)
|
||||
|
||||
// Cell is a content unit within a note. A note is composed of
|
||||
// multiple cells of different types.
|
||||
type Cell struct {
|
||||
ID string
|
||||
NodeID string
|
||||
Type CellType
|
||||
Contents []byte
|
||||
Ordinal int
|
||||
Created time.Time
|
||||
Modified time.Time
|
||||
}
|
||||
|
||||
// NewCell creates a Cell with a new UUID and current timestamps.
|
||||
func NewCell(nodeID string, cellType CellType, contents []byte) *Cell {
|
||||
now := time.Now().UTC()
|
||||
return &Cell{
|
||||
ID: core.NewUUID(),
|
||||
NodeID: nodeID,
|
||||
Type: cellType,
|
||||
Contents: contents,
|
||||
Created: now,
|
||||
Modified: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Store persists a Cell to the database.
|
||||
func (c *Cell) Store(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO cells (id, node_id, type, contents, ordinal, created, modified) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.NodeID, string(c.Type), c.Contents, c.Ordinal, db.ToDBTime(c.Created), db.ToDBTime(c.Modified))
|
||||
if err != nil {
|
||||
return fmt.Errorf("kg: failed to store cell: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a Cell by its ID.
|
||||
func (c *Cell) Get(ctx context.Context, tx *sql.Tx) error {
|
||||
if c.ID == "" {
|
||||
return fmt.Errorf("kg: cell missing ID: %w", core.ErrNoID)
|
||||
}
|
||||
|
||||
var cellType, created, modified string
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT node_id, type, contents, ordinal, created, modified FROM cells WHERE id=?`, c.ID)
|
||||
if err := row.Scan(&c.NodeID, &cellType, &c.Contents, &c.Ordinal, &created, &modified); err != nil {
|
||||
return fmt.Errorf("kg: failed to retrieve cell: %w", err)
|
||||
}
|
||||
c.Type = CellType(cellType)
|
||||
|
||||
var err error
|
||||
c.Created, err = db.FromDBTime(created, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Modified, err = db.FromDBTime(modified, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCellsForNode retrieves all cells for a node, ordered by ordinal.
|
||||
func GetCellsForNode(ctx context.Context, tx *sql.Tx, nodeID string) ([]*Cell, error) {
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, type, contents, ordinal, created, modified FROM cells WHERE node_id=? ORDER BY ordinal`, nodeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to retrieve cells for node: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var cells []*Cell
|
||||
for rows.Next() {
|
||||
c := &Cell{NodeID: nodeID}
|
||||
var cellType, created, modified string
|
||||
if err := rows.Scan(&c.ID, &cellType, &c.Contents, &c.Ordinal, &created, &modified); err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to scan cell: %w", err)
|
||||
}
|
||||
c.Type = CellType(cellType)
|
||||
c.Created, err = db.FromDBTime(created, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Modified, err = db.FromDBTime(modified, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cells = append(cells, c)
|
||||
}
|
||||
return cells, rows.Err()
|
||||
}
|
||||
104
kg/edge.go
Normal file
104
kg/edge.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package kg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/exo/core"
|
||||
"git.wntrmute.dev/kyle/exo/db"
|
||||
)
|
||||
|
||||
// EdgeRelation describes the type of relationship between two nodes.
|
||||
type EdgeRelation string
|
||||
|
||||
const (
|
||||
EdgeRelationChild EdgeRelation = "child"
|
||||
EdgeRelationParent EdgeRelation = "parent"
|
||||
EdgeRelationRelated EdgeRelation = "related"
|
||||
EdgeRelationArtifactLink EdgeRelation = "artifact_link"
|
||||
)
|
||||
|
||||
// Edge links a source node to a target node or artifact.
|
||||
type Edge struct {
|
||||
ID string
|
||||
SourceID string
|
||||
TargetID string
|
||||
Relation EdgeRelation
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// NewEdge creates an Edge with a new UUID and current timestamp.
|
||||
func NewEdge(sourceID, targetID string, relation EdgeRelation) *Edge {
|
||||
return &Edge{
|
||||
ID: core.NewUUID(),
|
||||
SourceID: sourceID,
|
||||
TargetID: targetID,
|
||||
Relation: relation,
|
||||
Created: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// Store persists an Edge to the database.
|
||||
func (e *Edge) Store(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO edges (id, source_id, target_id, relation, created) VALUES (?, ?, ?, ?, ?)`,
|
||||
e.ID, e.SourceID, e.TargetID, string(e.Relation), db.ToDBTime(e.Created))
|
||||
if err != nil {
|
||||
return fmt.Errorf("kg: failed to store edge: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEdgesFrom retrieves all edges originating from a given node.
|
||||
func GetEdgesFrom(ctx context.Context, tx *sql.Tx, sourceID string) ([]*Edge, error) {
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, target_id, relation, created FROM edges WHERE source_id=?`, sourceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to retrieve edges: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var edges []*Edge
|
||||
for rows.Next() {
|
||||
e := &Edge{SourceID: sourceID}
|
||||
var relation, created string
|
||||
if err := rows.Scan(&e.ID, &e.TargetID, &relation, &created); err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to scan edge: %w", err)
|
||||
}
|
||||
e.Relation = EdgeRelation(relation)
|
||||
e.Created, err = db.FromDBTime(created, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges = append(edges, e)
|
||||
}
|
||||
return edges, rows.Err()
|
||||
}
|
||||
|
||||
// GetEdgesTo retrieves all edges pointing to a given node/artifact.
|
||||
func GetEdgesTo(ctx context.Context, tx *sql.Tx, targetID string) ([]*Edge, error) {
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, source_id, relation, created FROM edges WHERE target_id=?`, targetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to retrieve incoming edges: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var edges []*Edge
|
||||
for rows.Next() {
|
||||
e := &Edge{TargetID: targetID}
|
||||
var relation, created string
|
||||
if err := rows.Scan(&e.ID, &e.SourceID, &relation, &created); err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to scan edge: %w", err)
|
||||
}
|
||||
e.Relation = EdgeRelation(relation)
|
||||
e.Created, err = db.FromDBTime(created, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges = append(edges, e)
|
||||
}
|
||||
return edges, rows.Err()
|
||||
}
|
||||
121
kg/fact.go
Normal file
121
kg/fact.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package kg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/exo/core"
|
||||
)
|
||||
|
||||
// Fact records an entity-attribute-value relationship with transactional
|
||||
// history. A Fact with Retraction=true marks a previous fact as no longer
|
||||
// valid without deleting history.
|
||||
type Fact struct {
|
||||
ID string
|
||||
EntityID string
|
||||
EntityName string
|
||||
AttributeID string
|
||||
AttributeName string
|
||||
Value core.Value
|
||||
TxTimestamp time.Time
|
||||
Retraction bool
|
||||
}
|
||||
|
||||
// NewFact creates a Fact with a new UUID and current transaction timestamp.
|
||||
func NewFact(entityID, entityName, attrID, attrName string, value core.Value) *Fact {
|
||||
return &Fact{
|
||||
ID: core.NewUUID(),
|
||||
EntityID: entityID,
|
||||
EntityName: entityName,
|
||||
AttributeID: attrID,
|
||||
AttributeName: attrName,
|
||||
Value: value,
|
||||
TxTimestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// Retract creates a retraction Fact for this fact's entity+attribute.
|
||||
func (f *Fact) Retract() *Fact {
|
||||
return &Fact{
|
||||
ID: core.NewUUID(),
|
||||
EntityID: f.EntityID,
|
||||
EntityName: f.EntityName,
|
||||
AttributeID: f.AttributeID,
|
||||
AttributeName: f.AttributeName,
|
||||
Value: f.Value,
|
||||
TxTimestamp: time.Now().UTC(),
|
||||
Retraction: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Store persists a Fact to the database.
|
||||
func (f *Fact) Store(ctx context.Context, tx *sql.Tx) error {
|
||||
retraction := 0
|
||||
if f.Retraction {
|
||||
retraction = 1
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO facts (id, entity_id, entity_name, attribute_id, attribute_name, value_contents, value_type, tx_timestamp, retraction) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
f.ID, f.EntityID, f.EntityName, f.AttributeID, f.AttributeName,
|
||||
f.Value.Contents, f.Value.Type, f.TxTimestamp.Unix(), retraction)
|
||||
if err != nil {
|
||||
return fmt.Errorf("kg: failed to store fact: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFactsForEntity retrieves all facts for a given entity, ordered by
|
||||
// transaction timestamp. Includes both assertions and retractions.
|
||||
func GetFactsForEntity(ctx context.Context, tx *sql.Tx, entityID string) ([]*Fact, error) {
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, entity_name, attribute_id, attribute_name, value_contents, value_type, tx_timestamp, retraction FROM facts WHERE entity_id=? ORDER BY tx_timestamp`,
|
||||
entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to retrieve facts for entity: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var facts []*Fact
|
||||
for rows.Next() {
|
||||
f := &Fact{EntityID: entityID}
|
||||
var txTS int64
|
||||
var retraction int
|
||||
if err := rows.Scan(&f.ID, &f.EntityName, &f.AttributeID, &f.AttributeName,
|
||||
&f.Value.Contents, &f.Value.Type, &txTS, &retraction); err != nil {
|
||||
return nil, fmt.Errorf("kg: failed to scan fact: %w", err)
|
||||
}
|
||||
f.TxTimestamp = time.Unix(txTS, 0)
|
||||
f.Retraction = retraction != 0
|
||||
facts = append(facts, f)
|
||||
}
|
||||
return facts, rows.Err()
|
||||
}
|
||||
|
||||
// GetCurrentFactsForEntity retrieves the current (non-retracted) facts for
|
||||
// an entity by applying the retraction logic: for each entity+attribute pair,
|
||||
// only the most recent non-retracted assertion is returned.
|
||||
func GetCurrentFactsForEntity(ctx context.Context, tx *sql.Tx, entityID string) ([]*Fact, error) {
|
||||
allFacts, err := GetFactsForEntity(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Track the latest fact per attribute. A retraction cancels the previous assertion.
|
||||
latest := map[string]*Fact{}
|
||||
for _, f := range allFacts {
|
||||
if f.Retraction {
|
||||
delete(latest, f.AttributeID)
|
||||
} else {
|
||||
latest[f.AttributeID] = f
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]*Fact, 0, len(latest))
|
||||
for _, f := range latest {
|
||||
result = append(result, f)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
417
kg/kg_test.go
Normal file
417
kg/kg_test.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package kg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/exo/artifacts"
|
||||
"git.wntrmute.dev/kyle/exo/core"
|
||||
"git.wntrmute.dev/kyle/exo/db"
|
||||
)
|
||||
|
||||
func mustOpenAndMigrate(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
database, err := db.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := db.Migrate(database); err != nil {
|
||||
t.Fatalf("Migrate failed: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func mustTX(t *testing.T, database *sql.DB) (*sql.Tx, context.Context) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tx, err := db.StartTX(ctx, database)
|
||||
if err != nil {
|
||||
t.Fatalf("StartTX failed: %v", err)
|
||||
}
|
||||
return tx, ctx
|
||||
}
|
||||
|
||||
// --- Node tests ---
|
||||
|
||||
func TestNodeStoreAndGet(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
node := NewNode("TestNote", NodeTypeNote)
|
||||
if err := node.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Node.Store failed: %v", err)
|
||||
}
|
||||
|
||||
got := &Node{ID: node.ID}
|
||||
if err := got.Get(ctx, tx); err != nil {
|
||||
t.Fatalf("Node.Get failed: %v", err)
|
||||
}
|
||||
|
||||
if got.Name != "TestNote" {
|
||||
t.Fatalf("name mismatch: got %q", got.Name)
|
||||
}
|
||||
if got.Type != NodeTypeNote {
|
||||
t.Fatalf("type mismatch: got %q", got.Type)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeHierarchy(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
parent := NewNode("ParentNote", NodeTypeNote)
|
||||
if err := parent.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("parent Store failed: %v", err)
|
||||
}
|
||||
|
||||
child := NewNode("ChildNote", NodeTypeNote)
|
||||
child.ParentID = parent.ID
|
||||
if err := child.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("child Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve parent and check children.
|
||||
gotParent := &Node{ID: parent.ID}
|
||||
if err := gotParent.Get(ctx, tx); err != nil {
|
||||
t.Fatalf("parent Get failed: %v", err)
|
||||
}
|
||||
if len(gotParent.Children) != 1 {
|
||||
t.Fatalf("expected 1 child, got %d", len(gotParent.Children))
|
||||
}
|
||||
if gotParent.Children[0] != child.ID {
|
||||
t.Fatalf("child ID mismatch: got %q", gotParent.Children[0])
|
||||
}
|
||||
|
||||
// Retrieve child and check parent.
|
||||
gotChild := &Node{ID: child.ID}
|
||||
if err := gotChild.Get(ctx, tx); err != nil {
|
||||
t.Fatalf("child Get failed: %v", err)
|
||||
}
|
||||
if gotChild.ParentID != parent.ID {
|
||||
t.Fatalf("parent ID mismatch: got %q", gotChild.ParentID)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeWithTags(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
// Create tags first (shared pool).
|
||||
if err := artifacts.CreateTag(ctx, tx, "philosophy"); err != nil {
|
||||
t.Fatalf("CreateTag failed: %v", err)
|
||||
}
|
||||
|
||||
node := NewNode("PhilosophyNote", NodeTypeNote)
|
||||
node.Tags["philosophy"] = true
|
||||
if err := node.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Node.Store failed: %v", err)
|
||||
}
|
||||
|
||||
got := &Node{ID: node.ID}
|
||||
if err := got.Get(ctx, tx); err != nil {
|
||||
t.Fatalf("Node.Get failed: %v", err)
|
||||
}
|
||||
if !got.Tags["philosophy"] {
|
||||
t.Fatalf("tag not found: %v", got.Tags)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeByName(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
node := NewNode("FindMe", NodeTypeNote)
|
||||
if err := node.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
got, err := GetNodeByName(ctx, tx, "FindMe")
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeByName failed: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected to find node")
|
||||
}
|
||||
if got.ID != node.ID {
|
||||
t.Fatalf("ID mismatch: got %q, want %q", got.ID, node.ID)
|
||||
}
|
||||
|
||||
missing, err := GetNodeByName(ctx, tx, "NotHere")
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeByName failed: %v", err)
|
||||
}
|
||||
if missing != nil {
|
||||
t.Fatal("expected nil for missing node")
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cell tests ---
|
||||
|
||||
func TestCellStoreAndGet(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
node := NewNode("CellNote", NodeTypeNote)
|
||||
if err := node.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Node.Store failed: %v", err)
|
||||
}
|
||||
|
||||
cell := NewCell(node.ID, CellTypeMarkdown, []byte("# Hello\nThis is a note."))
|
||||
if err := cell.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Cell.Store failed: %v", err)
|
||||
}
|
||||
|
||||
got := &Cell{ID: cell.ID}
|
||||
if err := got.Get(ctx, tx); err != nil {
|
||||
t.Fatalf("Cell.Get failed: %v", err)
|
||||
}
|
||||
if got.NodeID != node.ID {
|
||||
t.Fatalf("node ID mismatch: got %q", got.NodeID)
|
||||
}
|
||||
if got.Type != CellTypeMarkdown {
|
||||
t.Fatalf("type mismatch: got %q", got.Type)
|
||||
}
|
||||
if string(got.Contents) != "# Hello\nThis is a note." {
|
||||
t.Fatalf("contents mismatch: got %q", got.Contents)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCellsForNode(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
node := NewNode("MultiCell", NodeTypeNote)
|
||||
if err := node.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
c1 := NewCell(node.ID, CellTypeMarkdown, []byte("First"))
|
||||
c1.Ordinal = 0
|
||||
c2 := NewCell(node.ID, CellTypeCode, []byte("fmt.Println()"))
|
||||
c2.Ordinal = 1
|
||||
c3 := NewCell(node.ID, CellTypeMarkdown, []byte("Last"))
|
||||
c3.Ordinal = 2
|
||||
|
||||
for _, c := range []*Cell{c1, c2, c3} {
|
||||
if err := c.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Cell.Store failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cells, err := GetCellsForNode(ctx, tx, node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCellsForNode failed: %v", err)
|
||||
}
|
||||
if len(cells) != 3 {
|
||||
t.Fatalf("expected 3 cells, got %d", len(cells))
|
||||
}
|
||||
if string(cells[0].Contents) != "First" {
|
||||
t.Fatalf("first cell content mismatch: %q", cells[0].Contents)
|
||||
}
|
||||
if cells[1].Type != CellTypeCode {
|
||||
t.Fatalf("second cell type mismatch: %q", cells[1].Type)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fact tests ---
|
||||
|
||||
func TestFactStoreAndGet(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
entityID := core.NewUUID()
|
||||
attrID := core.NewUUID()
|
||||
|
||||
fact := NewFact(entityID, "TestEntity", attrID, "color", core.Vals("blue"))
|
||||
if err := fact.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Fact.Store failed: %v", err)
|
||||
}
|
||||
|
||||
facts, err := GetFactsForEntity(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFactsForEntity failed: %v", err)
|
||||
}
|
||||
if len(facts) != 1 {
|
||||
t.Fatalf("expected 1 fact, got %d", len(facts))
|
||||
}
|
||||
if facts[0].Value.Contents != "blue" {
|
||||
t.Fatalf("value mismatch: %q", facts[0].Value.Contents)
|
||||
}
|
||||
if facts[0].Retraction {
|
||||
t.Fatal("should not be a retraction")
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactRetraction(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
entityID := core.NewUUID()
|
||||
attrID := core.NewUUID()
|
||||
|
||||
// Assert a fact.
|
||||
fact := NewFact(entityID, "Entity", attrID, "status", core.Vals("active"))
|
||||
if err := fact.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store assertion failed: %v", err)
|
||||
}
|
||||
|
||||
// Retract it.
|
||||
time.Sleep(10 * time.Millisecond) // ensure different timestamp
|
||||
retraction := fact.Retract()
|
||||
if err := retraction.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store retraction failed: %v", err)
|
||||
}
|
||||
|
||||
// All facts should include both.
|
||||
allFacts, err := GetFactsForEntity(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFactsForEntity failed: %v", err)
|
||||
}
|
||||
if len(allFacts) != 2 {
|
||||
t.Fatalf("expected 2 facts (assertion + retraction), got %d", len(allFacts))
|
||||
}
|
||||
|
||||
// Current facts should be empty (retracted).
|
||||
current, err := GetCurrentFactsForEntity(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCurrentFactsForEntity failed: %v", err)
|
||||
}
|
||||
if len(current) != 0 {
|
||||
t.Fatalf("expected 0 current facts after retraction, got %d", len(current))
|
||||
}
|
||||
|
||||
// Assert a new value.
|
||||
fact2 := NewFact(entityID, "Entity", attrID, "status", core.Vals("archived"))
|
||||
if err := fact2.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store second assertion failed: %v", err)
|
||||
}
|
||||
|
||||
current2, err := GetCurrentFactsForEntity(ctx, tx, entityID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetCurrentFactsForEntity failed: %v", err)
|
||||
}
|
||||
if len(current2) != 1 {
|
||||
t.Fatalf("expected 1 current fact, got %d", len(current2))
|
||||
}
|
||||
if current2[0].Value.Contents != "archived" {
|
||||
t.Fatalf("expected 'archived', got %q", current2[0].Value.Contents)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edge tests ---
|
||||
|
||||
func TestEdgeStoreAndGet(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
n1 := NewNode("Source", NodeTypeNote)
|
||||
n2 := NewNode("Target", NodeTypeNote)
|
||||
if err := n1.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store n1 failed: %v", err)
|
||||
}
|
||||
if err := n2.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store n2 failed: %v", err)
|
||||
}
|
||||
|
||||
edge := NewEdge(n1.ID, n2.ID, EdgeRelationRelated)
|
||||
if err := edge.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Edge.Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Get edges from source.
|
||||
fromEdges, err := GetEdgesFrom(ctx, tx, n1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEdgesFrom failed: %v", err)
|
||||
}
|
||||
if len(fromEdges) != 1 {
|
||||
t.Fatalf("expected 1 edge from source, got %d", len(fromEdges))
|
||||
}
|
||||
if fromEdges[0].TargetID != n2.ID {
|
||||
t.Fatalf("target mismatch: got %q", fromEdges[0].TargetID)
|
||||
}
|
||||
if fromEdges[0].Relation != EdgeRelationRelated {
|
||||
t.Fatalf("relation mismatch: got %q", fromEdges[0].Relation)
|
||||
}
|
||||
|
||||
// Get edges to target.
|
||||
toEdges, err := GetEdgesTo(ctx, tx, n2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEdgesTo failed: %v", err)
|
||||
}
|
||||
if len(toEdges) != 1 {
|
||||
t.Fatalf("expected 1 edge to target, got %d", len(toEdges))
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeToArtifactLink(t *testing.T) {
|
||||
database := mustOpenAndMigrate(t)
|
||||
tx, ctx := mustTX(t, database)
|
||||
|
||||
node := NewNode("Research", NodeTypeNote)
|
||||
if err := node.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
// Link to an artifact ID (doesn't need to exist in edges table).
|
||||
artifactID := core.NewUUID()
|
||||
edge := NewEdge(node.ID, artifactID, EdgeRelationArtifactLink)
|
||||
if err := edge.Store(ctx, tx); err != nil {
|
||||
t.Fatalf("Edge.Store failed: %v", err)
|
||||
}
|
||||
|
||||
edges, err := GetEdgesFrom(ctx, tx, node.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetEdgesFrom failed: %v", err)
|
||||
}
|
||||
if len(edges) != 1 {
|
||||
t.Fatalf("expected 1 edge, got %d", len(edges))
|
||||
}
|
||||
if edges[0].Relation != EdgeRelationArtifactLink {
|
||||
t.Fatalf("relation should be artifact_link, got %q", edges[0].Relation)
|
||||
}
|
||||
|
||||
if err := db.EndTX(tx, nil); err != nil {
|
||||
t.Fatalf("EndTX failed: %v", err)
|
||||
}
|
||||
}
|
||||
192
kg/node.go
Normal file
192
kg/node.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user