feat: fn_operations library — entities, relations, types_snapshot con FTS y ciclos
Paquete Go completo con modelos (Entity, Relation, RelationInput, TypeSnapshot), DB SQLite con WAL + FTS5 en entities, CRUD para las 4 tablas, validacion de integridad, deteccion de ciclos solo en relaciones causales (via != ''), y operaciones de alto nivel con snapshot automatico de tipos del registry. 9 tests, todos pasan.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package fn_operations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fn-registry/registry"
|
||||
)
|
||||
|
||||
// InsertEntityWithSnapshot inserts an entity, snapshotting its type from the registry if needed.
|
||||
// registryDB can be nil if the type is already snapshotted.
|
||||
func InsertEntityWithSnapshot(opsDB *DB, registryDB *registry.DB, e *Entity) error {
|
||||
if err := ValidateEntity(e); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if type is already snapshotted
|
||||
snap, err := opsDB.GetTypeSnapshot(e.TypeRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking type snapshot: %w", err)
|
||||
}
|
||||
|
||||
if snap == nil {
|
||||
// Need to fetch from registry
|
||||
if registryDB == nil {
|
||||
return fmt.Errorf("type %q not found in local snapshots and no registry provided", e.TypeRef)
|
||||
}
|
||||
if err := SnapshotType(opsDB, registryDB, e.TypeRef); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return opsDB.InsertEntity(e)
|
||||
}
|
||||
|
||||
// SnapshotType fetches a type from the registry and copies it to types_snapshot.
|
||||
func SnapshotType(opsDB *DB, registryDB *registry.DB, typeID string) error {
|
||||
t, err := registryDB.GetType(typeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching type %q from registry: %w", typeID, err)
|
||||
}
|
||||
|
||||
snap := &TypeSnapshot{
|
||||
ID: t.ID,
|
||||
Version: t.Version,
|
||||
Lang: t.Lang,
|
||||
Algebraic: string(t.Algebraic),
|
||||
Definition: t.Definition,
|
||||
Description: t.Description,
|
||||
SnappedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
return opsDB.InsertTypeSnapshot(snap)
|
||||
}
|
||||
|
||||
// InsertRelationSafe validates, checks for cycles, and inserts a relation.
|
||||
func InsertRelationSafe(db *DB, r *Relation) error {
|
||||
entities, err := buildEntitySet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateRelation(r, entities); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// from_entity is required when not using relation_inputs
|
||||
if r.FromEntity == "" {
|
||||
return fmt.Errorf("from_entity is required (use InsertRelationWithInputs for multi-input relations)")
|
||||
}
|
||||
|
||||
// Cycle detection only for causal relations
|
||||
if r.Via != "" {
|
||||
if err := DetectCycle(db, r.FromEntity, r.ToEntity); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return db.InsertRelation(r)
|
||||
}
|
||||
|
||||
// InsertRelationWithInputs validates and inserts a relation with multiple inputs in a transaction.
|
||||
func InsertRelationWithInputs(db *DB, r *Relation, inputs []RelationInput) error {
|
||||
entities, err := buildEntitySet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateRelation(r, entities); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateRelationInputs(inputs, entities); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Cycle detection for each input if causal
|
||||
if r.Via != "" {
|
||||
for _, input := range inputs {
|
||||
if err := DetectCycle(db, input.EntityID, r.ToEntity); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Conn().Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Insert relation
|
||||
now := time.Now().UTC()
|
||||
if r.CreatedAt.IsZero() {
|
||||
r.CreatedAt = now
|
||||
}
|
||||
r.UpdatedAt = now
|
||||
|
||||
var startedAt, endedAt *string
|
||||
if r.StartedAt != nil {
|
||||
s := r.StartedAt.Format(time.RFC3339)
|
||||
startedAt = &s
|
||||
}
|
||||
if r.EndedAt != nil {
|
||||
s := r.EndedAt.Format(time.RFC3339)
|
||||
endedAt = &s
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT OR REPLACE INTO relations (id, name, from_entity, to_entity, via, description, purity, direction, weight, status, started_at, ended_at, "order", tags, notes, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.ID, r.Name, r.FromEntity, r.ToEntity, r.Via, r.Description,
|
||||
r.Purity, string(r.Direction), r.Weight, string(r.Status),
|
||||
startedAt, endedAt, r.Order, marshalStrings(r.Tags), r.Notes,
|
||||
r.CreatedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting relation: %w", err)
|
||||
}
|
||||
|
||||
// Insert inputs
|
||||
for _, ri := range inputs {
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO relation_inputs (id, relation_id, entity_id, role, "order")
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
ri.ID, ri.RelationID, ri.EntityID, ri.Role, ri.Order,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting relation_input: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Graph holds the full entity-relation graph for a project.
|
||||
type Graph struct {
|
||||
Entities []Entity
|
||||
Relations []Relation
|
||||
Inputs map[string][]RelationInput
|
||||
}
|
||||
|
||||
// GetEntityGraph returns all entities and relations for visualization.
|
||||
func GetEntityGraph(db *DB) (*Graph, error) {
|
||||
entities, err := db.ListEntities("", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing entities: %w", err)
|
||||
}
|
||||
|
||||
relations, err := db.ListRelations("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing relations: %w", err)
|
||||
}
|
||||
|
||||
inputs := map[string][]RelationInput{}
|
||||
for _, r := range relations {
|
||||
ri, err := db.GetRelationInputs(r.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting inputs for relation %s: %w", r.ID, err)
|
||||
}
|
||||
if len(ri) > 0 {
|
||||
inputs[r.ID] = ri
|
||||
}
|
||||
}
|
||||
|
||||
return &Graph{
|
||||
Entities: entities,
|
||||
Relations: relations,
|
||||
Inputs: inputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildEntitySet(db *DB) (map[string]bool, error) {
|
||||
all, err := db.ListEntities("", "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building entity set: %w", err)
|
||||
}
|
||||
set := make(map[string]bool, len(all))
|
||||
for _, e := range all {
|
||||
set[e.ID] = true
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
Reference in New Issue
Block a user