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,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.

View File

@@ -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");
});
});

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
export type {
DefinePluginCode,
DefinePluginSpec,
PluginContextOf,
PluginModule,
RequestContextOf,
} from "./define-plugin.js";
export { DefinePluginError, definePlugin } from "./define-plugin.js";