Skip to content
quarry Artifact [9592f4fe49]
Login

Artifact [9592f4fe49]

Artifact 9592f4fe49a7cf7e9ff3637d63d65397ba3fa34d9654b594f02ee988aebe9261:


// 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
}