This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user