12 KiB
Capability Spec: api-plugin
Package: @suaveplan/api-plugin
Status: ACTIVE
ADDED Requirements
Requirement: definePlugin factory produces a typed PluginDescriptor
The package SHALL export definePlugin<TConfig>(descriptor): PluginDescriptor<TConfig> that returns the descriptor with full TConfig type inference flowing through configSchema?: ZodType<TConfig>, defaults?: Partial<TConfig>, and the 4 lifecycle hooks (onRegister/onInit/onReady/onClose), each receiving a PluginContext typed for TConfig.
Scenario: descriptor preserves TConfig through the type system
- WHEN
definePlugin({ name, version, configSchema: MyConfigSchema, onInit: (ctx) => ctx.getOwnConfig() })is invoked - THEN
ctx.getOwnConfig()returnsz.infer<typeof MyConfigSchema>(nounknownwidening)
Verified by: src/define/define.test.ts::"definePlugin preserves TConfig type" AND src/define/define.spec-conformance.test.ts::"expectTypeOf getOwnConfig matches configSchema infer"
Requirement: PluginRegistry runs the 4-phase lifecycle in dep order
PluginRegistry.initialize(context) SHALL run topologically-sorted plugins through onRegister → onInit → onReady in that order, calling each hook on each plugin exactly once. shutdown() SHALL run onClose in reverse dep order. Each hook receives a PluginContext whose signal: AbortSignal is bound to the registry's initSignal / shutdownSignal.
Scenario: lifecycle hooks fire in dep order
- WHEN plugins
a(no deps) andb(depends ona) register, andinitialize()is awaited - THEN
a.onRegister,a.onInit,a.onReadycomplete before any ofb's lifecycle hooks
Scenario: shutdown reverses the order
- WHEN the same registry is shut down
- THEN
b.onClosecompletes beforea.onClose
Verified by: src/registry/registry.test.ts::"initialize runs onRegister/onInit/onReady in dep order" AND src/registry/registry.test.ts::"shutdown calls onClose in reverse dep order"
Requirement: Topological sort uses Kahn's algorithm with deterministic tie-break
topologicalSort(nodes) SHALL implement Kahn's algorithm. Equal-in-degree nodes SHALL be processed in alphabetical order of id (stable across runs, independent of registration order). Missing required deps go into the unresolved array. Cycles raise PluginError(PLUGIN_CIRCULAR_DEPENDENCY) carrying the offending cycle path.
Scenario: equal-in-degree nodes sort alphabetically
- WHEN plugins
z,a,mare registered in that order with no deps - THEN
getSortedPlugins()returns them in order[a, m, z]regardless of registration order
Scenario: cycle throws with cycle path
- WHEN plugins form a cycle
a → b → c → aandinitialize()is awaited - THEN the promise rejects with
PluginError(PLUGIN_CIRCULAR_DEPENDENCY)whosecontext.cycleequals["a", "b", "c", "a"]
Verified by: src/topo-sort/topo-sort.test.ts::"sorts equal-in-degree nodes alphabetically" AND src/topo-sort/topo-sort.test.ts::"throws PLUGIN_CIRCULAR_DEPENDENCY with cycle path"
Requirement: PluginRegistry enforces initTimeout per hook
The registry SHALL accept initTimeout (default 30000ms) and shutdownTimeout (default 10000ms) in PluginRegistryOptions. If any single lifecycle hook on any plugin exceeds the per-hook budget, the registry SHALL reject with PluginError(PLUGIN_INIT_TIMEOUT) carrying context.plugin and context.hook. Timeout uses the injected Clock and is implemented via AbortSignal (the plugin's ctx.signal is aborted when the timer fires).
Scenario: hung onInit hits the timeout
- WHEN a plugin's
onInitreturns a Promise that never resolves andinitialize()is awaited withinitTimeout: 100 - THEN the promise rejects within ~100ms with
PluginError(PLUGIN_INIT_TIMEOUT)whosecontext.pluginis the offending plugin name
Verified by: src/registry/registry.test.ts::"initTimeout aborts a hung onInit" AND src/registry/registry.test.ts::"shutdownTimeout aborts a hung onClose"
Requirement: PluginContext.getService is type-safe via ServiceRegistry module augmentation
The package SHALL export interface ServiceRegistry {} (empty by default) from ./service-registry. registerService<K extends keyof ServiceRegistry>(name: K, service: ServiceRegistry[K]) and getService<K extends keyof ServiceRegistry>(name: K): ServiceRegistry[K] | undefined SHALL be typed against augmentations. Untyped string keys (not in ServiceRegistry) SHALL fail TypeScript compilation; runtime behaviour preserved for forward compatibility.
Scenario: augmented service slot types correctly
- WHEN a consumer declares
declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { db: DbAdapter } }and callsctx.getService("db") - THEN the return type is
DbAdapter | undefined(no cast required)
Scenario: unknown service name fails compilation
- WHEN a consumer calls
ctx.getService("unknown-slot")without augmentingServiceRegistry - THEN the compile fails
Verified by: src/context/context.spec-conformance.test.ts::"expectTypeOf getService(K) returns ServiceRegistry[K] | undefined" AND src/context/context.spec-conformance.test.ts::"unknown service key produces a type error"
Requirement: HookExecutor handles non-lifecycle hooks only
The package SHALL ship HookExecutor with registerHook(name, handler, pluginName, priority), unregisterPlugin(name), execute(name, ...args) (sequential, abort on first throw → PluginError(PLUGIN_HOOK_FAILED)), and executeAll(name, ...args) (parallel via Promise.allSettled). Lifecycle hooks (onRegister/onInit/onReady/onClose) SHALL NOT be dispatched via the executor — registry owns them. The executor's hook namespace is for plugin-contributed custom hooks AND the request/tenant/db hooks that ship in sibling packages.
Scenario: registry does not invoke lifecycle hooks via executor
- WHEN a plugin's
onInitis called by the registry and the executor is queried forgetHookCount("onInit") - THEN the count is 0 — lifecycle hooks are not registered with the executor
Scenario: custom hooks dispatch with priority
- WHEN two plugins register handlers for a custom hook
"my-extension"with priorities 10 and 5, thenexecutor.execute("my-extension", ...args)runs - THEN the priority-10 handler runs first
Verified by: src/hook-executor/hook-executor.test.ts::"lifecycle hooks are not registered with the executor" AND src/hook-executor/hook-executor.test.ts::"dispatch order respects priority" AND src/hook-executor/hook-executor.test.ts::"execute aborts on first throw with PLUGIN_HOOK_FAILED"
Requirement: Every PLUGIN_* error code is thrown from a production path
PLUGIN_CODES SHALL define exactly 10 codes (PLUGIN_NOT_FOUND, PLUGIN_ALREADY_REGISTERED, PLUGIN_INIT_FAILED, PLUGIN_DEP_MISSING, PLUGIN_CIRCULAR_DEPENDENCY, PLUGIN_INIT_TIMEOUT, PLUGIN_SHUTDOWN_FAILED, PLUGIN_INVALID_CONFIG, PLUGIN_HOOK_FAILED, PLUGIN_ABORTED). Every code SHALL be raised by at least one production call site.
Scenario: spec conformance exercises every code
- WHEN the conformance test exercises each code's production path
- THEN every code appears in the assertions
Verified by: src/api-plugin.spec-conformance.test.ts::"every PLUGIN_* code is thrown from a production path"
Requirement: Universal-tier purity, subpath exports, sibling docs
The package SHALL run unchanged in Bun, Node, and the browser (no node:fs, no process.cwd() outside lazy CLI paths). package.json SHALL declare a subpath export per feature module. Every implementation .ts SHALL have a sibling .md of ≥200 words. No export * in feature barrels.
Scenario: dep-graph asserts package shape
- WHEN
tests/dep-graph.test.tsinspects package.jsondependencies+exports - THEN dependencies are restricted to
@suaveplan/error,@suaveplan/events,@suaveplan/types,tslib,zod,@opentelemetry/api; every feature module is reachable via a declared subpath
Verified by: tests/dep-graph.test.ts::"dependencies match the universal allowlist" AND tests/dep-graph.test.ts::"subpath exports cover every feature module" AND tests/dep-graph.test.ts::"every implementation .ts has a sibling .md"
Requirement: 100% coverage with property test for topo-sort stability
The package SHALL enforce 100/100/100/100 statements/branches/functions/lines via Istanbul. tests/property.test.ts SHALL use fast-check (numRuns: 200) to assert that for arbitrary acyclic dep graphs [id → deps], topologicalSort produces an order in which every node appears after every one of its declared dependencies AND the order is reproducible (calling sort twice on the same input yields identical output).
Scenario: dep-order invariant holds for arbitrary graphs
- WHEN fast-check generates random acyclic dep graphs and
topologicalSortis called - THEN for every node N in the result, every dep of N appears earlier in the result
Verified by: tests/property.test.ts::"sorted order satisfies dep invariant" AND tests/property.test.ts::"sort is deterministic across repeated calls"
Requirement: PluginRegistry.seedServices bulk-registers entries before plugin init
The package SHALL add PluginRegistry.seedServices<TMap extends Partial<{ [K in keyof ServiceRegistry]: ServiceRegistry[K] }>>(map: TMap): void. Calling it stores every key/value pair in an internal seeded-services map. When initialize() later constructs per-plugin contexts, every seeded entry SHALL appear in ctx.getService(name) BEFORE any plugin's onRegister runs. Calling seedServices after initialize() has started SHALL raise PluginError(PLUGIN_INVALID_STATE).
Scenario: seeded service is resolvable from the first plugin's onRegister
- WHEN
registry.seedServices({ app: stubApp })is called, then a plugin withonRegister(ctx) { ctx.getService("app") }is registered, thenregistry.initialize()is awaited - THEN the plugin's
onRegisterresolvesstubApp(noundefined)
Scenario: later seed overrides earlier seed
- WHEN
registry.seedServices({ logger: l1 })is followed byregistry.seedServices({ logger: l2 })beforeinitialize() - THEN the next plugin's
ctx.getService("logger")returnsl2
Scenario: seedServices after initialize raises PLUGIN_INVALID_STATE
- WHEN
registry.initialize()is awaited and thenregistry.seedServices({ app: stubApp })is called - THEN
PluginError(PLUGIN_INVALID_STATE)is thrown
Verified by: src/registry/seed-services.test.ts::"seeded service resolvable from first plugin's onRegister" AND src/registry/seed-services.test.ts::"later seed overrides earlier" AND src/registry/seed-services.test.ts::"seedServices after initialize raises PLUGIN.INVALID_STATE"
Requirement: PluginRegistry exposes PLUGIN_INVALID_STATE in the code registry
PLUGIN_CODES SHALL include PLUGIN_INVALID_STATE (taking the registry to 11 codes total). The code SHALL be raised by seedServices post-initialize(); existing 10 codes are preserved.
Scenario: existing codes unchanged
- WHEN the spec-conformance test enumerates
PLUGIN_CODES - THEN the set is the union of the existing 10 codes plus
PLUGIN_INVALID_STATE(no removals)
Verified by: src/api-plugin.spec-conformance.test.ts::"PLUGIN_CODES contains PLUGIN_INVALID_STATE alongside the existing 10"