NextMyDay/internal/core/module/registry.go
Claus Lohmar d7d2c7d2b6 feat(module): add the module registry, discovery and the home module (SPEC-008)
- Build-time discovery (D-028, the Caddy pattern): a module self-registers from
  init(); internal/modules/imports_gen.go (go:generate) blank-imports every
  module directory, so THE DIRECTORY IS THE SOURCE OF TRUTH
- The registry: deterministic registration order, config-backed enable/disable
  (D-038), required modules, name-prefixed migrations, Mount() into the door
- The name-collision guard now also fails a module named for a reserved
  level-1 workflow (SPEC-009 §13 Q-1) — access · static · api · media · ws ·
  dev — derived from routing.ReservedWorkflows, `home` excepted (D-2)
- home: the reference dashboard module, and the core Modules page
2026-10-14 10:53:57 +00:00

411 lines
13 KiB
Go

package module
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/routing"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// Sentinel errors.
var (
// ErrUnknownModule is returned for a name that names no discovered module.
ErrUnknownModule = errors.New("module: unknown module")
// ErrRequiredModule is returned when a required module would be disabled
// (D-038, AC-4).
ErrRequiredModule = errors.New("module: required modules cannot be disabled")
// ErrDuplicateModule is returned when two directories claim the same name.
ErrDuplicateModule = errors.New("module: duplicate module name")
// ErrCoreDispatchName is returned when a discovered module claims a name the
// CORE dispatches for itself — either a core dispatch module (auth · setup ·
// recovery · core, SPEC-008 §9 D-9) or a reserved level-1 workflow (access ·
// setup · api · static · media · ws · dev, SPEC-009 §13 Q-1). The name would
// silently lose precedence — or silently shadow the core — so the module
// FAILS TO LOAD. `home` is the sole exception (SPEC-009 §13 D-2).
ErrCoreDispatchName = errors.New("module: name is reserved by the core")
)
// CoreDispatchNames are the module (PARAM2 / `do`) names the CORE registers into
// the door's dispatch registry (SPEC-008 §9 D-9, SPEC-009 §8):
//
// auth — the SPEC-006 enroll/login flow + the SPEC-007 `recover` action
// setup — the SPEC-005 wizard's steps
// recovery — the SPEC-007 approve/deny actions
// core — the SPEC-008 Modules page
//
// A discovered module may not claim one of them: the door would either drop the
// discovered module (RegisterModule overwrites by name) or the discovered module
// would shadow the core. Either way it would be a SILENT loss of precedence, so
// the load fails closed instead.
var CoreDispatchNames = []string{"auth", "setup", "recovery", "core"}
// ReservedWorkflowNames are the reserved level-1 workflows (SPEC-002 §4) that a
// discovered module may NOT claim (SPEC-009 §13 Q-1). It is DERIVED from
// routing.ReservedWorkflows — the ONE source for the reserved names (D-004) —
// with `home` removed (SPEC-009 §13 D-2).
//
// `home` is deliberately EXCLUDED. `home` is both a reserved level-1 workflow
// AND the PARAM2 dashboard module, and the dashboard *is* a discovered module
// (internal/modules/home — the reference module, SPEC-008 AC-1): the two are
// separate switches, not a collision (SPEC-002 §4.4). The core registers no
// `home` *module*, so nothing can be shadowed. The derived set is therefore
// access · setup · api · static · media · ws · dev.
var ReservedWorkflowNames = reservedWorkflowNames()
// ReservedModuleNames is the full guarded set — the core dispatch modules plus
// the reserved level-1 workflows (minus `home`). A discovered module claiming
// any of these names FAILS TO LOAD (SPEC-008 §9 D-9, SPEC-009 §13 Q-1).
var ReservedModuleNames = unionNames(CoreDispatchNames, ReservedWorkflowNames)
// reservedWorkflowNames derives the guarded reserved-workflow set from the one
// source (routing.ReservedWorkflows), excluding the `home` exception.
func reservedWorkflowNames() []string {
out := make([]string, 0, len(routing.ReservedWorkflows))
for _, n := range routing.ReservedWorkflows {
if n == "home" {
continue // the exception — the reference dashboard module IS `home`
}
out = append(out, n)
}
return out
}
// unionNames concatenates name lists, dropping duplicates, preserving order.
func unionNames(lists ...[]string) []string {
var out []string
seen := make(map[string]bool)
for _, l := range lists {
for _, n := range l {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
}
return out
}
// IsCoreDispatchName reports whether name is a core dispatch module name — the
// core dispatch set above (SPEC-008 §9 D-9).
func IsCoreDispatchName(name string) bool {
name = strings.TrimSpace(name)
for _, n := range CoreDispatchNames {
if n == name {
return true
}
}
return false
}
// IsReservedWorkflowName reports whether name is a reserved level-1 workflow a
// discovered module may not claim — home excluded (SPEC-009 §13 Q-1).
func IsReservedWorkflowName(name string) bool {
name = strings.TrimSpace(name)
for _, n := range ReservedWorkflowNames {
if n == name {
return true
}
}
return false
}
// IsReservedModuleName reports whether name is reserved by the core — the guard
// consulted by Add. It is true for a core dispatch module OR a reserved level-1
// workflow (`home` excepted) — SPEC-008 §9 D-9, SPEC-009 §13 Q-1.
func IsReservedModuleName(name string) bool {
name = strings.TrimSpace(name)
for _, n := range ReservedModuleNames {
if n == name {
return true
}
}
return false
}
// Registry is the runtime module registry: the discovered modules, in
// registration order, together with their enabled state (D-038).
//
// A disabled module is NOT in the registry (SPEC-008 §6): it is not mounted, so
// its routes 404 and its nav entry is hidden. Disabling never deletes data.
type Registry struct {
order []Module
byName map[string]Module
enabled map[string]bool
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry {
return &Registry{
byName: make(map[string]Module),
enabled: make(map[string]bool),
}
}
// Load discovers the modules self-registered in this binary and builds the
// runtime registry, applying the `modules:` map from config (D-038). A module
// absent from cfg keeps its default: required modules are always on, every
// other module defaults to on.
//
// A discovered module with a name reserved by the core FAILS THE LOAD
// (SPEC-008 §9 D-9, SPEC-009 §13 Q-1): the error is returned AND logged, so a
// silent loss of precedence is impossible.
func Load(cfg map[string]bool) (*Registry, error) {
r, err := LoadModules(Discovered(), cfg)
if err != nil {
slog.Default().Error("module: refusing to load the discovered module set", "error", err)
return nil, err
}
return r, nil
}
// LoadModules builds a registry from an explicit module set. Load uses it with
// Discovered(); tests use it with fakes.
func LoadModules(mods []Module, cfg map[string]bool) (*Registry, error) {
r := NewRegistry()
for _, m := range mods {
if err := m.Register(r); err != nil {
return nil, fmt.Errorf("module: register %s: %w", moduleName(m), err)
}
}
for _, m := range r.order {
if m.Required() {
r.enabled[m.Name()] = true
continue
}
on := true // default on; an explicit config value wins
if v, ok := cfg[m.Name()]; ok {
on = v
}
r.enabled[m.Name()] = on
}
return r, nil
}
// Add records a module. It is called by a module's Register method. It fails
// closed on a nil module, an empty name, a name reserved by the core (a core
// dispatch module or a reserved level-1 workflow — SPEC-008 §9 D-9, SPEC-009
// §13 Q-1), a duplicate name, or a malformed route/action set.
func (r *Registry) Add(m Module) error {
if m == nil {
return errors.New("module: nil module")
}
name := strings.TrimSpace(m.Name())
if name == "" {
return errors.New("module: empty module name")
}
// The guard runs BEFORE the registry is touched: a discovered module with a
// reserved name must fail to load, never silently lose (or steal) precedence.
if IsReservedModuleName(name) {
return fmt.Errorf("%w: %q", ErrCoreDispatchName, name)
}
if _, dup := r.byName[name]; dup {
return fmt.Errorf("%w: %q", ErrDuplicateModule, name)
}
if err := validate(name, m.Routes(), m.Actions()); err != nil {
return err
}
r.order = append(r.order, m)
r.byName[name] = m
r.enabled[name] = true // default on; Load applies config, SetEnabled overrides
return nil
}
// Modules returns every registered module in registration order (deterministic
// — never map iteration, AC-2).
func (r *Registry) Modules() []Module {
out := make([]Module, len(r.order))
copy(out, r.order)
return out
}
// Get returns the registered module by name.
func (r *Registry) Get(name string) (Module, bool) {
m, ok := r.byName[strings.TrimSpace(name)]
return m, ok
}
// Enabled reports whether a module is mounted. An unknown module is not
// enabled.
func (r *Registry) Enabled(name string) bool { return r.enabled[strings.TrimSpace(name)] }
// Required reports whether the named module is required.
func (r *Registry) Required(name string) bool {
m, ok := r.Get(name)
return ok && m.Required()
}
// SetEnabled turns a module on or off. It refuses an unknown module and a
// required module being turned off (D-038, AC-4). It changes the RUNTIME state
// only; the config file is written by the Modules page (applies at next start).
func (r *Registry) SetEnabled(name string, on bool) error {
name = strings.TrimSpace(name)
m, ok := r.byName[name]
if !ok {
return fmt.Errorf("%w: %q", ErrUnknownModule, name)
}
if !on && m.Required() {
return fmt.Errorf("%w: %q", ErrRequiredModule, name)
}
r.enabled[name] = on
return nil
}
// Nav returns the names of the enabled modules, in registration order — the
// navigation the shell renders. A disabled module is absent (AC-3: its nav
// hides). Rendering is SPEC-009 (web layer); this is the seam the shell reads.
func (r *Registry) Nav() []string {
out := make([]string, 0, len(r.order))
for _, m := range r.order {
if r.enabled[m.Name()] {
out = append(out, m.Name())
}
}
return out
}
// Migrations returns the migrations of every ENABLED module, in registration
// order, with each version prefixed by the module name. The core owns the
// runner (AC-5); the prefix keeps module schemas from colliding with the core
// schema or with each other.
//
// A disabled module's migrations are not applied — it is not mounted — but
// disabling never drops the tables an earlier run created (AC-3).
func (r *Registry) Migrations() []store.Migration {
var out []store.Migration
for _, m := range r.order {
if !r.enabled[m.Name()] {
continue
}
for _, mig := range m.Migrations() {
out = append(out, store.Migration{
Version: m.Name() + "/" + mig.Version,
SQL: mig.SQL,
})
}
}
return out
}
// Mount registers every ENABLED module's routes and actions into the door's
// dispatch registry. A disabled module is skipped, so the pure model reports it
// as not found and its routes 404 (SPEC-008 §6, AC-3).
func (r *Registry) Mount(dst *server.MapRegistry) {
if dst == nil {
return
}
for _, m := range r.order {
if !r.enabled[m.Name()] {
continue
}
mm := server.NewModule(m.Name())
for _, rt := range m.Routes() {
if rt.Default {
mm.SetDefault(rt.Function, rt.Handler)
continue
}
mm.AddRoute(rt.Name, rt.Function, rt.Handler)
}
for _, a := range m.Actions() {
mm.AddAction(a.Name, a.Function, a.Handler)
}
dst.RegisterModule(mm)
}
}
// Start runs every enabled module's optional background work, in registration
// order. The first failure stops the sequence.
func (r *Registry) Start(ctx context.Context) error {
for _, m := range r.order {
if !r.enabled[m.Name()] {
continue
}
if err := m.Start(ctx); err != nil {
return fmt.Errorf("module: start %s: %w", m.Name(), err)
}
}
return nil
}
// LogLoaded writes the loaded module set at startup (D-028 guardrail: "log the
// loaded set — a visible registry beats hidden magic", AC-2).
func (r *Registry) LogLoaded(l *slog.Logger) {
if l == nil {
l = slog.Default()
}
all := make([]string, 0, len(r.order))
on := make([]string, 0, len(r.order))
off := make([]string, 0, len(r.order))
for _, m := range r.order {
all = append(all, m.Name())
if r.enabled[m.Name()] {
on = append(on, m.Name())
} else {
off = append(off, m.Name())
}
}
l.Info("myday: modules loaded",
"count", len(all), "loaded", all, "enabled", on, "disabled", off)
}
// validate rejects a malformed route/action set so a broken module fails at
// startup, not at request time.
func validate(name string, routes []Route, actions []Action) error {
seenRoutes := make(map[string]bool, len(routes))
defaults := 0
for _, rt := range routes {
if rt.Default {
defaults++
if defaults > 1 {
return fmt.Errorf("module: %s declares more than one default route", name)
}
} else {
key := strings.TrimSpace(rt.Name)
if key == "" {
return fmt.Errorf("module: %s has a route with an empty name", name)
}
if seenRoutes[key] {
return fmt.Errorf("module: %s declares route %q twice", name, key)
}
seenRoutes[key] = true
}
if strings.TrimSpace(rt.Function) == "" {
return fmt.Errorf("module: %s has a route with an empty function", name)
}
if rt.Handler == nil {
return fmt.Errorf("module: %s route %q has no handler", name, rt.Name)
}
}
seenActions := make(map[string]bool, len(actions))
for _, a := range actions {
key := strings.TrimSpace(a.Name)
if key == "" {
return fmt.Errorf("module: %s has an action with an empty name", name)
}
if seenActions[key] {
return fmt.Errorf("module: %s declares action %q twice", name, key)
}
seenActions[key] = true
if strings.TrimSpace(a.Function) == "" {
return fmt.Errorf("module: %s action %q has an empty function", name, key)
}
if a.Handler == nil {
return fmt.Errorf("module: %s action %q has no handler", name, key)
}
}
return nil
}
// moduleName is a defensive Name() for error messages (a nil module never gets
// here).
func moduleName(m Module) string {
if m == nil {
return "<nil>"
}
return m.Name()
}