// 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"
"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.Admin,
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
},
})
}