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

63 lines
7.1 KiB
Markdown

# Add @suaveplan/api-plugin — plugin system foundation (1 of 5)
**Package**: `@suaveplan/api-plugin` (NEW, universal-tier-server)
**Status**: PROPOSED
**Type**: new-package (split from upstream `@suaveplan/api`)
- **Spec delta**: ./specs/api-plugin/spec.md
- **Companions**: `add-api-tenant`, `add-api-openapi`, `add-api-migrations`, `enterprise-api`
- **Position in chain**: foundation — gates all 4 companions
## Why
The upstream `@suaveplan/api` package (8.7k LOC) bundles 7 architectural layers into one. The plugin system (`plugin/` + `hooks/` + topo-sort util — 2,034 LOC) is the foundation every other layer depends on. Splitting it out lets the tenant / openapi / migrations / app layers compose against a stable, fully-tested plug-and-play primitive without dragging the whole framework along.
The upstream plugin system has known design issues that genesis fixes: dual-runtime hook execution (registry calls lifecycle hooks directly AND HookExecutor registers them — duplication hazard), unsafe `getService<T>(name)` casts, unenforced `initTimeout` option, and asymmetric error policy between init (abort-on-throw) and shutdown (swallow-and-log). Forty-plus in-flight `enterprise-api-plugin-*` change folders are all blocked on this landing.
## What changes
Net-new package at `0.1.0`. ADDED:
- `definePlugin<TConfig>(descriptor): PluginDescriptor<TConfig>` — typed plugin factory. Descriptor includes `name`, `version`, `dependencies`, `optionalDependencies`, `priority?`, `enabledByDefault?`, `configSchema?: ZodType`, and lifecycle hooks (`onRegister/onInit/onReady/onClose`).
- `PluginRegistry` class — single canonical registry. Methods: `register`, `registerAll`, `get`, `has`, `getAll`, `getSortedPlugins`, `initialize(context)`, `shutdown()`, `disable(name)`. Internally owns the topo-sort + cycle detection.
- `PluginContext` — runtime injection: `{ logger: LoggerLike, eventBus: EventBusLike, getOwnConfig<T>(): T, registerService<K extends ServiceKey>(name: K, service: ServiceRegistry[K]): void, getService<K extends ServiceKey>(name: K): ServiceRegistry[K] | undefined, hasService(name: string): boolean, signal: AbortSignal }`.
- `ServiceRegistry` interface (empty by default) — designed for **module augmentation** so plugins declare their typed slots: `declare module "@suaveplan/api-plugin/service-registry" { interface ServiceRegistry { db: DbAdapter } }`.
- `topologicalSort(nodes): { sorted, unresolved }` — Kahn's algorithm + DFS cycle-detection diagnostic. Equal-in-degree nodes sorted alphabetically by id (deterministic).
- `validateDependencies(nodes)` — single canonical validator (upstream has two; we ship one).
- `HookExecutor` for **non-lifecycle hooks only** (request/db/custom plugin-contributed hooks). Lifecycle hooks (`onRegister/onInit/onReady/onClose`) are owned by `PluginRegistry` and called once per plugin — never via the executor. Eliminates upstream's dual-runtime hazard.
- `PluginError extends SuaveplanError<PluginErrorCode>` with `defineCodes("PLUGIN", [...])` registry. 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`.
- `PluginRegistryOptions` — `{ initTimeout?: number (default 30000), shutdownTimeout?: number (default 10000), strictOptionalDeps?: boolean, clock?: Clock }`. **`initTimeout` is enforced** — exceeded init rejects with `PluginError(PLUGIN_INIT_TIMEOUT)`. Same for shutdown.
- `AbortSignal` propagation — every lifecycle hook receives `ctx.signal`; aborted ops reject with `PluginError(PLUGIN_ABORTED)`.
- Subpath exports per feature module: `./define`, `./registry`, `./context`, `./service-registry`, `./hook-executor`, `./topo-sort`, `./error-codes`, `./types`.
## Out of scope
- **Tenant lifecycle hooks** (`onTenantCreate/Update/Delete/Suspend/Activate`, `onPluginEnable/Disable`). Belong in `@suaveplan/api-tenant` (sibling change `add-api-tenant`) so non-tenant apps don't pay the surface cost.
- **Request lifecycle hooks** (`onRequest/onResponse/onRequestError`). Belong in `@suaveplan/api` orchestrator + middleware layer (`enterprise-api`) because they need Hono request context to fire.
- **Database migration auto-application.** The registry's upstream `applyMigrations()` between register/init is dropped. Migrations are owned by `@suaveplan/api-migrations` and run explicitly by the app orchestrator at the appropriate boot phase.
- **OpenAPI registry integration.** `@suaveplan/api-openapi` consumes the plugin context but does not live inside the plugin package.
- **Filesystem plugin discovery** (`PluginLoader.autoDiscover()`). Has two known bugs upstream (prefix mismatch between filter and rewrite) and adds a Node-only filesystem dep to what is otherwise a universal-tier package. Belongs in a follow-up `@suaveplan/api-plugin-loader` only if a real consumer asks. Explicit `plugins: [foo, bar]` arrays are the v1 contract.
## Source mapping
| Capability | Upstream source | Notes |
|---|---|---|
| Plugin interface + descriptor | `../suaveplan/packages/server/api/src/plugin/plugin.interface.ts` L1-L201 | port shape; switch hook surface to 4 lifecycle hooks only (others move to sibling packages) |
| `definePlugin` factory | `../suaveplan/packages/server/api/src/plugin/plugin-metadata.ts` L77-L123 | port shape; drop decorator helpers (require `reflect-metadata`) |
| `PluginRegistry` class | `../suaveplan/packages/server/api/src/plugin/plugin-registry.ts` L44-L433 | port + enforce `initTimeout`, unify lifecycle runtime, switch errors to `defineCodes` registry, drop `applyMigrations` |
| `PluginContext` | `../suaveplan/packages/server/api/src/plugin/plugin-context.ts` L43-L227 | port shape; `registerService`/`getService` become typed via `ServiceRegistry` module augmentation; drop `getPluginConfig(name)` cross-plugin coupling; ALS-based `getCurrentXxx()` helpers dropped (callers thread `ctx` explicitly) |
| `topologicalSort` + `detectCycle` + `validateDependencies` | `../suaveplan/packages/server/api/src/utils/topological-sort.ts` L1-L157 | verbatim port + alphabetical tie-break for stable order; drop duplicate `validateDependencies` in registry |
| `HookExecutor` (priority-ordered dispatch) | `../suaveplan/packages/server/api/src/hooks/hook-executor.ts` L1-L221 | port shape; **excludes the 4 lifecycle hook slots** (those are registry-owned); switch to `defineCodes` errors |
| Plugin error subclasses | `../suaveplan/packages/server/api/src/errors/plugin-errors.ts` L1-L97 | replace with `defineCodes("PLUGIN", [...])` registry + `PluginError` discriminant |
## Impact
### Affected dependents
None today — net-new on genesis. Forward-declared consumers (all sibling changes in this set + the 40+ in-flight `enterprise-api-plugin-*` packages):
- `@suaveplan/api-tenant` (sibling) — adds tenant lifecycle hooks on top
- `@suaveplan/api-openapi` (sibling) — registers routes via plugin `onInit`
- `@suaveplan/api` (sibling) — composes the registry into `SuaveApp`
- Every `@suaveplan/api-plugin-*` package (40+ in `openspec/changes/`) — implements `PluginDescriptor`