Files
2026-05-27 23:29:53 +01:00

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() returns z.infer<typeof MyConfigSchema> (no unknown widening)

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) and b (depends on a) register, and initialize() is awaited
  • THEN a.onRegister, a.onInit, a.onReady complete before any of b's lifecycle hooks

Scenario: shutdown reverses the order

  • WHEN the same registry is shut down
  • THEN b.onClose completes before a.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, m are 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 → a and initialize() is awaited
  • THEN the promise rejects with PluginError(PLUGIN_CIRCULAR_DEPENDENCY) whose context.cycle equals ["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 onInit returns a Promise that never resolves and initialize() is awaited with initTimeout: 100
  • THEN the promise rejects within ~100ms with PluginError(PLUGIN_INIT_TIMEOUT) whose context.plugin is 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 calls ctx.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 augmenting ServiceRegistry
  • 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 onInit is called by the registry and the executor is queried for getHookCount("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, then executor.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.ts inspects package.json dependencies + 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 topologicalSort is 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 with onRegister(ctx) { ctx.getService("app") } is registered, then registry.initialize() is awaited
  • THEN the plugin's onRegister resolves stubApp (no undefined)

Scenario: later seed overrides earlier seed

  • WHEN registry.seedServices({ logger: l1 }) is followed by registry.seedServices({ logger: l2 }) before initialize()
  • THEN the next plugin's ctx.getService("logger") returns l2

Scenario: seedServices after initialize raises PLUGIN_INVALID_STATE

  • WHEN registry.initialize() is awaited and then registry.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"