Files
suaveplan/openspec/archive/2026-05-18-add-api-plugin/proposal.md
2026-05-27 23:29:53 +01:00

7.1 KiB

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