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 }