// Package ops is quarry's seam: every capability is an op — one named,
// typed unit in a single registry. The registry has exactly three consumers
// (the API router, HTML handlers, the test harnesses), and the API spec is
// generated FROM the registry, never maintained beside it. Only this package
// may import the data planes. See docs/rfcs/0001-quarry-forge.md §9.
package ops
import (
"context"
"fmt"
"net/http"
"reflect"
"sort"
"strings"
)
// Kind separates reads from writes. Queries are in-process reads over the
// local index and must be safe to retry and cache; Commands mutate and are
// routed (lease, durability) inside their Run.
type Kind int
const (
Query Kind = iota
Command
)
func (k Kind) String() string {
if k == Query {
return "query"
}
return "command"
}
// Auth is the whole v1 auth model (0001 §8).
type Auth int
const (
Anonymous Auth = iota
Token
Admin
)
// Op is the registration shape. In and Out must be structs whose fields are
// expressible as both form/URL values and JSON — flat, no exotic nesting.
type Op[In, Out any] struct {
Name string // "timeline.list", "chat.post"
Kind Kind
Auth Auth
Route string // "GET /api/v1/r/{repo}/timeline"
Run func(context.Context, In) (Out, error)
}
// Descriptor is the type-erased registry entry the consumers work from.
type Descriptor struct {
Name string
Kind Kind
Auth Auth
Method string // "GET", "POST"
Pattern string // ServeMux 1.22 pattern: "/api/v1/r/{repo}/timeline"
In reflect.Type
Out reflect.Type
run func(context.Context, any) (any, error)
}
// Registry holds every op. One instance per process, filled by an explicit
// RegisterAll at startup — no init() magic, so the wiring stays greppable.
type Registry struct {
byName map[string]*Descriptor
byRoute map[string]*Descriptor
}
func NewRegistry() *Registry {
return &Registry{
byName: make(map[string]*Descriptor),
byRoute: make(map[string]*Descriptor),
}
}
// Register validates and adds one op. It rejects, rather than papers over:
// duplicate names, duplicate routes, kind/method mismatches (queries are
// GETs, commands are POSTs — Principle 1), and non-struct In/Out.
func Register[In, Out any](r *Registry, op Op[In, Out]) error {
if op.Name == "" {
return fmt.Errorf("ops: op with route %q has no name", op.Route)
}
if op.Run == nil {
return fmt.Errorf("ops: %s: nil Run", op.Name)
}
if _, dup := r.byName[op.Name]; dup {
return fmt.Errorf("ops: duplicate op name %q", op.Name)
}
method, pattern, err := splitRoute(op.Route)
if err != nil {
return fmt.Errorf("ops: %s: %w", op.Name, err)
}
if op.Kind == Query && method != http.MethodGet {
return fmt.Errorf("ops: %s: query ops must route as GET, got %s", op.Name, method)
}
if op.Kind == Command && method != http.MethodPost {
return fmt.Errorf("ops: %s: command ops must route as POST, got %s", op.Name, method)
}
routeKey := method + " " + pattern
if other, dup := r.byRoute[routeKey]; dup {
return fmt.Errorf("ops: %s: route %q already registered by %s", op.Name, routeKey, other.Name)
}
inT := reflect.TypeFor[In]()
outT := reflect.TypeFor[Out]()
if inT.Kind() != reflect.Struct {
return fmt.Errorf("ops: %s: In must be a struct, got %s", op.Name, inT.Kind())
}
if outT.Kind() != reflect.Struct {
return fmt.Errorf("ops: %s: Out must be a struct, got %s", op.Name, outT.Kind())
}
d := &Descriptor{
Name: op.Name,
Kind: op.Kind,
Auth: op.Auth,
Method: method,
Pattern: pattern,
In: inT,
Out: outT,
run: func(ctx context.Context, in any) (any, error) {
typed, ok := in.(In)
if !ok {
return nil, fmt.Errorf("ops: %s: input is %T, want %s", op.Name, in, inT)
}
return op.Run(ctx, typed)
},
}
r.byName[op.Name] = d
r.byRoute[routeKey] = d
return nil
}
// Invoke runs a registered op by name. in must be the op's In struct value.
func (r *Registry) Invoke(ctx context.Context, name string, in any) (any, error) {
d, ok := r.byName[name]
if !ok {
return nil, fmt.Errorf("ops: unknown op %q", name)
}
return d.run(ctx, in)
}
// Lookup returns the descriptor for one op.
func (r *Registry) Lookup(name string) (*Descriptor, bool) {
d, ok := r.byName[name]
return d, ok
}
// All returns every descriptor sorted by name — the enumeration order the
// spec generator and the golden-test harness both rely on.
func (r *Registry) All() []*Descriptor {
out := make([]*Descriptor, 0, len(r.byName))
for _, d := range r.byName {
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func splitRoute(route string) (method, pattern string, err error) {
method, pattern, found := strings.Cut(route, " ")
if !found || pattern == "" {
return "", "", fmt.Errorf("route %q must be \"METHOD /path\"", route)
}
if method != http.MethodGet && method != http.MethodPost {
return "", "", fmt.Errorf("route %q: method must be GET or POST", route)
}
if !strings.HasPrefix(pattern, "/") {
return "", "", fmt.Errorf("route %q: pattern must start with /", route)
}
return method, pattern, nil
}