Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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