first commit
This commit is contained in:
111
docs/api-stack/api/api-plugin/src/context/context.md
Normal file
111
docs/api-stack/api/api-plugin/src/context/context.md
Normal 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.
|
||||
96
docs/api-stack/api/api-plugin/src/define/define.md
Normal file
96
docs/api-stack/api/api-plugin/src/define/define.md
Normal 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.
|
||||
92
docs/api-stack/api/api-plugin/src/error-codes/error-codes.md
Normal file
92
docs/api-stack/api/api-plugin/src/error-codes/error-codes.md
Normal 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 }`).
|
||||
104
docs/api-stack/api/api-plugin/src/hook-executor/hook-executor.md
Normal file
104
docs/api-stack/api/api-plugin/src/hook-executor/hook-executor.md
Normal 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.
|
||||
144
docs/api-stack/api/api-plugin/src/registry/registry.md
Normal file
144
docs/api-stack/api/api-plugin/src/registry/registry.md
Normal 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.
|
||||
@@ -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.
|
||||
96
docs/api-stack/api/api-plugin/src/topo-sort/topo-sort.md
Normal file
96
docs/api-stack/api/api-plugin/src/topo-sort/topo-sort.md
Normal 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.
|
||||
90
docs/api-stack/api/api-plugin/src/types/api-plugin.md
Normal file
90
docs/api-stack/api/api-plugin/src/types/api-plugin.md
Normal 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.
|
||||
Reference in New Issue
Block a user