Initial Commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# `define-plugin` — author-facing plugin DX
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`definePlugin` is the canonical entry point plugin authors call to declare their `name` / `version` / `requires` / `isolation` / `trustLevel` plus optional lifecycle hooks (`install`, `start`, `stop`, `uninstall`, `health`). It threads the literal-array `requires` through to the `PluginContext<Caps>` and `RequestContext<Caps>` so undeclared capability access becomes a compile-time TypeScript error — the type system, not a runtime guard, is what enforces the capability surface.
|
||||
|
||||
The runtime work is light: validate `name` / `version` are non-empty, reject duplicate ids inside `requires`, and return a frozen record. The heavy lifting (manifest cross-check, capability resolution, FSM hand-off) lives in the host's lifecycle driver; `definePlugin` is the type-safety + ergonomics layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `const Caps extends ReadonlyArray<CapabilityId>` generic — captures literal-array `requires` so `ctx.<cap>` is narrowed exactly to the declared ids.
|
||||
- Synchronous validation for the two failure modes the type system cannot catch: empty `name`, empty `version`, duplicate id.
|
||||
- Frozen output record — safe to hash for the rollback journal.
|
||||
- Lightweight `DefinePluginError` (plain `Error` subclass) — no `SuaveplanError` dependency on the hot author-side path.
|
||||
- Type aliases `PluginContextOf<P>` / `RequestContextOf<P>` derive the per-hook context shape from the module record.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "@suaveplan/api-host/define-plugin";
|
||||
|
||||
export default definePlugin({
|
||||
name: "billing",
|
||||
version: "1.2.3",
|
||||
requires: ["db", "auth.session", "logger"] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
async start(ctx) {
|
||||
ctx.capabilities.logger.info("billing: started");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Derive request-context type for downstream router wiring:
|
||||
|
||||
```ts
|
||||
import {
|
||||
definePlugin,
|
||||
type RequestContextOf,
|
||||
} from "@suaveplan/api-host/define-plugin";
|
||||
|
||||
const plugin = definePlugin({
|
||||
name: "billing",
|
||||
version: "1.2.3",
|
||||
requires: ["db", "auth.session"] as const,
|
||||
isolation: "isolated",
|
||||
trustLevel: "third-party",
|
||||
async install(ctx) {
|
||||
// ctx.capabilities.db is `AdminDatabaseLike` here.
|
||||
await ctx.capabilities.db.execute("INSERT INTO ...");
|
||||
},
|
||||
async start(ctx) {
|
||||
ctx.capabilities.db; // ok
|
||||
// ctx.capabilities.events; // <- compile error: "events" not declared
|
||||
},
|
||||
});
|
||||
|
||||
type Req = RequestContextOf<typeof plugin>;
|
||||
// Req.capabilities.db is `TenantScopedDatabaseLike` here.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `definePlugin<const Caps>(spec): PluginModule<Caps>` — validates + freezes.
|
||||
- `DefinePluginSpec<Caps>` — input shape with optional lifecycle hooks.
|
||||
- `PluginModule<Caps>` — `Readonly<DefinePluginSpec<Caps>>`.
|
||||
- `PluginContextOf<P>` / `RequestContextOf<P>` — per-module derived context types.
|
||||
- `DefinePluginError` — `code` ∈ `DEFINE_PLUGIN.INVALID_NAME` | `DEFINE_PLUGIN.INVALID_VERSION` | `DEFINE_PLUGIN.DUPLICATE_CAPABILITY`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `const Caps extends ReadonlyArray<CapabilityId>` constraint relies on TypeScript 5.0+ `const` generic inference — callers MUST write `requires: ["db"] as const` (or `satisfies CapabilityRequirements`) for the narrowing to work. Without `as const`, TypeScript widens the tuple to `string[]` and the capability map degrades to the full `CapabilityId` union. The validation is intentionally minimal: deeper manifest-vs-module cross-check is the host loader's job (Chunk B+) so author errors surface in two places — fast / type-only here, slow / runtime + manifest-aware there. The frozen output ensures the rollback journal can content-hash a stable representation.
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DefinePluginError, definePlugin } from "./define-plugin.js";
|
||||
|
||||
describe("definePlugin", () => {
|
||||
it("freezes and returns the spec record", { timeout: 5000 }, () => {
|
||||
const mod = definePlugin({
|
||||
name: "p",
|
||||
version: "1.0.0",
|
||||
requires: ["db", "auth.session"] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
start: () => undefined,
|
||||
});
|
||||
expect(mod.name).toBe("p");
|
||||
expect(mod.version).toBe("1.0.0");
|
||||
expect(mod.requires).toEqual(["db", "auth.session"]);
|
||||
expect(Object.isFrozen(mod)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an empty requires tuple", { timeout: 5000 }, () => {
|
||||
const mod = definePlugin({
|
||||
name: "empty-caps",
|
||||
version: "0.1.0",
|
||||
requires: [] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
start: () => undefined,
|
||||
});
|
||||
expect(mod.requires).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an empty name", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
name: "",
|
||||
version: "1.0.0",
|
||||
requires: [] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
start: () => undefined,
|
||||
})
|
||||
).toThrow(DefinePluginError);
|
||||
try {
|
||||
definePlugin({
|
||||
name: "",
|
||||
version: "1.0.0",
|
||||
requires: [] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
start: () => undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(DefinePluginError);
|
||||
expect((e as DefinePluginError).code).toBe(
|
||||
"DEFINE_PLUGIN.INVALID_NAME"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty version", { timeout: 5000 }, () => {
|
||||
try {
|
||||
definePlugin({
|
||||
name: "p",
|
||||
version: "",
|
||||
requires: [] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
start: () => undefined,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(DefinePluginError);
|
||||
expect((e as DefinePluginError).code).toBe(
|
||||
"DEFINE_PLUGIN.INVALID_VERSION"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate capability ids", { timeout: 5000 }, () => {
|
||||
try {
|
||||
definePlugin({
|
||||
name: "p",
|
||||
version: "1.0.0",
|
||||
requires: ["db", "db"] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
start: () => undefined,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(DefinePluginError);
|
||||
expect((e as DefinePluginError).code).toBe(
|
||||
"DEFINE_PLUGIN.DUPLICATE_CAPABILITY"
|
||||
);
|
||||
expect((e as DefinePluginError).message).toContain("db");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves optional install/stop/uninstall/health hooks", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const mod = definePlugin({
|
||||
name: "p",
|
||||
version: "1.0.0",
|
||||
requires: [] as const,
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
install: () => {
|
||||
calls.push("install");
|
||||
},
|
||||
start: () => {
|
||||
calls.push("start");
|
||||
},
|
||||
stop: () => {
|
||||
calls.push("stop");
|
||||
},
|
||||
uninstall: () => {
|
||||
calls.push("uninstall");
|
||||
},
|
||||
health: () => {
|
||||
calls.push("health");
|
||||
},
|
||||
});
|
||||
const fakeCtx = {} as Parameters<NonNullable<typeof mod.install>>[0];
|
||||
await mod.install?.(fakeCtx);
|
||||
await mod.start(fakeCtx);
|
||||
await mod.stop?.(fakeCtx);
|
||||
await mod.uninstall?.(fakeCtx);
|
||||
await mod.health?.(fakeCtx);
|
||||
expect(calls).toEqual([
|
||||
"install",
|
||||
"start",
|
||||
"stop",
|
||||
"uninstall",
|
||||
"health",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinePluginError", () => {
|
||||
it("carries name and code", { timeout: 5000 }, () => {
|
||||
const e = new DefinePluginError("DEFINE_PLUGIN.INVALID_NAME", "x");
|
||||
expect(e.name).toBe("DefinePluginError");
|
||||
expect(e.code).toBe("DEFINE_PLUGIN.INVALID_NAME");
|
||||
expect(e.message).toBe("x");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import type {
|
||||
CapabilityId,
|
||||
CapabilityRequirements,
|
||||
IsolationTier,
|
||||
PluginContext,
|
||||
RequestContext,
|
||||
TrustLevel,
|
||||
} from "@suaveplan/api-types";
|
||||
|
||||
/**
|
||||
* Author-facing plugin author "spec" — every field the user supplies to
|
||||
* {@link definePlugin}. The `Caps` generic threads the literal list of
|
||||
* `requires` capabilities through to `PluginContext<Caps>` /
|
||||
* `RequestContext<Caps>` so undeclared accesses are TypeScript errors.
|
||||
*
|
||||
* Lifecycle hooks (`install`, `start`, `stop`, `uninstall`) all receive the
|
||||
* plugin-level context with admin / unscoped database access. Per-request
|
||||
* route handlers receive {@link RequestContext} with the tenant-scoped
|
||||
* variants of every declared capability.
|
||||
*/
|
||||
export interface DefinePluginSpec<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
> {
|
||||
/** Stable plugin name; MUST match `manifest.name`. */
|
||||
readonly name: string;
|
||||
/** Plugin semver; MUST match `manifest.version`. */
|
||||
readonly version: string;
|
||||
/**
|
||||
* The literal-array list of capability ids the plugin needs. Use
|
||||
* `requires: ["db", "auth.session"] as const satisfies CapabilityRequirements`
|
||||
* so the `Caps` generic is inferred as the literal tuple.
|
||||
*/
|
||||
readonly requires: Caps;
|
||||
/**
|
||||
* Isolation tier — `trusted` / `isolated` / `sandboxed`. Stage-2
|
||||
* host honours every tier at the FSM / capability-surface level;
|
||||
* Stage-3 ships the worker-proxy plumbing for `isolated` and
|
||||
* `sandboxed`.
|
||||
*/
|
||||
readonly isolation: IsolationTier;
|
||||
/** Trust level — provenance signal independent of isolation. */
|
||||
readonly trustLevel: TrustLevel;
|
||||
/**
|
||||
* Optional install hook. Receives the plugin-level context and runs
|
||||
* once before the first `start()`. Throws here transition the plugin
|
||||
* to `install_failed`.
|
||||
*/
|
||||
readonly install?: (ctx: PluginContext<Caps>) => Promise<void> | void;
|
||||
/**
|
||||
* Required start hook. Throws here transition the plugin to
|
||||
* `start_failed`; the rollback journal then auto-rolls back to the
|
||||
* prior known-good specifier.
|
||||
*/
|
||||
readonly start: (ctx: PluginContext<Caps>) => Promise<void> | void;
|
||||
/**
|
||||
* Optional stop hook. Throws here are reported in admin output but
|
||||
* do NOT prevent the plugin from transitioning to `stopped` (the
|
||||
* host always reaches a terminal state on stop).
|
||||
*/
|
||||
readonly stop?: (ctx: PluginContext<Caps>) => Promise<void> | void;
|
||||
/**
|
||||
* Optional uninstall hook — drop / archive / keep data per
|
||||
* `manifest.uninstall.dataPolicy`.
|
||||
*/
|
||||
readonly uninstall?: (ctx: PluginContext<Caps>) => Promise<void> | void;
|
||||
/**
|
||||
* Optional health probe — invoked by the host's periodic health
|
||||
* poller. Plain return → healthy; throw → unhealthy.
|
||||
*/
|
||||
readonly health?: (ctx: PluginContext<Caps>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link definePlugin} — the frozen, typed module record the
|
||||
* host's loader imports and hands to the lifecycle driver. The runtime
|
||||
* shape is identical to the input spec; the value is intentionally
|
||||
* frozen so the host can hash a stable representation for the rollback
|
||||
* journal.
|
||||
*/
|
||||
export type PluginModule<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
> = Readonly<DefinePluginSpec<Caps>>;
|
||||
|
||||
/**
|
||||
* Type-level helper: given a plugin module declared with
|
||||
* `requires: ["db", "auth.session"] as const`, this resolves to the
|
||||
* exact `PluginContext` shape the lifecycle hooks see.
|
||||
*/
|
||||
export type PluginContextOf<P extends PluginModule> = PluginContext<
|
||||
P["requires"]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Type-level helper: per-request context shape for a plugin module.
|
||||
*/
|
||||
export type RequestContextOf<P extends PluginModule> = RequestContext<
|
||||
P["requires"]
|
||||
>;
|
||||
|
||||
/**
|
||||
* Validate-and-freeze plugin authoring entry point. The `Caps` generic
|
||||
* is inferred from the literal-array `requires` field — call as
|
||||
* `definePlugin({ requires: ["db"] as const, ... })` to get the
|
||||
* narrowed `ctx` types.
|
||||
*
|
||||
* Throws synchronously for two cases the type system cannot catch:
|
||||
* - duplicate ids inside `requires`,
|
||||
* - empty `name` or `version`.
|
||||
*
|
||||
* Throws are surfaced via {@link DefinePluginError} which extends the
|
||||
* standard `Error` shape and carries a stable code. The host loader's
|
||||
* own validation re-checks manifest-vs-module consistency on install,
|
||||
* so this validation is best-effort author-side; the host always has
|
||||
* the authoritative view.
|
||||
*/
|
||||
export function definePlugin<const Caps extends ReadonlyArray<CapabilityId>>(
|
||||
spec: DefinePluginSpec<Caps>
|
||||
): PluginModule<Caps> {
|
||||
if (spec.name.length === 0) {
|
||||
throw new DefinePluginError(
|
||||
"DEFINE_PLUGIN.INVALID_NAME",
|
||||
"definePlugin: name must be non-empty"
|
||||
);
|
||||
}
|
||||
if (spec.version.length === 0) {
|
||||
throw new DefinePluginError(
|
||||
"DEFINE_PLUGIN.INVALID_VERSION",
|
||||
"definePlugin: version must be non-empty"
|
||||
);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const id of spec.requires) {
|
||||
if (seen.has(id)) {
|
||||
throw new DefinePluginError(
|
||||
"DEFINE_PLUGIN.DUPLICATE_CAPABILITY",
|
||||
`definePlugin: duplicate capability id in requires: ${id}`
|
||||
);
|
||||
}
|
||||
seen.add(id);
|
||||
}
|
||||
return Object.freeze({ ...spec }) as PluginModule<Caps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable error-code union for {@link definePlugin}-time validation
|
||||
* failures. Lives next to the function because the validation is
|
||||
* synchronous + author-side; promoting these to the global
|
||||
* {@link HostError} namespace would imply a host-runtime concern,
|
||||
* which they are not.
|
||||
*/
|
||||
export type DefinePluginCode =
|
||||
| "DEFINE_PLUGIN.INVALID_NAME"
|
||||
| "DEFINE_PLUGIN.INVALID_VERSION"
|
||||
| "DEFINE_PLUGIN.DUPLICATE_CAPABILITY";
|
||||
|
||||
/**
|
||||
* Author-side validation error. Plain `Error` subclass — `definePlugin`
|
||||
* runs in the user's bundle and we don't pull `@suaveplan/error`'s
|
||||
* heavier `SuaveplanError` machinery into every consumer's runtime path.
|
||||
*/
|
||||
export class DefinePluginError extends Error {
|
||||
public readonly code: DefinePluginCode;
|
||||
public constructor(code: DefinePluginCode, message: string) {
|
||||
super(message);
|
||||
this.name = "DefinePluginError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
DefinePluginCode,
|
||||
DefinePluginSpec,
|
||||
PluginContextOf,
|
||||
PluginModule,
|
||||
RequestContextOf,
|
||||
} from "./define-plugin.js";
|
||||
export { DefinePluginError, definePlugin } from "./define-plugin.js";
|
||||
Reference in New Issue
Block a user