Documentation
¶
Overview ¶
Package comments holds user commentary attached to entities: a remark about a property or a view section, stored alongside the graph rather than in it.
Why this is not in the graph ¶
A comment is a remark *about* an entity, not a fact *in* the domain model the operator declared in schema.yaml. Modeling one as an entity would mean fabricating a type the operator never wrote, and would drag commentary through every mechanism the graph owns: the audit log, version capture, the search index, `analyze_*`, and `/_schema`. None of those are wanted for a note someone left on a field.
So this is a separate service with its own backends, deliberately outside store.Store and entitymanager — the same call internal/userstate makes for snoozes, and for the same reason.
Authorship is stamped, never supplied ¶
Comment.Author and Comment.ID are written by the server from the request principal; a client-supplied value for either is ignored. This is the reason the service owns its own write path rather than routing through the graph: none of the in-graph seams can stamp a trustworthy identity (automation template vars resolve to the *git config* user, computed properties cannot see the principal, and the elevated Lua write handle deliberately exposes no entity write). A forgeable author on a comment is a forgeable attribution of what someone said.
Ordering and identity are part of the contract ¶
Store.List returns comments oldest-first, ties broken by ID, so a thread renders identically on every backend. Implementations must not return storage order. [commentstest.RunAll] pins this, along with the concurrency and re-key behavior below — a contract asserted in one place beats three backends that each behave slightly differently.
Lifecycle ¶
Comments are keyed by target entity ID, so the service tracks the graph: Service.EntityRenamed re-keys a target's comments and Service.EntityDeleted removes them. Rename emits exactly one store callback (never delete+put), so a service that ignored it would silently strand every comment on a renamed entity.
Index ¶
- Constants
- Variables
- func ApplyReplacement(body string, a Anchor) (string, error)
- func Detached(a Anchor, properties map[string]bool, body *Body) bool
- func FacePrefixPattern(id string) string
- func FindRenderedQuote(body, quote, prefix, suffix string) (start, end int, ok bool)
- func LiveProperties[D, S any](declared map[string]D, set map[string]S) map[string]bool
- func Replaceable(t *TextAnchor) bool
- func SortComments(list []Comment)
- func ValidateBody(body string) error
- type Accepted
- type Action
- type AddInput
- type AddRequest
- type Anchor
- type AnchorKind
- type AnchorView
- type Authorizer
- func (a *Authorizer) CanAdd(ctx context.Context, target Target) bool
- func (a *Authorizer) CanDelete(ctx context.Context, target Target, c Comment, principalUser string) bool
- func (a *Authorizer) CanRead(ctx context.Context, target Target) bool
- func (a *Authorizer) CanUpdate(ctx context.Context, target Target, c Comment, principalUser string) bool
- type Body
- type Clock
- type Comment
- type OpError
- type PermissionGate
- type Service
- func (s *Service) Add(ctx context.Context, target Target, req AddRequest) (Comment, error)
- func (s *Service) Count(ctx context.Context, targets []Target) (map[string]int, error)
- func (s *Service) Delete(ctx context.Context, target Target, id string) error
- func (s *Service) EntityDeleted(ctx context.Context, entityID string) error
- func (s *Service) EntityFaceDeleted(ctx context.Context, entityID string, face entity.Face) error
- func (s *Service) EntityRenamed(ctx context.Context, oldID, newID string) error
- func (s *Service) FaceMoved(ctx context.Context, entityType, entityID string, from, to entity.Face) error
- func (s *Service) Get(ctx context.Context, target Target, id string) (Comment, error)
- func (s *Service) List(ctx context.Context, target Target) ([]Comment, error)
- func (s *Service) SetResolved(ctx context.Context, target Target, id string, resolved bool) (bool, error)
- func (s *Service) Update(ctx context.Context, target Target, id, body string, resolved bool) error
- type Span
- type Store
- type Target
- type TargetReadGate
- type TextAnchor
- type TextMatch
- type View
Constants ¶
const ( // MaxBodyBytes caps a single comment body. MaxBodyBytes = 16 * 1024 // MaxPerTarget caps how many comments one entity may carry. // // ADVISORY on the database backends, not a hard limit. Service.Add checks // it by listing then counting, which is a check-then-act: with several // rela-server processes on one database (the topology pgcomments exists to // serve), N concurrent posts to a thread at the limit all read the same // count and all insert. The file and memory tiers are single-writer, so // there the check holds exactly. // // Left as-is deliberately. The cap exists to bound the FILE backend's // whole-thread document reads, so overshooting it by a handful of rows on a // backend that pages costs nothing; making it exact would mean a // conditional insert and a new method on comments.Store, which is a // contract change for an invariant that does not need to be exact. MaxPerTarget = 500 // MinQuoteRunes is the shortest text selection that may be anchored. // // Matched to the matcher's own floor (it refuses queries under 5 bytes): // below this a quote is too generic to re-locate, so accepting one would // mint a comment that detaches on the next edit. MinQuoteRunes = 5 // MaxQuoteBytes caps a stored quote. Generous for a sentence or two, and // bounded so a caller cannot store an entire body as an "anchor" — the // resolver's cost scales with quote length. MaxQuoteBytes = 2000 )
Limits on a single comment and on one target's thread. Both exist to bound the file backend, which holds a target's whole thread in one document that is read in full on every List.
const ( // ConfidenceExact and above renders as a normal highlight. ConfidenceExact = 0.80 // ConfidenceUncertain and above renders highlighted but flagged as moved; // below it the anchor is treated as detached. Matches the library's own // MinConfidence floor, so anything it resolves at all lands in a band. ConfidenceUncertain = 0.50 )
Confidence bands for a resolved text anchor.
Three tiers rather than the library's binary resolved/orphaned, because the middle band is the one a reader needs warning about: a highlight rendered at 0.55 confidence is a guess, and presenting it identically to an exact match would quietly attach a remark to text nobody wrote it about.
Variables ¶
var ( // ErrNotFound reports that no comment with the given ID exists on the // target. Returned by Get, Update and Delete. ErrNotFound = errors.New("comments: not found") // ErrEmptyBody reports a comment whose body is empty or whitespace-only. ErrEmptyBody = errors.New("comments: body must not be empty") // ErrBodyTooLong reports a body over [MaxBodyBytes]. ErrBodyTooLong = errors.New("comments: body too long") // ErrBodyControlChars reports a body containing control characters other // than newline and tab. The file backend serializes to YAML, where a NUL // is at best lossy and at worst breaks the document. ErrBodyControlChars = errors.New("comments: body contains control characters") // ErrTooManyComments reports that the target is at [MaxPerTarget]. ErrTooManyComments = errors.New("comments: target has too many comments") // ErrUnknownAuthor reports an attempt to write with no resolved principal. // Refused rather than stored as a placeholder: an "unknown" author makes // every *-own permission check meaningless, since no one can prove // ownership of a comment nobody is recorded as having written. ErrUnknownAuthor = errors.New("comments: cannot attribute comment to an unknown principal") // ErrInvalidAnchor reports a structurally invalid anchor (unknown kind, or // an empty ref). Note this is NOT the "ref names nothing" case: an anchor // pointing at a property that has since been removed is a soft condition // per DEC-HWZHA and surfaces as a warning on read, never an error. ErrInvalidAnchor = errors.New("comments: invalid anchor") // ErrInvalidReplacement reports a suggested replacement that is too long, // carries characters that could disguise it, sits on an anchor that cannot // take one, or targets a quote that crosses a block boundary. ErrInvalidReplacement = errors.New("comments: invalid replacement") // ErrNoSuggestion reports an accept on a comment that carries no // replacement. A state conflict rather than malformed input: the request // is well-formed, the comment simply has nothing to apply. ErrNoSuggestion = errors.New("comments: comment has no suggested replacement") // ErrSuggestionStale reports that the quoted text can no longer be located // with enough certainty to replace it. Accepting then would splice the // replacement into text nobody suggested changing. ErrSuggestionStale = errors.New("comments: the suggested text no longer matches the body") )
Sentinel errors. Callers map these to transport-level responses; the HTTP layer turns ErrNotFound into a 404 and the validation errors into 400s.
Functions ¶
func ApplyReplacement ¶
ApplyReplacement returns body with the anchor's quoted range replaced by its suggested replacement (TKT-S5C0K3).
Returns ErrNoSuggestion when the anchor carries no replacement, and ErrSuggestionStale unless BOTH hold:
- The located span is the quote, ignoring whitespace. The resolver can land on a fuzzy match; replacing one would overwrite text that differs from what the suggester quoted.
- The location is certain: an exact-band confidence, or a quote that occurs exactly once. The band alone is too strict, because confidence also scores the surrounding context, so a unique quote whose neighboring sentence was edited drops into the uncertain band while its location is not in doubt.
Offsets come from ResolveText and are sliced as-is: the span may be longer than the quote where the resolver absorbed a reflowed line break.
func Detached ¶
Detached reports whether a no longer names anything on an entity: a property anchor whose name is not in properties (see LiveProperties), or a text anchor whose quote body cannot locate. A section anchor is never detached here, since it names a view heading rather than part of the entity.
func FacePrefixPattern ¶
FacePrefixPattern builds the SQL LIKE pattern matching every FACED thread of an entity id ("id@draft", never the bare "id"), escaped for `ESCAPE '\'`.
Shared by the database backends for the reason SortComments and MergeThreads are: the escaping below is subtle, and two copies of subtle code are two chances to fix a bug in one of them. It lives here rather than in either backend because neither may import the other.
The id is escaped because an entity id may legally contain an underscore (entity.ValidateID admits [A-Za-z0-9_-]), and an unescaped "_" is LIKE's single-character wildcard — so renaming "TKT_1" would also re-key "TKT-1", silently merging two unrelated threads. Backslash is escaped first so it cannot double-escape what follows; "%" cannot appear in a valid id but is escaped anyway, since this function's correctness should not depend on a grammar declared in another package.
Note this handles WILDCARDS only. Some databases' LIKE is ASCII case-insensitive, which no pattern can express; a backend with that LIKE pairs this with a byte-exact guard (comments/sqlitecomments does).
func FindRenderedQuote ¶
FindRenderedQuote locates a quote taken from RENDERED markdown within the markdown SOURCE.
A browser selection yields display text: list markers, backticks and heading hashes are gone, and blocks are separated by plain newlines. A selection that crosses a bullet or a code span therefore never occurs verbatim in the source, so a plain strings.Index finds nothing — which is exactly what made "the selected text was not found" fire on those selections.
quotefind walks the parsed markdown tree to build a rendered→source position map, so it matches what the user actually saw against what is actually stored.
prefix and suffix are what pick the RIGHT occurrence ¶
A short quote frequently occurs more than once ("eordend" appears inside both "Ongeordend" and "Geordend"). Without context this resolves to the FIRST occurrence, so a comment on the second silently anchors — and highlights — somewhere the user never selected. The caller must therefore pass the text surrounding the selection; empty context is only safe for a quote known to be unique.
Returns ok=false when the quote cannot be located, which the caller reports as a 400 rather than storing an anchor that could never resolve.
func LiveProperties ¶
LiveProperties is the set of names a property anchor counts as present in: the type's declared properties, set or not, since a comment on an empty field is about a field that exists; and any property set on the entity, since hand-edited frontmatter can carry names the schema lacks.
func Replaceable ¶
func Replaceable(t *TextAnchor) bool
Replaceable reports whether a suggested replacement may target t: it must lie within one markdown block. The same rule Anchor.Validate applies to a posted suggestion.
func SortComments ¶
func SortComments(list []Comment)
SortComments orders comments oldest-first with the ID as a tie-break.
Shared by every backend so ordering is defined in ONE place: a thread must render identically regardless of which backend served it, and two implementations sorting "the same way" independently is how that guarantee quietly stops holding. The ID tie-break matters because a clock with coarse resolution can stamp two comments in one tick.
func ValidateBody ¶
ValidateBody applies the size and character rules to a comment body.
Allowlist-shaped: newline and tab are the only control characters permitted, because a body is prose. Everything else in the C0 range (and DEL) is refused rather than escaped, since the file backend round-trips through YAML where those bytes are lossy.
Types ¶
type Accepted ¶
type Accepted struct {
// Content is the entity body after the write.
Content string `json:"content"`
// Warnings are the soft validation findings of the write.
Warnings []entity.Warning `json:"warnings,omitempty"`
}
Accepted is the result of applying a comment's suggested replacement.
type AddInput ¶
type AddInput struct {
Kind AnchorKind
Ref string
Quote string
QuotePrefix string
QuoteSuffix string
// Replacement suggests substitute text for the quote (text anchors only).
// Nil is no suggestion; "" suggests deleting the quote.
Replacement *string
Body string
}
AddInput is a request to add a comment.
For a text anchor the caller supplies only the quote and its surrounding text; the stored descriptors are derived from the entity body, as on the HTTP route, so a caller cannot persist context the entity does not have.
type AddRequest ¶
AddRequest is the caller-supplied part of a new comment.
Note what is absent: ID, Author and CreatedAt. They are not fields a caller may set, so they are not fields this struct carries — the wire type cannot express the forgery, rather than expressing it and having it stripped later.
type Anchor ¶
type Anchor struct {
Kind AnchorKind `json:"kind" yaml:"kind"`
Ref string `json:"ref" yaml:"ref"`
// Text carries the descriptors for an [AnchorText] anchor, and is nil for
// every other kind. A pointer so a property or section comment serializes
// exactly as it did before this field existed — which is what lets stage 2
// ship without migrating a single stored comment.
Text *TextAnchor `json:"text,omitempty" yaml:"text,omitempty"`
// Replacement is a suggested substitute for the quoted text of an
// [AnchorText] anchor (TKT-S5C0K3), and nil for every other kind.
//
// A pointer because the empty string is a meaningful suggestion ("delete
// this text") and must stay distinct from "no suggestion". It lives on the
// anchor rather than the comment because, like the anchor, it is fixed at
// creation: [Store.Update] never touches it, so a suggestion cannot be
// swapped after someone has reviewed it. It is not on [TextAnchor], which
// mirrors the locator library and holds only what finds the text.
Replacement *string `json:"replacement,omitempty" yaml:"replacement,omitempty"`
}
Anchor locates a comment within its target entity.
Both current kinds anchor by NAME (a property name, a section slug), never by offset, which is what makes them immune to edits of the entity body. That is the whole reason stage 1 ships these two kinds and defers text ranges.
func (Anchor) Validate ¶
Validate reports whether the anchor is structurally usable.
It checks shape only. Whether Ref names a property or section that currently exists is deliberately NOT checked: an entity may be edited, or hand-edited outside rela, so a ref that resolves to nothing is an expected state that surfaces as a warning on read (DEC-HWZHA).
type AnchorKind ¶
type AnchorKind string
AnchorKind identifies what part of an entity a comment is attached to.
The set is deliberately open to extension: stage 2 adds a text-range kind carrying a quote and surrounding context, which is why Anchor is a struct with a kind discriminator rather than a bare property name. Adding a kind must not require migrating stored comments.
const ( // AnchorProperty attaches a comment to a named property. Ref is the // property name. AnchorProperty AnchorKind = "property" // AnchorSection attaches a comment to a view section. Ref is the // section's slug id, which is derived from the operator-authored view // heading rather than from user content — so it survives edits to the // entity body. AnchorSection AnchorKind = "section" // AnchorText attaches a comment to a RANGE of the entity body (stage 2). // // Unlike the two name-based kinds, this one anchors to content that the // user can edit out from under it. Ref is unused; [Anchor.Text] carries // the quote plus the surrounding context that lets it be re-located after // an edit, and a resolution that fails is reported as detached rather than // dropped (DEC-HWZHA). AnchorText AnchorKind = "text" )
type AnchorView ¶
type AnchorView struct {
Kind AnchorKind `json:"kind"`
Ref string `json:"ref,omitempty"`
Quote string `json:"quote,omitempty"`
Replacement *string `json:"replacement,omitempty"`
}
AnchorView is the part of an Anchor a reader is shown.
type Authorizer ¶
type Authorizer struct {
// contains filtered or unexported fields
}
Authorizer decides whether a principal may act on a target's comments.
It exists so the permission vocabulary is interpreted in ONE place: the own/any split, the read floor and the inert/fail-closed rule are decided here rather than at each HTTP handler, where a missed check is invisible.
func NewAuthorizer ¶
func NewAuthorizer(perms PermissionGate, reads TargetReadGate, policyActive bool) *Authorizer
NewAuthorizer returns an Authorizer over the supplied gates.
policyActive distinguishes the two ways a gate can be absent, and the distinction is load-bearing (the same rule the statemachine transition guard documents):
- No policy configured at all: the Authorizer is INERT and permits. This is the CLI/desktop tier, where there is no principal to authorize.
- A policy IS configured but a gate is missing: that is a wiring failure on a served path, and the Authorizer FAILS CLOSED. A policy-backed deployment must not silently open commenting because plumbing broke.
func (*Authorizer) CanAdd ¶
func (a *Authorizer) CanAdd(ctx context.Context, target Target) bool
CanAdd reports whether the principal may add a comment to target.
Note this does NOT require `comment:read`: write-only commenting is a deliberate posture (leave a remark on something you cannot otherwise discuss), mirroring the entity-verb rule where create is exempt from the covering-read requirement. The target must still be readable — you cannot comment on an entity you cannot see.
func (*Authorizer) CanDelete ¶
func (a *Authorizer) CanDelete(ctx context.Context, target Target, c Comment, principalUser string) bool
CanDelete reports whether the principal may remove c.
func (*Authorizer) CanRead ¶
func (a *Authorizer) CanRead(ctx context.Context, target Target) bool
CanRead reports whether the principal may list target's comments.
Two conditions, both required: the target must be readable, and the principal must hold `comment:read` for it. The read floor is what stops a comment thread becoming an existence oracle — without it, a principal granted comment:read globally could probe which entities exist by asking for their comments.
func (*Authorizer) CanUpdate ¶
func (a *Authorizer) CanUpdate(ctx context.Context, target Target, c Comment, principalUser string) bool
CanUpdate reports whether the principal may edit or resolve c.
The -any permission implies the -own one, so a moderator needs only comment:update-any rather than both.
type Body ¶
type Body struct {
// contains filtered or unexported fields
}
Body is an entity body prepared for resolving many text anchors against it.
A list read resolves every comment on one body; preparing it once keeps that to one pass over the body for matching and at most one markdown parse for highlight segments, instead of one of each per comment.
Not safe for concurrent use: the segment parse is built lazily, since a body with no text comments never needs it.
func (*Body) Acceptable ¶
Acceptable reports whether ApplyReplacement would succeed on the body.
func (*Body) ResolveText ¶
func (b *Body) ResolveText(a *TextAnchor) TextMatch
ResolveText locates a text anchor within the body, with highlight segments. See the package-level ResolveText for the matching rules.
type Clock ¶
Clock supplies the current time. Injected rather than read from the wall clock so tests can pin CreatedAt and assert List's ordering deterministically — the same treatment userstate gives expiry, and for the same reason.
Nil: never; NewService substitutes time.Now.
type Comment ¶
type Comment struct {
ID string `json:"id" yaml:"id"`
Author string `json:"author" yaml:"author"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitzero" yaml:"updated_at,omitempty"`
Anchor Anchor `json:"anchor" yaml:"anchor"`
Body string `json:"body" yaml:"body"`
Resolved bool `json:"resolved" yaml:"resolved"`
}
Comment is one remark attached to one entity.
ID, Author and CreatedAt are server-written on Add; a value supplied by a client for any of them is discarded (see the package doc). Body is markdown, stored verbatim and rendered — sanitized — by the caller.
func MergeThreads ¶
MergeThreads appends arriving to occupying, dropping any arrival whose ID the destination already uses, and returns the result sorted.
This is what a Rename into an OCCUPIED destination must do, and it is here for the same reason SortComments is: every backend owes the same answer, and two implementations agreeing independently is how that quietly stops being true. The database backends get it from ON CONFLICT DO NOTHING; the in-memory and file backends call this.
The destination's own comment wins on a collision — it is the one a reader may already have seen. Dropping the arrival rather than keeping both matters because the service addresses a comment by (target, id): a thread holding one ID twice makes the second copy unreachable, and an Update or Delete aimed at it silently hits the first instead. Collisions need a restored backup or a re-import to arise at all (IDs carry 80 bits of crypto/rand), but the damage they do is lasting rather than transient.
type OpError ¶
type OpError struct {
Code string
Title string
Detail string
// Faces lists the addressable faces for `face_required`.
Faces []string
}
OpError is a refused comment operation, in the vocabulary of the HTTP API: Code is the error type's last segment (`not_found`, `forbidden`, `face_required`, …), and Title and Detail are the texts the HTTP route answers with. A second transport renders it instead of re-deriving the wording, so the two surfaces cannot drift.
type PermissionGate ¶
type PermissionGate interface {
HoldsPermission(ctx context.Context, subjectID, permission string) bool
}
PermissionGate answers whether the ctx principal holds a named permission for one entity.
Declared here, at the consumer, rather than beside the ACL: this package needs exactly one question answered and has no business seeing the rest of the resolver. The wiring site supplies an adapter over acl.Request.HoldsPermissionForEntity, which resolves permissions conferred by an ownership relation to the subject as well as global grants.
Nil: a nil PermissionGate means "no policy configured"; Authorizer treats that as permitting everything, matching the CLI/desktop tier where there is no principal to authorize.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the write path for comments: it stamps identity, mints IDs, validates input, and delegates persistence to a Store.
It is the only thing that constructs a Comment, which is what makes the server-written fields trustworthy — there is no path where a client-supplied ID or Author reaches storage.
func NewService ¶
NewService returns a Service over st.
Nil: st is required and rejected when nil — a Service with no store would silently discard every comment, and the failure would surface far from its cause. clock may be nil, in which case time.Now is used.
func (*Service) Add ¶
Add stores a new comment on target, attributed to the ctx principal.
The author is taken from ctx and never from the request. An unstamped principal is refused (ErrUnknownAuthor) rather than recorded as "unknown": a comment nobody is recorded as having written can never satisfy an *-own permission check, so storing one creates a record its author can neither edit nor delete.
func (*Service) Count ¶
Count returns the thread size of each target, keyed by Target.Key; see Store.Count. The ACL gate is the caller's job, as with List.
func (*Service) EntityDeleted ¶
EntityDeleted drops the deleted entity's comments.
Unlike the CalDAV alias service — which deliberately KEEPS its references so a stale client write can be refused — comments are dropped: rela permits id reuse, so a later entity taking this id would otherwise inherit the previous occupant's thread and present someone else's remarks as its own.
func (*Service) EntityFaceDeleted ¶
EntityFaceDeleted drops the thread of one deleted face, leaving the entity's other faces untouched.
Dropped for the reason EntityDeleted gives: a face of the same name created later would otherwise inherit remarks about content that no longer exists.
func (*Service) EntityRenamed ¶
EntityRenamed re-keys the renamed entity's comments.
This implements entitymanager's AliasRewriter hook rather than [store.EntityObserver], for the reason that hook documents: every store fires the observer as `_ = o.EntityRenamed(...)`, discarding the error. That is the right trade for a search index, which can be rebuilt from the store. It is the wrong trade here — comments exist ONLY in the comment store, so a swallowed re-key failure leaves a thread filed under an id nothing resolves to, still on disk and completely unreachable, with no signal anywhere.
Load-bearing either way: rename emits exactly one notification, never a delete followed by a create, so a service that ignored it would strand every comment on every renamed entity.
func (*Service) FaceMoved ¶
func (s *Service) FaceMoved(ctx context.Context, entityType, entityID string, from, to entity.Face) error
FaceMoved moves a thread from one face of an entity to another, for a row that a data migration relocated rather than deleted (BUG-6OZBP9).
Moved, not dropped: the content is the same, it only sits at a new coordinate. migrate_face and adopt-face move rows off the zero coordinate, which is where every comment written before the type declared faces is stored, so dropping here would erase commentary because of a schema change.
Built from List, Add and Delete rather than a new Store method, so every backend already honors it. Add persists a comment as given, so author, timestamps, anchor and resolved flag all survive the move.
Idempotent, which is what lets a re-run of a failed migration converge: a comment already at the destination is skipped, and each source comment is deleted only after it has arrived. On an id collision the destination's comment wins, for the reason MergeThreads gives. Only the comments this call listed are deleted, so a comment posted at the source meanwhile stays there for the next call to move rather than being lost.
MaxPerTarget is not enforced: merging into an occupied destination may exceed it. The cap limits what a client can post; refusing here would strand comments mid-migration instead, which is worse than a long thread.
func (*Service) Get ¶
Get returns one comment by ID, or ErrNotFound.
It exists chiefly so a handler can resolve a comment's author *before* deciding whether an *-own permission covers the requested mutation.
The ACL gate is the caller's job, as with List.
func (*Service) List ¶
List returns the target's comments, oldest first.
The ACL gate is the caller's job: this returns everything stored for the target. Callers on a served path must have resolved the target's read verdict and the comment:read permission first.
func (*Service) SetResolved ¶
func (s *Service) SetResolved(ctx context.Context, target Target, id string, resolved bool) (bool, error)
SetResolved flips a comment's resolved flag only if it differs, reporting whether this call changed it. See Store.SetResolved. Authorization is the caller's job.
type Store ¶
type Store interface {
// List returns the target's comments, oldest first, ties broken by ID.
// A target with no comments yields an empty slice, not an error.
List(ctx context.Context, target Target) ([]Comment, error)
// Get returns one comment, or [ErrNotFound] if the target holds no
// comment with that ID.
//
// Scoped to the target, so an ID is only ever resolved within one face:
// a comment stored under `TKT-1@draft` must not be reachable through
// `TKT-1`. That is the same scoping List and Delete use, and it is what
// stops an authorization check reading a record from a face the request
// never named.
//
// Separate from List because the database backends serve this from
// `PRIMARY KEY (target_key, id)` as a single-row read. Authorizing an
// edit needs one comment's author; going through List would fetch up to
// [MaxPerTarget] rows with their bodies to find it.
Get(ctx context.Context, target Target, id string) (Comment, error)
// Count returns the number of comments on each target, keyed by
// [Target.Key]. A target with no comments is absent from the map.
//
// Batched because a list page shows a count per row: one call per page
// keeps the cost independent of the page size on the database backends
// (one GROUP BY query). Resolved comments are counted too; the count is
// the size of the thread, as the thread view shows it.
Count(ctx context.Context, targets []Target) (map[string]int, error)
// Add appends c to the target's thread. The caller has already set ID,
// Author and CreatedAt; implementations persist them as given rather
// than minting their own, so the values in an audit trail and the values
// stored agree.
Add(ctx context.Context, target Target, c Comment) error
// Update replaces the body and resolved flag of the comment with the
// given ID. Returns [ErrNotFound] if it does not exist. Author,
// CreatedAt and Anchor are immutable — editing a comment must not let it
// change who said it or what it was about.
Update(ctx context.Context, target Target, id string, body string, resolved bool) error
// SetResolved sets only the resolved flag, and only when it differs from
// the stored value, as one atomic step. changed reports whether this call
// flipped it; false means it already held that value. Returns
// [ErrNotFound] if the comment does not exist.
//
// Conditional because accepting a suggestion claims the comment by
// resolving it: two requests, possibly on two server processes sharing one
// database, must not both believe they won. It leaves the body alone so a
// concurrent edit to it is not overwritten.
SetResolved(ctx context.Context, target Target, id string, resolved bool) (changed bool, err error)
// Delete removes one comment. Returns [ErrNotFound] if absent.
Delete(ctx context.Context, target Target, id string) error
// DeleteTarget removes every comment on ONE target (one face). Deleting a
// target with no comments is not an error.
DeleteTarget(ctx context.Context, target Target) error
// DeleteAllFaces removes every thread belonging to an entity id, across
// all of its faces.
//
// Distinct from DeleteTarget: an entity delete takes the whole entity with
// it, so leaving a faced thread behind would strand comments at an id
// nothing can reach.
DeleteAllFaces(ctx context.Context, entityID string) error
// Rename re-keys a target's comments from oldID to newID, preserving
// order. Renaming a target with no comments is not an error.
Rename(ctx context.Context, oldID, newID string) error
}
Store persists comments per target entity.
Every implementation must pass [commentstest.RunAll], which pins the parts of this contract that are easy to get subtly different: List's ordering, the server-minted ID, and that concurrent Adds to one target both survive.
Nil: no method returns a nil error with a nil result; List returns an empty slice rather than nil when a target has no comments, and Get reports a missing comment as ErrNotFound rather than a zero Comment.
type Target ¶
type Target struct {
Type string
ID string
// Face scopes the thread to one content state of the entity (FEAT-9CD2MX).
// The zero value addresses the default face, which is also what a faceless
// project always uses.
//
// Comments are PER FACE because a face is a distinct piece of content: a
// remark on the draft ("this paragraph needs a source") is not a remark on
// the published version, and surfacing it there would attach feedback to
// text that may not even contain the quote. The read gate is per face too,
// so a shared thread would also leak across a boundary the entity itself
// maintains.
Face entity.Face
}
Target identifies the entity a thread of comments belongs to.
Type is carried alongside ID because the HTTP surface is addressed by (type, id) and the read gate needs both; it is not part of the storage key, since entity IDs are unique across types.
func (Target) Key ¶
Key is the storage key for a target's thread.
It is entity.FormatStateRef, so the DEFAULT face serializes to the bare id — which is what lets faces arrive without migrating a single stored comment: every thread written before faces existed is already at its correct key.
type TargetReadGate ¶
TargetReadGate answers whether the ctx principal may read the target entity at all.
Separate from PermissionGate because it answers a different question and has a different failure mode: a denied read must be indistinguishable from a missing entity, whereas a denied permission may say which permission was missing (a permission name is config, and config is not secret).
Nil: a nil TargetReadGate means "no policy configured" and permits.
type TextAnchor ¶
type TextAnchor struct {
Quote string `json:"quote" yaml:"quote"`
Prefix string `json:"prefix,omitempty" yaml:"prefix,omitempty"`
Suffix string `json:"suffix,omitempty" yaml:"suffix,omitempty"`
ContainingSentence string `json:"containing_sentence,omitempty" yaml:"containing_sentence,omitempty"`
HeadingContext string `json:"heading_context,omitempty" yaml:"heading_context,omitempty"`
// ParagraphIndex is 0-based within the section named by HeadingContext,
// or -1 when not applicable. Zero is a MEANINGFUL value (the first
// paragraph), so this is never omitempty — dropping it would silently
// retarget a comment to whatever paragraph the zero value implies.
ParagraphIndex int `json:"paragraph_index" yaml:"paragraph_index"`
}
TextAnchor is the stage-2 descriptor set for a body text range.
The fields mirror github.com/vloothuis/textanchor's Anchor exactly, because they are handed to it verbatim on resolve. They are stored rather than a byte offset for the reason the whole feature exists: an offset is invalidated by any edit earlier in the body, and on the fs backend by a plain re-save, since fsstore reflows every body to 80 columns on write.
Quote alone is not enough — the same words can occur twice — so Prefix and Suffix disambiguate, ContainingSentence rescues short generic quotes, and the structural pair (HeadingContext, ParagraphIndex) survives a rewrite of the quoted sentence itself.
func NewTextAnchor ¶
func NewTextAnchor(body string, start, end int) (*TextAnchor, error)
NewTextAnchor builds a text anchor for the selection body[start:end].
Offsets are into the body AS STORED, so a caller working from rendered text must map back to source coordinates first — see FindRenderedQuote.
type TextMatch ¶
type TextMatch struct {
Start int
End int
// Segments splits [Start, End) into one span per markdown block of text,
// for highlighting: one inline element cannot cross a block boundary.
// Block markup and code are left out, so a range over nothing but code
// has none. Set only by [Body.ResolveText].
Segments []Span
Confidence float64
// Uncertain marks the middle band: located, but far enough from an exact
// match that the UI should say so.
Uncertain bool
// Detached means the quote could not be found. Start/End are meaningless.
Detached bool
// Reason carries the resolver's explanation when Detached.
Reason string
}
TextMatch is the outcome of locating a text anchor in a body.
Start/End are byte offsets into the body that was searched. They are NOT derivable from the quote's length: the resolver absorbs interior whitespace runs, so a range may legitimately be longer than Quote (a quote written with a space where the stored body now has a newline). Always slice with Start/End — never Start+len(Quote).
func ResolveText ¶
func ResolveText(body string, a *TextAnchor) TextMatch
ResolveText locates a text anchor within body.
Nil: a nil descriptor resolves as detached rather than panicking — a stored comment with a text kind and no descriptor is corrupt, not a crash.
The body is passed as stored. No normalisation happens here: textanchor v0.2.0 matches on a whitespace-collapsed form internally and maps its result back to original coordinates, so a quote spanning fsstore's 80-column reflow resolves without the caller flattening anything. (Before v0.2.0 that case hard-orphaned, which is why this function does not exist for v0.1.0.)
It does not compute highlight segments; Body.ResolveText does.
type View ¶
type View struct {
ID string `json:"id"`
Author string `json:"author"`
// CreatedAt is UTC, to the second, as on the HTTP list.
CreatedAt time.Time `json:"created_at"`
Anchor AnchorView `json:"anchor"`
Body string `json:"body"`
Resolved bool `json:"resolved"`
Detached bool `json:"detached,omitempty"`
Editable bool `json:"editable"`
Deletable bool `json:"deletable"`
Acceptable bool `json:"acceptable,omitempty"`
}
View is one comment as the requesting principal sees it.
It is a projection, not the stored Comment: the anchor carries only what the HTTP list serves (kind, ref, quote, replacement), never the context the store captured around a quote, which describes a body the caller may no longer be able to read.
The flags are computed per read against the live entity and the caller's grants, never stored: Detached, Editable and Deletable as on the HTTP list, Acceptable as there too (an open suggestion that still locates; it does not include the entity update right, which the accept re-checks).
Directories
¶
| Path | Synopsis |
|---|---|
|
Package commentstest is the conformance suite every comments.Store implementation must pass.
|
Package commentstest is the conformance suite every comments.Store implementation must pass. |
|
Package filecomments stores comments as YAML, one file per target entity, under a directory the caller roots (conventionally `.rela/comments/`).
|
Package filecomments stores comments as YAML, one file per target entity, under a directory the caller roots (conventionally `.rela/comments/`). |
|
Package memcomments is an in-memory comments.Store for tests and the memory build.
|
Package memcomments is an in-memory comments.Store for tests and the memory build. |
|
Package pgcomments is the PostgreSQL-backed comments.Store.
|
Package pgcomments is the PostgreSQL-backed comments.Store. |
|
Package sqlitecomments is the SQLite-backed comments.Store.
|
Package sqlitecomments is the SQLite-backed comments.Store. |