Artifact 376a0ed9dd8048b6c046e6eec73dc72f619da8c92de33a1ba3022edb40739210:
- File
internal/ops/ops.go
— part of check-in
[9005c16485]
at
2026-08-16 01:05:22
on branch trunk
— write path: wiki/ticket ops, forms, Basic auth over sync secrets
wiki.save + ticket.new/set through zit#71 verbs with read-your-writes; edit/new pages; Token ops gate on HTTP Basic against the RFC-derived shared secret (same credentials as sync). Harness stores now carry authored wiki+tickets; wiki surfaces join disposability. (user: dmestas, size: 6498) [annotate] [blame] [check-ins using]
// 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" "errors" "fmt" "net/http" "reflect" "sort" "strings" ) // ErrNotFound marks an op error as "the referenced entity does not // exist" — consumers map it to 404-shaped responses instead of treating // it as a server fault. Wrap with fmt.Errorf("...: %w", ErrNotFound). var ErrNotFound = errors.New("not found") // 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" Doc string // one-line summary; becomes the spec's operation summary 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 Doc 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 op.Doc == "" { return fmt.Errorf("ops: %s: Doc is required (it becomes the spec summary)", 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()) } if err := checkValidateTags(inT); err != nil { return fmt.Errorf("ops: %s: In: %w", op.Name, err) } d := &Descriptor{ Name: op.Name, Doc: op.Doc, 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 } // NewIn returns a pointer to a zero In struct for this op — the decode // target the codecs fill before Invoke. func (d *Descriptor) NewIn() any { return reflect.New(d.In).Interface() } // 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 } // ctxUserKey carries the authenticated principal through Invoke; the // HTTP consumers set it after verifying credentials (0001 §8 — the // registry stays transport-free, enforcement lives at the edges). type ctxUserKey struct{} // WithUser returns ctx carrying the authenticated user name. func WithUser(ctx context.Context, user string) context.Context { return context.WithValue(ctx, ctxUserKey{}, user) } // User returns the authenticated user, or "" when anonymous. func User(ctx context.Context) string { u, _ := ctx.Value(ctxUserKey{}).(string) return u }