Initial Commit
This commit is contained in:
119
packages/api-stack/api/api-plugin/README.md
Normal file
119
packages/api-stack/api/api-plugin/README.md
Normal 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.
|
||||
|
||||
109
packages/api-stack/api/api-plugin/package.json
Normal file
109
packages/api-stack/api/api-plugin/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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]
|
||||
>();
|
||||
});
|
||||
});
|
||||
111
packages/api-stack/api/api-plugin/src/context/context.md
Normal file
111
packages/api-stack/api/api-plugin/src/context/context.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# context
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `context` module ships the runtime object every plugin lifecycle
|
||||
hook receives. `PluginContext<TConfig>` carries the structural `logger`
|
||||
and `eventBus` (typed against the universal contracts in
|
||||
`@suaveplan/types`), the in-flight `signal` (an `AbortSignal` the
|
||||
registry aborts on timeout or shutdown), the plugin's own merged config
|
||||
via `getOwnConfig()`, and the typed `registerService`/`getService`/
|
||||
`hasService` trio backed by the shared `ServiceRegistry` augmentation
|
||||
surface.
|
||||
|
||||
`createPluginContext` is the constructor — the registry calls it once
|
||||
per plugin per lifecycle phase, binding that plugin's config and the
|
||||
shared `services` Map. The Map instance is shared across every plugin
|
||||
in the same boot, so a producer plugin's `registerService` is visible
|
||||
to every later consumer's `getService`.
|
||||
|
||||
## Features
|
||||
|
||||
- Universal — no `node:fs`, no DOM, no Hono dependency. Works in
|
||||
Bun/Node/browser.
|
||||
- Typed `getService<K>` / `registerService<K>` via the augmentable
|
||||
`ServiceRegistry` interface; untyped string keys fail at compile time.
|
||||
- `signal: AbortSignal` for graceful cooperative cancellation — every
|
||||
lifecycle hook can branch on `ctx.signal.aborted` or thread the signal
|
||||
into long-running async work.
|
||||
- `getOwnConfig()` returns the calling plugin's `TConfig` with full
|
||||
schema inference from `definePlugin`.
|
||||
- Stateless — no internal closures keep references after the hook
|
||||
returns; the `services` Map lifetime is owned by the registry.
|
||||
- No `getPluginConfig(name)` cross-plugin coupling (unlike upstream).
|
||||
Reading another plugin's config goes through a typed service.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
A plugin hook reading its own config and registering a service:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@suaveplan/api-plugin";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry { http: { port: number } }
|
||||
}
|
||||
|
||||
export const httpPlugin = definePlugin<{ port: number }>({
|
||||
name: "http",
|
||||
version: "0.1.0",
|
||||
defaults: { port: 3000 },
|
||||
onInit(ctx) {
|
||||
const cfg = ctx.getOwnConfig();
|
||||
ctx.registerService("http", { port: cfg.port });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A consumer plugin observing the abort signal during async setup:
|
||||
|
||||
```ts
|
||||
export const slowPlugin = definePlugin({
|
||||
name: "slow",
|
||||
version: "0.1.0",
|
||||
dependencies: ["http"],
|
||||
async onReady(ctx) {
|
||||
const http = ctx.getService("http");
|
||||
if (!http) return;
|
||||
ctx.logger.info("warming up");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = () => reject(new Error("aborted"));
|
||||
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
||||
setTimeout(() => {
|
||||
ctx.signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature |
|
||||
|---|---|
|
||||
| `createPluginContext<TConfig>(options)` | `(CreatePluginContextOptions<TConfig>) => PluginContext<TConfig>` |
|
||||
| `CreatePluginContextOptions<TConfig>` | `{ logger, eventBus, signal, config, services }` |
|
||||
| `PluginContext<TConfig>` | `{ logger, eventBus, signal, getOwnConfig(), registerService<K>, getService<K>, hasService }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shared `services` Map is intentionally not snapshotted or copied
|
||||
between phases — that would defeat the producer/consumer model. The
|
||||
registry passes the same reference into every call to
|
||||
`createPluginContext`, so registration order across phases is
|
||||
preserved.
|
||||
|
||||
`registerService` casts the `K` parameter to `string` once when writing
|
||||
to the Map; everywhere else the API is typed against
|
||||
`ServiceRegistry[K]`. Because consumers augment via `declare module`,
|
||||
the cast is the only place the runtime "knows" services are
|
||||
string-keyed — every public site is typed.
|
||||
|
||||
`signal` is read-only on the context; the registry holds the
|
||||
`AbortController` and decides when to abort (timeout / shutdown). Hooks
|
||||
must never construct their own signal and feed it back into the context.
|
||||
118
packages/api-stack/api/api-plugin/src/context/context.test.ts
Normal file
118
packages/api-stack/api/api-plugin/src/context/context.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
66
packages/api-stack/api/api-plugin/src/context/context.ts
Normal file
66
packages/api-stack/api/api-plugin/src/context/context.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
packages/api-stack/api/api-plugin/src/context/index.ts
Normal file
3
packages/api-stack/api/api-plugin/src/context/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { CreatePluginContextOptions } from "./context.js";
|
||||
export { createPluginContext } from "./context.js";
|
||||
export type { PluginContext } from "./context.types.js";
|
||||
96
packages/api-stack/api/api-plugin/src/define/define.md
Normal file
96
packages/api-stack/api/api-plugin/src/define/define.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# define
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `define` module exports a single function — `definePlugin<TConfig>(descriptor)`
|
||||
— that is the canonical entry point for authoring a plugin. The function
|
||||
returns its input unchanged at runtime; the value is entirely in its
|
||||
type signature, which threads the `TConfig` generic through every place
|
||||
the descriptor references it: `configSchema?: ZodType<TConfig>`,
|
||||
`defaults?: Partial<TConfig>`, and the four lifecycle hooks
|
||||
(`onRegister/onInit/onReady/onClose`). Inside each hook,
|
||||
`ctx.getOwnConfig()` returns `TConfig` with no cast required.
|
||||
|
||||
Using `definePlugin` is preferred over building a raw `PluginDescriptor`
|
||||
literal because it pins the generic at the call site. TypeScript will
|
||||
then flag mismatches at the source of the error (the hook body), not at
|
||||
the registration site one or two files away.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime overhead — the function body is `return descriptor`.
|
||||
- Full `TConfig` inference flows into hooks via `PluginContext<TConfig>`.
|
||||
- Compatible with descriptor field defaults (`description`, `dependencies`,
|
||||
`optionalDependencies`, `priority`, `enabledByDefault`, `tags`).
|
||||
- Plays nicely with `as const` literals — readers preserve `readonly`
|
||||
arrays for `dependencies` etc.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@suaveplan/api-plugin/define";
|
||||
import z from "zod";
|
||||
|
||||
export const auth = definePlugin({
|
||||
name: "auth",
|
||||
version: "0.1.0",
|
||||
configSchema: z.object({ sessionTtlSeconds: z.number().int().positive() }),
|
||||
defaults: { sessionTtlSeconds: 3600 },
|
||||
onInit(ctx) {
|
||||
const { sessionTtlSeconds } = ctx.getOwnConfig();
|
||||
ctx.logger.info("auth ready", { sessionTtlSeconds });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Multiple hooks with `await` and the abort signal:
|
||||
|
||||
```ts
|
||||
export const queue = definePlugin<{ concurrency: number }>({
|
||||
name: "queue",
|
||||
version: "0.1.0",
|
||||
dependencies: ["db"],
|
||||
defaults: { concurrency: 4 },
|
||||
async onInit(ctx) {
|
||||
const { concurrency } = ctx.getOwnConfig();
|
||||
ctx.logger.info("starting queue", { concurrency });
|
||||
},
|
||||
async onReady(ctx) {
|
||||
if (ctx.signal.aborted) return;
|
||||
ctx.logger.info("queue ready");
|
||||
},
|
||||
async onClose(ctx) {
|
||||
ctx.logger.info("queue draining");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature |
|
||||
|---|---|
|
||||
| `definePlugin<TConfig>(descriptor)` | `(PluginDescriptor<TConfig>) => PluginDescriptor<TConfig>` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function is intentionally an identity at runtime so it can be
|
||||
constant-folded by bundlers — the dependency graph rewards leaf
|
||||
helpers that the optimiser can drop. Because the `descriptor` parameter
|
||||
is typed as `PluginDescriptor<TConfig>`, TypeScript will not narrow
|
||||
`TConfig` away if the call site supplies an explicit generic
|
||||
(`definePlugin<MyConfig>({...})`). Implicit inference also works
|
||||
when `configSchema` is provided as a Zod schema, because the
|
||||
descriptor field is typed `ZodType<TConfig>` and Zod's schema generic
|
||||
is contravariant in this position.
|
||||
|
||||
Calling `definePlugin` is preferred over a bare object literal even
|
||||
though both are runtime-equivalent: the call site is the right place
|
||||
for an editor to surface "Quick Info" on the generic, and it keeps the
|
||||
public author surface consistent across the 40+ in-flight
|
||||
`@suaveplan/api-plugin-*` packages.
|
||||
53
packages/api-stack/api/api-plugin/src/define/define.test.ts
Normal file
53
packages/api-stack/api/api-plugin/src/define/define.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
19
packages/api-stack/api/api-plugin/src/define/define.ts
Normal file
19
packages/api-stack/api/api-plugin/src/define/define.ts
Normal 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;
|
||||
}
|
||||
1
packages/api-stack/api/api-plugin/src/define/index.ts
Normal file
1
packages/api-stack/api/api-plugin/src/define/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { definePlugin } from "./define.js";
|
||||
@@ -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 }`).
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { PluginErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
PLUGIN_CODES,
|
||||
PluginError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
HookHandler,
|
||||
LifecycleHookName,
|
||||
} from "./hook-executor.js";
|
||||
export {
|
||||
createHookExecutor,
|
||||
HookExecutor,
|
||||
LIFECYCLE_HOOK_NAMES,
|
||||
} from "./hook-executor.js";
|
||||
49
packages/api-stack/api/api-plugin/src/index.ts
Normal file
49
packages/api-stack/api/api-plugin/src/index.ts
Normal 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";
|
||||
10
packages/api-stack/api/api-plugin/src/registry/index.ts
Normal file
10
packages/api-stack/api/api-plugin/src/registry/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
PluginRegistryOptions,
|
||||
ScheduleTimer,
|
||||
ScheduleTimerHandle,
|
||||
SeedServicesMap,
|
||||
} from "./registry.js";
|
||||
export {
|
||||
createPluginRegistry,
|
||||
PluginRegistry,
|
||||
} from "./registry.js";
|
||||
144
packages/api-stack/api/api-plugin/src/registry/registry.md
Normal file
144
packages/api-stack/api/api-plugin/src/registry/registry.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `registry` module owns `PluginRegistry`, the single canonical
|
||||
orchestrator for the plugin system. Its job is to:
|
||||
|
||||
1. Accept descriptor registrations (`register`/`registerAll`), validating
|
||||
each plugin's merged config against its Zod `configSchema` at the
|
||||
boundary.
|
||||
2. Resolve dependencies — `validateDependencies` runs first, then
|
||||
`topologicalSort` produces the deterministic lifecycle order.
|
||||
3. Walk every registered plugin through the four-phase lifecycle:
|
||||
`onRegister → onInit → onReady` forward, then `onClose` in reverse.
|
||||
4. Enforce per-hook timeouts — `initTimeout` (default 30000ms) for the
|
||||
forward phases, `shutdownTimeout` (default 10000ms) for `onClose`.
|
||||
When a hook exceeds its budget, the plugin's `ctx.signal` aborts and
|
||||
the registry rejects with `PluginError(PLUGIN.INIT_TIMEOUT)` or
|
||||
`PluginError(PLUGIN.SHUTDOWN_FAILED)`.
|
||||
5. Wrap every hook invocation in an OTel span (`plugin.<hook>`) so
|
||||
downstream observability stacks see plugin boot latency without
|
||||
instrumentation in each plugin.
|
||||
|
||||
The registry deliberately does NOT auto-apply migrations (that was the
|
||||
upstream design; here, migrations live in `@suaveplan/api-migrations`)
|
||||
and does NOT dispatch lifecycle hooks via the `HookExecutor` — those
|
||||
are owned here, called exactly once per plugin per phase.
|
||||
|
||||
## Features
|
||||
|
||||
- 4-phase lifecycle with strict dep ordering.
|
||||
- `initTimeout`/`shutdownTimeout` enforced via `AbortSignal`.
|
||||
- Zod-validated config at the boundary (`PLUGIN.INVALID_CONFIG`).
|
||||
- Topological sort + cycle detection (`PLUGIN.CIRCULAR_DEPENDENCY`).
|
||||
- Required-dep validation (`PLUGIN.DEP_MISSING`); optional-strict mode.
|
||||
- `disable(name)` for runtime plugin shutdown.
|
||||
- OTel span per hook (`plugin.onRegister`, `plugin.onInit`, …).
|
||||
- Shared `services` Map across all hooks for `registerService`/`getService`.
|
||||
- `seedServices(map)` for host-side bulk-registration of canonical
|
||||
service slots **before** plugin init — the seeded entries appear in
|
||||
every plugin's `ctx.getService(name)` from the very first `onRegister`
|
||||
call. Idempotent; last write wins. Raises `PLUGIN.INVALID_STATE` when
|
||||
called after `initialize()` has begun.
|
||||
- Lifecycle events emitted via the injected (or built-in) `EventBus`
|
||||
(`plugin:registering`, `plugin:onRegister`, `plugin:onInit`,
|
||||
`plugin:onReady`, `plugin:closed`, `plugin:disabled`).
|
||||
- Exposed `getHookExecutor()` for sibling packages contributing
|
||||
non-lifecycle hooks.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createPluginRegistry } from "@suaveplan/api-plugin/registry";
|
||||
import { definePlugin } from "@suaveplan/api-plugin/define";
|
||||
|
||||
const registry = createPluginRegistry({ initTimeout: 5_000 });
|
||||
|
||||
registry.registerAll([
|
||||
definePlugin({ name: "db", version: "0.1.0", onInit(ctx) { ctx.registerService("db", makeDb()); } }),
|
||||
definePlugin({ name: "api", version: "0.1.0", dependencies: ["db"], async onReady(ctx) { ctx.logger.info("api ready"); } }),
|
||||
]);
|
||||
|
||||
await registry.initialize();
|
||||
// later …
|
||||
await registry.shutdown();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Injected logger, event bus, and clock for testable boot logs:
|
||||
|
||||
```ts
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const registry = createPluginRegistry({
|
||||
initTimeout: 500,
|
||||
logger: structuredLogger,
|
||||
eventBus: new EventBus(),
|
||||
clock: () => fakeNow,
|
||||
strictOptionalDeps: true,
|
||||
});
|
||||
|
||||
registry.getEventBus().on("plugin:onReady", ({ name }) => {
|
||||
console.log("ready:", name);
|
||||
});
|
||||
|
||||
await registry.initialize();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Member | Description |
|
||||
|---|---|
|
||||
| `new PluginRegistry(options?)` / `createPluginRegistry(options?)` | Construct |
|
||||
| `.register(descriptor, config?)` | Add a plugin |
|
||||
| `.registerAll(descriptors)` | Bulk-add |
|
||||
| `.seedServices(map)` | Bulk-seed canonical host services into the shared registry before plugin init. Throws `PLUGIN.INVALID_STATE` post-`initialize()`. |
|
||||
| `.get(name)` / `.has(name)` / `.getAll()` | Inspect |
|
||||
| `.getSortedPlugins()` | Run validation + topo-sort and return ordered instances |
|
||||
| `.initialize()` | Four-phase lifecycle in dep order |
|
||||
| `.shutdown()` | `onClose` in reverse dep order |
|
||||
| `.disable(name)` | Single-plugin teardown |
|
||||
| `.getEventBus()` / `.getHookExecutor()` | Plumbing access |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Timeouts are enforced with an injectable `ScheduleTimer` so tests can
|
||||
swap the global `setTimeout` for a deterministic fake. The default
|
||||
binds `globalThis.setTimeout`/`clearTimeout`, which exist in every
|
||||
target runtime (Bun, Node, browser). The Promise race is implemented
|
||||
manually rather than via `Promise.race` so the abort path can also
|
||||
mark the plugin's `ctx.signal` aborted before the timeout error
|
||||
propagates — hooks that observe the signal see it in the "aborted"
|
||||
state by the time they re-enter the event loop.
|
||||
|
||||
`runPhase` distinguishes between `PluginError` (passed through unmodified
|
||||
so the original code is preserved) and arbitrary throws (wrapped as
|
||||
`PLUGIN.INIT_FAILED` with the original error attached via `cause`).
|
||||
That keeps the typed-code contract — every leaf error has a
|
||||
`PLUGIN.*` code — while still preserving the underlying diagnostic.
|
||||
|
||||
`shutdown` collects every per-plugin failure rather than aborting on
|
||||
the first, so a misbehaving plugin near the start of the reverse-order
|
||||
walk does not block subsequent cleanups. After the walk completes, the
|
||||
registry throws `PLUGIN.SHUTDOWN_FAILED` if any plugin failed — the
|
||||
caller can inspect `context.failures` for the full list.
|
||||
|
||||
The OTel tracer is resolved via `trace.getTracer("@suaveplan/api-plugin")`
|
||||
at construction time. When no SDK is registered the call returns the
|
||||
noop tracer, so production deployments without OTel pay zero overhead.
|
||||
|
||||
`seedServices` keeps its entries in an internal `seededServices: Map<
|
||||
string, unknown>` and only copies them into the shared per-context
|
||||
`services` map at the start of `initialize()` — i.e. **before** the
|
||||
first `onRegister`. Plugin-side `registerService` calls overlay on the
|
||||
same shared map, so later overlays are visible to siblings that come
|
||||
after them in dep order. The `initializing` guard rejects re-entrant
|
||||
seeds during the lifecycle walk because the per-context snapshot has
|
||||
already been built, and accepting the seed silently would create a
|
||||
state where some plugins see the seed and others do not.
|
||||
627
packages/api-stack/api/api-plugin/src/registry/registry.test.ts
Normal file
627
packages/api-stack/api/api-plugin/src/registry/registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
576
packages/api-stack/api/api-plugin/src/registry/registry.ts
Normal file
576
packages/api-stack/api/api-plugin/src/registry/registry.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
ServiceKey,
|
||||
ServiceRegistry,
|
||||
} from "./service-registry.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
4
packages/api-stack/api/api-plugin/src/topo-sort/index.ts
Normal file
4
packages/api-stack/api/api-plugin/src/topo-sort/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
topologicalSort,
|
||||
validateDependencies,
|
||||
} from "./topo-sort.js";
|
||||
96
packages/api-stack/api/api-plugin/src/topo-sort/topo-sort.md
Normal file
96
packages/api-stack/api/api-plugin/src/topo-sort/topo-sort.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# topo-sort
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `topo-sort` module owns the deterministic dependency-ordering primitive
|
||||
the `PluginRegistry` uses to schedule lifecycle hook execution. Given a
|
||||
list of `PluginNode<T>` records describing each plugin's id and required
|
||||
dependencies, `topologicalSort` returns a list ordered so every node
|
||||
appears after every one of its declared dependencies. The implementation
|
||||
is Kahn's algorithm with an alphabetical tie-break, so two runs with the
|
||||
same input always produce byte-identical output — critical for
|
||||
reproducible boot logs and snapshot tests downstream.
|
||||
|
||||
The module also exports `validateDependencies`, a non-sorting cousin
|
||||
that simply checks whether every required dep exists in the input set.
|
||||
The registry runs this first when `strictOptionalDeps` is set; the sort
|
||||
runs second.
|
||||
|
||||
## Features
|
||||
|
||||
- **Kahn's algorithm** — O(V + E) topological order, no recursion-depth
|
||||
limit.
|
||||
- **Alphabetical tie-break** — equal-in-degree nodes process in sorted
|
||||
`id` order, so the result is independent of registration order.
|
||||
- **Unresolved dep collection** — required deps that are not in the
|
||||
input set are returned in `unresolved` (sorted, deduplicated).
|
||||
- **Cycle detection via DFS** — when the input contains a cycle, the
|
||||
sort throws `PluginError(PLUGIN.CIRCULAR_DEPENDENCY)` whose
|
||||
`context.cycle` holds the cycle path including both endpoints
|
||||
(`["a", "b", "c", "a"]`).
|
||||
- **Pure** — no side effects, no Clock/RNG/IO dependencies.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { topologicalSort } from "@suaveplan/api-plugin/topo-sort";
|
||||
|
||||
const { sorted, unresolved } = topologicalSort([
|
||||
{ id: "b", data: null, dependencies: ["a"] },
|
||||
{ id: "a", data: null, dependencies: [] },
|
||||
]);
|
||||
// sorted.map(n => n.id) === ["a", "b"]
|
||||
// unresolved === []
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Detecting a cycle and recovering the path:
|
||||
|
||||
```ts
|
||||
import { topologicalSort } from "@suaveplan/api-plugin/topo-sort";
|
||||
import { PluginError } from "@suaveplan/api-plugin/error-codes";
|
||||
|
||||
try {
|
||||
topologicalSort([
|
||||
{ id: "a", data: null, dependencies: ["b"] },
|
||||
{ id: "b", data: null, dependencies: ["c"] },
|
||||
{ id: "c", data: null, dependencies: ["a"] },
|
||||
]);
|
||||
} catch (e) {
|
||||
if (e instanceof PluginError && e.code === "PLUGIN.CIRCULAR_DEPENDENCY") {
|
||||
console.error("cycle:", (e.context as { cycle: string[] }).cycle);
|
||||
// ["a", "b", "c", "a"] (start node depends on input + alpha sort)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature |
|
||||
|---|---|
|
||||
| `topologicalSort<T>(nodes)` | `(ReadonlyArray<PluginNode<T>>) => TopologicalSortResult<T>` — throws on cycle |
|
||||
| `validateDependencies<T>(nodes)` | `(ReadonlyArray<PluginNode<T>>) => { valid: boolean; missing: ReadonlyMap<string, readonly string[]> }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The alphabetical tie-break is implemented by sorting the `ready` queue
|
||||
every time new nodes become in-degree-zero, not by sorting the input
|
||||
once. Sorting once is insufficient because Kahn's discovers
|
||||
ready-nodes incrementally as it drains the graph — two nodes that
|
||||
become ready at different iterations would still be processed in
|
||||
discovery order. The repeated sort is O(k log k) where k is the
|
||||
current queue size; total work stays O(V log V + E).
|
||||
|
||||
The cycle detector uses an iterative DFS rooted at the alphabetically
|
||||
first unvisited node, then sorts each node's outgoing dependencies
|
||||
before recursion so the discovered cycle path is also deterministic.
|
||||
That makes the error message stable across runs and easy to snapshot.
|
||||
|
||||
`unresolved` is returned as a sorted, deduplicated array so consumers
|
||||
can render diagnostic messages without further normalisation. The
|
||||
registry decides what to do — strict mode throws, lenient mode logs.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
142
packages/api-stack/api/api-plugin/src/topo-sort/topo-sort.ts
Normal file
142
packages/api-stack/api/api-plugin/src/topo-sort/topo-sort.ts
Normal 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 };
|
||||
}
|
||||
90
packages/api-stack/api/api-plugin/src/types/api-plugin.md
Normal file
90
packages/api-stack/api/api-plugin/src/types/api-plugin.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# types — api-plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `types` module holds every shared shape used across the api-plugin
|
||||
package: the immutable `PluginDescriptor<TConfig>` returned by
|
||||
`definePlugin`, the mutable `PluginInstance<TConfig>` held by the
|
||||
registry, the seven-state `PluginState` enum that describes lifecycle
|
||||
progression, the generic `PluginNode<T>` consumed by the topological
|
||||
sorter, and the typed `PluginLifecycleHook<TConfig>` callback signature.
|
||||
Everything in this file is types-only — no runtime, no side effects.
|
||||
|
||||
Splitting types into a dedicated module keeps every consumer
|
||||
(`registry`, `define`, `context`, `topo-sort`, `hook-executor`) on the
|
||||
same source of truth without circular-import gymnastics. Augmenting
|
||||
`@suaveplan/api-plugin/types` is not the supported extension surface
|
||||
(`service-registry` handles that), but importing from it is the canonical
|
||||
way for sibling packages (api-tenant, api-openapi, api-migrations) to
|
||||
type their own surfaces against the foundation contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `PluginState` — seven-value union covering the full lifecycle.
|
||||
- `PluginDescriptor<TConfig>` — name, version, dependencies, optional
|
||||
dependencies, priority, enabledByDefault, configSchema (`ZodType`),
|
||||
defaults, tags, plus the four lifecycle hooks
|
||||
(`onRegister/onInit/onReady/onClose`).
|
||||
- `PluginInstance<TConfig>` — descriptor + mutable state + timestamps.
|
||||
- `PluginNode<T>` — dep-graph adapter shape consumed by `topologicalSort`.
|
||||
- `PluginLifecycleHook<TConfig>` — `(ctx: PluginContext<TConfig>) => void | Promise<void>`.
|
||||
- `TopologicalSortResult<T>` — `{ sorted, unresolved }` tuple.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { PluginDescriptor, PluginInstance } from "@suaveplan/api-plugin/types";
|
||||
|
||||
const descriptor: PluginDescriptor<{ port: number }> = {
|
||||
name: "http",
|
||||
version: "0.1.0",
|
||||
defaults: { port: 3000 },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Typing a custom registry-like adapter:
|
||||
|
||||
```ts
|
||||
import type { PluginInstance, PluginNode } from "@suaveplan/api-plugin/types";
|
||||
|
||||
function toNodes(
|
||||
instances: ReadonlyArray<PluginInstance>
|
||||
): ReadonlyArray<PluginNode<PluginInstance>> {
|
||||
return instances.map((i) => ({
|
||||
id: i.plugin.name,
|
||||
data: i,
|
||||
dependencies: i.plugin.dependencies ?? [],
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `PluginState` | type | `"pending" \| "registered" \| "initializing" \| "initialized" \| "ready" \| "disabled" \| "error"` |
|
||||
| `PluginLifecycleHook<TConfig>` | type | `(ctx: PluginContext<TConfig>) => void \| Promise<void>` |
|
||||
| `PluginDescriptor<TConfig>` | interface | Plain data + 4 lifecycle hooks |
|
||||
| `PluginInstance<TConfig>` | interface | `{ plugin, state, config, error?, loadedAt?, initializedAt?, readyAt?, closedAt? }` |
|
||||
| `PluginNode<T>` | interface | `{ id, data, dependencies }` |
|
||||
| `TopologicalSortResult<T>` | interface | `{ sorted, unresolved }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`PluginDescriptor` is declared with `readonly` on every field including
|
||||
the hook callbacks so consumers cannot mutate descriptors at runtime — a
|
||||
common upstream bug source where one plugin clobbered another's
|
||||
`onInit`. `PluginInstance` keeps its mutable fields plain (no `readonly`)
|
||||
because the registry updates them as it walks the lifecycle.
|
||||
|
||||
Timestamps are `number` (epoch-ms) rather than `Date` so the registry can
|
||||
inject a `Clock` from `@suaveplan/types` (`() => number`) and pass-through
|
||||
without conversion. The `TConfig` generic flows from `definePlugin` →
|
||||
descriptor → instance → context → `getOwnConfig()` so consumers never
|
||||
need to cast.
|
||||
96
packages/api-stack/api/api-plugin/src/types/api-plugin.ts
Normal file
96
packages/api-stack/api/api-plugin/src/types/api-plugin.ts
Normal 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[];
|
||||
}
|
||||
8
packages/api-stack/api/api-plugin/src/types/index.ts
Normal file
8
packages/api-stack/api/api-plugin/src/types/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
PluginDescriptor,
|
||||
PluginInstance,
|
||||
PluginLifecycleHook,
|
||||
PluginNode,
|
||||
PluginState,
|
||||
TopologicalSortResult,
|
||||
} from "./api-plugin.js";
|
||||
119
packages/api-stack/api/api-plugin/tests/dep-graph.test.ts
Normal file
119
packages/api-stack/api/api-plugin/tests/dep-graph.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
67
packages/api-stack/api/api-plugin/tests/property.test.ts
Normal file
67
packages/api-stack/api/api-plugin/tests/property.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
39
packages/api-stack/api/api-plugin/tests/setup/polyfills.ts
Normal file
39
packages/api-stack/api/api-plugin/tests/setup/polyfills.ts
Normal 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;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
35
packages/api-stack/api/api-plugin/vite.config.ts
Normal file
35
packages/api-stack/api/api-plugin/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/api-stack/api/api-plugin/vitest.config.ts
Normal file
32
packages/api-stack/api/api-plugin/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user