182 lines
12 KiB
Markdown
182 lines
12 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|