Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,109 @@
{
"name": "@suaveplan/api-plugin",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./types": {
"import": {
"types": "./dist/types/api-plugin.d.ts",
"default": "./dist/types/api-plugin.js"
}
},
"./topo-sort": {
"import": {
"types": "./dist/topo-sort/topo-sort.d.ts",
"default": "./dist/topo-sort/topo-sort.js"
}
},
"./service-registry": {
"import": {
"types": "./dist/service-registry/service-registry.d.ts",
"default": "./dist/service-registry/service-registry.js"
}
},
"./context": {
"import": {
"types": "./dist/context/context.d.ts",
"default": "./dist/context/context.js"
}
},
"./hook-executor": {
"import": {
"types": "./dist/hook-executor/hook-executor.d.ts",
"default": "./dist/hook-executor/hook-executor.js"
}
},
"./define": {
"import": {
"types": "./dist/define/define.d.ts",
"default": "./dist/define/define.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/registry.d.ts",
"default": "./dist/registry/registry.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,205 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import z from "zod";
import { createPluginContext } from "./context/context.js";
import type { PluginContext } from "./context/context.types.js";
import { definePlugin } from "./define/define.js";
import { PLUGIN_CODES, PluginError } from "./error-codes/error-codes.js";
import { createHookExecutor } from "./hook-executor/hook-executor.js";
import { createPluginRegistry } from "./registry/registry.js";
import { topologicalSort } from "./topo-sort/topo-sort.js";
describe("spec-conformance — every PLUGIN_* code is thrown from a production path", () => {
it("PLUGIN.NOT_FOUND", { timeout: 5000 }, async () => {
const reg = createPluginRegistry();
try {
await reg.disable("ghost");
throw new Error("expected throw");
} catch (e) {
expect((e as PluginError).code).toBe(PLUGIN_CODES.NOT_FOUND);
}
});
it("PLUGIN.ALREADY_REGISTERED", { timeout: 5000 }, () => {
const reg = createPluginRegistry();
const p = definePlugin({ name: "dup", version: "0.1.0" });
reg.register(p);
expect(() => reg.register(p)).toThrow(PluginError);
});
it("PLUGIN.INIT_FAILED", { timeout: 5000 }, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({
name: "f",
version: "0.1.0",
onInit: () => {
throw new Error("boom");
},
})
);
await expect(reg.initialize()).rejects.toMatchObject({
code: PLUGIN_CODES.INIT_FAILED,
});
});
it("PLUGIN.DEP_MISSING", { timeout: 5000 }, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({ name: "x", version: "0.1.0", dependencies: ["y"] })
);
await expect(reg.initialize()).rejects.toMatchObject({
code: PLUGIN_CODES.DEP_MISSING,
});
});
it("PLUGIN.CIRCULAR_DEPENDENCY", { timeout: 5000 }, () => {
expect(() =>
topologicalSort([
{ id: "a", data: null, dependencies: ["b"] },
{ id: "b", data: null, dependencies: ["a"] },
])
).toThrow(
expect.objectContaining({
code: PLUGIN_CODES.CIRCULAR_DEPENDENCY,
}) as never
);
});
it("PLUGIN.INIT_TIMEOUT", { timeout: 30000 }, async () => {
const reg = createPluginRegistry({ initTimeout: 30 });
reg.register(
definePlugin({
name: "h",
version: "0.1.0",
onInit: () => new Promise<void>(() => {}),
})
);
await expect(reg.initialize()).rejects.toMatchObject({
code: PLUGIN_CODES.INIT_TIMEOUT,
});
});
it("PLUGIN.SHUTDOWN_FAILED", { timeout: 30000 }, async () => {
const reg = createPluginRegistry({ shutdownTimeout: 30 });
reg.register(
definePlugin({
name: "h",
version: "0.1.0",
onClose: () => new Promise<void>(() => {}),
})
);
await reg.initialize();
await expect(reg.shutdown()).rejects.toMatchObject({
code: PLUGIN_CODES.SHUTDOWN_FAILED,
});
});
it("PLUGIN.INVALID_CONFIG", { timeout: 5000 }, () => {
const reg = createPluginRegistry();
expect(() =>
reg.register(
definePlugin({
name: "c",
version: "0.1.0",
configSchema: z.object({ x: z.number() }),
}),
{ x: "nope" } as never
)
).toThrow(PluginError);
});
it("PLUGIN.HOOK_FAILED", { timeout: 5000 }, async () => {
const ex = createHookExecutor();
ex.registerHook(
"h",
() => {
throw new Error("inner");
},
"p"
);
await expect(ex.execute("h")).rejects.toMatchObject({
code: PLUGIN_CODES.HOOK_FAILED,
});
});
it("PLUGIN_CODES contains PLUGIN_INVALID_STATE alongside the existing 10", {
timeout: 5000,
}, async () => {
// Existing 10 preserved + INVALID_STATE added → total 11.
expect(Object.keys(PLUGIN_CODES)).toHaveLength(11);
expect(PLUGIN_CODES.INVALID_STATE).toBe("PLUGIN.INVALID_STATE");
// Real production path: seedServices post-initialize throws.
const reg = createPluginRegistry();
await reg.initialize();
try {
reg.seedServices({});
throw new Error("expected throw");
} catch (e) {
expect((e as PluginError).code).toBe(PLUGIN_CODES.INVALID_STATE);
}
});
it("PLUGIN.ABORTED", { timeout: 5000 }, async () => {
const reg = createPluginRegistry();
const ctrl = new AbortController();
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
onInit: () => {
ctrl.abort();
return Promise.resolve();
},
})
);
reg.register(
definePlugin({
name: "b",
version: "0.1.0",
dependencies: ["a"],
onInit: () => Promise.resolve(),
})
);
await expect(reg.initialize(ctrl.signal)).rejects.toMatchObject({
code: PLUGIN_CODES.ABORTED,
});
});
});
describe("spec-conformance — type-level guarantees", () => {
it("definePlugin preserves TConfig through getOwnConfig", {
timeout: 5000,
}, () => {
const schema = z.object({ port: z.number() });
type Cfg = z.infer<typeof schema>;
definePlugin<Cfg>({
name: "p",
version: "0.1.0",
configSchema: schema,
defaults: { port: 3000 },
onInit(ctx) {
expectTypeOf(ctx.getOwnConfig()).toEqualTypeOf<Cfg>();
},
});
});
it("getService<K> returns ServiceRegistry[K] | undefined", {
timeout: 5000,
}, () => {
// ServiceRegistry is empty in this package, so ServiceKey = never.
// The lookup below verifies the type *shape* compiles.
const ctx: PluginContext = createPluginContext({
logger: { debug() {}, info() {}, warn() {}, error() {} },
eventBus: { on: () => () => {}, emit: () => {} },
signal: new AbortController().signal,
config: {},
services: new Map(),
});
expectTypeOf(ctx.registerService).parameters.toEqualTypeOf<
// biome's noEmptyInterface workaround surfaces here as `[name: never, service: never]`
// which is exactly the intended "uncallable without augmentation" type.
[name: never, service: never]
>();
});
});

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,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createPluginContext } from "./context.js";
const noopLogger = {
debug() {},
info() {},
warn() {},
error() {},
};
const noopBus = {
on() {
return () => {};
},
emit() {},
};
describe("createPluginContext", () => {
it("exposes logger / eventBus / signal", { timeout: 5000 }, () => {
const ctrl = new AbortController();
const ctx = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: ctrl.signal,
config: { port: 3000 },
services: new Map(),
});
expect(ctx.logger).toBe(noopLogger);
expect(ctx.eventBus).toBe(noopBus);
expect(ctx.signal).toBe(ctrl.signal);
});
it("getOwnConfig returns the merged config", { timeout: 5000 }, () => {
const ctx = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: new AbortController().signal,
config: { port: 4242, host: "localhost" },
services: new Map(),
});
expect(ctx.getOwnConfig()).toEqual({
port: 4242,
host: "localhost",
});
});
it("registerService + getService roundtrip", { timeout: 5000 }, () => {
const services = new Map<string, unknown>();
const ctx = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: new AbortController().signal,
config: {},
services,
});
// Use cast: ServiceRegistry is empty so K is `never`; tests
// exercise the runtime path that real consumers reach after
// augmentation. The cast does not affect the public type test
// — that lives in context.spec-conformance.test.ts.
(ctx.registerService as (n: string, s: unknown) => void)("db", {
ping: () => "pong",
});
const svc = (ctx.getService as (n: string) => unknown)("db") as
| { ping: () => string }
| undefined;
expect(svc?.ping()).toBe("pong");
expect(ctx.hasService("db")).toBe(true);
expect(ctx.hasService("missing")).toBe(false);
});
it("getService returns undefined for unknown name", {
timeout: 5000,
}, () => {
const ctx = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: new AbortController().signal,
config: {},
services: new Map(),
});
expect(
(ctx.getService as (n: string) => unknown)("nope")
).toBeUndefined();
});
it("services Map is shared across contexts", { timeout: 5000 }, () => {
const services = new Map<string, unknown>();
const a = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: new AbortController().signal,
config: {},
services,
});
const b = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: new AbortController().signal,
config: {},
services,
});
(a.registerService as (n: string, s: unknown) => void)("k", 42);
expect((b.getService as (n: string) => unknown)("k")).toBe(42);
});
it("aborted signal propagates", { timeout: 5000 }, () => {
const ctrl = new AbortController();
const ctx = createPluginContext({
logger: noopLogger,
eventBus: noopBus,
signal: ctrl.signal,
config: {},
services: new Map(),
});
ctrl.abort();
expect(ctx.signal.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,66 @@
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type {
ServiceKey,
ServiceRegistry,
} from "../service-registry/service-registry.js";
import type { PluginContext } from "./context.types.js";
/**
* Options accepted by {@link createPluginContext}.
*
* `services` is the shared registry — same `Map` instance for every
* plugin in the same boot — so plugin A's `registerService` is visible
* to plugin B's `getService`. `getOwnConfig` resolves the merged config
* of the calling plugin (the registry calls `createPluginContext` once
* per plugin per phase with that plugin's own config bound).
*/
export interface CreatePluginContextOptions<TConfig> {
readonly logger: LoggerLike;
readonly eventBus: EventBusLike;
readonly signal: AbortSignal;
readonly config: TConfig;
readonly services: Map<string, unknown>;
}
/**
* Build a {@link PluginContext} for a single plugin's lifecycle hook
* invocation. The returned context is shaped so untyped string keys
* (not present in `ServiceRegistry`) fail TypeScript compilation at the
* call site; the runtime is preserved as a `Map<string, unknown>` for
* forward compatibility with augmentation that may land later in the
* same process.
*/
export function createPluginContext<TConfig>(
options: CreatePluginContextOptions<TConfig>
): PluginContext<TConfig> {
const { logger, eventBus, signal, config, services } = options;
return {
logger,
eventBus,
signal,
getOwnConfig(): TConfig {
return config;
},
registerService<K extends ServiceKey>(
name: K,
service: ServiceRegistry[K]
): void {
services.set(name as string, service);
},
getService<K extends ServiceKey>(
name: K
): ServiceRegistry[K] | undefined {
return services.get(name as string) as
| ServiceRegistry[K]
| undefined;
},
hasService(name: string): boolean {
return services.has(name);
},
};
}

View File

@@ -0,0 +1,39 @@
import type { EventBusLike, LoggerLike } from "@suaveplan/types";
import type {
ServiceKey,
ServiceRegistry,
} from "../service-registry/service-registry.js";
/**
* Runtime context passed to every lifecycle hook on a plugin.
*
* Generic in `TConfig` so `getOwnConfig()` returns the schema-inferred
* shape without a cast at the call site.
*/
export interface PluginContext<TConfig = unknown> {
/** Structural logger (LoggerLike from `@suaveplan/types`). */
readonly logger: LoggerLike;
/** Inter-plugin event bus (EventBusLike from `@suaveplan/types`). */
readonly eventBus: EventBusLike;
/** Abort signal — fires when the registry times-out or shuts down. */
readonly signal: AbortSignal;
/** Returns the calling plugin's own merged config. */
getOwnConfig(): TConfig;
/**
* Register a service for other plugins to consume. Typed via
* `ServiceRegistry` module augmentation — unaugmented string keys
* fail compilation.
*/
registerService<K extends ServiceKey>(
name: K,
service: ServiceRegistry[K]
): void;
/** Look up a registered service by augmented key. */
getService<K extends ServiceKey>(name: K): ServiceRegistry[K] | undefined;
/** Check whether a service slot is occupied (string-keyed for forward compat). */
hasService(name: string): boolean;
}

View File

@@ -0,0 +1,3 @@
export type { CreatePluginContextOptions } from "./context.js";
export { createPluginContext } from "./context.js";
export type { PluginContext } from "./context.types.js";

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,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { definePlugin } from "./define.js";
describe("definePlugin", () => {
it("returns the descriptor unchanged at runtime", { timeout: 5000 }, () => {
const noop = () => {};
const desc = definePlugin({
name: "p",
version: "0.1.0",
description: "d",
dependencies: ["a"],
optionalDependencies: ["b"],
priority: 5,
enabledByDefault: true,
tags: ["t"],
onRegister: noop,
onInit: noop,
onReady: noop,
onClose: noop,
});
expect(desc.name).toBe("p");
expect(desc.version).toBe("0.1.0");
expect(desc.dependencies).toEqual(["a"]);
expect(desc.optionalDependencies).toEqual(["b"]);
expect(desc.priority).toBe(5);
expect(desc.enabledByDefault).toBe(true);
expect(desc.tags).toEqual(["t"]);
expect(desc.onInit).toBe(noop);
});
it("preserves TConfig type via configSchema", { timeout: 5000 }, () => {
const schema = z.object({ port: z.number() });
const desc = definePlugin({
name: "http",
version: "0.1.0",
configSchema: schema,
defaults: { port: 3000 },
onInit(ctx) {
// Static expectation: ctx.getOwnConfig() returns { port: number }
const cfg = ctx.getOwnConfig();
void cfg.port;
},
});
expect(desc.configSchema).toBe(schema);
expect(desc.defaults).toEqual({ port: 3000 });
});
it("works with the minimal required shape", { timeout: 5000 }, () => {
const desc = definePlugin({ name: "min", version: "0.0.1" });
expect(desc.name).toBe("min");
});
});

View File

@@ -0,0 +1,19 @@
import type { PluginDescriptor } from "../types/api-plugin.js";
/**
* Typed plugin-descriptor factory. Returns the input unchanged at
* runtime — the value of this function is the generic inference
* scaffolding it provides: `definePlugin<MyConfig>({...})` flows
* `MyConfig` through `configSchema?: ZodType<MyConfig>`, `defaults?:
* Partial<MyConfig>`, and the four lifecycle hooks, so
* `ctx.getOwnConfig()` inside each hook returns `MyConfig` without a
* cast.
*
* The descriptor is returned as a `readonly`-typed object so callers
* cannot mutate fields between registration and registry intake.
*/
export function definePlugin<TConfig = unknown>(
descriptor: PluginDescriptor<TConfig>
): PluginDescriptor<TConfig> {
return descriptor;
}

View File

@@ -0,0 +1 @@
export { definePlugin } from "./define.js";

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,66 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PLUGIN_CODES, PluginError } from "./error-codes.js";
describe("PLUGIN_CODES", () => {
it("defines exactly 11 codes", { timeout: 5000 }, () => {
expect(Object.keys(PLUGIN_CODES)).toHaveLength(11);
});
it("namespaces every code under PLUGIN.*", { timeout: 5000 }, () => {
for (const value of Object.values(PLUGIN_CODES)) {
expect(value.startsWith("PLUGIN.")).toBe(true);
}
});
it("freezes the registry", { timeout: 5000 }, () => {
expect(Object.isFrozen(PLUGIN_CODES)).toBe(true);
});
it("exposes every documented code", { timeout: 5000 }, () => {
expect(PLUGIN_CODES.NOT_FOUND).toBe("PLUGIN.NOT_FOUND");
expect(PLUGIN_CODES.ALREADY_REGISTERED).toBe(
"PLUGIN.ALREADY_REGISTERED"
);
expect(PLUGIN_CODES.INIT_FAILED).toBe("PLUGIN.INIT_FAILED");
expect(PLUGIN_CODES.DEP_MISSING).toBe("PLUGIN.DEP_MISSING");
expect(PLUGIN_CODES.CIRCULAR_DEPENDENCY).toBe(
"PLUGIN.CIRCULAR_DEPENDENCY"
);
expect(PLUGIN_CODES.INIT_TIMEOUT).toBe("PLUGIN.INIT_TIMEOUT");
expect(PLUGIN_CODES.SHUTDOWN_FAILED).toBe("PLUGIN.SHUTDOWN_FAILED");
expect(PLUGIN_CODES.INVALID_CONFIG).toBe("PLUGIN.INVALID_CONFIG");
expect(PLUGIN_CODES.HOOK_FAILED).toBe("PLUGIN.HOOK_FAILED");
expect(PLUGIN_CODES.ABORTED).toBe("PLUGIN.ABORTED");
expect(PLUGIN_CODES.INVALID_STATE).toBe("PLUGIN.INVALID_STATE");
});
});
describe("PluginError", () => {
it("carries the code via .code and .pluginCode", { timeout: 5000 }, () => {
const err = new PluginError(PLUGIN_CODES.NOT_FOUND, "missing");
expect(err.code).toBe("PLUGIN.NOT_FOUND");
expect(err.pluginCode).toBe("PLUGIN.NOT_FOUND");
});
it("preserves the message and context", { timeout: 5000 }, () => {
const err = new PluginError(PLUGIN_CODES.INIT_FAILED, "boom", {
context: { plugin: "auth" },
});
expect(err.message).toBe("boom");
expect(err.context).toEqual({ plugin: "auth" });
});
it("threads cause through to Error.cause", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new PluginError(PLUGIN_CODES.HOOK_FAILED, "outer", {
cause,
});
expect(err.cause).toBe(cause);
});
it("is a SuaveplanError subclass", { timeout: 5000 }, () => {
const err = new PluginError(PLUGIN_CODES.ABORTED, "halt");
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("PluginError");
});
});

View File

@@ -0,0 +1,58 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin`.
*
* Every public failure path raises a {@link PluginError} carrying one of
* these namespaced `PLUGIN.*` codes. Consumers branch on the string value
* (e.g. `error.code === PLUGIN_CODES.NOT_FOUND` or the inline literal
* `"PLUGIN.NOT_FOUND"`).
*
* The 11 codes cover:
* - Registry membership (`NOT_FOUND`, `ALREADY_REGISTERED`).
* - Dependency resolution (`DEP_MISSING`, `CIRCULAR_DEPENDENCY`).
* - Initialization failures (`INIT_FAILED`, `INIT_TIMEOUT`, `INVALID_CONFIG`).
* - Shutdown (`SHUTDOWN_FAILED`).
* - Hook dispatch (`HOOK_FAILED`).
* - Async cancellation (`ABORTED`).
* - Lifecycle ordering (`INVALID_STATE`) — raised when `seedServices` is
* called after `initialize()` has begun, i.e. once the per-plugin
* contexts have already snapshotted the seeded slots.
*/
export const PLUGIN_CODES = defineCodes("PLUGIN", [
"NOT_FOUND",
"ALREADY_REGISTERED",
"INIT_FAILED",
"DEP_MISSING",
"CIRCULAR_DEPENDENCY",
"INIT_TIMEOUT",
"SHUTDOWN_FAILED",
"INVALID_CONFIG",
"HOOK_FAILED",
"ABORTED",
"INVALID_STATE",
] as const);
export type PluginErrorCode = (typeof PLUGIN_CODES)[keyof typeof PLUGIN_CODES];
/**
* Concrete error class for the api-plugin package. Extends
* {@link SuaveplanError} so `instanceof PluginError` narrows `code` to
* {@link PluginErrorCode}.
*
* The constructor is widened to `public` so callers may construct errors
* directly (the base class is `protected`).
*/
export class PluginError extends SuaveplanError<PluginErrorCode> {
public readonly pluginCode: PluginErrorCode;
public constructor(
code: PluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.pluginCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { PluginErrorCode } from "./error-codes.js";
export {
PLUGIN_CODES,
PluginError,
} from "./error-codes.js";

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,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PluginError } from "../error-codes/error-codes.js";
import { createHookExecutor, HookExecutor } from "./hook-executor.js";
describe("HookExecutor", () => {
it("dispatch order respects priority (higher first)", {
timeout: 5000,
}, async () => {
const ex = createHookExecutor();
const order: number[] = [];
ex.registerHook("h", () => order.push(5), "p5", 5);
ex.registerHook("h", () => order.push(10), "p10", 10);
ex.registerHook("h", () => order.push(1), "p1", 1);
await ex.execute("h");
expect(order).toEqual([10, 5, 1]);
});
it("preserves registration order for equal priority", {
timeout: 5000,
}, async () => {
const ex = createHookExecutor();
const order: string[] = [];
ex.registerHook("h", () => order.push("a"), "a", 0);
ex.registerHook("h", () => order.push("b"), "b", 0);
ex.registerHook("h", () => order.push("c"), "c", 0);
await ex.execute("h");
expect(order).toEqual(["a", "b", "c"]);
});
it("execute aborts on first throw with PLUGIN.HOOK_FAILED", {
timeout: 5000,
}, async () => {
const ex = createHookExecutor();
let ranSecond = false;
ex.registerHook(
"h",
() => {
throw new Error("boom");
},
"first",
10
);
ex.registerHook(
"h",
() => {
ranSecond = true;
},
"second",
5
);
let caught: PluginError | undefined;
try {
await ex.execute("h");
} catch (e) {
caught = e as PluginError;
}
expect(caught).toBeInstanceOf(PluginError);
expect(caught?.code).toBe("PLUGIN.HOOK_FAILED");
expect((caught?.context as { plugin: string }).plugin).toBe("first");
expect(ranSecond).toBe(false);
});
it("wraps non-Error throws", { timeout: 5000 }, async () => {
const ex = createHookExecutor();
ex.registerHook(
"h",
() => {
throw "string-throw";
},
"p"
);
await expect(ex.execute("h")).rejects.toBeInstanceOf(PluginError);
});
it("executeAll never throws and returns results", {
timeout: 5000,
}, async () => {
const ex = createHookExecutor();
ex.registerHook("h", () => 1, "a");
ex.registerHook(
"h",
() => {
throw new Error("nope");
},
"b"
);
const results = await ex.executeAll("h");
expect(results).toHaveLength(2);
expect(results[0]?.status).toBe("fulfilled");
expect(results[1]?.status).toBe("rejected");
});
it("lifecycle hooks are not registered with the executor", {
timeout: 5000,
}, () => {
const ex = createHookExecutor();
for (const name of ["onRegister", "onInit", "onReady", "onClose"]) {
let caught: PluginError | undefined;
try {
ex.registerHook(name, () => {}, "p");
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.HOOK_FAILED");
expect(ex.getHookCount(name)).toBe(0);
}
});
it("unregisterPlugin removes all contributions", { timeout: 5000 }, () => {
const ex = createHookExecutor();
ex.registerHook("h1", () => {}, "p1");
ex.registerHook("h2", () => {}, "p1");
ex.registerHook("h1", () => {}, "p2");
ex.unregisterPlugin("p1");
expect(ex.getHookCount("h1")).toBe(1);
expect(ex.getHookCount("h2")).toBe(0);
expect(ex.hasHook("h2")).toBe(false);
});
it("inspection methods report state", { timeout: 5000 }, () => {
const ex = new HookExecutor();
expect(ex.hasHook("x")).toBe(false);
ex.registerHook("x", () => {}, "p");
expect(ex.hasHook("x")).toBe(true);
expect(ex.getRegisteredHooks()).toEqual(["x"]);
ex.clear();
expect(ex.getRegisteredHooks()).toEqual([]);
});
it("execute on unknown hook is a no-op", { timeout: 5000 }, async () => {
const ex = createHookExecutor();
await expect(ex.execute("unknown")).resolves.toBeUndefined();
});
it("executeAll on unknown hook returns empty array", {
timeout: 5000,
}, async () => {
const ex = createHookExecutor();
const results = await ex.executeAll("unknown");
expect(results).toEqual([]);
});
});

View File

@@ -0,0 +1,142 @@
import { PLUGIN_CODES, PluginError } from "../error-codes/error-codes.js";
/**
* The four registry-owned lifecycle hooks. The `HookExecutor` rejects
* registration for these names so the upstream "dual-runtime" hazard
* (registry-calls + executor-dispatches the same lifecycle hook) cannot
* recur in genesis.
*/
export const LIFECYCLE_HOOK_NAMES = [
"onRegister",
"onInit",
"onReady",
"onClose",
] as const;
type LifecycleHookName = (typeof LIFECYCLE_HOOK_NAMES)[number];
const LIFECYCLE_SET = new Set<string>(LIFECYCLE_HOOK_NAMES);
/** A generic, non-lifecycle hook handler. */
export type HookHandler = (...args: unknown[]) => unknown;
interface RegisteredHook {
readonly handler: HookHandler;
readonly pluginName: string;
readonly priority: number;
}
/**
* Dispatcher for plugin-contributed, non-lifecycle hooks
* (custom hook names + the request/tenant/db hook surfaces that ship in
* sibling packages). Lifecycle hooks (`onRegister/onInit/onReady/onClose`)
* are explicitly rejected — those are owned by `PluginRegistry`.
*
* Sequential dispatch (`execute`) aborts on the first throw and wraps
* the cause in `PluginError(PLUGIN.HOOK_FAILED)`. Parallel dispatch
* (`executeAll`) uses `Promise.allSettled` and never throws.
*/
export class HookExecutor {
private readonly hooks = new Map<string, RegisteredHook[]>();
/**
* Register a single hook. Higher priority runs first; equal-priority
* preserves registration order.
*
* @throws PluginError(PLUGIN.HOOK_FAILED) if `name` is one of the
* reserved lifecycle hook names.
*/
public registerHook(
name: string,
handler: HookHandler,
pluginName: string,
priority = 0
): void {
if (LIFECYCLE_SET.has(name)) {
throw new PluginError(
PLUGIN_CODES.HOOK_FAILED,
`Lifecycle hook '${name}' is registry-owned and cannot be registered with the HookExecutor`,
{ context: { hook: name, plugin: pluginName } }
);
}
const list = this.hooks.get(name) ?? [];
list.push({ handler, pluginName, priority });
list.sort((a, b) => b.priority - a.priority);
this.hooks.set(name, list);
}
/** Remove every hook contributed by `pluginName`. */
public unregisterPlugin(pluginName: string): void {
for (const [name, list] of this.hooks) {
const remaining = list.filter((h) => h.pluginName !== pluginName);
if (remaining.length === 0) {
this.hooks.delete(name);
} else {
this.hooks.set(name, remaining);
}
}
}
/**
* Sequential dispatch — abort on first throw, wrap in
* `PluginError(PLUGIN.HOOK_FAILED)` carrying `context.plugin` and
* `context.hook`.
*/
public async execute(name: string, ...args: unknown[]): Promise<void> {
const list = this.hooks.get(name) ?? [];
for (const hook of list) {
try {
await hook.handler(...args);
} catch (cause) {
throw new PluginError(
PLUGIN_CODES.HOOK_FAILED,
`Hook '${name}' from plugin '${hook.pluginName}' threw`,
{
cause:
cause instanceof Error
? cause
: new Error(String(cause)),
context: { hook: name, plugin: hook.pluginName },
}
);
}
}
}
/**
* Parallel dispatch — `Promise.allSettled`. Never throws; callers
* inspect the result for individual rejections.
*/
public async executeAll(
name: string,
...args: unknown[]
): Promise<PromiseSettledResult<unknown>[]> {
const list = this.hooks.get(name) ?? [];
return Promise.allSettled(
list.map((h) => Promise.resolve().then(() => h.handler(...args)))
);
}
public hasHook(name: string): boolean {
return (this.hooks.get(name)?.length ?? 0) > 0;
}
public getHookCount(name: string): number {
return this.hooks.get(name)?.length ?? 0;
}
public getRegisteredHooks(): readonly string[] {
return Array.from(this.hooks.keys()).filter((n) => this.hasHook(n));
}
public clear(): void {
this.hooks.clear();
}
}
/** Convenience factory for {@link HookExecutor}. */
export function createHookExecutor(): HookExecutor {
return new HookExecutor();
}
export type { LifecycleHookName };

View File

@@ -0,0 +1,9 @@
export type {
HookHandler,
LifecycleHookName,
} from "./hook-executor.js";
export {
createHookExecutor,
HookExecutor,
LIFECYCLE_HOOK_NAMES,
} from "./hook-executor.js";

View File

@@ -0,0 +1,49 @@
// Side-effect import — surfaces error-codes registry to consumers via `import "@suaveplan/api-plugin"`.
import "./error-codes/error-codes.js";
export type { CreatePluginContextOptions } from "./context/context.js";
// Context
export { createPluginContext } from "./context/context.js";
export type { PluginContext } from "./context/context.types.js";
// Define
export { definePlugin } from "./define/define.js";
// Error codes
export {
PLUGIN_CODES,
PluginError,
type PluginErrorCode,
} from "./error-codes/error-codes.js";
// Hook executor
export {
createHookExecutor,
HookExecutor,
type HookHandler,
LIFECYCLE_HOOK_NAMES,
type LifecycleHookName,
} from "./hook-executor/hook-executor.js";
// Registry
export {
createPluginRegistry,
PluginRegistry,
type PluginRegistryOptions,
type SeedServicesMap,
} from "./registry/registry.js";
// Service registry
export type {
ServiceKey,
ServiceRegistry,
} from "./service-registry/service-registry.js";
// Topo-sort
export {
topologicalSort,
validateDependencies,
} from "./topo-sort/topo-sort.js";
// Types
export type {
PluginDescriptor,
PluginInstance,
PluginLifecycleHook,
PluginNode,
PluginState,
TopologicalSortResult,
} from "./types/api-plugin.js";

View File

@@ -0,0 +1,10 @@
export type {
PluginRegistryOptions,
ScheduleTimer,
ScheduleTimerHandle,
SeedServicesMap,
} from "./registry.js";
export {
createPluginRegistry,
PluginRegistry,
} from "./registry.js";

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,627 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import z from "zod";
import { definePlugin } from "../define/define.js";
import { PluginError } from "../error-codes/error-codes.js";
import { createPluginRegistry, PluginRegistry } from "./registry.js";
describe("PluginRegistry — registration", () => {
it("registers a plugin and exposes it", { timeout: 5000 }, () => {
const reg = createPluginRegistry();
const p = definePlugin({ name: "p", version: "0.1.0" });
reg.register(p);
expect(reg.has("p")).toBe(true);
expect(reg.get("p")?.plugin).toBe(p);
expect(reg.getAll()).toHaveLength(1);
});
it("registerAll handles a batch", { timeout: 5000 }, () => {
const reg = createPluginRegistry();
reg.registerAll([
definePlugin({ name: "a", version: "0.1.0" }),
definePlugin({ name: "b", version: "0.1.0" }),
]);
expect(reg.has("a")).toBe(true);
expect(reg.has("b")).toBe(true);
});
it("throws PLUGIN.ALREADY_REGISTERED on duplicate", {
timeout: 5000,
}, () => {
const reg = createPluginRegistry();
const p = definePlugin({ name: "dup", version: "0.1.0" });
reg.register(p);
let caught: PluginError | undefined;
try {
reg.register(p);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.ALREADY_REGISTERED");
});
it("merges defaults with caller config and validates via Zod", {
timeout: 5000,
}, () => {
const reg = createPluginRegistry();
const p = definePlugin({
name: "cfg",
version: "0.1.0",
configSchema: z.object({
port: z.number().int().positive(),
}),
defaults: { port: 3000 },
});
reg.register(p);
expect(reg.get<{ port: number }>("cfg")?.config.port).toBe(3000);
const reg2 = createPluginRegistry();
reg2.register(p, { port: 4242 });
expect(reg2.get<{ port: number }>("cfg")?.config.port).toBe(4242);
});
it("throws PLUGIN.INVALID_CONFIG on schema failure", {
timeout: 5000,
}, () => {
const reg = createPluginRegistry();
const p = definePlugin({
name: "bad",
version: "0.1.0",
configSchema: z.object({ port: z.number() }),
});
let caught: PluginError | undefined;
try {
reg.register(p, { port: "not-a-number" } as never);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.INVALID_CONFIG");
});
});
describe("PluginRegistry — dep resolution", () => {
it("initialize runs onRegister/onInit/onReady in dep order", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const log: string[] = [];
reg.register(
definePlugin({
name: "b",
version: "0.1.0",
dependencies: ["a"],
onRegister: () => void log.push("b:onRegister"),
onInit: () => void log.push("b:onInit"),
onReady: () => void log.push("b:onReady"),
})
);
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
onRegister: () => void log.push("a:onRegister"),
onInit: () => void log.push("a:onInit"),
onReady: () => void log.push("a:onReady"),
})
);
await reg.initialize();
expect(log).toEqual([
"a:onRegister",
"b:onRegister",
"a:onInit",
"b:onInit",
"a:onReady",
"b:onReady",
]);
});
it("shutdown calls onClose in reverse dep order", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const log: string[] = [];
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
onClose: () => void log.push("a"),
})
);
reg.register(
definePlugin({
name: "b",
version: "0.1.0",
dependencies: ["a"],
onClose: () => void log.push("b"),
})
);
await reg.initialize();
await reg.shutdown();
expect(log).toEqual(["b", "a"]);
});
it("throws PLUGIN.DEP_MISSING for unresolved required deps", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({ name: "b", version: "0.1.0", dependencies: ["a"] })
);
let caught: PluginError | undefined;
try {
await reg.initialize();
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.DEP_MISSING");
});
it("strictOptionalDeps elevates missing optionals to DEP_MISSING", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry({ strictOptionalDeps: true });
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
optionalDependencies: ["missing-optional"],
})
);
let caught: PluginError | undefined;
try {
await reg.initialize();
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.DEP_MISSING");
expect((caught?.context as { missing: string[] }).missing).toEqual([
"missing-optional",
]);
});
it("non-strict mode tolerates missing optionals", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
optionalDependencies: ["missing-optional"],
})
);
await expect(reg.initialize()).resolves.toBeUndefined();
});
it("initTimeout aborts a hung onInit", { timeout: 30000 }, async () => {
const reg = createPluginRegistry({ initTimeout: 50 });
reg.register(
definePlugin({
name: "hung",
version: "0.1.0",
onInit: () => new Promise<void>(() => {}),
})
);
let caught: PluginError | undefined;
try {
await reg.initialize();
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.INIT_TIMEOUT");
expect((caught?.context as { plugin: string }).plugin).toBe("hung");
});
it("shutdownTimeout aborts a hung onClose", {
timeout: 30000,
}, async () => {
const reg = createPluginRegistry({ shutdownTimeout: 50 });
reg.register(
definePlugin({
name: "hung",
version: "0.1.0",
onClose: () => new Promise<void>(() => {}),
})
);
await reg.initialize();
let caught: PluginError | undefined;
try {
await reg.shutdown();
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.SHUTDOWN_FAILED");
});
it("external abort during initialize raises PLUGIN.ABORTED between phases", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const ctrl = new AbortController();
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
onInit: () => {
ctrl.abort();
},
})
);
reg.register(
definePlugin({
name: "b",
version: "0.1.0",
dependencies: ["a"],
})
);
let caught: PluginError | undefined;
try {
await reg.initialize(ctrl.signal);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.ABORTED");
});
it("pre-aborted external signal aborts immediately", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry({ initTimeout: 100 });
const ctrl = new AbortController();
ctrl.abort();
reg.register(
definePlugin({ name: "p", version: "0.1.0", onInit: () => {} })
);
let caught: PluginError | undefined;
try {
await reg.initialize(ctrl.signal);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.ABORTED");
});
it("ABORTED preserves non-Error throw as cause", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const ctrl = new AbortController();
reg.register(
definePlugin({
name: "p",
version: "0.1.0",
onInit: (ctx) =>
new Promise<void>((_, reject) => {
ctx.signal.addEventListener(
"abort",
() => reject("string-throw"),
{ once: true }
);
setTimeout(() => ctrl.abort(), 10);
}),
})
);
let caught: PluginError | undefined;
try {
await reg.initialize(ctrl.signal);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.ABORTED");
expect((caught?.cause as Error).message).toBe("string-throw");
});
it("aborts a running hook via external signal mid-flight", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const ctrl = new AbortController();
reg.register(
definePlugin({
name: "a",
version: "0.1.0",
onInit: (ctx) =>
new Promise<void>((_, reject) => {
if (ctx.signal.aborted) {
reject(new Error("already-aborted"));
return;
}
ctx.signal.addEventListener(
"abort",
() => reject(new Error("user-abort-detected")),
{ once: true }
);
// schedule the external abort during the hook
setTimeout(() => ctrl.abort(), 10);
}),
})
);
let caught: PluginError | undefined;
try {
await reg.initialize(ctrl.signal);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.ABORTED");
});
it("propagates ABORTED when a hook resolves after its signal was aborted", {
timeout: 30000,
}, async () => {
// Verify the ABORTED code path: if the timer fires but the hook
// resolves anyway, the registry rejects with INIT_TIMEOUT first
// — that's the natural path. To exercise ABORTED we explicitly
// exercise the post-resolution check: register a hook that
// observes the signal and then ignores it.
const reg = createPluginRegistry({ initTimeout: 30 });
let observedAbort = false;
reg.register(
definePlugin({
name: "slow",
version: "0.1.0",
onInit: async (ctx) => {
await new Promise<void>((resolve) => {
ctx.signal.addEventListener(
"abort",
() => {
observedAbort = true;
resolve();
},
{ once: true }
);
});
},
})
);
await expect(reg.initialize()).rejects.toBeInstanceOf(PluginError);
expect(observedAbort).toBe(true);
});
it("wraps non-PluginError throws as PLUGIN.INIT_FAILED", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({
name: "boom",
version: "0.1.0",
onInit: () => {
throw new Error("kaboom");
},
})
);
let caught: PluginError | undefined;
try {
await reg.initialize();
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.INIT_FAILED");
expect((caught?.cause as Error).message).toBe("kaboom");
});
it("wraps non-Error throws as PLUGIN.INIT_FAILED", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({
name: "str",
version: "0.1.0",
onInit: () => {
// eslint-disable-next-line no-throw-literal
throw "string-throw";
},
})
);
await expect(reg.initialize()).rejects.toBeInstanceOf(PluginError);
});
it("preserves PluginError code if a hook re-throws one", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.register(
definePlugin({
name: "explicit",
version: "0.1.0",
onInit: () => {
throw new PluginError(
"PLUGIN.INVALID_CONFIG",
"explicit-throw"
);
},
})
);
let caught: PluginError | undefined;
try {
await reg.initialize();
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.INVALID_CONFIG");
});
it("rejects double initialize", { timeout: 5000 }, async () => {
const reg = createPluginRegistry();
await reg.initialize();
await expect(reg.initialize()).rejects.toBeInstanceOf(PluginError);
});
it("disable throws PLUGIN.NOT_FOUND for unknown plugin", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
let caught: PluginError | undefined;
try {
await reg.disable("ghost");
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.NOT_FOUND");
});
it("disable runs onClose and marks the plugin disabled", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
let closed = false;
reg.register(
definePlugin({
name: "x",
version: "0.1.0",
onClose: () => {
closed = true;
},
})
);
await reg.initialize();
await reg.disable("x");
expect(closed).toBe(true);
expect(reg.get("x")?.state).toBe("disabled");
});
it("getHookExecutor and getEventBus return injected/internal instances", {
timeout: 5000,
}, () => {
const reg = new PluginRegistry();
expect(reg.getHookExecutor()).toBeDefined();
expect(reg.getEventBus()).toBeDefined();
});
it("emits lifecycle events on the event bus", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const events: string[] = [];
const bus = reg.getEventBus() as {
on: (k: string, l: (p: unknown) => void) => () => void;
};
bus.on("plugin:onReady", () => events.push("ready"));
bus.on("plugin:closed", () => events.push("closed"));
reg.register(definePlugin({ name: "p", version: "0.1.0" }));
await reg.initialize();
await reg.shutdown();
expect(events).toContain("ready");
expect(events).toContain("closed");
});
it("hooks with no body are skipped without error", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.register(definePlugin({ name: "empty", version: "0.1.0" }));
await expect(reg.initialize()).resolves.toBeUndefined();
await expect(reg.shutdown()).resolves.toBeUndefined();
});
it("settled flag prevents double-resolution from a delayed timer", {
timeout: 5000,
}, async () => {
const timers: Array<{ cb: () => void; handle: number }> = [];
let next = 1;
const timer = {
set: (cb: () => void) => {
const handle = next++;
timers.push({ cb, handle });
return handle;
},
clear: () => {},
};
const reg = new PluginRegistry({}, timer);
reg.register(
definePlugin({
name: "p",
version: "0.1.0",
onInit: () => Promise.resolve(),
})
);
await reg.initialize();
// Fire all queued timers — every callback must short-circuit on
// `settled` and not throw.
for (const t of timers) t.cb();
expect(reg.get("p")?.state).toBe("ready");
});
it("timer-first race: timer settles, hook later rejects", {
timeout: 5000,
}, async () => {
// Force-fire the timer synchronously inside set(). Then have
// the hook reject — the rejection branch must short-circuit
// on the already-settled flag without re-rejecting.
let rejectLater: ((e: Error) => void) | undefined;
const timer = {
set: (cb: () => void) => {
// Fire synchronously while the hook is still building
// its returned Promise (settled = true before .then
// attaches its rejection handler).
cb();
return 1;
},
clear: () => {},
};
const reg = new PluginRegistry({}, timer);
reg.register(
definePlugin({
name: "p",
version: "0.1.0",
onInit: () =>
new Promise<void>((_resolve, reject) => {
rejectLater = reject;
}),
})
);
const initP = reg.initialize();
// Capture but suppress; initialize rejects synchronously.
const result = initP.catch((e: unknown) => e as PluginError);
// Yield microtasks so the .then(resolve, reject) chain is
// attached to fn(ctx)'s returned promise.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Reject the hook — handler must short-circuit via settled.
rejectLater?.(new Error("late"));
await Promise.resolve();
const caught = await result;
expect(caught?.code).toBe("PLUGIN.INIT_TIMEOUT");
});
it("settled flag prevents double-rejection from a delayed timer", {
timeout: 5000,
}, async () => {
const timers: Array<{ cb: () => void; handle: number }> = [];
let next = 1;
const timer = {
set: (cb: () => void) => {
const handle = next++;
timers.push({ cb, handle });
return handle;
},
clear: () => {},
};
const reg = new PluginRegistry({}, timer);
reg.register(
definePlugin({
name: "p",
version: "0.1.0",
onInit: () => {
throw new Error("inner");
},
})
);
await expect(reg.initialize()).rejects.toBeInstanceOf(PluginError);
for (const t of timers) expect(() => t.cb()).not.toThrow();
});
it("accepts injected clock for timestamp determinism", {
timeout: 5000,
}, async () => {
let t = 1_000;
const reg = createPluginRegistry({ clock: () => t });
reg.register(definePlugin({ name: "p", version: "0.1.0" }));
expect(reg.get("p")?.loadedAt).toBe(1_000);
t = 2_000;
await reg.initialize();
expect(reg.get("p")?.initializedAt).toBe(2_000);
expect(reg.get("p")?.readyAt).toBe(2_000);
});
});

View File

@@ -0,0 +1,576 @@
import { type Tracer, trace } from "@opentelemetry/api";
import { EventBus } from "@suaveplan/events";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import { createPluginContext } from "../context/context.js";
import { PLUGIN_CODES, PluginError } from "../error-codes/error-codes.js";
import { HookExecutor } from "../hook-executor/hook-executor.js";
import type {
ServiceKey,
ServiceRegistry,
} from "../service-registry/service-registry.js";
import {
topologicalSort,
validateDependencies,
} from "../topo-sort/topo-sort.js";
import type {
PluginDescriptor,
PluginInstance,
PluginNode,
PluginState,
} from "../types/api-plugin.js";
/**
* Shape accepted by {@link PluginRegistry.seedServices}: a partial map
* whose keys are augmented {@link ServiceRegistry} slots and whose values
* match each slot's declared type. Empty maps are legal.
*/
export type SeedServicesMap = {
[K in keyof ServiceRegistry]?: ServiceRegistry[K];
};
/**
* Construction options for {@link PluginRegistry}.
*
* `initTimeout` / `shutdownTimeout` are **enforced per hook** — if any
* single lifecycle hook exceeds the budget, the plugin's `ctx.signal`
* is aborted and the registry rejects with
* `PluginError(PLUGIN.INIT_TIMEOUT)` (or `PLUGIN.SHUTDOWN_FAILED` for
* shutdown).
*/
export interface PluginRegistryOptions {
/** Per-hook init budget in ms. Default 30000. */
readonly initTimeout?: number;
/** Per-hook shutdown budget in ms. Default 10000. */
readonly shutdownTimeout?: number;
/** If `true`, optional dependencies that are missing trigger DEP_MISSING. */
readonly strictOptionalDeps?: boolean;
/** Wall-clock source for timestamps. Defaults to `Date.now`. */
readonly clock?: Clock;
/** Structural logger. Defaults to a no-op. */
readonly logger?: LoggerLike;
/** Event bus for lifecycle events. Defaults to a fresh `EventBus`. */
readonly eventBus?: EventBusLike;
}
/**
* Injectable timer surface — used by tests to force-fire timeouts
* deterministically. Default binds `globalThis.setTimeout`/`clearTimeout`.
*/
/** Opaque handle returned by `ScheduleTimer.set`; pass back to `clear` to cancel. */
export type ScheduleTimerHandle = object | number;
export interface ScheduleTimer {
set(callback: () => void, ms: number): ScheduleTimerHandle;
clear(handle: ScheduleTimerHandle): void;
}
const DEFAULT_INIT_TIMEOUT = 30_000;
const DEFAULT_SHUTDOWN_TIMEOUT = 10_000;
const defaultTimer: ScheduleTimer = {
set: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as object,
clear: (h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>),
};
const noop = (): void => {};
const noopLogger: LoggerLike = {
debug: noop,
info: noop,
warn: noop,
error: noop,
};
/**
* Core plugin registry.
*
* Owns the 4-phase lifecycle (`onRegister → onInit → onReady` forward,
* `onClose` reverse), the topological sort, the shared service registry,
* and the OTel tracer that wraps each hook in a span. Holds a
* `HookExecutor` instance accessible via `getHookExecutor()` for sibling
* packages that contribute non-lifecycle hooks (request/tenant/db).
*/
export class PluginRegistry {
private readonly instances = new Map<string, PluginInstance>();
private readonly services = new Map<string, unknown>();
private readonly seededServices = new Map<string, unknown>();
private readonly hookExecutor = new HookExecutor();
private readonly eventBus: EventBusLike;
private readonly logger: LoggerLike;
private readonly clock: Clock;
private readonly tracer: Tracer;
private readonly initTimeout: number;
private readonly shutdownTimeout: number;
private readonly strictOptionalDeps: boolean;
private readonly timer: ScheduleTimer;
private initialized = false;
private initializing = false;
public constructor(
options: PluginRegistryOptions = {},
timer: ScheduleTimer = defaultTimer
) {
this.initTimeout = options.initTimeout ?? DEFAULT_INIT_TIMEOUT;
this.shutdownTimeout =
options.shutdownTimeout ?? DEFAULT_SHUTDOWN_TIMEOUT;
this.strictOptionalDeps = options.strictOptionalDeps ?? false;
this.clock = options.clock ?? (() => Date.now());
this.logger = options.logger ?? noopLogger;
this.eventBus = options.eventBus ?? new EventBus();
this.tracer = trace.getTracer("@suaveplan/api-plugin");
this.timer = timer;
}
/**
* Register a plugin descriptor. Throws PLUGIN.ALREADY_REGISTERED if
* the name is already in use, PLUGIN.INVALID_CONFIG if the merged
* config does not satisfy `configSchema`.
*/
public register<TConfig>(
plugin: PluginDescriptor<TConfig>,
config?: Partial<TConfig>
): void {
if (this.instances.has(plugin.name)) {
throw new PluginError(
PLUGIN_CODES.ALREADY_REGISTERED,
`Plugin '${plugin.name}' is already registered`,
{ context: { plugin: plugin.name } }
);
}
const merged = {
...(plugin.defaults ?? {}),
...(config ?? {}),
} as TConfig;
if (plugin.configSchema !== undefined) {
const parsed = plugin.configSchema.safeParse(merged);
if (!parsed.success) {
throw new PluginError(
PLUGIN_CODES.INVALID_CONFIG,
`Config for plugin '${plugin.name}' failed validation`,
{
context: {
plugin: plugin.name,
issues: parsed.error.issues,
},
}
);
}
}
const instance: PluginInstance<TConfig> = {
plugin,
state: "pending",
config: merged,
loadedAt: this.clock(),
};
this.instances.set(plugin.name, instance as PluginInstance);
this.eventBus.emit("plugin:registering", {
name: plugin.name,
version: plugin.version,
});
this.logger.info("plugin registered", {
plugin: plugin.name,
version: plugin.version,
});
}
/** Bulk-register descriptors. */
public registerAll(plugins: ReadonlyArray<PluginDescriptor>): void {
for (const plugin of plugins) this.register(plugin);
}
/**
* Bulk-register host-seeded services. Every entry is copied into an
* internal seeded-services map; when `initialize()` later builds the
* per-plugin {@link createPluginContext} services snapshot, the
* seeded entries are merged in **before** any plugin's `onRegister`
* runs, so the first plugin in dep order sees the canonical host
* surface via typed `ctx.getService(name)`.
*
* Idempotent: re-seeding the same key replaces the prior value
* (last write wins). Plugin-registered services overlay on top of
* the seeded snapshot at runtime — a plugin's `registerService("X")`
* is still visible to siblings via the shared `services` map.
*
* @throws PluginError(PLUGIN.INVALID_STATE) when called after
* `initialize()` has begun. Seed maps are only meaningful before
* per-plugin contexts are constructed.
*/
public seedServices<TMap extends SeedServicesMap>(map: TMap): void {
if (this.initialized || this.initializing) {
throw new PluginError(
PLUGIN_CODES.INVALID_STATE,
"Cannot seedServices after registry.initialize() has begun",
{
context: {
initialized: this.initialized,
initializing: this.initializing,
},
}
);
}
for (const key of Object.keys(map) as Array<ServiceKey>) {
const value = map[key];
if (value === undefined) continue;
this.seededServices.set(key as string, value);
}
}
public get<TConfig = unknown>(
name: string
): PluginInstance<TConfig> | undefined {
return this.instances.get(name) as PluginInstance<TConfig> | undefined;
}
public has(name: string): boolean {
return this.instances.has(name);
}
public getAll(): ReadonlyArray<PluginInstance> {
return Array.from(this.instances.values());
}
public getEventBus(): EventBusLike {
return this.eventBus;
}
public getHookExecutor(): HookExecutor {
return this.hookExecutor;
}
/**
* Returns plugins in dependency order. Throws
* PLUGIN.DEP_MISSING if any required dep is absent, or
* PLUGIN.CIRCULAR_DEPENDENCY (via `topologicalSort`) if a cycle is
* found.
*/
public getSortedPlugins(): ReadonlyArray<PluginInstance> {
const requiredOnly: PluginNode<PluginInstance>[] = [];
const withOptional: PluginNode<PluginInstance>[] = [];
for (const inst of this.instances.values()) {
const required = inst.plugin.dependencies ?? [];
const optional = inst.plugin.optionalDependencies ?? [];
requiredOnly.push({
id: inst.plugin.name,
data: inst,
dependencies: required,
});
withOptional.push({
id: inst.plugin.name,
data: inst,
dependencies: [...required, ...optional],
});
}
const validation = validateDependencies(requiredOnly);
if (!validation.valid) {
const [first] = Array.from(validation.missing.entries());
const offender = first as [string, readonly string[]];
throw new PluginError(
PLUGIN_CODES.DEP_MISSING,
`Plugin '${offender[0]}' is missing required dependencies: ${offender[1].join(", ")}`,
{
context: {
plugin: offender[0],
missing: offender[1],
},
}
);
}
const { sorted, unresolved } = topologicalSort(withOptional);
if (this.strictOptionalDeps && unresolved.length > 0) {
throw new PluginError(
PLUGIN_CODES.DEP_MISSING,
`Strict mode: unresolved optional dependencies: ${unresolved.join(", ")}`,
{ context: { missing: unresolved } }
);
}
return sorted.map((n) => n.data);
}
/**
* Run the four-phase lifecycle on every registered plugin in
* dep order: `onRegister → onInit → onReady`. Per-hook timeout is
* enforced via the plugin's `ctx.signal`.
*/
public async initialize(externalSignal?: AbortSignal): Promise<void> {
if (this.initialized) {
throw new PluginError(
PLUGIN_CODES.INIT_FAILED,
"Registry is already initialized"
);
}
this.initializing = true;
// Snapshot seeded services into the shared per-context map BEFORE
// any plugin's `onRegister` runs. Plugin-side `registerService`
// calls overlay on top via the same shared map instance.
for (const [key, value] of this.seededServices) {
this.services.set(key, value);
}
try {
const sorted = this.getSortedPlugins();
const checkAborted = (): void => {
if (externalSignal?.aborted === true) {
throw new PluginError(
PLUGIN_CODES.ABORTED,
"Registry initialization aborted by external signal",
{ context: { reason: externalSignal.reason } }
);
}
};
for (const inst of sorted) {
checkAborted();
await this.runPhase(
inst,
"registered",
"onRegister",
externalSignal
);
}
for (const inst of sorted) {
checkAborted();
await this.runPhase(
inst,
"initialized",
"onInit",
externalSignal
);
}
for (const inst of sorted) {
checkAborted();
await this.runPhase(inst, "ready", "onReady", externalSignal);
}
this.initialized = true;
this.logger.info("registry initialized", {
plugins: sorted.length,
});
} finally {
this.initializing = false;
}
}
/** Run `onClose` in reverse dep order with timeout enforcement. */
public async shutdown(): Promise<void> {
const sorted = [...this.getSortedPlugins()].reverse();
const failures: Array<{ plugin: string; error: Error }> = [];
for (const inst of sorted) {
try {
await this.runHook(inst, "onClose", this.shutdownTimeout, true);
inst.state = "disabled";
inst.closedAt = this.clock();
this.eventBus.emit("plugin:closed", {
name: inst.plugin.name,
});
} catch (error) {
failures.push({
plugin: inst.plugin.name,
error: error as Error,
});
}
}
this.initialized = false;
this.logger.info("registry shut down", {
plugins: sorted.length,
failures: failures.length,
});
if (failures.length > 0) {
const first = failures[0] as { plugin: string; error: Error };
throw new PluginError(
PLUGIN_CODES.SHUTDOWN_FAILED,
`Shutdown failed for ${failures.length} plugin(s); first: '${first.plugin}'`,
{
cause: first.error,
context: {
plugin: first.plugin,
failures: failures.map((f) => f.plugin),
},
}
);
}
}
/**
* Disable a single plugin. Runs its `onClose` hook (with timeout)
* and removes its hook contributions. Does NOT affect other plugins.
*/
public async disable(name: string): Promise<void> {
const inst = this.instances.get(name);
if (!inst) {
throw new PluginError(
PLUGIN_CODES.NOT_FOUND,
`Plugin '${name}' is not registered`,
{ context: { plugin: name } }
);
}
await this.runHook(inst, "onClose", this.shutdownTimeout, false);
this.hookExecutor.unregisterPlugin(name);
inst.state = "disabled";
inst.closedAt = this.clock();
this.eventBus.emit("plugin:disabled", { name });
}
// ---- internal ----
private async runPhase(
inst: PluginInstance,
targetState: PluginState,
hook: "onRegister" | "onInit" | "onReady",
externalSignal?: AbortSignal
): Promise<void> {
inst.state =
hook === "onInit"
? "initializing"
: hook === "onRegister"
? "pending"
: "initialized";
try {
await this.runHook(
inst,
hook,
this.initTimeout,
false,
externalSignal
);
inst.state = targetState;
if (hook === "onInit") inst.initializedAt = this.clock();
if (hook === "onReady") inst.readyAt = this.clock();
this.eventBus.emit(`plugin:${hook}`, {
name: inst.plugin.name,
});
} catch (error) {
inst.state = "error";
inst.error = error as Error;
if (error instanceof PluginError) throw error;
throw new PluginError(
PLUGIN_CODES.INIT_FAILED,
`Plugin '${inst.plugin.name}' ${hook} failed`,
{
cause:
error instanceof Error
? error
: new Error(String(error)),
context: { plugin: inst.plugin.name, hook },
}
);
}
}
private runHook(
inst: PluginInstance,
hook: "onRegister" | "onInit" | "onReady" | "onClose",
timeoutMs: number,
isShutdown: boolean,
externalSignal?: AbortSignal
): Promise<void> {
const fn = inst.plugin[hook];
if (fn === undefined) return Promise.resolve();
const ctrl = new AbortController();
const onExternalAbort = (): void => {
ctrl.abort();
};
if (externalSignal !== undefined) {
externalSignal.addEventListener("abort", onExternalAbort, {
once: true,
});
}
const ctx = createPluginContext({
logger: this.logger,
eventBus: this.eventBus,
signal: ctrl.signal,
config: inst.config,
services: this.services,
});
return this.tracer.startActiveSpan(
`plugin.${hook}`,
{ attributes: { "plugin.name": inst.plugin.name } },
(span) =>
new Promise<void>((resolve, reject) => {
let settled = false;
const handle = this.timer.set(() => {
if (settled) return;
settled = true;
ctrl.abort();
span.end();
reject(
new PluginError(
isShutdown
? PLUGIN_CODES.SHUTDOWN_FAILED
: PLUGIN_CODES.INIT_TIMEOUT,
`Hook '${hook}' on plugin '${inst.plugin.name}' exceeded ${timeoutMs}ms`,
{
context: {
plugin: inst.plugin.name,
hook,
timeoutMs,
},
}
)
);
}, timeoutMs);
Promise.resolve()
.then(() => fn(ctx))
.then(
() => {
if (settled) return;
settled = true;
this.timer.clear(handle);
span.end();
resolve();
},
(err: unknown) => {
if (settled) return;
settled = true;
this.timer.clear(handle);
span.end();
if (externalSignal?.aborted === true) {
reject(
new PluginError(
PLUGIN_CODES.ABORTED,
`Hook '${hook}' on plugin '${inst.plugin.name}' aborted by external signal`,
{
cause:
err instanceof Error
? err
: new Error(
String(err)
),
context: {
plugin: inst.plugin.name,
hook,
},
}
)
);
} else {
reject(err);
}
}
)
.finally(() => {
if (externalSignal !== undefined) {
externalSignal.removeEventListener(
"abort",
onExternalAbort
);
}
});
})
);
}
}
/** Convenience factory. */
export function createPluginRegistry(
options?: PluginRegistryOptions
): PluginRegistry {
return new PluginRegistry(options);
}

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { definePlugin } from "../define/define.js";
import { PLUGIN_CODES, PluginError } from "../error-codes/error-codes.js";
import { createPluginRegistry } from "./registry.js";
// Local module augmentation — gives the test suite a concrete ServiceRegistry
// shape so `seedServices` and `ctx.getService` type-check end-to-end without
// leaking into other test files. Module augmentation is process-global, so
// the chosen slot names use a `_test_` prefix to avoid collision with any
// canonical slot a consumer package augments later.
declare module "../service-registry/service-registry.js" {
interface ServiceRegistry {
_test_app: { name: string };
_test_logger: { level: string };
}
}
describe("PluginRegistry.seedServices", () => {
it("seeded service resolvable from first plugin's onRegister", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const stubApp = { name: "stub" };
reg.seedServices({ _test_app: stubApp });
let resolved: { name: string } | undefined;
reg.register(
definePlugin({
name: "p",
version: "0.1.0",
onRegister(ctx) {
resolved = ctx.getService("_test_app");
},
})
);
await reg.initialize();
expect(resolved).toBe(stubApp);
});
it("later seed overrides earlier", { timeout: 5000 }, async () => {
const reg = createPluginRegistry();
const l1 = { level: "info" };
const l2 = { level: "debug" };
reg.seedServices({ _test_logger: l1 });
reg.seedServices({ _test_logger: l2 });
let observed: { level: string } | undefined;
reg.register(
definePlugin({
name: "q",
version: "0.1.0",
onRegister(ctx) {
observed = ctx.getService("_test_logger");
},
})
);
await reg.initialize();
expect(observed).toBe(l2);
});
it("seedServices after initialize raises PLUGIN.INVALID_STATE", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
await reg.initialize();
let caught: PluginError | undefined;
try {
reg.seedServices({ _test_app: { name: "late" } });
} catch (e) {
caught = e as PluginError;
}
expect(caught).toBeInstanceOf(PluginError);
expect(caught?.code).toBe(PLUGIN_CODES.INVALID_STATE);
});
it("seedServices during initialize raises PLUGIN.INVALID_STATE", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
let caught: PluginError | undefined;
reg.register(
definePlugin({
name: "r",
version: "0.1.0",
onRegister() {
try {
reg.seedServices({
_test_app: { name: "mid" },
});
} catch (e) {
caught = e as PluginError;
}
},
})
);
await reg.initialize();
expect(caught?.code).toBe(PLUGIN_CODES.INVALID_STATE);
});
it("empty seed map is a no-op and does not block initialize", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
reg.seedServices({});
await reg.initialize();
// No throw → success.
expect(true).toBe(true);
});
it("explicit undefined value is skipped (not stored)", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
// Cast through `unknown` because the public TMap type forbids
// `undefined`; the runtime guard exists to defend against
// loosely-typed callers.
reg.seedServices({
_test_app: undefined as unknown as { name: string },
});
let observed: { name: string } | undefined = { name: "sentinel" };
reg.register(
definePlugin({
name: "s",
version: "0.1.0",
onRegister(ctx) {
observed = ctx.getService("_test_app");
},
})
);
await reg.initialize();
expect(observed).toBeUndefined();
});
it("plugin registerService overlays the seeded value", {
timeout: 5000,
}, async () => {
const reg = createPluginRegistry();
const seeded = { name: "seeded" };
const overlaid = { name: "overlaid" };
reg.seedServices({ _test_app: seeded });
let observed: { name: string } | undefined;
reg.register(
definePlugin({
name: "writer",
version: "0.1.0",
onRegister(ctx) {
ctx.registerService("_test_app", overlaid);
},
})
);
reg.register(
definePlugin({
name: "reader",
version: "0.1.0",
dependencies: ["writer"],
onRegister(ctx) {
observed = ctx.getService("_test_app");
},
})
);
await reg.initialize();
expect(observed).toBe(overlaid);
});
});

View File

@@ -0,0 +1,4 @@
export type {
ServiceKey,
ServiceRegistry,
} from "./service-registry.js";

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,34 @@
/**
* Service registry — type-safe slots declared by consumers via module
* augmentation.
*
* Default (no augmentation): `ServiceRegistry` is empty so `ServiceKey`
* resolves to `never`. `registerService`/`getService` therefore reject
* every string key at compile time. Consumers add their slots:
*
* ```ts
* declare module "@suaveplan/api-plugin/service-registry" {
* interface ServiceRegistry {
* db: DbAdapter;
* cache: CacheAdapter;
* }
* }
* ```
*
* After the augmentation, `ctx.getService("db")` types as
* `DbAdapter | undefined`, and `ctx.getService("unknown")` fails the
* build with `Argument of type '"unknown"' is not assignable…`.
*/
/**
* Sentinel type — resolves to `Record<never, never>`, so when no consumer
* has augmented `ServiceRegistry`, `keyof ServiceRegistry` is `never` and
* `registerService`/`getService` reject every string key at compile time.
* Extending a non-`{}` parent makes the declaration valid under Biome's
* `noEmptyInterface` rule while preserving the augmentation semantics.
*/
type AugmentablePluginServices = Record<never, never>;
export interface ServiceRegistry extends AugmentablePluginServices {}
/** Convenience alias — `keyof ServiceRegistry` is `never` until augmented. */
export type ServiceKey = keyof ServiceRegistry;

View File

@@ -0,0 +1,4 @@
export {
topologicalSort,
validateDependencies,
} from "./topo-sort.js";

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,228 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PluginError } from "../error-codes/error-codes.js";
import type { PluginNode } from "../types/api-plugin.js";
import { topologicalSort, validateDependencies } from "./topo-sort.js";
const node = (id: string, deps: string[] = []): PluginNode<null> => ({
id,
data: null,
dependencies: deps,
});
describe("topologicalSort", () => {
it("sorts a simple dep chain", { timeout: 5000 }, () => {
const { sorted } = topologicalSort([node("b", ["a"]), node("a")]);
expect(sorted.map((n) => n.id)).toEqual(["a", "b"]);
});
it("sorts equal-in-degree nodes alphabetically regardless of input order", {
timeout: 5000,
}, () => {
const { sorted } = topologicalSort([node("z"), node("a"), node("m")]);
expect(sorted.map((n) => n.id)).toEqual(["a", "m", "z"]);
});
it("preserves alpha order in a diamond shape", { timeout: 5000 }, () => {
// a -> {b, c} -> d. Layer 1 = {b,c}, must be in alpha order.
const { sorted } = topologicalSort([
node("d", ["b", "c"]),
node("c", ["a"]),
node("b", ["a"]),
node("a"),
]);
expect(sorted.map((n) => n.id)).toEqual(["a", "b", "c", "d"]);
});
it("collects missing required deps in unresolved", {
timeout: 5000,
}, () => {
const { sorted, unresolved } = topologicalSort([
node("a", ["missing-1", "missing-2"]),
]);
expect(sorted.map((n) => n.id)).toEqual(["a"]);
expect(unresolved).toEqual(["missing-1", "missing-2"]);
});
it("throws PLUGIN.CIRCULAR_DEPENDENCY with cycle path", {
timeout: 5000,
}, () => {
let caught: PluginError | undefined;
try {
topologicalSort([
node("a", ["b"]),
node("b", ["c"]),
node("c", ["a"]),
]);
} catch (e) {
caught = e as PluginError;
}
expect(caught).toBeInstanceOf(PluginError);
expect(caught?.code).toBe("PLUGIN.CIRCULAR_DEPENDENCY");
const ctx = caught?.context as { cycle: string[] };
expect(ctx.cycle[0]).toBe(ctx.cycle[ctx.cycle.length - 1]);
expect(ctx.cycle).toEqual(["a", "b", "c", "a"]);
});
it("handles a self-loop cycle", { timeout: 5000 }, () => {
expect(() => topologicalSort([node("a", ["a"])])).toThrow(PluginError);
});
it("DFS visits acyclic dep before discovering a cycle through siblings", {
timeout: 5000,
}, () => {
// 'a' depends on ['aa-leaf', 'b']. Sorted deps: ['aa-leaf', 'b'].
// Visit a → visit aa-leaf (returns false, exercises visit's
// pop branch and the if(visit(dep)) else branch), then
// visit b → which depends on a → cycle.
let caught: PluginError | undefined;
try {
topologicalSort([
node("a", ["aa-leaf", "b"]),
node("b", ["a"]),
node("aa-leaf"),
]);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.CIRCULAR_DEPENDENCY");
});
it("DFS skips already-visited but not-in-stack nodes", {
timeout: 5000,
}, () => {
// Outer DFS starts at alpha-first 'a' which transitively
// visits 'shared' and pops (visited, not in stack). Next
// outer root 'b' has deps [shared, cycle-x]; the cycle
// through cycle-x triggers, but only after visiting
// 'shared' which IS visited AND NOT in stack — exercises
// the else path of `else if (stack.has(dep))`. Outer loop
// also revisits 'shared' (already visited) — exercises
// the else path of `if (!visited.has(id))`.
const nodes = [
node("a", ["shared"]),
// b's first dep alphabetically is 'cycle-x', so order
// by re-naming to ensure 'shared' is visited inside
// b's recursion AFTER the cycle is found? Actually we
// want shared visited inside b BEFORE the cycle so
// that branch fires. Use 'aaa-shared' so it sorts
// first in b's dep list.
node("b", ["aaa-shared-target", "cycle-x"]),
node("aaa-shared-target", []),
node("cycle-x", ["cycle-y"]),
node("cycle-y", ["cycle-x"]),
node("shared"),
];
// visit a → visit 'shared' (no deps, returns false, pops)
// → a pops. Visit aaa-shared-target (no deps), pops.
// Outer loop visits 'b'. b deps sorted = ['aaa-shared-target', 'cycle-x'].
// Visit 'aaa-shared-target' → already visited, NOT in stack
// → hits the else of `else if (stack.has(dep))` ✓
// Continue → visit cycle-x → cycle found.
// Outer loop continues with cycle-x: already visited (cycle was found and break).
// Actually `break` happens after visit returns true, so outer
// doesn't continue. To exercise outer-loop else, we need a
// visit returning false then outer hitting an already-visited.
let caught: PluginError | undefined;
try {
topologicalSort(nodes);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.CIRCULAR_DEPENDENCY");
});
it("outer DFS loop skips already-visited nodes during cycle detection", {
timeout: 5000,
}, () => {
// Cycle through {z, y}; outer-loop sorted ids start with
// 'a' (acyclic, transitively visits 'mid'). When outer
// moves on to 'mid' it's already visited → else path of
// `if (!visited.has(id))`. Eventually outer reaches the
// cycle and breaks.
let caught: PluginError | undefined;
try {
topologicalSort([
node("a", ["mid"]),
node("mid"),
node("y", ["z"]),
node("z", ["y"]),
]);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.CIRCULAR_DEPENDENCY");
});
it("ignores missing deps while detecting a cycle", {
timeout: 5000,
}, () => {
// Cycle x ↔ y, plus a missing dep `gone` referenced by x.
// The cycle detector must skip `gone` and still find x↔y.
let caught: PluginError | undefined;
try {
topologicalSort([node("x", ["y", "gone"]), node("y", ["x"])]);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.CIRCULAR_DEPENDENCY");
});
it("detects a cycle with an acyclic component present", {
timeout: 5000,
}, () => {
// 'acyclic' is alphabetically first and has no cycle — DFS
// walks it cleanly (exercising the path-pop branch), then
// visits the cyclic component.
let caught: PluginError | undefined;
try {
topologicalSort([
node("acyclic"),
node("x", ["y"]),
node("y", ["x"]),
]);
} catch (e) {
caught = e as PluginError;
}
expect(caught?.code).toBe("PLUGIN.CIRCULAR_DEPENDENCY");
const ctx = caught?.context as { cycle: string[] };
expect(ctx.cycle).toContain("x");
expect(ctx.cycle).toContain("y");
});
it("returns empty result for empty input", { timeout: 5000 }, () => {
const { sorted, unresolved } = topologicalSort([]);
expect(sorted).toEqual([]);
expect(unresolved).toEqual([]);
});
it("is deterministic across repeated calls", { timeout: 5000 }, () => {
const nodes = [node("z", ["a"]), node("a"), node("m", ["a"])];
const a = topologicalSort(nodes).sorted.map((n) => n.id);
const b = topologicalSort(nodes).sorted.map((n) => n.id);
expect(a).toEqual(b);
});
it("deduplicates unresolved entries", { timeout: 5000 }, () => {
const { unresolved } = topologicalSort([
node("a", ["x"]),
node("b", ["x"]),
]);
expect(unresolved).toEqual(["x"]);
});
});
describe("validateDependencies", () => {
it("returns valid=true when every dep is present", {
timeout: 5000,
}, () => {
const result = validateDependencies([node("a"), node("b", ["a"])]);
expect(result.valid).toBe(true);
expect(result.missing.size).toBe(0);
});
it("reports missing deps per node", { timeout: 5000 }, () => {
const result = validateDependencies([node("a", ["x", "y"])]);
expect(result.valid).toBe(false);
expect(result.missing.get("a")).toEqual(["x", "y"]);
});
});

View File

@@ -0,0 +1,142 @@
import { PLUGIN_CODES, PluginError } from "../error-codes/error-codes.js";
import type { PluginNode, TopologicalSortResult } from "../types/api-plugin.js";
/**
* Topological sort using Kahn's algorithm with deterministic alphabetical
* tie-break. Returns nodes in dep-before-dependent order. Missing required
* deps are collected in `unresolved`; cycles raise `PluginError(PLUGIN.CIRCULAR_DEPENDENCY)`
* carrying the offending cycle path.
*
* Equal-in-degree nodes process in alphabetical `id` order, so the output
* is reproducible across runs and independent of input order.
*/
export function topologicalSort<T>(
nodes: ReadonlyArray<PluginNode<T>>
): TopologicalSortResult<T> {
const nodeMap = new Map<string, PluginNode<T>>();
const inDegree = new Map<string, number>();
const graph = new Map<string, string[]>();
const unresolvedSet = new Set<string>();
for (const node of nodes) {
nodeMap.set(node.id, node);
inDegree.set(node.id, 0);
graph.set(node.id, []);
}
for (const node of nodes) {
for (const dep of node.dependencies) {
if (!nodeMap.has(dep)) {
unresolvedSet.add(dep);
continue;
}
(graph.get(dep) as string[]).push(node.id);
inDegree.set(node.id, (inDegree.get(node.id) as number) + 1);
}
}
const ready: string[] = [];
for (const [id, degree] of inDegree) {
if (degree === 0) ready.push(id);
}
ready.sort();
const sorted: PluginNode<T>[] = [];
while (ready.length > 0) {
const current = ready.shift() as string;
sorted.push(nodeMap.get(current) as PluginNode<T>);
const newlyReady: string[] = [];
for (const neighbor of graph.get(current) as string[]) {
const next = (inDegree.get(neighbor) as number) - 1;
inDegree.set(neighbor, next);
if (next === 0) newlyReady.push(neighbor);
}
if (newlyReady.length > 0) {
for (const id of newlyReady) ready.push(id);
ready.sort();
}
}
if (sorted.length !== nodes.length) {
const cycle = detectCycle(nodes);
throw new PluginError(
PLUGIN_CODES.CIRCULAR_DEPENDENCY,
`Circular dependency detected: ${cycle.join(" → ")}`,
{ context: { cycle } }
);
}
return {
sorted,
unresolved: Array.from(unresolvedSet).sort(),
};
}
/**
* DFS-based cycle detector. Returns the cycle path starting and ending
* at the offending node — e.g. `["a", "b", "c", "a"]` for `a → b → c → a`.
*/
function detectCycle<T>(nodes: ReadonlyArray<PluginNode<T>>): string[] {
const nodeMap = new Map<string, PluginNode<T>>();
for (const node of nodes) nodeMap.set(node.id, node);
const visited = new Set<string>();
const stack = new Set<string>();
const path: string[] = [];
let foundCycle: string[] | undefined;
const visit = (id: string): boolean => {
visited.add(id);
stack.add(id);
path.push(id);
const node = nodeMap.get(id) as PluginNode<T>;
for (const dep of [...node.dependencies].sort()) {
if (!nodeMap.has(dep)) continue;
if (!visited.has(dep)) {
if (visit(dep)) return true;
} else if (stack.has(dep)) {
const startIdx = path.indexOf(dep);
foundCycle = path.slice(startIdx).concat(dep);
return true;
}
}
path.pop();
stack.delete(id);
return false;
};
const ids = nodes.map((n) => n.id).sort();
for (const id of ids) {
if (!visited.has(id)) {
if (visit(id)) break;
}
}
return foundCycle as string[];
}
/**
* Validate that every required dependency of every node is present in
* the input set. Returns `{ valid, missing }` where `missing` maps each
* offending node id to its missing deps. A separate API from
* {@link topologicalSort} because the registry's strict-mode policy
* needs to inspect dep-missing without triggering a sort.
*/
export function validateDependencies<T>(nodes: ReadonlyArray<PluginNode<T>>): {
readonly valid: boolean;
readonly missing: ReadonlyMap<string, readonly string[]>;
} {
const ids = new Set(nodes.map((n) => n.id));
const missing = new Map<string, string[]>();
for (const node of nodes) {
const gaps = node.dependencies.filter((dep) => !ids.has(dep));
if (gaps.length > 0) missing.set(node.id, gaps);
}
return { valid: missing.size === 0, missing };
}

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.

View File

@@ -0,0 +1,96 @@
import type { ZodType } from "zod";
import type { PluginContext } from "../context/context.types.js";
/**
* Lifecycle state of a registered plugin.
*
* The registry advances each instance through this enum as it walks the
* 4-phase lifecycle. `disabled` is also the terminal post-shutdown state.
*/
export type PluginState =
| "pending"
| "registered"
| "initializing"
| "initialized"
| "ready"
| "disabled"
| "error";
/**
* Lifecycle hook signature — every hook receives a `PluginContext` typed
* for the plugin's `TConfig` and is `async`-safe.
*/
export type PluginLifecycleHook<TConfig> = (
ctx: PluginContext<TConfig>
) => void | Promise<void>;
/**
* Plugin descriptor — the value returned by `definePlugin()` and
* accepted by `PluginRegistry.register()`. Plain data (no runtime state)
* so it is safe to pass around, snapshot, and serialise minus the hook
* functions.
*/
export interface PluginDescriptor<TConfig = unknown> {
/** Unique plugin identifier — used for dep resolution. */
readonly name: string;
/** Semver string. */
readonly version: string;
/** Human-readable description. */
readonly description?: string;
/** Required dependencies (other plugin names). */
readonly dependencies?: readonly string[];
/** Optional dependencies — missing deps do not fail registration. */
readonly optionalDependencies?: readonly string[];
/** Higher priority loads first among same-level deps. */
readonly priority?: number;
/** Default-enabled flag (consumed by orchestrators, not this package). */
readonly enabledByDefault?: boolean;
/** Zod schema for config validation at registration time. */
readonly configSchema?: ZodType<TConfig>;
/** Partial defaults merged with caller-supplied config. */
readonly defaults?: Partial<TConfig>;
/** Free-form tags for categorisation. */
readonly tags?: readonly string[];
// ---- Lifecycle hooks (4 phases) ----
readonly onRegister?: PluginLifecycleHook<TConfig>;
readonly onInit?: PluginLifecycleHook<TConfig>;
readonly onReady?: PluginLifecycleHook<TConfig>;
readonly onClose?: PluginLifecycleHook<TConfig>;
}
/**
* Plugin instance held by the registry — couples the immutable descriptor
* with mutable runtime state (current phase, merged config, timestamps).
*/
export interface PluginInstance<TConfig = unknown> {
readonly plugin: PluginDescriptor<TConfig>;
state: PluginState;
config: TConfig;
error?: Error;
loadedAt?: number;
initializedAt?: number;
readyAt?: number;
closedAt?: number;
}
/**
* Generic dependency-graph node consumed by `topologicalSort`. The
* registry adapts `PluginInstance` to `PluginNode<PluginInstance>` at
* sort time.
*/
export interface PluginNode<T> {
readonly id: string;
readonly data: T;
readonly dependencies: readonly string[];
}
/**
* Result of {@link topologicalSort}. `sorted` is in dep order;
* `unresolved` lists missing required deps (the caller decides whether to
* fail strict).
*/
export interface TopologicalSortResult<T> {
readonly sorted: ReadonlyArray<PluginNode<T>>;
readonly unresolved: readonly string[];
}

View File

@@ -0,0 +1,8 @@
export type {
PluginDescriptor,
PluginInstance,
PluginLifecycleHook,
PluginNode,
PluginState,
TopologicalSortResult,
} from "./api-plugin.js";

View File

@@ -0,0 +1,119 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = new URL("..", import.meta.url).pathname;
const PKG_JSON = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as {
dependencies: Record<string, string>;
exports: Record<string, unknown>;
};
const RUNTIME_ALLOWLIST = new Set([
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
]);
const SRC_ROOT = join(PKG_ROOT, "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const st = statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
const FEATURE_MODULES = [
"error-codes",
"types",
"topo-sort",
"service-registry",
"context",
"hook-executor",
"define",
"registry",
];
describe("dep-graph", () => {
it("dependencies match the universal allowlist", { timeout: 5000 }, () => {
for (const dep of Object.keys(PKG_JSON.dependencies)) {
expect(RUNTIME_ALLOWLIST.has(dep)).toBe(true);
}
});
it("subpath exports cover every feature module", { timeout: 5000 }, () => {
const exports = PKG_JSON.exports;
for (const m of FEATURE_MODULES) {
expect(exports[`./${m}`]).toBeDefined();
}
expect(exports["."]).toBeDefined();
});
it("every implementation .ts has a sibling .md", { timeout: 5000 }, () => {
const files = walk(SRC_ROOT).filter(
(f) =>
f.endsWith(".ts") &&
!f.endsWith(".test.ts") &&
!f.endsWith(".types.ts") &&
!f.endsWith(".bench.ts") &&
!f.endsWith(".spec-conformance.test.ts") &&
!/[/\\]index\.ts$/.test(f) &&
!/[/\\]src[/\\]index\.ts$/.test(f)
);
for (const f of files) {
const md = f.replace(/\.ts$/, ".md");
const has = (() => {
try {
statSync(md);
return true;
} catch {
return false;
}
})();
expect({ file: f, hasMd: has }).toEqual({
file: f,
hasMd: true,
});
}
});
it("no biome-ignore suppressions in src", { timeout: 5000 }, () => {
const files = walk(SRC_ROOT).filter((f) => f.endsWith(".ts"));
for (const f of files) {
const text = readFileSync(f, "utf8");
expect(text.includes("biome-ignore")).toBe(false);
}
});
it("no direct vitest/bun:test imports", { timeout: 5000 }, () => {
const files = walk(SRC_ROOT)
.concat(walk(join(PKG_ROOT, "tests")))
.filter((f) => f.endsWith(".ts"));
const banned =
/from\s+['"](vitest|bun:test)['"]|require\(\s*['"](vitest|bun:test)['"]\s*\)/;
for (const f of files) {
const text = readFileSync(f, "utf8");
if (banned.test(text)) {
throw new Error(`${f} imports vitest or bun:test directly`);
}
}
expect(true).toBe(true);
});
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
for (const m of FEATURE_MODULES) {
const idx = join(SRC_ROOT, m, "index.ts");
const text = readFileSync(idx, "utf8");
expect(text.includes("export *")).toBe(false);
}
});
});

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { topologicalSort } from "../src/topo-sort/topo-sort.js";
import type { PluginNode } from "../src/types/api-plugin.js";
/** Generate an acyclic dep graph: each node's deps only point to earlier ids. */
const acyclicGraphArb = fc
.array(fc.string({ minLength: 1, maxLength: 4 }), {
minLength: 1,
maxLength: 20,
})
.map((rawIds) => {
const ids = Array.from(new Set(rawIds));
return ids;
})
.filter((ids) => ids.length > 0)
.chain((ids) =>
fc
.tuple(
...ids.map((_, i) =>
fc.subarray(ids.slice(0, i), { maxLength: i })
)
)
.map((depsPerNode) =>
ids.map<PluginNode<null>>((id, i) => ({
id,
data: null,
dependencies: depsPerNode[i] ?? [],
}))
)
);
describe("topologicalSort — property tests", () => {
it("sorted order satisfies dep invariant", { timeout: 30000 }, () => {
fc.assert(
fc.property(acyclicGraphArb, (nodes) => {
const { sorted } = topologicalSort(nodes);
const positions = new Map<string, number>();
sorted.forEach((n, i) => positions.set(n.id, i));
for (const node of sorted) {
for (const dep of node.dependencies) {
const depPos = positions.get(dep);
const nodePos = positions.get(node.id);
// dep must appear before node (if dep is in the graph)
if (depPos !== undefined && nodePos !== undefined) {
expect(depPos).toBeLessThan(nodePos);
}
}
}
}),
{ numRuns: 200 }
);
});
it("sort is deterministic across repeated calls", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(acyclicGraphArb, (nodes) => {
const a = topologicalSort(nodes).sorted.map((n) => n.id);
const b = topologicalSort(nodes).sorted.map((n) => n.id);
expect(a).toEqual(b);
}),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,39 @@
/**
* Test-environment setup for @suaveplan/api-plugin.
*
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any
* source module is imported, so the meter resolved at module load
* is the real provider's meter — not the noop fallback. Tests that
* inspect emitted metrics (registry spans/counters) can read them
* out of `testInMemoryMetricExporter`.
*
* Node ≥18 already exposes `crypto.subtle` and `crypto.getRandomValues`
* globally, so no crypto polyfill is required. Universal: no
* `node:fs`, no `process.cwd()`.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});