diff --git a/PROGRESS.md b/PROGRESS.md index 53cb9e4..fb0c4da 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -60,7 +60,28 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`. - `cmd/exod/main.go` - `cmd/exo/main.go`, `cmd/exo/yaml.go` -## Phase 4: Knowledge Graph — NOT STARTED +## Phase 4: Knowledge Graph — COMPLETE + +**Deliverables:** +- [x] `kg` package: `Node`, `Cell`, `Fact`, `Edge` types with `Store`/`Get` methods +- [x] Database schema migration (`002_knowledge_graph.sql`): nodes, cells, facts, edges, node_tags, node_categories tables +- [x] Node hierarchy (parent/children), C2 wiki-style naming, lookup by name +- [x] Cell content types (markdown, code, plain) with ordinal ordering +- [x] EAV fact storage with transaction timestamps and retraction support +- [x] `GetCurrentFactsForEntity` applies retraction logic to return only active facts +- [x] Edge types: child, parent, related, artifact_link (cross-pillar references) +- [x] Protobuf definitions (`proto/exo/v1/kg.proto`): Node, Cell, Fact, Edge messages and KnowledgeGraphService +- [x] gRPC service: CreateNode, GetNode, AddCell, RecordFact, GetFacts, AddEdge, GetEdges +- [x] CLI commands: `exo node create `, `exo node get ` +- [x] KG service registered in exod +- [x] Shared tag/category pool: nodes use the same tags/categories tables as artifacts +- [x] Full test coverage (kg package: 10 tests covering nodes, cells, facts, edges, hierarchy, retractions) + +**Files created:** +- `kg/node.go`, `kg/cell.go`, `kg/fact.go`, `kg/edge.go`, `kg/kg_test.go` +- `db/migrations/002_knowledge_graph.sql` +- `proto/exo/v1/kg.proto`, `proto/exo/v1/kg.pb.go`, `proto/exo/v1/kg_grpc.pb.go` +- `server/kg_server.go` ## Phase 5: Desktop Application — NOT STARTED (Kotlin, out of scope for Go backend) diff --git a/cmd/exo/main.go b/cmd/exo/main.go index eff638d..79612a1 100644 --- a/cmd/exo/main.go +++ b/cmd/exo/main.go @@ -29,6 +29,8 @@ Commands: cat add [...] Create categories cat list List all categories search tag Search artifacts by tag + node create Create a knowledge graph node + node get Get a node with its cells version Print version Environment: @@ -53,13 +55,15 @@ func main() { runCat(os.Args[2:]) case "search": runSearch(os.Args[2:]) + case "node": + runNode(os.Args[2:]) default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) usage() } } -func dial() pb.ArtifactServiceClient { +func dialConn() *grpc.ClientConn { addr := os.Getenv("EXO_ADDR") if addr == "" { addr = "localhost:9090" @@ -70,8 +74,15 @@ func dial() pb.ArtifactServiceClient { if err != nil { log.Fatalf("exo: failed to connect to %s: %v", addr, err) } - // Connection will be closed when the process exits. - return pb.NewArtifactServiceClient(conn) + return conn +} + +func dial() pb.ArtifactServiceClient { + return pb.NewArtifactServiceClient(dialConn()) +} + +func dialKG() pb.KnowledgeGraphServiceClient { + return pb.NewKnowledgeGraphServiceClient(dialConn()) } func runImport(args []string) { @@ -203,3 +214,62 @@ func runSearch(args []string) { os.Exit(1) } } + +func runNode(args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: exo node [...]") + os.Exit(1) + } + + client := dialKG() + ctx := context.Background() + + switch args[0] { + case "create": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: exo node create ") + os.Exit(1) + } + resp, err := client.CreateNode(ctx, &pb.CreateNodeRequest{ + Name: args[1], + Type: "note", + }) + if err != nil { + log.Fatalf("exo: failed to create node: %v", err) + } + fmt.Printf("created node: %s\n", resp.Id) + + case "get": + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: exo node get ") + os.Exit(1) + } + resp, err := client.GetNode(ctx, &pb.GetNodeRequest{Id: args[1]}) + if err != nil { + log.Fatalf("exo: failed to get node: %v", err) + } + n := resp.Node + fmt.Printf("ID: %s\n", n.Id) + fmt.Printf("Name: %s\n", n.Name) + fmt.Printf("Type: %s\n", n.Type) + fmt.Printf("Parent: %s\n", n.ParentId) + fmt.Printf("Created: %s\n", n.Created) + fmt.Printf("Modified: %s\n", n.Modified) + if len(n.Children) > 0 { + fmt.Printf("Children: %v\n", n.Children) + } + if len(n.Tags) > 0 { + fmt.Printf("Tags: %v\n", n.Tags) + } + if len(resp.Cells) > 0 { + fmt.Printf("Cells: %d\n", len(resp.Cells)) + for _, c := range resp.Cells { + fmt.Printf(" [%d] %s (%s): %s\n", c.Ordinal, c.Id, c.Type, string(c.Contents)) + } + } + + default: + fmt.Fprintf(os.Stderr, "unknown node subcommand: %s\n", args[0]) + os.Exit(1) + } +} diff --git a/cmd/exod/main.go b/cmd/exod/main.go index 7849d43..5db4510 100644 --- a/cmd/exod/main.go +++ b/cmd/exod/main.go @@ -71,6 +71,7 @@ func main() { grpcServer := grpc.NewServer() pb.RegisterArtifactServiceServer(grpcServer, server.NewArtifactServer(database, blobStore)) + pb.RegisterKnowledgeGraphServiceServer(grpcServer, server.NewKGServer(database)) // Graceful shutdown on SIGINT/SIGTERM. sigCh := make(chan os.Signal, 1) diff --git a/db/db_test.go b/db/db_test.go index 215c3b2..2f14ead 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -197,7 +197,7 @@ func TestSchemaVersion(t *testing.T) { if err != nil { t.Fatalf("getCurrentVersion failed: %v", err) } - if version != 1 { - t.Fatalf("expected schema version 1, got %d", version) + if version < 1 { + t.Fatalf("expected schema version >= 1, got %d", version) } } diff --git a/db/migrations/002_knowledge_graph.sql b/db/migrations/002_knowledge_graph.sql new file mode 100644 index 0000000..a982308 --- /dev/null +++ b/db/migrations/002_knowledge_graph.sql @@ -0,0 +1,82 @@ +-- Migration 002: Knowledge graph tables +-- Adds nodes, cells, facts, and graph edges to the unified database. + +-- Nodes are entities in the knowledge graph. +-- A node is either a note or an artifact link. +CREATE TABLE IF NOT EXISTS nodes +( + id TEXT PRIMARY KEY, + parent_id TEXT, -- parent node ID (C2 wiki style hierarchy) + name TEXT NOT NULL, -- human-readable name + type TEXT NOT NULL DEFAULT 'note', -- 'note' or 'artifact_link' + created TEXT NOT NULL, -- ISO 8601 UTC + modified TEXT NOT NULL, -- ISO 8601 UTC + FOREIGN KEY (parent_id) REFERENCES nodes (id) +); +CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes (parent_id); +CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes (name); + +-- Many-to-many junction: nodes <-> tags. +CREATE TABLE IF NOT EXISTS node_tags +( + node_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + FOREIGN KEY (node_id) REFERENCES nodes (id), + FOREIGN KEY (tag_id) REFERENCES tags (id) +); + +-- Many-to-many junction: nodes <-> categories. +CREATE TABLE IF NOT EXISTS node_categories +( + node_id TEXT NOT NULL, + category_id TEXT NOT NULL, + FOREIGN KEY (node_id) REFERENCES nodes (id), + FOREIGN KEY (category_id) REFERENCES categories (id) +); + +-- Cells are content units within a note. +-- Inspired by Quiver's cell-based structure. +CREATE TABLE IF NOT EXISTS cells +( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'markdown', -- 'markdown', 'code', etc. + contents BLOB, + ordinal INTEGER NOT NULL DEFAULT 0, -- display order within the node + created TEXT NOT NULL, + modified TEXT NOT NULL, + FOREIGN KEY (node_id) REFERENCES nodes (id) +); +CREATE INDEX IF NOT EXISTS idx_cells_node ON cells (node_id); + +-- Facts record EAV relationships with transactional history. +-- entity + attribute + transaction form the natural key. +CREATE TABLE IF NOT EXISTS facts +( + id TEXT PRIMARY KEY, + entity_id TEXT NOT NULL, -- the subject (node or artifact UUID) + entity_name TEXT NOT NULL, -- human-readable entity name + attribute_id TEXT NOT NULL, -- attribute UUID + attribute_name TEXT NOT NULL, -- human-readable attribute name + value_contents TEXT NOT NULL, + value_type TEXT NOT NULL, + tx_timestamp INTEGER NOT NULL, -- Unix epoch of the transaction + retraction INTEGER NOT NULL DEFAULT 0 -- 1 = this fact is retracted +); +CREATE INDEX IF NOT EXISTS idx_facts_entity ON facts (entity_id); +CREATE INDEX IF NOT EXISTS idx_facts_attribute ON facts (attribute_id); + +-- Graph edges link nodes to other nodes or artifacts. +CREATE TABLE IF NOT EXISTS edges +( + id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, -- source node UUID + target_id TEXT NOT NULL, -- target node or artifact UUID + relation TEXT NOT NULL, -- 'child', 'parent', 'related', 'artifact_link', etc. + created TEXT NOT NULL, + FOREIGN KEY (source_id) REFERENCES nodes (id) +); +CREATE INDEX IF NOT EXISTS idx_edges_source ON edges (source_id); +CREATE INDEX IF NOT EXISTS idx_edges_target ON edges (target_id); + +INSERT INTO schema_version (version, applied) VALUES (2, datetime('now')); diff --git a/kg/cell.go b/kg/cell.go new file mode 100644 index 0000000..17b1833 --- /dev/null +++ b/kg/cell.go @@ -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() +} diff --git a/kg/edge.go b/kg/edge.go new file mode 100644 index 0000000..d997491 --- /dev/null +++ b/kg/edge.go @@ -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() +} diff --git a/kg/fact.go b/kg/fact.go new file mode 100644 index 0000000..d25350e --- /dev/null +++ b/kg/fact.go @@ -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 +} diff --git a/kg/kg_test.go b/kg/kg_test.go new file mode 100644 index 0000000..ce94eec --- /dev/null +++ b/kg/kg_test.go @@ -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) + } +} diff --git a/kg/node.go b/kg/node.go new file mode 100644 index 0000000..24f2342 --- /dev/null +++ b/kg/node.go @@ -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 +} diff --git a/proto/exo/v1/kg.pb.go b/proto/exo/v1/kg.pb.go new file mode 100644 index 0000000..ad7ad17 --- /dev/null +++ b/proto/exo/v1/kg.pb.go @@ -0,0 +1,1347 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: exo/v1/kg.proto + +package exov1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Node is an entity in the knowledge graph. +type Node struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` // "note" or "artifact_link" + Created string `protobuf:"bytes,5,opt,name=created,proto3" json:"created,omitempty"` + Modified string `protobuf:"bytes,6,opt,name=modified,proto3" json:"modified,omitempty"` + Children []string `protobuf:"bytes,7,rep,name=children,proto3" json:"children,omitempty"` + Tags []string `protobuf:"bytes,8,rep,name=tags,proto3" json:"tags,omitempty"` + Categories []string `protobuf:"bytes,9,rep,name=categories,proto3" json:"categories,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Node) Reset() { + *x = Node{} + mi := &file_exo_v1_kg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Node) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Node) ProtoMessage() {} + +func (x *Node) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Node.ProtoReflect.Descriptor instead. +func (*Node) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{0} +} + +func (x *Node) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Node) GetParentId() string { + if x != nil { + return x.ParentId + } + return "" +} + +func (x *Node) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Node) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Node) GetCreated() string { + if x != nil { + return x.Created + } + return "" +} + +func (x *Node) GetModified() string { + if x != nil { + return x.Modified + } + return "" +} + +func (x *Node) GetChildren() []string { + if x != nil { + return x.Children + } + return nil +} + +func (x *Node) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *Node) GetCategories() []string { + if x != nil { + return x.Categories + } + return nil +} + +// Cell is a content unit within a note. +type Cell struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + NodeId string `protobuf:"bytes,2,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` // "markdown", "code", "plain" + Contents []byte `protobuf:"bytes,4,opt,name=contents,proto3" json:"contents,omitempty"` + Ordinal int32 `protobuf:"varint,5,opt,name=ordinal,proto3" json:"ordinal,omitempty"` + Created string `protobuf:"bytes,6,opt,name=created,proto3" json:"created,omitempty"` + Modified string `protobuf:"bytes,7,opt,name=modified,proto3" json:"modified,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Cell) Reset() { + *x = Cell{} + mi := &file_exo_v1_kg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Cell) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Cell) ProtoMessage() {} + +func (x *Cell) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Cell.ProtoReflect.Descriptor instead. +func (*Cell) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{1} +} + +func (x *Cell) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Cell) GetNodeId() string { + if x != nil { + return x.NodeId + } + return "" +} + +func (x *Cell) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Cell) GetContents() []byte { + if x != nil { + return x.Contents + } + return nil +} + +func (x *Cell) GetOrdinal() int32 { + if x != nil { + return x.Ordinal + } + return 0 +} + +func (x *Cell) GetCreated() string { + if x != nil { + return x.Created + } + return "" +} + +func (x *Cell) GetModified() string { + if x != nil { + return x.Modified + } + return "" +} + +// Fact records an EAV relationship with transactional history. +type Fact struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + EntityId string `protobuf:"bytes,2,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + EntityName string `protobuf:"bytes,3,opt,name=entity_name,json=entityName,proto3" json:"entity_name,omitempty"` + AttributeId string `protobuf:"bytes,4,opt,name=attribute_id,json=attributeId,proto3" json:"attribute_id,omitempty"` + AttributeName string `protobuf:"bytes,5,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"` + Value *Value `protobuf:"bytes,6,opt,name=value,proto3" json:"value,omitempty"` + TxTimestamp int64 `protobuf:"varint,7,opt,name=tx_timestamp,json=txTimestamp,proto3" json:"tx_timestamp,omitempty"` + Retraction bool `protobuf:"varint,8,opt,name=retraction,proto3" json:"retraction,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Fact) Reset() { + *x = Fact{} + mi := &file_exo_v1_kg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Fact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Fact) ProtoMessage() {} + +func (x *Fact) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Fact.ProtoReflect.Descriptor instead. +func (*Fact) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{2} +} + +func (x *Fact) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Fact) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +func (x *Fact) GetEntityName() string { + if x != nil { + return x.EntityName + } + return "" +} + +func (x *Fact) GetAttributeId() string { + if x != nil { + return x.AttributeId + } + return "" +} + +func (x *Fact) GetAttributeName() string { + if x != nil { + return x.AttributeName + } + return "" +} + +func (x *Fact) GetValue() *Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *Fact) GetTxTimestamp() int64 { + if x != nil { + return x.TxTimestamp + } + return 0 +} + +func (x *Fact) GetRetraction() bool { + if x != nil { + return x.Retraction + } + return false +} + +// Edge links nodes to other nodes or artifacts. +type Edge struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SourceId string `protobuf:"bytes,2,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + TargetId string `protobuf:"bytes,3,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + Relation string `protobuf:"bytes,4,opt,name=relation,proto3" json:"relation,omitempty"` + Created string `protobuf:"bytes,5,opt,name=created,proto3" json:"created,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Edge) Reset() { + *x = Edge{} + mi := &file_exo_v1_kg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Edge) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Edge) ProtoMessage() {} + +func (x *Edge) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Edge.ProtoReflect.Descriptor instead. +func (*Edge) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{3} +} + +func (x *Edge) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Edge) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *Edge) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *Edge) GetRelation() string { + if x != nil { + return x.Relation + } + return "" +} + +func (x *Edge) GetCreated() string { + if x != nil { + return x.Created + } + return "" +} + +type CreateNodeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + ParentId string `protobuf:"bytes,3,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"` + Tags []string `protobuf:"bytes,4,rep,name=tags,proto3" json:"tags,omitempty"` + Categories []string `protobuf:"bytes,5,rep,name=categories,proto3" json:"categories,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateNodeRequest) Reset() { + *x = CreateNodeRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateNodeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateNodeRequest) ProtoMessage() {} + +func (x *CreateNodeRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateNodeRequest.ProtoReflect.Descriptor instead. +func (*CreateNodeRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateNodeRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateNodeRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *CreateNodeRequest) GetParentId() string { + if x != nil { + return x.ParentId + } + return "" +} + +func (x *CreateNodeRequest) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *CreateNodeRequest) GetCategories() []string { + if x != nil { + return x.Categories + } + return nil +} + +type CreateNodeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateNodeResponse) Reset() { + *x = CreateNodeResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateNodeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateNodeResponse) ProtoMessage() {} + +func (x *CreateNodeResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateNodeResponse.ProtoReflect.Descriptor instead. +func (*CreateNodeResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{5} +} + +func (x *CreateNodeResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetNodeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetNodeRequest) Reset() { + *x = GetNodeRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNodeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNodeRequest) ProtoMessage() {} + +func (x *GetNodeRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetNodeRequest.ProtoReflect.Descriptor instead. +func (*GetNodeRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{6} +} + +func (x *GetNodeRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetNodeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Node *Node `protobuf:"bytes,1,opt,name=node,proto3" json:"node,omitempty"` + Cells []*Cell `protobuf:"bytes,2,rep,name=cells,proto3" json:"cells,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetNodeResponse) Reset() { + *x = GetNodeResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNodeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNodeResponse) ProtoMessage() {} + +func (x *GetNodeResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetNodeResponse.ProtoReflect.Descriptor instead. +func (*GetNodeResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{7} +} + +func (x *GetNodeResponse) GetNode() *Node { + if x != nil { + return x.Node + } + return nil +} + +func (x *GetNodeResponse) GetCells() []*Cell { + if x != nil { + return x.Cells + } + return nil +} + +type AddCellRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Contents []byte `protobuf:"bytes,3,opt,name=contents,proto3" json:"contents,omitempty"` + Ordinal int32 `protobuf:"varint,4,opt,name=ordinal,proto3" json:"ordinal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddCellRequest) Reset() { + *x = AddCellRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddCellRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddCellRequest) ProtoMessage() {} + +func (x *AddCellRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddCellRequest.ProtoReflect.Descriptor instead. +func (*AddCellRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{8} +} + +func (x *AddCellRequest) GetNodeId() string { + if x != nil { + return x.NodeId + } + return "" +} + +func (x *AddCellRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *AddCellRequest) GetContents() []byte { + if x != nil { + return x.Contents + } + return nil +} + +func (x *AddCellRequest) GetOrdinal() int32 { + if x != nil { + return x.Ordinal + } + return 0 +} + +type AddCellResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddCellResponse) Reset() { + *x = AddCellResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddCellResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddCellResponse) ProtoMessage() {} + +func (x *AddCellResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddCellResponse.ProtoReflect.Descriptor instead. +func (*AddCellResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{9} +} + +func (x *AddCellResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type RecordFactRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + EntityName string `protobuf:"bytes,2,opt,name=entity_name,json=entityName,proto3" json:"entity_name,omitempty"` + AttributeId string `protobuf:"bytes,3,opt,name=attribute_id,json=attributeId,proto3" json:"attribute_id,omitempty"` + AttributeName string `protobuf:"bytes,4,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"` + Value *Value `protobuf:"bytes,5,opt,name=value,proto3" json:"value,omitempty"` + Retraction bool `protobuf:"varint,6,opt,name=retraction,proto3" json:"retraction,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordFactRequest) Reset() { + *x = RecordFactRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordFactRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordFactRequest) ProtoMessage() {} + +func (x *RecordFactRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordFactRequest.ProtoReflect.Descriptor instead. +func (*RecordFactRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{10} +} + +func (x *RecordFactRequest) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +func (x *RecordFactRequest) GetEntityName() string { + if x != nil { + return x.EntityName + } + return "" +} + +func (x *RecordFactRequest) GetAttributeId() string { + if x != nil { + return x.AttributeId + } + return "" +} + +func (x *RecordFactRequest) GetAttributeName() string { + if x != nil { + return x.AttributeName + } + return "" +} + +func (x *RecordFactRequest) GetValue() *Value { + if x != nil { + return x.Value + } + return nil +} + +func (x *RecordFactRequest) GetRetraction() bool { + if x != nil { + return x.Retraction + } + return false +} + +type RecordFactResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordFactResponse) Reset() { + *x = RecordFactResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordFactResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordFactResponse) ProtoMessage() {} + +func (x *RecordFactResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordFactResponse.ProtoReflect.Descriptor instead. +func (*RecordFactResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{11} +} + +func (x *RecordFactResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetFactsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + CurrentOnly bool `protobuf:"varint,2,opt,name=current_only,json=currentOnly,proto3" json:"current_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetFactsRequest) Reset() { + *x = GetFactsRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetFactsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFactsRequest) ProtoMessage() {} + +func (x *GetFactsRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFactsRequest.ProtoReflect.Descriptor instead. +func (*GetFactsRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{12} +} + +func (x *GetFactsRequest) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +func (x *GetFactsRequest) GetCurrentOnly() bool { + if x != nil { + return x.CurrentOnly + } + return false +} + +type GetFactsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Facts []*Fact `protobuf:"bytes,1,rep,name=facts,proto3" json:"facts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetFactsResponse) Reset() { + *x = GetFactsResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetFactsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFactsResponse) ProtoMessage() {} + +func (x *GetFactsResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFactsResponse.ProtoReflect.Descriptor instead. +func (*GetFactsResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{13} +} + +func (x *GetFactsResponse) GetFacts() []*Fact { + if x != nil { + return x.Facts + } + return nil +} + +type AddEdgeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceId string `protobuf:"bytes,1,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` + TargetId string `protobuf:"bytes,2,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + Relation string `protobuf:"bytes,3,opt,name=relation,proto3" json:"relation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddEdgeRequest) Reset() { + *x = AddEdgeRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddEdgeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddEdgeRequest) ProtoMessage() {} + +func (x *AddEdgeRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddEdgeRequest.ProtoReflect.Descriptor instead. +func (*AddEdgeRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{14} +} + +func (x *AddEdgeRequest) GetSourceId() string { + if x != nil { + return x.SourceId + } + return "" +} + +func (x *AddEdgeRequest) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +func (x *AddEdgeRequest) GetRelation() string { + if x != nil { + return x.Relation + } + return "" +} + +type AddEdgeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddEdgeResponse) Reset() { + *x = AddEdgeResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddEdgeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddEdgeResponse) ProtoMessage() {} + +func (x *AddEdgeResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddEdgeResponse.ProtoReflect.Descriptor instead. +func (*AddEdgeResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{15} +} + +func (x *AddEdgeResponse) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetEdgesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"` + Direction string `protobuf:"bytes,2,opt,name=direction,proto3" json:"direction,omitempty"` // "from", "to", or "both" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetEdgesRequest) Reset() { + *x = GetEdgesRequest{} + mi := &file_exo_v1_kg_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetEdgesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetEdgesRequest) ProtoMessage() {} + +func (x *GetEdgesRequest) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetEdgesRequest.ProtoReflect.Descriptor instead. +func (*GetEdgesRequest) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{16} +} + +func (x *GetEdgesRequest) GetNodeId() string { + if x != nil { + return x.NodeId + } + return "" +} + +func (x *GetEdgesRequest) GetDirection() string { + if x != nil { + return x.Direction + } + return "" +} + +type GetEdgesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Edges []*Edge `protobuf:"bytes,1,rep,name=edges,proto3" json:"edges,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetEdgesResponse) Reset() { + *x = GetEdgesResponse{} + mi := &file_exo_v1_kg_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetEdgesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetEdgesResponse) ProtoMessage() {} + +func (x *GetEdgesResponse) ProtoReflect() protoreflect.Message { + mi := &file_exo_v1_kg_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetEdgesResponse.ProtoReflect.Descriptor instead. +func (*GetEdgesResponse) Descriptor() ([]byte, []int) { + return file_exo_v1_kg_proto_rawDescGZIP(), []int{17} +} + +func (x *GetEdgesResponse) GetEdges() []*Edge { + if x != nil { + return x.Edges + } + return nil +} + +var File_exo_v1_kg_proto protoreflect.FileDescriptor + +const file_exo_v1_kg_proto_rawDesc = "" + + "\n" + + "\x0fexo/v1/kg.proto\x12\x06exo.v1\x1a\x13exo/v1/common.proto\"\xe1\x01\n" + + "\x04Node\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\tparent_id\x18\x02 \x01(\tR\bparentId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x12\n" + + "\x04type\x18\x04 \x01(\tR\x04type\x12\x18\n" + + "\acreated\x18\x05 \x01(\tR\acreated\x12\x1a\n" + + "\bmodified\x18\x06 \x01(\tR\bmodified\x12\x1a\n" + + "\bchildren\x18\a \x03(\tR\bchildren\x12\x12\n" + + "\x04tags\x18\b \x03(\tR\x04tags\x12\x1e\n" + + "\n" + + "categories\x18\t \x03(\tR\n" + + "categories\"\xaf\x01\n" + + "\x04Cell\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + + "\anode_id\x18\x02 \x01(\tR\x06nodeId\x12\x12\n" + + "\x04type\x18\x03 \x01(\tR\x04type\x12\x1a\n" + + "\bcontents\x18\x04 \x01(\fR\bcontents\x12\x18\n" + + "\aordinal\x18\x05 \x01(\x05R\aordinal\x12\x18\n" + + "\acreated\x18\x06 \x01(\tR\acreated\x12\x1a\n" + + "\bmodified\x18\a \x01(\tR\bmodified\"\x86\x02\n" + + "\x04Fact\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\tentity_id\x18\x02 \x01(\tR\bentityId\x12\x1f\n" + + "\ventity_name\x18\x03 \x01(\tR\n" + + "entityName\x12!\n" + + "\fattribute_id\x18\x04 \x01(\tR\vattributeId\x12%\n" + + "\x0eattribute_name\x18\x05 \x01(\tR\rattributeName\x12#\n" + + "\x05value\x18\x06 \x01(\v2\r.exo.v1.ValueR\x05value\x12!\n" + + "\ftx_timestamp\x18\a \x01(\x03R\vtxTimestamp\x12\x1e\n" + + "\n" + + "retraction\x18\b \x01(\bR\n" + + "retraction\"\x86\x01\n" + + "\x04Edge\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\tsource_id\x18\x02 \x01(\tR\bsourceId\x12\x1b\n" + + "\ttarget_id\x18\x03 \x01(\tR\btargetId\x12\x1a\n" + + "\brelation\x18\x04 \x01(\tR\brelation\x12\x18\n" + + "\acreated\x18\x05 \x01(\tR\acreated\"\x8c\x01\n" + + "\x11CreateNodeRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1b\n" + + "\tparent_id\x18\x03 \x01(\tR\bparentId\x12\x12\n" + + "\x04tags\x18\x04 \x03(\tR\x04tags\x12\x1e\n" + + "\n" + + "categories\x18\x05 \x03(\tR\n" + + "categories\"$\n" + + "\x12CreateNodeResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\" \n" + + "\x0eGetNodeRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"W\n" + + "\x0fGetNodeResponse\x12 \n" + + "\x04node\x18\x01 \x01(\v2\f.exo.v1.NodeR\x04node\x12\"\n" + + "\x05cells\x18\x02 \x03(\v2\f.exo.v1.CellR\x05cells\"s\n" + + "\x0eAddCellRequest\x12\x17\n" + + "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x12\n" + + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1a\n" + + "\bcontents\x18\x03 \x01(\fR\bcontents\x12\x18\n" + + "\aordinal\x18\x04 \x01(\x05R\aordinal\"!\n" + + "\x0fAddCellResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xe0\x01\n" + + "\x11RecordFactRequest\x12\x1b\n" + + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12\x1f\n" + + "\ventity_name\x18\x02 \x01(\tR\n" + + "entityName\x12!\n" + + "\fattribute_id\x18\x03 \x01(\tR\vattributeId\x12%\n" + + "\x0eattribute_name\x18\x04 \x01(\tR\rattributeName\x12#\n" + + "\x05value\x18\x05 \x01(\v2\r.exo.v1.ValueR\x05value\x12\x1e\n" + + "\n" + + "retraction\x18\x06 \x01(\bR\n" + + "retraction\"$\n" + + "\x12RecordFactResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"Q\n" + + "\x0fGetFactsRequest\x12\x1b\n" + + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12!\n" + + "\fcurrent_only\x18\x02 \x01(\bR\vcurrentOnly\"6\n" + + "\x10GetFactsResponse\x12\"\n" + + "\x05facts\x18\x01 \x03(\v2\f.exo.v1.FactR\x05facts\"f\n" + + "\x0eAddEdgeRequest\x12\x1b\n" + + "\tsource_id\x18\x01 \x01(\tR\bsourceId\x12\x1b\n" + + "\ttarget_id\x18\x02 \x01(\tR\btargetId\x12\x1a\n" + + "\brelation\x18\x03 \x01(\tR\brelation\"!\n" + + "\x0fAddEdgeResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"H\n" + + "\x0fGetEdgesRequest\x12\x17\n" + + "\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x1c\n" + + "\tdirection\x18\x02 \x01(\tR\tdirection\"6\n" + + "\x10GetEdgesResponse\x12\"\n" + + "\x05edges\x18\x01 \x03(\v2\f.exo.v1.EdgeR\x05edges2\xd3\x03\n" + + "\x15KnowledgeGraphService\x12C\n" + + "\n" + + "CreateNode\x12\x19.exo.v1.CreateNodeRequest\x1a\x1a.exo.v1.CreateNodeResponse\x12:\n" + + "\aGetNode\x12\x16.exo.v1.GetNodeRequest\x1a\x17.exo.v1.GetNodeResponse\x12:\n" + + "\aAddCell\x12\x16.exo.v1.AddCellRequest\x1a\x17.exo.v1.AddCellResponse\x12C\n" + + "\n" + + "RecordFact\x12\x19.exo.v1.RecordFactRequest\x1a\x1a.exo.v1.RecordFactResponse\x12=\n" + + "\bGetFacts\x12\x17.exo.v1.GetFactsRequest\x1a\x18.exo.v1.GetFactsResponse\x12:\n" + + "\aAddEdge\x12\x16.exo.v1.AddEdgeRequest\x1a\x17.exo.v1.AddEdgeResponse\x12=\n" + + "\bGetEdges\x12\x17.exo.v1.GetEdgesRequest\x1a\x18.exo.v1.GetEdgesResponseB|\n" + + "\n" + + "com.exo.v1B\aKgProtoP\x01Z,git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1\xa2\x02\x03EXX\xaa\x02\x06Exo.V1\xca\x02\x06Exo\\V1\xe2\x02\x12Exo\\V1\\GPBMetadata\xea\x02\aExo::V1b\x06proto3" + +var ( + file_exo_v1_kg_proto_rawDescOnce sync.Once + file_exo_v1_kg_proto_rawDescData []byte +) + +func file_exo_v1_kg_proto_rawDescGZIP() []byte { + file_exo_v1_kg_proto_rawDescOnce.Do(func() { + file_exo_v1_kg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_exo_v1_kg_proto_rawDesc), len(file_exo_v1_kg_proto_rawDesc))) + }) + return file_exo_v1_kg_proto_rawDescData +} + +var file_exo_v1_kg_proto_msgTypes = make([]protoimpl.MessageInfo, 18) +var file_exo_v1_kg_proto_goTypes = []any{ + (*Node)(nil), // 0: exo.v1.Node + (*Cell)(nil), // 1: exo.v1.Cell + (*Fact)(nil), // 2: exo.v1.Fact + (*Edge)(nil), // 3: exo.v1.Edge + (*CreateNodeRequest)(nil), // 4: exo.v1.CreateNodeRequest + (*CreateNodeResponse)(nil), // 5: exo.v1.CreateNodeResponse + (*GetNodeRequest)(nil), // 6: exo.v1.GetNodeRequest + (*GetNodeResponse)(nil), // 7: exo.v1.GetNodeResponse + (*AddCellRequest)(nil), // 8: exo.v1.AddCellRequest + (*AddCellResponse)(nil), // 9: exo.v1.AddCellResponse + (*RecordFactRequest)(nil), // 10: exo.v1.RecordFactRequest + (*RecordFactResponse)(nil), // 11: exo.v1.RecordFactResponse + (*GetFactsRequest)(nil), // 12: exo.v1.GetFactsRequest + (*GetFactsResponse)(nil), // 13: exo.v1.GetFactsResponse + (*AddEdgeRequest)(nil), // 14: exo.v1.AddEdgeRequest + (*AddEdgeResponse)(nil), // 15: exo.v1.AddEdgeResponse + (*GetEdgesRequest)(nil), // 16: exo.v1.GetEdgesRequest + (*GetEdgesResponse)(nil), // 17: exo.v1.GetEdgesResponse + (*Value)(nil), // 18: exo.v1.Value +} +var file_exo_v1_kg_proto_depIdxs = []int32{ + 18, // 0: exo.v1.Fact.value:type_name -> exo.v1.Value + 0, // 1: exo.v1.GetNodeResponse.node:type_name -> exo.v1.Node + 1, // 2: exo.v1.GetNodeResponse.cells:type_name -> exo.v1.Cell + 18, // 3: exo.v1.RecordFactRequest.value:type_name -> exo.v1.Value + 2, // 4: exo.v1.GetFactsResponse.facts:type_name -> exo.v1.Fact + 3, // 5: exo.v1.GetEdgesResponse.edges:type_name -> exo.v1.Edge + 4, // 6: exo.v1.KnowledgeGraphService.CreateNode:input_type -> exo.v1.CreateNodeRequest + 6, // 7: exo.v1.KnowledgeGraphService.GetNode:input_type -> exo.v1.GetNodeRequest + 8, // 8: exo.v1.KnowledgeGraphService.AddCell:input_type -> exo.v1.AddCellRequest + 10, // 9: exo.v1.KnowledgeGraphService.RecordFact:input_type -> exo.v1.RecordFactRequest + 12, // 10: exo.v1.KnowledgeGraphService.GetFacts:input_type -> exo.v1.GetFactsRequest + 14, // 11: exo.v1.KnowledgeGraphService.AddEdge:input_type -> exo.v1.AddEdgeRequest + 16, // 12: exo.v1.KnowledgeGraphService.GetEdges:input_type -> exo.v1.GetEdgesRequest + 5, // 13: exo.v1.KnowledgeGraphService.CreateNode:output_type -> exo.v1.CreateNodeResponse + 7, // 14: exo.v1.KnowledgeGraphService.GetNode:output_type -> exo.v1.GetNodeResponse + 9, // 15: exo.v1.KnowledgeGraphService.AddCell:output_type -> exo.v1.AddCellResponse + 11, // 16: exo.v1.KnowledgeGraphService.RecordFact:output_type -> exo.v1.RecordFactResponse + 13, // 17: exo.v1.KnowledgeGraphService.GetFacts:output_type -> exo.v1.GetFactsResponse + 15, // 18: exo.v1.KnowledgeGraphService.AddEdge:output_type -> exo.v1.AddEdgeResponse + 17, // 19: exo.v1.KnowledgeGraphService.GetEdges:output_type -> exo.v1.GetEdgesResponse + 13, // [13:20] is the sub-list for method output_type + 6, // [6:13] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_exo_v1_kg_proto_init() } +func file_exo_v1_kg_proto_init() { + if File_exo_v1_kg_proto != nil { + return + } + file_exo_v1_common_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_exo_v1_kg_proto_rawDesc), len(file_exo_v1_kg_proto_rawDesc)), + NumEnums: 0, + NumMessages: 18, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_exo_v1_kg_proto_goTypes, + DependencyIndexes: file_exo_v1_kg_proto_depIdxs, + MessageInfos: file_exo_v1_kg_proto_msgTypes, + }.Build() + File_exo_v1_kg_proto = out.File + file_exo_v1_kg_proto_goTypes = nil + file_exo_v1_kg_proto_depIdxs = nil +} diff --git a/proto/exo/v1/kg.proto b/proto/exo/v1/kg.proto new file mode 100644 index 0000000..927c8b7 --- /dev/null +++ b/proto/exo/v1/kg.proto @@ -0,0 +1,145 @@ +syntax = "proto3"; + +package exo.v1; + +option go_package = "git.wntrmute.dev/kyle/exo/proto/exo/v1;exov1"; + +import "exo/v1/common.proto"; + +// Node is an entity in the knowledge graph. +message Node { + string id = 1; + string parent_id = 2; + string name = 3; + string type = 4; // "note" or "artifact_link" + string created = 5; + string modified = 6; + repeated string children = 7; + repeated string tags = 8; + repeated string categories = 9; +} + +// Cell is a content unit within a note. +message Cell { + string id = 1; + string node_id = 2; + string type = 3; // "markdown", "code", "plain" + bytes contents = 4; + int32 ordinal = 5; + string created = 6; + string modified = 7; +} + +// Fact records an EAV relationship with transactional history. +message Fact { + string id = 1; + string entity_id = 2; + string entity_name = 3; + string attribute_id = 4; + string attribute_name = 5; + Value value = 6; + int64 tx_timestamp = 7; + bool retraction = 8; +} + +// Edge links nodes to other nodes or artifacts. +message Edge { + string id = 1; + string source_id = 2; + string target_id = 3; + string relation = 4; + string created = 5; +} + +// --- Service messages --- + +message CreateNodeRequest { + string name = 1; + string type = 2; + string parent_id = 3; + repeated string tags = 4; + repeated string categories = 5; +} + +message CreateNodeResponse { + string id = 1; +} + +message GetNodeRequest { + string id = 1; +} + +message GetNodeResponse { + Node node = 1; + repeated Cell cells = 2; +} + +message AddCellRequest { + string node_id = 1; + string type = 2; + bytes contents = 3; + int32 ordinal = 4; +} + +message AddCellResponse { + string id = 1; +} + +message RecordFactRequest { + string entity_id = 1; + string entity_name = 2; + string attribute_id = 3; + string attribute_name = 4; + Value value = 5; + bool retraction = 6; +} + +message RecordFactResponse { + string id = 1; +} + +message GetFactsRequest { + string entity_id = 1; + bool current_only = 2; +} + +message GetFactsResponse { + repeated Fact facts = 1; +} + +message AddEdgeRequest { + string source_id = 1; + string target_id = 2; + string relation = 3; +} + +message AddEdgeResponse { + string id = 1; +} + +message GetEdgesRequest { + string node_id = 1; + string direction = 2; // "from", "to", or "both" +} + +message GetEdgesResponse { + repeated Edge edges = 1; +} + +// KnowledgeGraphService provides operations for the knowledge graph pillar. +service KnowledgeGraphService { + // Nodes + rpc CreateNode(CreateNodeRequest) returns (CreateNodeResponse); + rpc GetNode(GetNodeRequest) returns (GetNodeResponse); + + // Cells + rpc AddCell(AddCellRequest) returns (AddCellResponse); + + // Facts + rpc RecordFact(RecordFactRequest) returns (RecordFactResponse); + rpc GetFacts(GetFactsRequest) returns (GetFactsResponse); + + // Edges + rpc AddEdge(AddEdgeRequest) returns (AddEdgeResponse); + rpc GetEdges(GetEdgesRequest) returns (GetEdgesResponse); +} diff --git a/proto/exo/v1/kg_grpc.pb.go b/proto/exo/v1/kg_grpc.pb.go new file mode 100644 index 0000000..7c8d579 --- /dev/null +++ b/proto/exo/v1/kg_grpc.pb.go @@ -0,0 +1,361 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: exo/v1/kg.proto + +package exov1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + KnowledgeGraphService_CreateNode_FullMethodName = "/exo.v1.KnowledgeGraphService/CreateNode" + KnowledgeGraphService_GetNode_FullMethodName = "/exo.v1.KnowledgeGraphService/GetNode" + KnowledgeGraphService_AddCell_FullMethodName = "/exo.v1.KnowledgeGraphService/AddCell" + KnowledgeGraphService_RecordFact_FullMethodName = "/exo.v1.KnowledgeGraphService/RecordFact" + KnowledgeGraphService_GetFacts_FullMethodName = "/exo.v1.KnowledgeGraphService/GetFacts" + KnowledgeGraphService_AddEdge_FullMethodName = "/exo.v1.KnowledgeGraphService/AddEdge" + KnowledgeGraphService_GetEdges_FullMethodName = "/exo.v1.KnowledgeGraphService/GetEdges" +) + +// KnowledgeGraphServiceClient is the client API for KnowledgeGraphService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// KnowledgeGraphService provides operations for the knowledge graph pillar. +type KnowledgeGraphServiceClient interface { + // Nodes + CreateNode(ctx context.Context, in *CreateNodeRequest, opts ...grpc.CallOption) (*CreateNodeResponse, error) + GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) + // Cells + AddCell(ctx context.Context, in *AddCellRequest, opts ...grpc.CallOption) (*AddCellResponse, error) + // Facts + RecordFact(ctx context.Context, in *RecordFactRequest, opts ...grpc.CallOption) (*RecordFactResponse, error) + GetFacts(ctx context.Context, in *GetFactsRequest, opts ...grpc.CallOption) (*GetFactsResponse, error) + // Edges + AddEdge(ctx context.Context, in *AddEdgeRequest, opts ...grpc.CallOption) (*AddEdgeResponse, error) + GetEdges(ctx context.Context, in *GetEdgesRequest, opts ...grpc.CallOption) (*GetEdgesResponse, error) +} + +type knowledgeGraphServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewKnowledgeGraphServiceClient(cc grpc.ClientConnInterface) KnowledgeGraphServiceClient { + return &knowledgeGraphServiceClient{cc} +} + +func (c *knowledgeGraphServiceClient) CreateNode(ctx context.Context, in *CreateNodeRequest, opts ...grpc.CallOption) (*CreateNodeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateNodeResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_CreateNode_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *knowledgeGraphServiceClient) GetNode(ctx context.Context, in *GetNodeRequest, opts ...grpc.CallOption) (*GetNodeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetNodeResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_GetNode_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *knowledgeGraphServiceClient) AddCell(ctx context.Context, in *AddCellRequest, opts ...grpc.CallOption) (*AddCellResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AddCellResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_AddCell_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *knowledgeGraphServiceClient) RecordFact(ctx context.Context, in *RecordFactRequest, opts ...grpc.CallOption) (*RecordFactResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RecordFactResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_RecordFact_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *knowledgeGraphServiceClient) GetFacts(ctx context.Context, in *GetFactsRequest, opts ...grpc.CallOption) (*GetFactsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetFactsResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_GetFacts_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *knowledgeGraphServiceClient) AddEdge(ctx context.Context, in *AddEdgeRequest, opts ...grpc.CallOption) (*AddEdgeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AddEdgeResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_AddEdge_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *knowledgeGraphServiceClient) GetEdges(ctx context.Context, in *GetEdgesRequest, opts ...grpc.CallOption) (*GetEdgesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetEdgesResponse) + err := c.cc.Invoke(ctx, KnowledgeGraphService_GetEdges_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// KnowledgeGraphServiceServer is the server API for KnowledgeGraphService service. +// All implementations must embed UnimplementedKnowledgeGraphServiceServer +// for forward compatibility. +// +// KnowledgeGraphService provides operations for the knowledge graph pillar. +type KnowledgeGraphServiceServer interface { + // Nodes + CreateNode(context.Context, *CreateNodeRequest) (*CreateNodeResponse, error) + GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) + // Cells + AddCell(context.Context, *AddCellRequest) (*AddCellResponse, error) + // Facts + RecordFact(context.Context, *RecordFactRequest) (*RecordFactResponse, error) + GetFacts(context.Context, *GetFactsRequest) (*GetFactsResponse, error) + // Edges + AddEdge(context.Context, *AddEdgeRequest) (*AddEdgeResponse, error) + GetEdges(context.Context, *GetEdgesRequest) (*GetEdgesResponse, error) + mustEmbedUnimplementedKnowledgeGraphServiceServer() +} + +// UnimplementedKnowledgeGraphServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedKnowledgeGraphServiceServer struct{} + +func (UnimplementedKnowledgeGraphServiceServer) CreateNode(context.Context, *CreateNodeRequest) (*CreateNodeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateNode not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) GetNode(context.Context, *GetNodeRequest) (*GetNodeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetNode not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) AddCell(context.Context, *AddCellRequest) (*AddCellResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AddCell not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) RecordFact(context.Context, *RecordFactRequest) (*RecordFactResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RecordFact not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) GetFacts(context.Context, *GetFactsRequest) (*GetFactsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetFacts not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) AddEdge(context.Context, *AddEdgeRequest) (*AddEdgeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AddEdge not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) GetEdges(context.Context, *GetEdgesRequest) (*GetEdgesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetEdges not implemented") +} +func (UnimplementedKnowledgeGraphServiceServer) mustEmbedUnimplementedKnowledgeGraphServiceServer() {} +func (UnimplementedKnowledgeGraphServiceServer) testEmbeddedByValue() {} + +// UnsafeKnowledgeGraphServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to KnowledgeGraphServiceServer will +// result in compilation errors. +type UnsafeKnowledgeGraphServiceServer interface { + mustEmbedUnimplementedKnowledgeGraphServiceServer() +} + +func RegisterKnowledgeGraphServiceServer(s grpc.ServiceRegistrar, srv KnowledgeGraphServiceServer) { + // If the following call panics, it indicates UnimplementedKnowledgeGraphServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&KnowledgeGraphService_ServiceDesc, srv) +} + +func _KnowledgeGraphService_CreateNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateNodeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).CreateNode(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_CreateNode_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).CreateNode(ctx, req.(*CreateNodeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KnowledgeGraphService_GetNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetNodeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).GetNode(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_GetNode_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).GetNode(ctx, req.(*GetNodeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KnowledgeGraphService_AddCell_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddCellRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).AddCell(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_AddCell_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).AddCell(ctx, req.(*AddCellRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KnowledgeGraphService_RecordFact_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RecordFactRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).RecordFact(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_RecordFact_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).RecordFact(ctx, req.(*RecordFactRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KnowledgeGraphService_GetFacts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetFactsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).GetFacts(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_GetFacts_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).GetFacts(ctx, req.(*GetFactsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KnowledgeGraphService_AddEdge_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddEdgeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).AddEdge(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_AddEdge_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).AddEdge(ctx, req.(*AddEdgeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _KnowledgeGraphService_GetEdges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetEdgesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KnowledgeGraphServiceServer).GetEdges(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: KnowledgeGraphService_GetEdges_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KnowledgeGraphServiceServer).GetEdges(ctx, req.(*GetEdgesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// KnowledgeGraphService_ServiceDesc is the grpc.ServiceDesc for KnowledgeGraphService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var KnowledgeGraphService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "exo.v1.KnowledgeGraphService", + HandlerType: (*KnowledgeGraphServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "CreateNode", + Handler: _KnowledgeGraphService_CreateNode_Handler, + }, + { + MethodName: "GetNode", + Handler: _KnowledgeGraphService_GetNode_Handler, + }, + { + MethodName: "AddCell", + Handler: _KnowledgeGraphService_AddCell_Handler, + }, + { + MethodName: "RecordFact", + Handler: _KnowledgeGraphService_RecordFact_Handler, + }, + { + MethodName: "GetFacts", + Handler: _KnowledgeGraphService_GetFacts_Handler, + }, + { + MethodName: "AddEdge", + Handler: _KnowledgeGraphService_AddEdge_Handler, + }, + { + MethodName: "GetEdges", + Handler: _KnowledgeGraphService_GetEdges_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "exo/v1/kg.proto", +} diff --git a/server/kg_server.go b/server/kg_server.go new file mode 100644 index 0000000..ca8605d --- /dev/null +++ b/server/kg_server.go @@ -0,0 +1,307 @@ +package server + +import ( + "context" + "database/sql" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "git.wntrmute.dev/kyle/exo/artifacts" + "git.wntrmute.dev/kyle/exo/core" + "git.wntrmute.dev/kyle/exo/db" + "git.wntrmute.dev/kyle/exo/kg" + pb "git.wntrmute.dev/kyle/exo/proto/exo/v1" +) + +// KGServer implements the KnowledgeGraphService gRPC service. +type KGServer struct { + pb.UnimplementedKnowledgeGraphServiceServer + database *sql.DB +} + +// NewKGServer creates a new KGServer. +func NewKGServer(database *sql.DB) *KGServer { + return &KGServer{database: database} +} + +func (s *KGServer) CreateNode(ctx context.Context, req *pb.CreateNodeRequest) (*pb.CreateNodeResponse, error) { + if req.Name == "" { + return nil, status.Error(codes.InvalidArgument, "name is required") + } + + nodeType := kg.NodeType(req.Type) + if nodeType == "" { + nodeType = kg.NodeTypeNote + } + + node := kg.NewNode(req.Name, nodeType) + node.ParentID = req.ParentId + node.Tags = core.MapFromList(req.Tags) + node.Categories = core.MapFromList(req.Categories) + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + // Create tags and categories idempotently. + for tag := range node.Tags { + if err := artifacts.CreateTag(ctx, tx, tag); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to create tag: %v", err) + } + } + for cat := range node.Categories { + if err := artifacts.CreateCategory(ctx, tx, cat); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to create category: %v", err) + } + } + + if err := node.Store(ctx, tx); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to store node: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + return &pb.CreateNodeResponse{Id: node.ID}, nil +} + +func (s *KGServer) GetNode(ctx context.Context, req *pb.GetNodeRequest) (*pb.GetNodeResponse, error) { + if req.Id == "" { + return nil, status.Error(codes.InvalidArgument, "id is required") + } + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + node := &kg.Node{ID: req.Id} + if err := node.Get(ctx, tx); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.NotFound, "node not found: %v", err) + } + + cells, err := kg.GetCellsForNode(ctx, tx, req.Id) + if err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to get cells: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + resp := &pb.GetNodeResponse{ + Node: &pb.Node{ + Id: node.ID, + ParentId: node.ParentID, + Name: node.Name, + Type: string(node.Type), + Created: db.ToDBTime(node.Created), + Modified: db.ToDBTime(node.Modified), + Children: node.Children, + Tags: core.ListFromMap(node.Tags), + Categories: core.ListFromMap(node.Categories), + }, + } + for _, c := range cells { + resp.Cells = append(resp.Cells, &pb.Cell{ + Id: c.ID, + NodeId: c.NodeID, + Type: string(c.Type), + Contents: c.Contents, + Ordinal: int32(c.Ordinal), //nolint:gosec // ordinal values are small + Created: db.ToDBTime(c.Created), + Modified: db.ToDBTime(c.Modified), + }) + } + + return resp, nil +} + +func (s *KGServer) AddCell(ctx context.Context, req *pb.AddCellRequest) (*pb.AddCellResponse, error) { + if req.NodeId == "" { + return nil, status.Error(codes.InvalidArgument, "node_id is required") + } + + cellType := kg.CellType(req.Type) + if cellType == "" { + cellType = kg.CellTypeMarkdown + } + + cell := kg.NewCell(req.NodeId, cellType, req.Contents) + cell.Ordinal = int(req.Ordinal) + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + if err := cell.Store(ctx, tx); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to store cell: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + return &pb.AddCellResponse{Id: cell.ID}, nil +} + +func (s *KGServer) RecordFact(ctx context.Context, req *pb.RecordFactRequest) (*pb.RecordFactResponse, error) { + if req.EntityId == "" { + return nil, status.Error(codes.InvalidArgument, "entity_id is required") + } + + value := core.Value{} + if req.Value != nil { + value = core.Value{Contents: req.Value.Contents, Type: req.Value.Type} + } + + fact := kg.NewFact(req.EntityId, req.EntityName, req.AttributeId, req.AttributeName, value) + fact.Retraction = req.Retraction + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + if err := fact.Store(ctx, tx); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to store fact: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + return &pb.RecordFactResponse{Id: fact.ID}, nil +} + +func (s *KGServer) GetFacts(ctx context.Context, req *pb.GetFactsRequest) (*pb.GetFactsResponse, error) { + if req.EntityId == "" { + return nil, status.Error(codes.InvalidArgument, "entity_id is required") + } + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + var facts []*kg.Fact + if req.CurrentOnly { + facts, err = kg.GetCurrentFactsForEntity(ctx, tx, req.EntityId) + } else { + facts, err = kg.GetFactsForEntity(ctx, tx, req.EntityId) + } + if err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to get facts: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + resp := &pb.GetFactsResponse{} + for _, f := range facts { + resp.Facts = append(resp.Facts, &pb.Fact{ + Id: f.ID, + EntityId: f.EntityID, + EntityName: f.EntityName, + AttributeId: f.AttributeID, + AttributeName: f.AttributeName, + Value: &pb.Value{Contents: f.Value.Contents, Type: f.Value.Type}, + TxTimestamp: f.TxTimestamp.Unix(), + Retraction: f.Retraction, + }) + } + + return resp, nil +} + +func (s *KGServer) AddEdge(ctx context.Context, req *pb.AddEdgeRequest) (*pb.AddEdgeResponse, error) { + if req.SourceId == "" || req.TargetId == "" { + return nil, status.Error(codes.InvalidArgument, "source_id and target_id are required") + } + + edge := kg.NewEdge(req.SourceId, req.TargetId, kg.EdgeRelation(req.Relation)) + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + if err := edge.Store(ctx, tx); err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to store edge: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + return &pb.AddEdgeResponse{Id: edge.ID}, nil +} + +func (s *KGServer) GetEdges(ctx context.Context, req *pb.GetEdgesRequest) (*pb.GetEdgesResponse, error) { + if req.NodeId == "" { + return nil, status.Error(codes.InvalidArgument, "node_id is required") + } + + tx, err := db.StartTX(ctx, s.database) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to start transaction: %v", err) + } + + var edges []*kg.Edge + switch req.Direction { + case "to": + edges, err = kg.GetEdgesTo(ctx, tx, req.NodeId) + case "from", "": + edges, err = kg.GetEdgesFrom(ctx, tx, req.NodeId) + case "both": + from, err2 := kg.GetEdgesFrom(ctx, tx, req.NodeId) + if err2 != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to get edges: %v", err2) + } + to, err2 := kg.GetEdgesTo(ctx, tx, req.NodeId) + if err2 != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to get edges: %v", err2) + } + edges = append(from, to...) + default: + _ = tx.Rollback() + return nil, status.Errorf(codes.InvalidArgument, "direction must be 'from', 'to', or 'both'") + } + if err != nil { + _ = tx.Rollback() + return nil, status.Errorf(codes.Internal, "failed to get edges: %v", err) + } + + if err := tx.Commit(); err != nil { + return nil, status.Errorf(codes.Internal, "failed to commit: %v", err) + } + + resp := &pb.GetEdgesResponse{} + for _, e := range edges { + resp.Edges = append(resp.Edges, &pb.Edge{ + Id: e.ID, + SourceId: e.SourceID, + TargetId: e.TargetID, + Relation: string(e.Relation), + Created: db.ToDBTime(e.Created), + }) + } + + return resp, nil +}