internal/artifact/relations.go
Rama main · réplica de lectura del repositorio original.
package artifact
import (
"slices"
"strconv"
"github.com/stump-wtf/cairn/internal/errs"
)
// A relation is one artifact naming another it replies to, derives from, or
// follows. Relations are declared at create and never edited: an edge written
// after the fact would rewrite the provenance record the artifact exists to
// keep (SPEC-0024 REQ "Relation Kinds and Limits").
//
// The kind set is deliberately closed and small. Each member answers a
// different question about why the link exists, and anything answerable by a
// tag is a tag, not a relation.
// RelationKind is the type of a relation between two artifacts.
type RelationKind string
const (
RelationReplyTo RelationKind = "reply_to"
RelationDerivedFrom RelationKind = "derived_from"
RelationFollows RelationKind = "follows"
)
// relationKinds is the closed kind set, in the order a surface lists it.
var relationKinds = []RelationKind{RelationReplyTo, RelationDerivedFrom, RelationFollows}
// MaxRelations caps the relations one artifact may declare. The bound is what
// lets the read path return the whole set without paging it, and it keeps one
// create from turning the reverse lookup into a scan.
const MaxRelations = 8
// maxRelationIDBytes bounds a target public id before lookup. A minted id is
// far shorter, so anything longer is either a mistyped paste or an attempt to
// push a payload into a column sized for ids.
const maxRelationIDBytes = 32
// Relation is one declared link: the kind and the target's public id. The id is
// what the creator named; a read that cannot open the target returns neither it
// nor a title (SPEC-0024 REQ "Relations on Read").
type Relation struct {
Kind RelationKind
ID string
}
// ErrRelationTargetNotFound is the single failure every unusable target
// produces: an id that does not exist, one whose artifact has expired, and one
// the creator cannot read all produce this error and nothing else. The
// distinction between those three is itself a probe answer, so the create that
// fails carries no id, no reason, and no hint of which case it was
// (SPEC-0024 REQ "Relation Targets Must Be Readable by the Creator").
//
// It is validation-coded, not a new violation reason: the wire shape stays the
// bare validation envelope, which is what makes the three responses
// byte-identical. A new reason would have to join SPEC-0019's closed registry
// and its public error reference before it could be emitted.
var ErrRelationTargetNotFound = errs.New(errs.CodeValidation, "relation target not found")
// RelationTargetNotFound is the error a create returns for a target it cannot
// use. It is always the same value: callers match it with errors.Is.
func RelationTargetNotFound() error { return ErrRelationTargetNotFound }
// NormalizeRelations validates declared relations and returns them deduplicated
// on (kind, id) in first-occurrence order, which is the order a read renders
// them in and the order their positions are stored in. Nothing is reordered,
// lowercased or truncated. Nil or empty input yields nil.
//
// Every failing relation is reported, each as a violation on relations[i], so a
// caller fixes them all in one round trip.
//
// Governing: SPEC-0024 REQ "Relation Kinds and Limits"; ADR-0025,
// SPEC-0019 VE-1, VE-4
func NormalizeRelations(raw []Relation) ([]Relation, error) {
if len(raw) == 0 {
return nil, nil
}
out := make([]Relation, 0, len(raw))
seen := make(map[Relation]struct{}, len(raw))
var bad []*errs.Invalid
tooMany := false
for i, r := range raw {
if inv := CheckRelation(r, relationField(i), errs.LocBody); inv != nil {
bad = append(bad, inv)
continue
}
if _, dup := seen[r]; dup {
continue
}
if len(out) == MaxRelations {
tooMany = true
continue
}
seen[r] = struct{}{}
out = append(out, r)
}
if tooMany {
bad = append(bad, TooManyRelations("relations", errs.LocBody))
}
if inv := errs.Join(bad...); inv != nil {
return nil, inv
}
return out, nil
}
// ValidateRelation checks one relation: a kind from the closed set and a
// well-formed target id.
func ValidateRelation(r Relation) error {
if inv := CheckRelation(r, "relation", errs.LocBody); inv != nil {
return inv
}
return nil
}
// CheckRelation is ValidateRelation reporting a violation on the caller's own
// field and location, or nil when the relation is valid.
//
// Governing: SPEC-0024 REQ "Relation Kinds and Limits", REQ "Input Handling";
// ADR-0025
func CheckRelation(r Relation, field string, loc errs.Location) *errs.Invalid {
if inv := CheckRelationKind(r.Kind, field+".kind", loc); inv != nil {
return inv
}
return CheckRelationID(r.ID, field+".id", loc)
}
// CheckRelationKind validates a relation kind against the closed set.
func CheckRelationKind(k RelationKind, field string, loc errs.Location) *errs.Invalid {
switch {
case k == "":
return errs.Violate(field, loc, errs.ReasonRequired)
case !slices.Contains(relationKinds, k):
return errs.Violate(field, loc, errs.ReasonUnknownValue,
errs.WithValue(string(k)), errs.WithExpect("reply_to, derived_from or follows"))
}
return nil
}
// CheckRelationID validates a target id against the public-id grammar before
// anything looks it up: base62, within the length bound, and not a reserved
// route word (which no minted id can be). An id that fails here is reported
// precisely, which is safe — it names the caller's own typo, not the state of
// some other artifact.
//
// Governing: ADR-0005, SPEC-0024 REQ "Input Handling"
func CheckRelationID(id, field string, loc errs.Location) *errs.Invalid {
switch {
case id == "":
return errs.Violate(field, loc, errs.ReasonRequired)
case len(id) > maxRelationIDBytes:
return errs.Violate(field, loc, errs.ReasonTooLong,
errs.WithValue(id), errs.WithLimit(maxRelationIDBytes, errs.UnitBytes))
}
for i := 0; i < len(id); i++ {
c := id[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
continue
}
return errs.Violate(field, loc, errs.ReasonInvalidCharset,
errs.WithValue(id), errs.WithExpect("base62"))
}
return nil
}
// TooManyRelations is the violation for a list of more than MaxRelations
// distinct relations.
func TooManyRelations(field string, loc errs.Location) *errs.Invalid {
return errs.Violate(field, loc, errs.ReasonTooMany, errs.WithLimit(MaxRelations, errs.UnitCount))
}
// relationField names one relation in a list for its violation, the way
// tags[i] names one tag.
func relationField(i int) string {
return "relations[" + strconv.Itoa(i) + "]"
}
// validateNormalizedRelations is the aggregate invariant: relations already
// normalized, so a persisted artifact can never hold a malformed or repeated
// relation, and never more than MaxRelations of them.
func validateNormalizedRelations(relations []Relation) error {
norm, err := NormalizeRelations(relations)
if err != nil {
return err
}
if len(norm) != len(relations) {
return errs.Validationf("artifact: relations must not repeat")
}
return nil
}