NextMyDay/internal/core/module/module.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

150 lines
5.5 KiB
Go

// Package module is MyDay's module registry (SPEC-008): the module CONTRACT,
// the runtime registry, the build-time DISCOVERY seam, and the core Modules
// page.
//
// Discovery is at BUILD time, not runtime (D-028, the Caddy pattern): a module
// is a package under internal/modules/<name>/ that self-registers from its
// init() by calling Add; a generated aggregator (internal/modules/imports_gen.go,
// produced by `go generate`) blank-imports every module directory, so THE
// DIRECTORY IS THE SOURCE OF TRUTH. Adding a module is: add the directory →
// `go generate` → rebuild. No core edits.
//
// The registry makes the loaded set explicit and deterministic: modules keep
// their registration order (never map iteration), the loaded set is logged at
// startup, and enable/disable is config-backed (D-038) — a disabled module is
// not mounted, so its routes 404; its data is never deleted.
//
// Spec: docs/specs/spec-008-module-registry.md.
package module
import (
"context"
"strings"
"sync"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/server"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store"
)
// Migration is one ordered, named schema migration owned by a module. It reuses
// the core runner's type so the CORE owns the runner (SPEC-008 §4/AC-5): the
// registry prefixes each version with the module name, avoiding collisions with
// the core schema and between modules.
type Migration = store.Migration
// Handler is a dispatched module function. It is the door's handler signature:
// the module receives the resolved RequestContext — identification and routing,
// never a raw path (the rule of the door, AGENT.md §3.2).
type Handler = server.Handler
// Route is one path-mode function a module declares (SPEC-008 §4, §4.2):
// `/{GUID}/{module}/{name}/…` resolves to Function.
type Route struct {
// Name is the PARAM3 value the route answers (e.g. "pto"). Ignored when
// Default is set.
Name string
// Function is the resolved function name the door dispatches on.
Function string
// Handler runs the function.
Handler Handler
// Default marks the route served when PARAM3 is absent or trailing
// (SPEC-002 §3.4): a module declares at most one.
Default bool
}
// Action is one action-mode function a module declares (SPEC-008 §4, §4.3):
// `POST /{GUID}?do={module}&action={name}` resolves to Function.
type Action struct {
// Name is the `action` value the module answers.
Name string
// Function is the resolved function name the door dispatches on.
Function string
// Handler runs the function.
Handler Handler
}
// Module is the module contract every module implements (SPEC-008 §4). The
// default skeleton lives in internal/modules/<name>/ as app.go (this manifest) ·
// routing.go (Routes/Actions) · handlers.go (the handlers), per D-037.
type Module interface {
// Name is the PARAM2 segment — the registry key ("home", "time", …).
Name() string
// Required reports whether the module is always on. A required module
// cannot be disabled (D-038, AC-4).
Required() bool
// Migrations is the module's own schema, versioned and applied in
// registration order by the core runner (AC-5).
Migrations() []Migration
// Routes are the module's path-mode functions (§4.2).
Routes() []Route
// Actions are the module's action-mode functions (§4.3).
Actions() []Action
// Register adds the module to the runtime registry. The loader calls it
// once per discovered module; the usual implementation is `r.Add(m)`.
Register(r *Registry) error
// Start is OPTIONAL background work (integrations, sync). A module without
// background work returns nil.
Start(ctx context.Context) error
}
// Kind is an OPTIONAL capability a module may implement to declare its
// catalogue kind (SPEC-008 §4.8: records · domain · workflow · analytical ·
// integration). The Modules page lists it (§7); absent, KindOf reports
// "module".
type Kind interface {
Kind() string
}
// KindOf returns a module's kind for the Modules page, defaulting to "module"
// when the module does not implement Kind.
func KindOf(m Module) string {
if k, ok := m.(Kind); ok {
if s := strings.TrimSpace(k.Kind()); s != "" {
return s
}
}
return "module"
}
// ── build-time discovery (D-028) ─────────────────────────────────────────────
// discovered is the process-wide self-registration list. Modules append to it
// from init(); because the generated aggregator blank-imports every module
// directory IN SORTED ORDER, Go runs those init() functions in that order and
// the list is deterministic (D-028 guardrail: never map iteration).
var (
discoveryMu sync.Mutex
discovered []Module
)
// Add self-registers a module from its package init() (D-028). It is called by
// each module directory; the generated aggregator guarantees the package is
// imported, so the directory is the source of truth.
//
// A duplicate name is not detected here (init cannot fail usefully): the
// runtime Add catches it when Load builds the registry, which is loud and
// testable.
func Add(m Module) {
if m == nil {
return
}
discoveryMu.Lock()
discovered = append(discovered, m)
discoveryMu.Unlock()
}
// Discovered returns the process-wide, self-registered modules in registration
// order. It returns a copy: the caller may not mutate the registry through it.
func Discovered() []Module {
discoveryMu.Lock()
defer discoveryMu.Unlock()
out := make([]Module, len(discovered))
copy(out, discovered)
return out
}