Files
goclaw/internal/store/pg/knowledge_graph_temporal.go
viettranx d29c3dd943 refactor(store/pg): rename mustParseUUID to parseUUIDOrNil (honest name)
Final step of Phase 4: rename the silently-nil-on-error helper so its
behavior is self-documenting at the call site. No behavior change — all
remaining call sites (~24) were already classified as WARN-acceptable
(read-only SELECT WHERE paths with validated inputs from cursor
pagination or WS boundary) during sub-step 4b migration.

The parseUUID error variant stays as-is for CRITICAL writes.

Also cleans up a now-stale comment in vault_handler_upload.go.

Phase 4 Step 4d of agent identity hardening (TD-1).
2026-04-11 21:22:23 +07:00

116 lines
3.4 KiB
Go

package pg
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// ListEntitiesTemporal queries entities with temporal awareness.
// AsOf=nil: current facts only (valid_until IS NULL). AsOf set: facts valid at that time.
func (s *PGKnowledgeGraphStore) ListEntitiesTemporal(ctx context.Context, agentID, userID string, opts store.EntityListOptions, temporal store.TemporalQueryOptions) ([]store.Entity, error) {
aid := parseUUIDOrNil(agentID)
limit := opts.Limit
if limit <= 0 {
limit = 100
}
q := `SELECT id, agent_id, user_id, external_id, name, entity_type, description,
properties, source_id, confidence, created_at, updated_at, valid_from, valid_until
FROM kg_entities WHERE agent_id = $1 AND user_id = $2`
args := []any{aid, userID}
argN := 3
if opts.EntityType != "" {
q += fmt.Sprintf(` AND entity_type = $%d`, argN)
args = append(args, opts.EntityType)
argN++
}
// Temporal filter
if !temporal.IncludeExpired {
if temporal.AsOf != nil {
q += fmt.Sprintf(` AND valid_from <= $%d AND (valid_until IS NULL OR valid_until >= $%d)`, argN, argN)
args = append(args, *temporal.AsOf)
argN++
} else {
q += ` AND valid_until IS NULL`
}
}
// Tenant scope
tc, tcArgs, _, err := scopeClause(ctx, argN)
if err != nil {
return nil, err
}
if tc != "" {
q += tc
args = append(args, tcArgs...)
argN += len(tcArgs)
}
q += fmt.Sprintf(` ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, argN, argN+1)
args = append(args, limit, opts.Offset)
var tRows []entityTemporalRow
if err := pkgSqlxDB.SelectContext(ctx, &tRows, q, args...); err != nil {
return nil, fmt.Errorf("list entities temporal: %w", err)
}
entities := make([]store.Entity, len(tRows))
for i := range tRows {
entities[i] = tRows[i].toEntity()
}
return entities, nil
}
// SupersedeEntity atomically expires the old entity and inserts a replacement.
func (s *PGKnowledgeGraphStore) SupersedeEntity(ctx context.Context, old *store.Entity, replacement *store.Entity) error {
aid, err := parseUUID(old.AgentID)
if err != nil {
return fmt.Errorf("kg supersede entity: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("supersede begin tx: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC()
tid := tenantIDForInsert(ctx)
// Tenant scope for UPDATE
tc, tcArgs, _, err := scopeClause(ctx, 6)
if err != nil {
return err
}
// Expire old entity
expireArgs := append([]any{now, now, aid, old.UserID, old.ExternalID}, tcArgs...)
_, err = tx.ExecContext(ctx, `
UPDATE kg_entities SET valid_until = $1, updated_at = $2
WHERE agent_id = $3 AND user_id = $4 AND external_id = $5 AND valid_until IS NULL`+tc,
expireArgs...)
if err != nil {
return fmt.Errorf("supersede expire old: %w", err)
}
// Insert replacement with valid_from = now
props, _ := json.Marshal(replacement.Properties)
_, err = tx.ExecContext(ctx, `
INSERT INTO kg_entities (id, agent_id, user_id, external_id, name, entity_type,
description, properties, source_id, confidence, tenant_id, created_at, updated_at, valid_from)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,
aid, replacement.UserID, replacement.ExternalID,
replacement.Name, replacement.EntityType, replacement.Description,
props, replacement.SourceID, replacement.Confidence, tid, now, now)
if err != nil {
return fmt.Errorf("supersede insert new: %w", err)
}
return tx.Commit()
}