package inventory import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) type Repository struct{ pool *pgxpool.Pool } // PersistDiscovery atomically applies one source snapshot. It updates discovered // entities/facts/aliases/relations only; user overrides are deliberately untouched. func (r *Repository) PersistDiscovery(ctx context.Context, entity Entity, alias Alias, facts []Fact, relations []Relation) error { return r.InTx(ctx, func(txctx context.Context, tx pgx.Tx) error { if err := validateEntity(entity); err != nil { return err } if _, err := tx.Exec(txctx, `INSERT INTO entities (id,entity_type,canonical_name,display_name,status,first_seen_at,last_seen_at,tombstoned_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (id) DO UPDATE SET entity_type=EXCLUDED.entity_type,canonical_name=EXCLUDED.canonical_name,display_name=EXCLUDED.display_name,status=EXCLUDED.status,last_seen_at=EXCLUDED.last_seen_at,tombstoned_at=EXCLUDED.tombstoned_at`, entity.ID, entity.EntityType, entity.CanonicalName, entity.DisplayName, entity.Status, entity.FirstSeenAt, entity.LastSeenAt, entity.TombstonedAt); err != nil { return fmt.Errorf("persist discovery entity: %w", err) } if err := upsertAlias(txctx, tx, alias); err != nil { return err } for _, fact := range facts { if err := upsertFact(txctx, tx, fact); err != nil { return err } } for _, relation := range relations { if err := upsertRelation(txctx, tx, relation); err != nil { return err } } return nil }) } func NewRepository(pool *pgxpool.Pool) (*Repository, error) { if pool == nil { return nil, errors.New("inventory repository requires a database pool") } return &Repository{pool: pool}, nil } func (r *Repository) InTx(ctx context.Context, fn func(context.Context, pgx.Tx) error) error { tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return fmt.Errorf("begin inventory transaction: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if err := fn(ctx, tx); err != nil { return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit inventory transaction: %w", err) } return nil } func (r *Repository) CreateEntity(ctx context.Context, entity Entity) error { if err := validateEntity(entity); err != nil { return err } _, err := r.pool.Exec(ctx, `INSERT INTO entities (id, entity_type, canonical_name, display_name, status, first_seen_at, last_seen_at, tombstoned_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, entity.ID, entity.EntityType, entity.CanonicalName, entity.DisplayName, entity.Status, entity.FirstSeenAt, entity.LastSeenAt, entity.TombstonedAt) if err != nil { return fmt.Errorf("create entity: %w", err) } return nil } func (r *Repository) UpsertEntity(ctx context.Context, entity Entity) error { if err := validateEntity(entity); err != nil { return err } _, err := r.pool.Exec(ctx, `INSERT INTO entities (id, entity_type, canonical_name, display_name, status, first_seen_at, last_seen_at, tombstoned_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (id) DO UPDATE SET entity_type=EXCLUDED.entity_type, canonical_name=EXCLUDED.canonical_name, display_name=EXCLUDED.display_name, status=EXCLUDED.status, last_seen_at=EXCLUDED.last_seen_at, tombstoned_at=EXCLUDED.tombstoned_at`, entity.ID, entity.EntityType, entity.CanonicalName, entity.DisplayName, entity.Status, entity.FirstSeenAt, entity.LastSeenAt, entity.TombstonedAt) if err != nil { return fmt.Errorf("upsert entity: %w", err) } return nil } func (r *Repository) GetEntity(ctx context.Context, id string) (Entity, error) { var e Entity err := r.pool.QueryRow(ctx, `SELECT id, entity_type, canonical_name, display_name, status, first_seen_at, last_seen_at, tombstoned_at FROM entities WHERE id=$1`, id).Scan(&e.ID, &e.EntityType, &e.CanonicalName, &e.DisplayName, &e.Status, &e.FirstSeenAt, &e.LastSeenAt, &e.TombstonedAt) if err != nil { return Entity{}, fmt.Errorf("get entity: %w", err) } return e, nil } func (r *Repository) ListEntities(ctx context.Context, limit int) ([]Entity, error) { if limit < 1 || limit > 100000 { return nil, errors.New("entity list limit must be between 1 and 100000") } rows, err := r.pool.Query(ctx, `SELECT id, entity_type, canonical_name, display_name, status, first_seen_at, last_seen_at, tombstoned_at FROM entities ORDER BY canonical_name ASC, id ASC LIMIT $1`, limit) if err != nil { return nil, fmt.Errorf("list entities: %w", err) } defer rows.Close() result := make([]Entity, 0) for rows.Next() { var e Entity if err := rows.Scan(&e.ID, &e.EntityType, &e.CanonicalName, &e.DisplayName, &e.Status, &e.FirstSeenAt, &e.LastSeenAt, &e.TombstonedAt); err != nil { return nil, fmt.Errorf("scan entity: %w", err) } result = append(result, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list entities rows: %w", err) } return result, nil } func (r *Repository) ListEntitiesAfter(ctx context.Context, limit int, afterName, afterID string) ([]Entity, error) { if limit < 1 || limit > 100 { return nil, errors.New("entity page limit must be between 1 and 100") } rows, err := r.pool.Query(ctx, `SELECT id, entity_type, canonical_name, display_name, status, first_seen_at, last_seen_at, tombstoned_at FROM entities WHERE ($1 = '' OR canonical_name > $1 OR (canonical_name = $1 AND id::text > $2)) ORDER BY canonical_name ASC, id ASC LIMIT $3`, afterName, afterID, limit) if err != nil { return nil, fmt.Errorf("list entity page: %w", err) } defer rows.Close() result := make([]Entity, 0, limit) for rows.Next() { var e Entity if err := rows.Scan(&e.ID, &e.EntityType, &e.CanonicalName, &e.DisplayName, &e.Status, &e.FirstSeenAt, &e.LastSeenAt, &e.TombstonedAt); err != nil { return nil, fmt.Errorf("scan entity page: %w", err) } result = append(result, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list entity page rows: %w", err) } return result, nil } func (r *Repository) UpsertAlias(ctx context.Context, alias Alias) error { return upsertAlias(ctx, r.pool, alias) } func (r *Repository) ListFacts(ctx context.Context, entityID string) ([]Fact, error) { rows, err := r.pool.Query(ctx, `SELECT entity_id,field_name,source_id,value,observed_at,confidence,valid_until FROM entity_facts WHERE entity_id=$1 ORDER BY field_name ASC, source_id ASC`, entityID) if err != nil { return nil, fmt.Errorf("list entity facts: %w", err) } defer rows.Close() result := make([]Fact, 0) for rows.Next() { var f Fact if err := rows.Scan(&f.EntityID, &f.FieldName, &f.SourceID, &f.Value, &f.ObservedAt, &f.Confidence, &f.ValidUntil); err != nil { return nil, fmt.Errorf("scan entity fact: %w", err) } result = append(result, f) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list entity facts rows: %w", err) } return result, nil } func (r *Repository) GetOverride(ctx context.Context, entityID, field string) (Override, error) { var o Override err := r.pool.QueryRow(ctx, `SELECT entity_id,field_name,value,COALESCE(user_id::text,''),updated_at FROM entity_overrides WHERE entity_id=$1 AND field_name=$2`, entityID, field).Scan(&o.EntityID, &o.FieldName, &o.Value, &o.UserID, &o.UpdatedAt) if err != nil { return Override{}, fmt.Errorf("get entity override: %w", err) } return o, nil } func (r *Repository) ListRelations(ctx context.Context, entityID string) ([]Relation, error) { rows, err := r.pool.Query(ctx, `SELECT id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at,tombstoned_at FROM entity_relations WHERE source_entity_id=$1 OR target_entity_id=$1 ORDER BY relation_type ASC,id ASC`, entityID) if err != nil { return nil, fmt.Errorf("list entity relations: %w", err) } defer rows.Close() result := make([]Relation, 0) for rows.Next() { var rel Relation if err := rows.Scan(&rel.ID, &rel.SourceEntityID, &rel.RelationType, &rel.TargetEntityID, &rel.SourceID, &rel.Confidence, &rel.Confirmed, &rel.FirstSeenAt, &rel.LastSeenAt, &rel.TombstonedAt); err != nil { return nil, fmt.Errorf("scan entity relation: %w", err) } result = append(result, rel) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list entity relations rows: %w", err) } return result, nil } func (r *Repository) DeleteFact(ctx context.Context, entityID, fieldName, sourceID string) error { _, err := r.pool.Exec(ctx, `DELETE FROM entity_facts WHERE entity_id=$1 AND field_name=$2 AND source_id=$3`, entityID, fieldName, sourceID) if err != nil { return fmt.Errorf("delete entity fact: %w", err) } return nil } func (r *Repository) DeleteRelation(ctx context.Context, sourceEntityID, relationType, targetEntityID, sourceID string) error { _, err := r.pool.Exec(ctx, `DELETE FROM entity_relations WHERE source_entity_id=$1 AND relation_type=$2 AND target_entity_id=$3 AND source_id=$4`, sourceEntityID, relationType, targetEntityID, sourceID) if err != nil { return fmt.Errorf("delete entity relation: %w", err) } return nil } func (r *Repository) UpsertFact(ctx context.Context, fact Fact) error { return upsertFact(ctx, r.pool, fact) } func (r *Repository) UpsertOverride(ctx context.Context, override Override) error { return upsertOverride(ctx, r.pool, override) } func (r *Repository) DeleteOverride(ctx context.Context, entityID, field string) error { _, err := r.pool.Exec(ctx, `DELETE FROM entity_overrides WHERE entity_id=$1 AND field_name=$2`, entityID, field) if err != nil { return fmt.Errorf("delete override: %w", err) } return nil } func (r *Repository) UpsertRelation(ctx context.Context, relation Relation) error { return upsertRelation(ctx, r.pool, relation) } func upsertAlias(ctx context.Context, q interface { Exec(context.Context, string, ...any) (pgconn.CommandTag, error) }, a Alias) error { if err := a.Validate(); err != nil { return err } _, err := q.Exec(ctx, `INSERT INTO entity_aliases (entity_id,source_id,external_type,external_id) VALUES ($1,$2,$3,$4) ON CONFLICT (source_id,external_type,external_id) DO UPDATE SET entity_id=EXCLUDED.entity_id`, a.EntityID, a.SourceID, a.ExternalType, a.ExternalID) if err != nil { return fmt.Errorf("upsert entity alias: %w", err) } return nil } func upsertFact(ctx context.Context, q interface { Exec(context.Context, string, ...any) (pgconn.CommandTag, error) }, f Fact) error { if err := f.Validate(); err != nil { return err } _, err := q.Exec(ctx, `INSERT INTO entity_facts (entity_id,field_name,source_id,value,observed_at,confidence,valid_until) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (entity_id,field_name,source_id) DO UPDATE SET value=EXCLUDED.value,observed_at=EXCLUDED.observed_at,confidence=EXCLUDED.confidence,valid_until=EXCLUDED.valid_until`, f.EntityID, f.FieldName, f.SourceID, f.Value, f.ObservedAt, f.Confidence, f.ValidUntil) if err != nil { return fmt.Errorf("upsert entity fact: %w", err) } return nil } func upsertOverride(ctx context.Context, q interface { Exec(context.Context, string, ...any) (pgconn.CommandTag, error) }, o Override) error { if err := o.Validate(); err != nil { return err } _, err := q.Exec(ctx, `INSERT INTO entity_overrides (entity_id,field_name,value,user_id,updated_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (entity_id,field_name) DO UPDATE SET value=EXCLUDED.value,user_id=EXCLUDED.user_id,updated_at=EXCLUDED.updated_at`, o.EntityID, o.FieldName, o.Value, nullableID(o.UserID), o.UpdatedAt) if err != nil { return fmt.Errorf("upsert entity override: %w", err) } return nil } func nullableID(value string) any { if value == "" { return nil } return value } func upsertRelation(ctx context.Context, q interface { Exec(context.Context, string, ...any) (pgconn.CommandTag, error) }, rel Relation) error { if rel.ID == "" || rel.SourceEntityID == "" || rel.TargetEntityID == "" || rel.SourceID == "" || rel.RelationType == "" || rel.FirstSeenAt == nil || rel.Confidence < 0 || rel.Confidence > 1 { return errors.New("invalid entity relation") } _, err := q.Exec(ctx, `INSERT INTO entity_relations (id,source_entity_id,relation_type,target_entity_id,source_id,confidence,confirmed,first_seen_at,last_seen_at,tombstoned_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (source_entity_id,relation_type,target_entity_id,source_id) DO UPDATE SET confidence=EXCLUDED.confidence,confirmed=EXCLUDED.confirmed,last_seen_at=EXCLUDED.last_seen_at,tombstoned_at=EXCLUDED.tombstoned_at`, rel.ID, rel.SourceEntityID, rel.RelationType, rel.TargetEntityID, rel.SourceID, rel.Confidence, rel.Confirmed, rel.FirstSeenAt, rel.LastSeenAt, rel.TombstonedAt) if err != nil { return fmt.Errorf("upsert entity relation: %w", err) } return nil } func validateEntity(e Entity) error { if e.ID == "" || e.EntityType == "" || e.CanonicalName == "" || e.DisplayName == "" || e.Status == "" || e.FirstSeenAt == nil { return errors.New("invalid entity") } if e.LastSeenAt != nil && e.LastSeenAt.Before(*e.FirstSeenAt) { return errors.New("entity last seen precedes first seen") } return nil } func MarshalValue(v any) ([]byte, error) { b, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("marshal inventory value: %w", err) } return b, nil }