// companion.go is RFC 0015 Phase 1: a repository's forum kept in a
// separate fossil-compatible store, bound to its parent by the forge.
//
// The fossil project is the shape this serves first: its source is one
// repository and its forum lives at fossil-scm.org/forum as another.
// Mirror both, bind them, and /r/fossil/forum reads the forum store
// while grants, notifications and search stay on fossil.
package api
import (
"context"
"fmt"
"sort"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ops"
)
// CompanionStore is the binding table (RFC 0015): which store holds a
// repository's forum. nil means this forge keeps no bindings, which is
// a forge without an OLTP.
type CompanionStore interface {
Companion(repo, feature string) (store string, ok bool, err error)
CompanionParent(store string) (repo, feature string, ok bool, err error)
BindCompanion(repo, feature, store string) error
}
// featureForum is the one feature RFC 0015 defines a companion for.
const featureForum = "forum"
// forumCompanion answers which store holds repo's forum, or "" when the
// forum is in the repository itself.
func (d Deps) forumCompanion(repo string) string {
if d.Companions == nil {
return ""
}
store, ok, err := d.Companions.Companion(repo, featureForum)
if err != nil || !ok {
return ""
}
return store
}
// companionParent answers whose companion a store is, or "" when it is
// a repository in its own right.
func (d Deps) companionParent(store string) string {
if d.Companions == nil {
return ""
}
repo, _, ok, err := d.Companions.CompanionParent(store)
if err != nil || !ok {
return ""
}
return repo
}
// forumRepoFor is repoFor for the forum ops: the reader is checked
// against the PARENT — its visibility, its letters, its not-found
// answer for a name they may not see — and the store handed back is
// whichever one holds the forum. Every forum op resolves through here
// and nothing else does, so the companion's name never reaches a gate.
func (d Deps) forumRepoFor(ctx context.Context, name string) (*Repo, error) {
parent, err := d.repoFor(ctx, name)
if err != nil {
return nil, err
}
store := d.forumCompanion(name)
if store == "" {
return parent, nil
}
repo, err := d.repo(store)
if err != nil {
// The binding names a store this node does not serve. That is
// an operator's problem, worded as one, never as "no forum".
return nil, fmt.Errorf("%s's forum is bound to %q, which this node does not serve: %w", name, store, err)
}
return repo, nil
}
type forumBindIn struct {
Repo string `json:"repo" validate:"required,max=64"`
// Store names the companion: an existing store with no check-ins,
// bound to nobody. Nothing is inferred from names; an admin says.
Store string `json:"store" validate:"required,max=64"`
}
type forumBindOut struct {
Repo string `json:"repo"`
Store string `json:"store"`
}
// registerCompanions mounts forum.bind (RFC 0015 Phase 1). Split and
// fold are Phase 2.
func registerCompanions(r *ops.Registry, d Deps) error {
return ops.Register(r, ops.Op[forumBindIn, forumBindOut]{
Name: "forum.bind",
Caps: "a",
Doc: "Bind an existing store as this repository's forum (RFC 0015). The store must hold no history beyond fossil's initial empty check-in and be nobody's companion; from then on /r/{repo}/forum reads and writes there, while grants, notifications and search stay on the repository. Mirror a fossil project and its forum repository, then bind them, and the pair works as upstream serves it.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/r/{repo}/forum/bind",
Run: func(ctx context.Context, in forumBindIn) (forumBindOut, error) {
if d.Companions == nil {
return forumBindOut{}, fmt.Errorf("this forge keeps no companion bindings: %w", ops.ErrInvalid)
}
if in.Store == in.Repo {
return forumBindOut{}, fmt.Errorf("a repository cannot be its own forum: %w", ops.ErrInvalid)
}
if _, err := d.repoFor(ctx, in.Repo); err != nil {
return forumBindOut{}, err
}
if d.companionParent(in.Repo) != "" {
return forumBindOut{}, fmt.Errorf("%s is itself a companion and cannot have one: %w", in.Repo, ops.ErrInvalid)
}
if have := d.forumCompanion(in.Repo); have != "" {
if have == in.Store {
return forumBindOut{Repo: in.Repo, Store: in.Store}, nil
}
return forumBindOut{}, fmt.Errorf("%s's forum is already bound to %s: %w", in.Repo, have, ops.ErrInvalid)
}
store, err := d.repo(in.Store)
if err != nil {
return forumBindOut{}, err
}
if parent := d.companionParent(in.Store); parent != "" {
return forumBindOut{}, fmt.Errorf("%s is already %s's forum: %w", in.Store, parent, ops.ErrInvalid)
}
if d.forumCompanion(in.Store) != "" {
return forumBindOut{}, fmt.Errorf("%s has a forum of its own and cannot be one: %w", in.Store, ops.ErrInvalid)
}
// A store with history is a project, not a forum. Every
// fossil repository is born with one "initial empty
// check-in" — the forum at fossil-scm.org has exactly that
// and 3,550 threads — so one check-in is not history and
// two is. The index is the cheap way to ask; a store without
// one cannot answer and is refused rather than assumed.
if store.Index == nil {
return forumBindOut{}, fmt.Errorf("%s is not indexed yet; bind it once it is: %w", in.Store, ops.ErrUnavailable)
}
n, err := store.Index.CheckinCount()
if err != nil {
return forumBindOut{}, err
}
if n > 1 {
return forumBindOut{}, fmt.Errorf("%s holds %d check-ins; a forum store holds nothing beyond fossil's initial empty one: %w", in.Store, n, ops.ErrInvalid)
}
if err := d.Companions.BindCompanion(in.Repo, featureForum, in.Store); err != nil {
return forumBindOut{}, err
}
return forumBindOut{Repo: in.Repo, Store: in.Store}, nil
},
})
}
// companionForumThreads counts the threads in a repository's companion,
// or zero when it has none.
//
// Best-effort and never an error: a count is decoration on a tab, and a
// binding to a store this node does not serve is an operator's problem
// that forumRepoFor words properly at the moment somebody opens the
// forum. Failing the whole counts op — which every repository page
// calls — would turn one misconfigured binding into a broken nav on a
// page that is otherwise fine.
func (d Deps) companionForumThreads(ctx context.Context, name string) int {
store := d.forumCompanion(name)
if store == "" {
return 0
}
repo, err := d.repo(store)
if err != nil || repo == nil || repo.Index == nil {
return 0
}
c, err := repo.Index.Counts(seesPrivate(ctx))
if err != nil {
return 0
}
return c.ForumThreads
}
// companionSearch asks a bound companion the same question the parent
// was asked, or nothing when there is no companion.
//
// Only the forum kinds reach it: a companion holds forum artifacts and
// nothing else, so asking it for check-ins or files is a query that can
// only answer empty. "everything" reaches it because a forum thread is
// part of everything.
func (d Deps) companionSearch(ctx context.Context, in searchIn) ([]index.SearchResult, error) {
switch in.Kind {
case "", "forum":
default:
return nil, nil
}
store := d.forumCompanion(in.Repo)
if store == "" {
return nil, nil
}
repo, err := d.repo(store)
if err != nil || repo == nil || repo.Index == nil {
// A binding naming a store this node does not serve is an
// operator's problem, and forumRepoFor words it properly the
// moment somebody opens the forum. A search says what it found
// rather than failing on a misconfiguration it did not cause.
return nil, nil
}
hits, err := repo.Index.Search(in.Q, in.Kind, in.Limit)
if err != nil {
return nil, err
}
// Decorated by the index that owns the rows: a companion's hash
// means nothing to the parent's tables.
if err := repo.Index.AttachSearchMeta(hits); err != nil {
return nil, err
}
return hits, nil
}
// mergeSearch interleaves two rankings into one.
//
// Each index ranks against its OWN corpus statistics, so bm25 from two
// stores is comparable only approximately — the same caveat search.all
// states about the fleet. Rank is already baked into each side's order,
// so the merge preserves it and breaks ties on ref to stay
// deterministic: an interleave, honestly, rather than a true joint
// ranking, which would need term frequencies across both stores that
// neither index keeps.
func mergeSearch(a, b []index.SearchResult, limit int) []index.SearchResult {
out := make([]index.SearchResult, 0, len(a)+len(b))
out = append(append(out, a...), b...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].Rank != out[j].Rank {
return out[i].Rank < out[j].Rank
}
return out[i].Ref < out[j].Ref
})
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out
}