first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
# @suaveplan/api-plugin - Plugin system foundation for the Suaveplan API — typed PluginDescriptor factory, PluginRegistry with 4-phase lifecycle (onRegister → onInit → onReady / onClose in reverse), deterministic Kahn topological sort with alphabetical tie-break, AbortSignal-enforced initTimeout/shutdownTimeout, type-safe ServiceRegistry via module augmentation, priority-ordered HookExecutor for non-lifecycle hooks, defineCodes-typed PluginError with ten PLUGIN.* codes. Universal — runs unchanged in Bun, Node, and the browser.
Plugin-system foundation for the Suaveplan API. Universal — runs unchanged in Bun, Node, and the browser.
<!-- begin:auto-docs -->
## Documentation tree
- [context](./src/context/context.md) — The `context` module ships the runtime object every plugin lifecycle hook receives. `PluginContext<TConfig>` carries th…
- [context](./src/context/context.md) — The `context` module ships the runtime object every plugin lifecycle hook receives. `PluginContext<TConfig>` carries th…
- [define](./src/define/define.md) — The `define` module exports a single function — `definePlugin<TConfig>(descriptor)` — that is the canonical entry point…
- [define](./src/define/define.md) — The `define` module exports a single function — `definePlugin<TConfig>(descriptor)` — that is the canonical entry point…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure mode the `@suaveplan/api-plugin` package can r…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the single source of truth for every failure mode the `@suaveplan/api-plugin` package can r…
- [hook-executor](./src/hook-executor/hook-executor.md) — The `hook-executor` module ships a small, priority-ordered dispatcher for **non-lifecycle** plugin hooks — custom event…
- [hook-executor](./src/hook-executor/hook-executor.md) — The `hook-executor` module ships a small, priority-ordered dispatcher for **non-lifecycle** plugin hooks — custom event…
- [registry](./src/registry/registry.md) — The `registry` module owns `PluginRegistry`, the single canonical orchestrator for the plugin system. Its job is to:
- [registry](./src/registry/registry.md) — The `registry` module owns `PluginRegistry`, the single canonical orchestrator for the plugin system. Its job is to:
- [service-registry](./src/service-registry/service-registry.md) — The `service-registry` module defines the single shared interface that plugins augment to publish typed services to oth…
- [service-registry](./src/service-registry/service-registry.md) — The `service-registry` module defines the single shared interface that plugins augment to publish typed services to oth…
- [topo-sort](./src/topo-sort/topo-sort.md) — The `topo-sort` module owns the deterministic dependency-ordering primitive the `PluginRegistry` uses to schedule lifec…
- [topo-sort](./src/topo-sort/topo-sort.md) — The `topo-sort` module owns the deterministic dependency-ordering primitive the `PluginRegistry` uses to schedule lifec…
- [types](./src/types/api-plugin.md) — The `types` module holds every shared shape used across the api-plugin package: the immutable `PluginDescriptor<TConfig…
- [api-plugin](./src/types/api-plugin.md) — The `types` module holds every shared shape used across the api-plugin package: the immutable `PluginDescriptor<TConfig…
<!-- end:auto-docs -->
## What you get
- `definePlugin<TConfig>(descriptor)` — typed plugin-descriptor factory.
- `PluginRegistry` — 4-phase lifecycle (`onRegister → onInit → onReady` forward, `onClose` reverse) with deterministic dep ordering, `initTimeout` / `shutdownTimeout` enforcement via `AbortSignal`, OTel span per hook.
- `topologicalSort` — Kahn's algorithm with alphabetical tie-break + DFS cycle detection.
- `ServiceRegistry` — empty interface designed for **module augmentation**. Plugins declare typed service slots; consumers get fully-typed `getService<K>` / `registerService<K>` with no casts.
- `HookExecutor` — priority-ordered, non-lifecycle hook dispatcher (custom + sibling-package request/tenant/db hooks). Rejects registration of the four lifecycle hook names — those are registry-owned.
- `PluginError` + `PLUGIN_CODES` — 10 stable error codes via `defineCodes`.
## Quick-start
```ts
import { createPluginRegistry, definePlugin } from "@suaveplan/api-plugin";
import z from "zod";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry { db: { query: (sql: string) => unknown } }
}
const db = definePlugin({
name: "db",
version: "0.1.0",
onInit(ctx) {
ctx.registerService("db", { query: (sql) => ({ sql }) });
},
});
const api = definePlugin<{ port: number }>({
name: "api",
version: "0.1.0",
dependencies: ["db"],
configSchema: z.object({ port: z.number().int().positive() }),
defaults: { port: 3000 },
async onReady(ctx) {
const { port } = ctx.getOwnConfig();
const db = ctx.getService("db");
ctx.logger.info("api ready", { port, hasDb: !!db });
},
});
const registry = createPluginRegistry({ initTimeout: 5_000 });
registry.registerAll([db, api]);
await registry.initialize();
// later …
await registry.shutdown();
```
## Modules
| Subpath | Exports |
|---|---|
| `@suaveplan/api-plugin` | barrel — everything below |
| `@suaveplan/api-plugin/define` | `definePlugin` |
| `@suaveplan/api-plugin/registry` | `PluginRegistry`, `createPluginRegistry`, `PluginRegistryOptions` |
| `@suaveplan/api-plugin/context` | `createPluginContext`, `PluginContext` |
| `@suaveplan/api-plugin/service-registry` | `ServiceRegistry` (augment me), `ServiceKey` |
| `@suaveplan/api-plugin/hook-executor` | `HookExecutor`, `createHookExecutor`, `LIFECYCLE_HOOK_NAMES` |
| `@suaveplan/api-plugin/topo-sort` | `topologicalSort`, `validateDependencies` |
| `@suaveplan/api-plugin/error-codes` | `PLUGIN_CODES`, `PluginError`, `PluginErrorCode` |
| `@suaveplan/api-plugin/types` | `PluginDescriptor`, `PluginInstance`, `PluginNode`, `PluginState`, `PluginLifecycleHook`, `TopologicalSortResult` |
## Augmenting `ServiceRegistry`
`ServiceRegistry` ships **empty**. Plugins augment it to publish typed services:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
cache: CacheAdapter;
auth: AuthAdapter;
}
}
```
After that:
```ts
ctx.getService("cache"); // CacheAdapter | undefined
ctx.getService("nope"); // type error — not augmented
```
## Error codes
`PLUGIN_CODES` defines ten 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 is raised by at least one production call site — the spec-conformance test pins this invariant.
## design decisions
- **Unified lifecycle runtime** — `PluginRegistry` calls `onRegister`/`onInit`/`onReady`/`onClose` directly; the `HookExecutor` rejects registration of these four names. Upstream's dual-runtime hazard is eliminated by construction.
- **`initTimeout`/`shutdownTimeout` are enforced** — every hook runs with an `AbortController`-bound `ctx.signal` that fires when the budget is exceeded.
- **`getService<K>` is type-safe** via `ServiceRegistry` module augmentation. No more `getService<MyType>(name)` casts.
- **Topological sort is deterministic** — alphabetical tie-break for equal-in-degree nodes makes boot logs reproducible across runs.
- **No `applyMigrations`** — migrations live in `@suaveplan/api-migrations`. The plugin registry knows nothing about databases.
- **No ALS singletons** (`getCurrentApp`, etc.). Callers thread `ctx` explicitly.

View File

@@ -0,0 +1,111 @@
# context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `context` module ships the runtime object every plugin lifecycle
hook receives. `PluginContext<TConfig>` carries the structural `logger`
and `eventBus` (typed against the universal contracts in
`@suaveplan/types`), the in-flight `signal` (an `AbortSignal` the
registry aborts on timeout or shutdown), the plugin's own merged config
via `getOwnConfig()`, and the typed `registerService`/`getService`/
`hasService` trio backed by the shared `ServiceRegistry` augmentation
surface.
`createPluginContext` is the constructor — the registry calls it once
per plugin per lifecycle phase, binding that plugin's config and the
shared `services` Map. The Map instance is shared across every plugin
in the same boot, so a producer plugin's `registerService` is visible
to every later consumer's `getService`.
## Features
- Universal — no `node:fs`, no DOM, no Hono dependency. Works in
Bun/Node/browser.
- Typed `getService<K>` / `registerService<K>` via the augmentable
`ServiceRegistry` interface; untyped string keys fail at compile time.
- `signal: AbortSignal` for graceful cooperative cancellation — every
lifecycle hook can branch on `ctx.signal.aborted` or thread the signal
into long-running async work.
- `getOwnConfig()` returns the calling plugin's `TConfig` with full
schema inference from `definePlugin`.
- Stateless — no internal closures keep references after the hook
returns; the `services` Map lifetime is owned by the registry.
- No `getPluginConfig(name)` cross-plugin coupling (unlike upstream).
Reading another plugin's config goes through a typed service.
## Usage: Basic
A plugin hook reading its own config and registering a service:
```ts
import { definePlugin } from "@suaveplan/api-plugin";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry { http: { port: number } }
}
export const httpPlugin = definePlugin<{ port: number }>({
name: "http",
version: "0.1.0",
defaults: { port: 3000 },
onInit(ctx) {
const cfg = ctx.getOwnConfig();
ctx.registerService("http", { port: cfg.port });
},
});
```
## Usage: Advanced
A consumer plugin observing the abort signal during async setup:
```ts
export const slowPlugin = definePlugin({
name: "slow",
version: "0.1.0",
dependencies: ["http"],
async onReady(ctx) {
const http = ctx.getService("http");
if (!http) return;
ctx.logger.info("warming up");
await new Promise<void>((resolve, reject) => {
const onAbort = () => reject(new Error("aborted"));
ctx.signal.addEventListener("abort", onAbort, { once: true });
setTimeout(() => {
ctx.signal.removeEventListener("abort", onAbort);
resolve();
}, 50);
});
},
});
```
## API Reference
| Export | Signature |
|---|---|
| `createPluginContext<TConfig>(options)` | `(CreatePluginContextOptions<TConfig>) => PluginContext<TConfig>` |
| `CreatePluginContextOptions<TConfig>` | `{ logger, eventBus, signal, config, services }` |
| `PluginContext<TConfig>` | `{ logger, eventBus, signal, getOwnConfig(), registerService<K>, getService<K>, hasService }` |
## Implementation Notes
The shared `services` Map is intentionally not snapshotted or copied
between phases — that would defeat the producer/consumer model. The
registry passes the same reference into every call to
`createPluginContext`, so registration order across phases is
preserved.
`registerService` casts the `K` parameter to `string` once when writing
to the Map; everywhere else the API is typed against
`ServiceRegistry[K]`. Because consumers augment via `declare module`,
the cast is the only place the runtime "knows" services are
string-keyed — every public site is typed.
`signal` is read-only on the context; the registry holds the
`AbortController` and decides when to abort (timeout / shutdown). Hooks
must never construct their own signal and feed it back into the context.

View File

@@ -0,0 +1,96 @@
# define
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `define` module exports a single function — `definePlugin<TConfig>(descriptor)`
— that is the canonical entry point for authoring a plugin. The function
returns its input unchanged at runtime; the value is entirely in its
type signature, which threads the `TConfig` generic through every place
the descriptor references it: `configSchema?: ZodType<TConfig>`,
`defaults?: Partial<TConfig>`, and the four lifecycle hooks
(`onRegister/onInit/onReady/onClose`). Inside each hook,
`ctx.getOwnConfig()` returns `TConfig` with no cast required.
Using `definePlugin` is preferred over building a raw `PluginDescriptor`
literal because it pins the generic at the call site. TypeScript will
then flag mismatches at the source of the error (the hook body), not at
the registration site one or two files away.
## Features
- Zero runtime overhead — the function body is `return descriptor`.
- Full `TConfig` inference flows into hooks via `PluginContext<TConfig>`.
- Compatible with descriptor field defaults (`description`, `dependencies`,
`optionalDependencies`, `priority`, `enabledByDefault`, `tags`).
- Plays nicely with `as const` literals — readers preserve `readonly`
arrays for `dependencies` etc.
## Usage: Basic
```ts
import { definePlugin } from "@suaveplan/api-plugin/define";
import z from "zod";
export const auth = definePlugin({
name: "auth",
version: "0.1.0",
configSchema: z.object({ sessionTtlSeconds: z.number().int().positive() }),
defaults: { sessionTtlSeconds: 3600 },
onInit(ctx) {
const { sessionTtlSeconds } = ctx.getOwnConfig();
ctx.logger.info("auth ready", { sessionTtlSeconds });
},
});
```
## Usage: Advanced
Multiple hooks with `await` and the abort signal:
```ts
export const queue = definePlugin<{ concurrency: number }>({
name: "queue",
version: "0.1.0",
dependencies: ["db"],
defaults: { concurrency: 4 },
async onInit(ctx) {
const { concurrency } = ctx.getOwnConfig();
ctx.logger.info("starting queue", { concurrency });
},
async onReady(ctx) {
if (ctx.signal.aborted) return;
ctx.logger.info("queue ready");
},
async onClose(ctx) {
ctx.logger.info("queue draining");
},
});
```
## API Reference
| Export | Signature |
|---|---|
| `definePlugin<TConfig>(descriptor)` | `(PluginDescriptor<TConfig>) => PluginDescriptor<TConfig>` |
## Implementation Notes
The function is intentionally an identity at runtime so it can be
constant-folded by bundlers — the dependency graph rewards leaf
helpers that the optimiser can drop. Because the `descriptor` parameter
is typed as `PluginDescriptor<TConfig>`, TypeScript will not narrow
`TConfig` away if the call site supplies an explicit generic
(`definePlugin<MyConfig>({...})`). Implicit inference also works
when `configSchema` is provided as a Zod schema, because the
descriptor field is typed `ZodType<TConfig>` and Zod's schema generic
is contravariant in this position.
Calling `definePlugin` is preferred over a bare object literal even
though both are runtime-equivalent: the call site is the right place
for an editor to surface "Quick Info" on the generic, and it keeps the
public author surface consistent across the 40+ in-flight
`@suaveplan/api-plugin-*` packages.

View File

@@ -0,0 +1,92 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module is the single source of truth for every failure
mode the `@suaveplan/api-plugin` package can raise. It exports
`PLUGIN_CODES` — a frozen record built by `defineCodes("PLUGIN", [...])`
from `@suaveplan/error` — and the concrete `PluginError` class. Consumers
dispatch on the literal `code` value rather than parsing string messages,
which keeps error handling forward-compatible across patch releases.
The registry contains exactly **11** codes. Every code is raised by at
least one production call site in this package; the spec-conformance
test pins this invariant. The newest entry, `INVALID_STATE`, is raised
by `PluginRegistry.seedServices(...)` when called after `initialize()`
has begun — see the registry module for details.
## Features
- Frozen, fully-inferred `PLUGIN_CODES` registry — TypeScript narrows
`code` to a string literal union without the consumer writing one.
- `PluginError` extends `SuaveplanError<PluginErrorCode>` so
`instanceof PluginError` narrows `.code` to the typed union.
- `pluginCode` field mirrors `code` for ergonomic switch-on access where
the developer wants the discriminant without the base-class union.
- Public constructor — base `SuaveplanError` is `protected`, this class
widens so the registry/context/hook-executor can construct instances
directly.
- Carries optional `context`, `cause`, `traceId`, and `clock` via
`SuaveplanErrorOptions`.
## Usage: Basic
```ts
import { PLUGIN_CODES, PluginError } from "@suaveplan/api-plugin/error-codes";
throw new PluginError(
PLUGIN_CODES.NOT_FOUND,
"plugin 'auth' is not registered",
{ context: { plugin: "auth" } }
);
```
## Usage: Advanced
Branch on the typed code in a single error handler:
```ts
import { PluginError } from "@suaveplan/api-plugin/error-codes";
function classify(err: unknown): "retry" | "fail" | "config" {
if (!(err instanceof PluginError)) return "fail";
switch (err.code) {
case "PLUGIN.INIT_TIMEOUT":
case "PLUGIN.ABORTED":
return "retry";
case "PLUGIN.INVALID_CONFIG":
case "PLUGIN.DEP_MISSING":
case "PLUGIN.CIRCULAR_DEPENDENCY":
case "PLUGIN.INVALID_STATE":
return "config";
default:
return "fail";
}
}
```
## API Reference
| Export | Kind | Description |
|---|---|---|
| `PLUGIN_CODES` | `const` | Frozen record `{ NOT_FOUND: "PLUGIN.NOT_FOUND", ... }` |
| `PluginErrorCode` | `type` | Union of every fully-qualified `PLUGIN.*` code |
| `PluginError` | `class` | `extends SuaveplanError<PluginErrorCode>`. Constructor `(code, message, options?)`. Public field `pluginCode: PluginErrorCode` |
## Implementation Notes
`defineCodes` returns a `Readonly` mapping with `Object.freeze`, so the
constant is safe to share across realms without defensive copies. The
namespace prefix (`PLUGIN`) is encoded at construction time — adding a
new code is a one-line change to the array literal, and TypeScript
propagates the new literal into every dependent module without an
explicit re-export.
`PluginError` retains the `pluginCode` field even though it duplicates
`code` because some consumers prefer the package-scoped name when
composing diagnostic payloads from multiple Suaveplan packages in a
single error envelope (e.g. `{ pluginCode, vaultCode, licenseCode }`).

View File

@@ -0,0 +1,104 @@
# hook-executor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `hook-executor` module ships a small, priority-ordered dispatcher
for **non-lifecycle** plugin hooks — custom events plugins contribute to
each other, plus the request/tenant/database hook surfaces that ship in
sibling packages (`@suaveplan/api`, `@suaveplan/api-tenant`,
`@suaveplan/api-migrations`). The four lifecycle hooks
(`onRegister/onInit/onReady/onClose`) are explicitly **rejected** by
`registerHook` so the upstream dual-runtime hazard (registry calls a
lifecycle hook directly AND the executor dispatches it) cannot recur.
`execute` runs handlers sequentially in priority order (higher first)
and aborts on the first throw, wrapping the cause in
`PluginError(PLUGIN.HOOK_FAILED)` carrying the offending plugin name
and hook name. `executeAll` runs handlers in parallel via
`Promise.allSettled`, returning the result vector so callers can
inspect each rejection.
## Features
- Priority sort — `registerHook` keeps the list ordered after every
insertion, so dispatch is O(n) and stable for equal priorities.
- Lifecycle-hook rejection — registering any of the four registry-owned
names throws `PLUGIN.HOOK_FAILED` synchronously at registration time.
- `unregisterPlugin(name)` removes every contribution from a plugin in
one call (used by `PluginRegistry.disable`).
- Sequential or parallel dispatch — `execute` aborts on first throw,
`executeAll` settles all and returns the result vector.
- Inspection — `hasHook`, `getHookCount`, `getRegisteredHooks`,
`clear` for tests and diagnostics.
## Usage: Basic
```ts
import { createHookExecutor } from "@suaveplan/api-plugin/hook-executor";
const hooks = createHookExecutor();
hooks.registerHook("on-cache-evict", (key) => console.log("evict", key), "cache-plugin", 10);
hooks.registerHook("on-cache-evict", (key) => console.log("audit", key), "audit-plugin", 5);
await hooks.execute("on-cache-evict", "session:42");
// prints "evict session:42" then "audit session:42"
```
## Usage: Advanced
Parallel dispatch with partial failures:
```ts
const results = await hooks.executeAll("warm-cache");
for (const result of results) {
if (result.status === "rejected") {
console.error("warm-cache hook failed:", result.reason);
}
}
```
Attempting to register a lifecycle hook is rejected:
```ts
import { PluginError } from "@suaveplan/api-plugin/error-codes";
try {
hooks.registerHook("onInit", () => {}, "bad-plugin");
} catch (e) {
if (e instanceof PluginError) console.log(e.code); // "PLUGIN.HOOK_FAILED"
}
```
## API Reference
| Export | Signature |
|---|---|
| `HookExecutor` | `class` — see methods below |
| `createHookExecutor()` | `() => HookExecutor` |
| `LIFECYCLE_HOOK_NAMES` | `readonly ["onRegister", "onInit", "onReady", "onClose"]` |
| `.registerHook(name, handler, pluginName, priority?)` | rejects lifecycle names |
| `.unregisterPlugin(pluginName)` | bulk-remove |
| `.execute(name, ...args)` | sequential, abort-on-throw |
| `.executeAll(name, ...args)` | parallel, settle-all |
| `.hasHook(name)` / `.getHookCount(name)` / `.getRegisteredHooks()` | inspection |
| `.clear()` | reset |
## Implementation Notes
The lifecycle-name guard is a single `Set.has` lookup against a frozen
`LIFECYCLE_HOOK_NAMES` constant. Both the constant and the type derived
from it (`LifecycleHookName`) are exported so sibling packages can
re-use the same authoritative list.
`execute` does not parallelise — handlers may have ordering
dependencies (a higher-priority handler may publish data a lower one
reads). `executeAll` is the explicit opt-in for fan-out scenarios
(metrics emitters, listeners with no producer ordering).
The wrapped `PluginError` carries the original throw as `cause` rather
than concatenating into the message — consumers can walk the cause
chain via `walkCauses` from `@suaveplan/error` to surface the inner
diagnostic without parsing strings.

View File

@@ -0,0 +1,144 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `registry` module owns `PluginRegistry`, the single canonical
orchestrator for the plugin system. Its job is to:
1. Accept descriptor registrations (`register`/`registerAll`), validating
each plugin's merged config against its Zod `configSchema` at the
boundary.
2. Resolve dependencies — `validateDependencies` runs first, then
`topologicalSort` produces the deterministic lifecycle order.
3. Walk every registered plugin through the four-phase lifecycle:
`onRegister → onInit → onReady` forward, then `onClose` in reverse.
4. Enforce per-hook timeouts — `initTimeout` (default 30000ms) for the
forward phases, `shutdownTimeout` (default 10000ms) for `onClose`.
When a hook exceeds its budget, the plugin's `ctx.signal` aborts and
the registry rejects with `PluginError(PLUGIN.INIT_TIMEOUT)` or
`PluginError(PLUGIN.SHUTDOWN_FAILED)`.
5. Wrap every hook invocation in an OTel span (`plugin.<hook>`) so
downstream observability stacks see plugin boot latency without
instrumentation in each plugin.
The registry deliberately does NOT auto-apply migrations (that was the
upstream design; here, migrations live in `@suaveplan/api-migrations`)
and does NOT dispatch lifecycle hooks via the `HookExecutor` — those
are owned here, called exactly once per plugin per phase.
## Features
- 4-phase lifecycle with strict dep ordering.
- `initTimeout`/`shutdownTimeout` enforced via `AbortSignal`.
- Zod-validated config at the boundary (`PLUGIN.INVALID_CONFIG`).
- Topological sort + cycle detection (`PLUGIN.CIRCULAR_DEPENDENCY`).
- Required-dep validation (`PLUGIN.DEP_MISSING`); optional-strict mode.
- `disable(name)` for runtime plugin shutdown.
- OTel span per hook (`plugin.onRegister`, `plugin.onInit`, …).
- Shared `services` Map across all hooks for `registerService`/`getService`.
- `seedServices(map)` for host-side bulk-registration of canonical
service slots **before** plugin init — the seeded entries appear in
every plugin's `ctx.getService(name)` from the very first `onRegister`
call. Idempotent; last write wins. Raises `PLUGIN.INVALID_STATE` when
called after `initialize()` has begun.
- Lifecycle events emitted via the injected (or built-in) `EventBus`
(`plugin:registering`, `plugin:onRegister`, `plugin:onInit`,
`plugin:onReady`, `plugin:closed`, `plugin:disabled`).
- Exposed `getHookExecutor()` for sibling packages contributing
non-lifecycle hooks.
## Usage: Basic
```ts
import { createPluginRegistry } from "@suaveplan/api-plugin/registry";
import { definePlugin } from "@suaveplan/api-plugin/define";
const registry = createPluginRegistry({ initTimeout: 5_000 });
registry.registerAll([
definePlugin({ name: "db", version: "0.1.0", onInit(ctx) { ctx.registerService("db", makeDb()); } }),
definePlugin({ name: "api", version: "0.1.0", dependencies: ["db"], async onReady(ctx) { ctx.logger.info("api ready"); } }),
]);
await registry.initialize();
// later …
await registry.shutdown();
```
## Usage: Advanced
Injected logger, event bus, and clock for testable boot logs:
```ts
import { EventBus } from "@suaveplan/events";
const registry = createPluginRegistry({
initTimeout: 500,
logger: structuredLogger,
eventBus: new EventBus(),
clock: () => fakeNow,
strictOptionalDeps: true,
});
registry.getEventBus().on("plugin:onReady", ({ name }) => {
console.log("ready:", name);
});
await registry.initialize();
```
## API Reference
| Member | Description |
|---|---|
| `new PluginRegistry(options?)` / `createPluginRegistry(options?)` | Construct |
| `.register(descriptor, config?)` | Add a plugin |
| `.registerAll(descriptors)` | Bulk-add |
| `.seedServices(map)` | Bulk-seed canonical host services into the shared registry before plugin init. Throws `PLUGIN.INVALID_STATE` post-`initialize()`. |
| `.get(name)` / `.has(name)` / `.getAll()` | Inspect |
| `.getSortedPlugins()` | Run validation + topo-sort and return ordered instances |
| `.initialize()` | Four-phase lifecycle in dep order |
| `.shutdown()` | `onClose` in reverse dep order |
| `.disable(name)` | Single-plugin teardown |
| `.getEventBus()` / `.getHookExecutor()` | Plumbing access |
## Implementation Notes
Timeouts are enforced with an injectable `ScheduleTimer` so tests can
swap the global `setTimeout` for a deterministic fake. The default
binds `globalThis.setTimeout`/`clearTimeout`, which exist in every
target runtime (Bun, Node, browser). The Promise race is implemented
manually rather than via `Promise.race` so the abort path can also
mark the plugin's `ctx.signal` aborted before the timeout error
propagates — hooks that observe the signal see it in the "aborted"
state by the time they re-enter the event loop.
`runPhase` distinguishes between `PluginError` (passed through unmodified
so the original code is preserved) and arbitrary throws (wrapped as
`PLUGIN.INIT_FAILED` with the original error attached via `cause`).
That keeps the typed-code contract — every leaf error has a
`PLUGIN.*` code — while still preserving the underlying diagnostic.
`shutdown` collects every per-plugin failure rather than aborting on
the first, so a misbehaving plugin near the start of the reverse-order
walk does not block subsequent cleanups. After the walk completes, the
registry throws `PLUGIN.SHUTDOWN_FAILED` if any plugin failed — the
caller can inspect `context.failures` for the full list.
The OTel tracer is resolved via `trace.getTracer("@suaveplan/api-plugin")`
at construction time. When no SDK is registered the call returns the
noop tracer, so production deployments without OTel pay zero overhead.
`seedServices` keeps its entries in an internal `seededServices: Map<
string, unknown>` and only copies them into the shared per-context
`services` map at the start of `initialize()` — i.e. **before** the
first `onRegister`. Plugin-side `registerService` calls overlay on the
same shared map, so later overlays are visible to siblings that come
after them in dep order. The `initializing` guard rejects re-entrant
seeds during the lifecycle walk because the per-context snapshot has
already been built, and accepting the seed silently would create a
state where some plugins see the seed and others do not.

View File

@@ -0,0 +1,110 @@
# service-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `service-registry` module defines the single shared interface that
plugins augment to publish typed services to other plugins. Plugin A
registers a `db` adapter; plugin B retrieves it with `ctx.getService("db")`
and receives the augmented type — no cast, no `unknown`, no runtime
shape-check. The interface ships **empty** so a consumer who has not
augmented it cannot accidentally type-pun a service slot.
This is the canonical solution to upstream's `getService<T>(name)` cast
hazard: there, `T` was a free type parameter, so a typo in the name or a
mismatch between producer and consumer compiled cleanly and crashed at
runtime. With module augmentation, the interface itself is the contract;
both ends agree on every key.
## Features
- `ServiceRegistry` — empty by default, augmented via `declare module`.
- `ServiceKey = keyof ServiceRegistry` — `never` until augmented.
- Zero runtime — types-only export.
- Subpath export `@suaveplan/api-plugin/service-registry` for direct
augmentation imports.
## Usage: Basic
Declare your plugin's service slot once, anywhere in the consumer code
that runs before the registry is created:
```ts
import type { DbAdapter } from "./db-adapter.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
db: DbAdapter;
}
}
```
Then registration and lookup are both type-safe:
```ts
import { definePlugin } from "@suaveplan/api-plugin";
export const dbPlugin = definePlugin({
name: "db",
version: "0.1.0",
onInit(ctx) {
ctx.registerService("db", createDbAdapter());
},
});
export const consumerPlugin = definePlugin({
name: "consumer",
version: "0.1.0",
dependencies: ["db"],
onReady(ctx) {
const db = ctx.getService("db"); // DbAdapter | undefined
db?.query("SELECT 1");
},
});
```
## Usage: Advanced
Multiple consumers may augment independently — TypeScript merges the
interface across declarations:
```ts
// package-a/services.d.ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry { cache: CacheAdapter; }
}
// package-b/services.d.ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry { auth: AuthAdapter; }
}
// Anywhere: both keys are now type-safe.
ctx.getService("cache"); // CacheAdapter | undefined
ctx.getService("auth"); // AuthAdapter | undefined
ctx.getService("xyz"); // type error — not augmented
```
## API Reference
| Export | Kind | Notes |
|---|---|---|
| `ServiceRegistry` | interface | Empty extension surface — augment via `declare module`. |
| `ServiceKey` | type | `keyof ServiceRegistry`; resolves to `never` without augmentation. |
## Implementation Notes
Biome's `noEmptyInterface` rule would flag a literally-empty interface
declaration. The module sidesteps the lint cleanly by extending a
`Record<never, never>` alias — semantically equivalent to `{}` for the
augmentation pattern but no longer "empty" in the syntactic sense Biome
inspects. The alias is private to the module; consumers only see the
public `ServiceRegistry` interface and the `ServiceKey` helper.
No runtime artefacts are emitted from this file — Vite/dts strip
declaration-only modules during the library build. Subpath consumers
benefit from a sub-1KB import that is entirely tree-shaken at consumer
build time.

View File

@@ -0,0 +1,96 @@
# topo-sort
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `topo-sort` module owns the deterministic dependency-ordering primitive
the `PluginRegistry` uses to schedule lifecycle hook execution. Given a
list of `PluginNode<T>` records describing each plugin's id and required
dependencies, `topologicalSort` returns a list ordered so every node
appears after every one of its declared dependencies. The implementation
is Kahn's algorithm with an alphabetical tie-break, so two runs with the
same input always produce byte-identical output — critical for
reproducible boot logs and snapshot tests downstream.
The module also exports `validateDependencies`, a non-sorting cousin
that simply checks whether every required dep exists in the input set.
The registry runs this first when `strictOptionalDeps` is set; the sort
runs second.
## Features
- **Kahn's algorithm** — O(V + E) topological order, no recursion-depth
limit.
- **Alphabetical tie-break** — equal-in-degree nodes process in sorted
`id` order, so the result is independent of registration order.
- **Unresolved dep collection** — required deps that are not in the
input set are returned in `unresolved` (sorted, deduplicated).
- **Cycle detection via DFS** — when the input contains a cycle, the
sort throws `PluginError(PLUGIN.CIRCULAR_DEPENDENCY)` whose
`context.cycle` holds the cycle path including both endpoints
(`["a", "b", "c", "a"]`).
- **Pure** — no side effects, no Clock/RNG/IO dependencies.
## Usage: Basic
```ts
import { topologicalSort } from "@suaveplan/api-plugin/topo-sort";
const { sorted, unresolved } = topologicalSort([
{ id: "b", data: null, dependencies: ["a"] },
{ id: "a", data: null, dependencies: [] },
]);
// sorted.map(n => n.id) === ["a", "b"]
// unresolved === []
```
## Usage: Advanced
Detecting a cycle and recovering the path:
```ts
import { topologicalSort } from "@suaveplan/api-plugin/topo-sort";
import { PluginError } from "@suaveplan/api-plugin/error-codes";
try {
topologicalSort([
{ id: "a", data: null, dependencies: ["b"] },
{ id: "b", data: null, dependencies: ["c"] },
{ id: "c", data: null, dependencies: ["a"] },
]);
} catch (e) {
if (e instanceof PluginError && e.code === "PLUGIN.CIRCULAR_DEPENDENCY") {
console.error("cycle:", (e.context as { cycle: string[] }).cycle);
// ["a", "b", "c", "a"] (start node depends on input + alpha sort)
}
}
```
## API Reference
| Export | Signature |
|---|---|
| `topologicalSort<T>(nodes)` | `(ReadonlyArray<PluginNode<T>>) => TopologicalSortResult<T>` — throws on cycle |
| `validateDependencies<T>(nodes)` | `(ReadonlyArray<PluginNode<T>>) => { valid: boolean; missing: ReadonlyMap<string, readonly string[]> }` |
## Implementation Notes
The alphabetical tie-break is implemented by sorting the `ready` queue
every time new nodes become in-degree-zero, not by sorting the input
once. Sorting once is insufficient because Kahn's discovers
ready-nodes incrementally as it drains the graph — two nodes that
become ready at different iterations would still be processed in
discovery order. The repeated sort is O(k log k) where k is the
current queue size; total work stays O(V log V + E).
The cycle detector uses an iterative DFS rooted at the alphabetically
first unvisited node, then sorts each node's outgoing dependencies
before recursion so the discovered cycle path is also deterministic.
That makes the error message stable across runs and easy to snapshot.
`unresolved` is returned as a sorted, deduplicated array so consumers
can render diagnostic messages without further normalisation. The
registry decides what to do — strict mode throws, lenient mode logs.

View File

@@ -0,0 +1,90 @@
# types — api-plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module holds every shared shape used across the api-plugin
package: the immutable `PluginDescriptor<TConfig>` returned by
`definePlugin`, the mutable `PluginInstance<TConfig>` held by the
registry, the seven-state `PluginState` enum that describes lifecycle
progression, the generic `PluginNode<T>` consumed by the topological
sorter, and the typed `PluginLifecycleHook<TConfig>` callback signature.
Everything in this file is types-only — no runtime, no side effects.
Splitting types into a dedicated module keeps every consumer
(`registry`, `define`, `context`, `topo-sort`, `hook-executor`) on the
same source of truth without circular-import gymnastics. Augmenting
`@suaveplan/api-plugin/types` is not the supported extension surface
(`service-registry` handles that), but importing from it is the canonical
way for sibling packages (api-tenant, api-openapi, api-migrations) to
type their own surfaces against the foundation contract.
## Features
- `PluginState` — seven-value union covering the full lifecycle.
- `PluginDescriptor<TConfig>` — name, version, dependencies, optional
dependencies, priority, enabledByDefault, configSchema (`ZodType`),
defaults, tags, plus the four lifecycle hooks
(`onRegister/onInit/onReady/onClose`).
- `PluginInstance<TConfig>` — descriptor + mutable state + timestamps.
- `PluginNode<T>` — dep-graph adapter shape consumed by `topologicalSort`.
- `PluginLifecycleHook<TConfig>` — `(ctx: PluginContext<TConfig>) => void | Promise<void>`.
- `TopologicalSortResult<T>` — `{ sorted, unresolved }` tuple.
## Usage: Basic
```ts
import type { PluginDescriptor, PluginInstance } from "@suaveplan/api-plugin/types";
const descriptor: PluginDescriptor<{ port: number }> = {
name: "http",
version: "0.1.0",
defaults: { port: 3000 },
};
```
## Usage: Advanced
Typing a custom registry-like adapter:
```ts
import type { PluginInstance, PluginNode } from "@suaveplan/api-plugin/types";
function toNodes(
instances: ReadonlyArray<PluginInstance>
): ReadonlyArray<PluginNode<PluginInstance>> {
return instances.map((i) => ({
id: i.plugin.name,
data: i,
dependencies: i.plugin.dependencies ?? [],
}));
}
```
## API Reference
| Export | Kind | Notes |
|---|---|---|
| `PluginState` | type | `"pending" \| "registered" \| "initializing" \| "initialized" \| "ready" \| "disabled" \| "error"` |
| `PluginLifecycleHook<TConfig>` | type | `(ctx: PluginContext<TConfig>) => void \| Promise<void>` |
| `PluginDescriptor<TConfig>` | interface | Plain data + 4 lifecycle hooks |
| `PluginInstance<TConfig>` | interface | `{ plugin, state, config, error?, loadedAt?, initializedAt?, readyAt?, closedAt? }` |
| `PluginNode<T>` | interface | `{ id, data, dependencies }` |
| `TopologicalSortResult<T>` | interface | `{ sorted, unresolved }` |
## Implementation Notes
`PluginDescriptor` is declared with `readonly` on every field including
the hook callbacks so consumers cannot mutate descriptors at runtime — a
common upstream bug source where one plugin clobbered another's
`onInit`. `PluginInstance` keeps its mutable fields plain (no `readonly`)
because the registry updates them as it walks the lifecycle.
Timestamps are `number` (epoch-ms) rather than `Date` so the registry can
inject a `Clock` from `@suaveplan/types` (`() => number`) and pass-through
without conversion. The `TConfig` generic flows from `definePlugin` →
descriptor → instance → context → `getOwnConfig()` so consumers never
need to cast.