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,82 @@
# @suaveplan/api-lifecycle - Plugin lifecycle FSM contract: 13-state discriminated union, legal-transition table, side-effect descriptors, retry policies for start_failed and crashed, per-plugin mutex contract, and reinstall criteria — the definition consumed by api-host's lifecycle driver. Pure definitions + factories; no host runtime.
Stage-1 contract package for the Suaveplan API plugin system: the
plugin-lifecycle finite state machine definition, the legal-transition
table, the side-effect descriptors per transition, the retry policies for
`start_failed` and `crashed`, the per-plugin mutex contract, and the
`requiresReinstall` criteria. `api-host` consumes this package
to execute the actual lifecycle — ships only the data and the
factory.
Backed by `@suaveplan/fsm` (state-machine runtime), `@suaveplan/retry`
(exponential backoff), `@suaveplan/error` (typed errors), and
`@suaveplan/events` (observers).
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines `LIFECYCLE_CODES` — the frozen, namespaced registry of the four error codes raised by the `@suaveplan/api-lifec…
- [error-codes](./src/error-codes/error-codes.md) — Defines `LIFECYCLE_CODES` — the frozen, namespaced registry of the four error codes raised by the `@suaveplan/api-lifec…
- [fsm-factory](./src/fsm-factory/fsm-factory.md) — Binds the `@suaveplan/api-lifecycle` definitions together — states, transitions, side-effects, and the per-plugin mutex…
- [fsm-factory](./src/fsm-factory/fsm-factory.md) — Binds the `@suaveplan/api-lifecycle` definitions together — states, transitions, side-effects, and the per-plugin mutex…
- [mutex-contract](./src/mutex-contract/mutex-contract.md) — Defines `PluginMutex` — the contract guaranteeing at most one lifecycle transition per plugin in flight at any time — t…
- [mutex-contract](./src/mutex-contract/mutex-contract.md) — Defines `PluginMutex` — the contract guaranteeing at most one lifecycle transition per plugin in flight at any time — t…
- [reinstall-criteria](./src/reinstall-criteria/reinstall-criteria.md) — Decides whether a manifest update from `prev` to `next` requires a full reinstall path (`uninstalled → installing → ins…
- [reinstall-criteria](./src/reinstall-criteria/reinstall-criteria.md) — Decides whether a manifest update from `prev` to `next` requires a full reinstall path (`uninstalled → installing → ins…
- [retry-policies](./src/retry-policies/retry-policies.md) — Owns the retry-budget contract for the two automatic-recovery paths in the lifecycle FSM: `start_failed` (3 attempts wi…
- [retry-policies](./src/retry-policies/retry-policies.md) — Owns the retry-budget contract for the two automatic-recovery paths in the lifecycle FSM: `start_failed` (3 attempts wi…
- [side-effects](./src/side-effects/side-effects.md) — Encodes every side-effect the host runtime SHALL execute on each documented transition — `mount-routes`, `unmount-route…
- [side-effects](./src/side-effects/side-effects.md) — Encodes every side-effect the host runtime SHALL execute on each documented transition — `mount-routes`, `unmount-route…
- [states](./src/states/states.md) — Owns the runtime view of the plugin lifecycle's 13-state union. The `PluginState` type itself is re-exported from `@sua…
- [states](./src/states/states.md) — Owns the runtime view of the plugin lifecycle's 13-state union. The `PluginState` type itself is re-exported from `@sua…
- [testing](./src/testing/conformance.md) — Exposes `runLifecycleConformance({ factory, label })` — the reusable test harness `api-host` lifecycle driver…
- [conformance](./src/testing/conformance.md) — Exposes `runLifecycleConformance({ factory, label })` — the reusable test harness `api-host` lifecycle driver…
- [transitions](./src/transitions/transitions.md) — Encodes the complete legal-transition graph of the plugin lifecycle FSM — every `(from, to, trigger)` edge documented i…
- [transitions](./src/transitions/transitions.md) — Encodes the complete legal-transition graph of the plugin lifecycle FSM — every `(from, to, trigger)` edge documented i…
<!-- end:auto-docs -->
## Modules
- [`states`](./src/states/states.md) — `PluginState` 13-state union,
`PLUGIN_STATES` tuple, `isLive` / `isParked` predicates.
- [`transitions`](./src/transitions/transitions.md) — `PLUGIN_TRANSITIONS`
edge table, `PluginTransitions` query class.
- [`fsm-factory`](./src/fsm-factory/fsm-factory.md) — `createPluginFsm`
binding to `@suaveplan/fsm`.
- [`side-effects`](./src/side-effects/side-effects.md) — `SIDE_EFFECTS`
descriptor map keyed `<from>|<to>`.
- [`retry-policies`](./src/retry-policies/retry-policies.md) —
`RETRY_POLICIES.startFailed`, `RETRY_POLICIES.crashed`, plus
`StartFailedTracker` and `CrashBudgetTracker`.
- [`mutex-contract`](./src/mutex-contract/mutex-contract.md) —
`PluginMutex` interface and `InMemoryPluginMutex` reference impl.
- [`reinstall-criteria`](./src/reinstall-criteria/reinstall-criteria.md) —
`requiresReinstall(prev, next)` and `compareSemver`.
- [`error-codes`](./src/error-codes/error-codes.md) — `LIFECYCLE_CODES`
registry plus `LifecycleError`.
- [`testing/conformance`](./src/testing/conformance.md) —
`runLifecycleConformance({ factory, label })` harness must
pass.
## Quick start
```ts
import { createPluginFsm } from "@suaveplan/api-lifecycle";
const fsm = createPluginFsm({ plugin: "billing" });
await fsm.transition("install");
await fsm.transition("installCompleted");
await fsm.transition("start");
await fsm.transition("startCompleted");
// fsm.state === "started"
```
## Error codes
| Code | Thrown when |
|---|---|
| `LIFECYCLE.ILLEGAL_TRANSITION` | `(from, trigger)` not in the legal table |
| `LIFECYCLE.MUTEX_HELD` | `assertAcquired` called on a null `tryAcquire` result |
| `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` | start_failed × 3 OR crashed × 3 in window |
| `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` | `next.version < prev.version` |

View File

@@ -0,0 +1,117 @@
{
"name": "@suaveplan/api-lifecycle",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Plugin lifecycle FSM contract: 13-state discriminated union, legal-transition table, side-effect descriptors, retry policies for start_failed and crashed, per-plugin mutex contract, and reinstall criteria — the definition consumed by api-host's lifecycle driver. Pure definitions + factories; no host runtime.",
"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"
}
},
"./states": {
"import": {
"types": "./dist/states/states.d.ts",
"default": "./dist/states/states.js"
}
},
"./transitions": {
"import": {
"types": "./dist/transitions/transitions.d.ts",
"default": "./dist/transitions/transitions.js"
}
},
"./fsm-factory": {
"import": {
"types": "./dist/fsm-factory/fsm-factory.d.ts",
"default": "./dist/fsm-factory/fsm-factory.js"
}
},
"./side-effects": {
"import": {
"types": "./dist/side-effects/side-effects.d.ts",
"default": "./dist/side-effects/side-effects.js"
}
},
"./retry-policies": {
"import": {
"types": "./dist/retry-policies/retry-policies.d.ts",
"default": "./dist/retry-policies/retry-policies.js"
}
},
"./mutex-contract": {
"import": {
"types": "./dist/mutex-contract/mutex-contract.d.ts",
"default": "./dist/mutex-contract/mutex-contract.js"
}
},
"./reinstall-criteria": {
"import": {
"types": "./dist/reinstall-criteria/reinstall-criteria.d.ts",
"default": "./dist/reinstall-criteria/reinstall-criteria.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/conformance.d.ts",
"default": "./dist/testing/conformance.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/fsm": "workspace:^",
"@suaveplan/retry": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,75 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `LIFECYCLE_CODES` — the frozen, namespaced registry of the four error
codes raised by the `@suaveplan/api-lifecycle` package — and `LifecycleError`,
the typed `SuaveplanError` subclass that carries them. Every failure path in
the lifecycle package (illegal transition, mutex contention, retry-budget
exhaustion, manifest version downgrade) constructs a `LifecycleError` so
callers can branch on either `instanceof LifecycleError` or the stable
`error.code` string.
## Features
- Four-code registry built via `@suaveplan/error`'s `defineCodes` helper, so
the produced map is type-narrowed to `{ readonly K: "LIFECYCLE.K" }`.
- `LifecycleError` extends `SuaveplanError<LifecycleCode>` — `instanceof`
works, and the name is set to `"LifecycleError"` for self-describing
stack traces.
- Frozen at module load (`Object.freeze`), so accidental mutation throws in
strict mode rather than silently corrupting the registry.
## Usage: Basic
```ts
import { LIFECYCLE_CODES, LifecycleError } from "@suaveplan/api-lifecycle/error-codes";
throw new LifecycleError(
LIFECYCLE_CODES.ILLEGAL_TRANSITION,
"Cannot transition from installed → stopped without going through stopping",
);
```
## Usage: Advanced
Discriminating in a generic error handler:
```ts
import { LifecycleError, LIFECYCLE_CODES } from "@suaveplan/api-lifecycle/error-codes";
function handle(err: unknown): "retry" | "park" | "abort" {
if (!(err instanceof LifecycleError)) return "abort";
switch (err.code) {
case LIFECYCLE_CODES.RETRY_BUDGET_EXHAUSTED:
return "park";
case LIFECYCLE_CODES.MUTEX_HELD:
return "retry";
default:
return "abort";
}
}
```
The `code` is a template-literal type so exhaustiveness checks in `switch`
statements catch missing branches at compile time.
## API Reference
- `LIFECYCLE_CODES` — frozen record. Keys: `ILLEGAL_TRANSITION`, `MUTEX_HELD`,
`RETRY_BUDGET_EXHAUSTED`, `REINSTALL_REQUIRED_VERSION_DOWNGRADE`.
- `type LifecycleCode` — the union of every code's fully-qualified string.
- `class LifecycleError` — constructor `(code, message, options?)` mirroring
the `SuaveplanError` shape.
## Implementation Notes
This file is the single source of truth for the namespace string `LIFECYCLE`.
Adding a new code requires appending the bare reason to the tuple passed to
`defineCodes` — the type is inferred from that tuple, so no other code change
is needed. The error class itself is `protected constructor` upstream; this
package narrows the namespace and adds `name = "LifecycleError"`.

View File

@@ -0,0 +1,52 @@
/**
* Spec-conformance: every LIFECYCLE_CODES entry MUST have a production
* throw-site somewhere in the package source tree. This guards against
* adding a code with no semantic usage (dead code) and against deleting
* a thrower without retiring the code.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LIFECYCLE_CODES } from "./error-codes.js";
const SRC_ROOT = resolve(__dirname, "..");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) {
out.push(...walk(p));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".types.ts")
) {
out.push(p);
}
}
return out;
}
function readProductionSources(): string {
return walk(SRC_ROOT)
.filter((p) => !p.includes("error-codes"))
.filter((p) => !p.includes("testing"))
.map((p) => readFileSync(p, "utf8"))
.join("\n");
}
describe("error-codes spec-conformance", () => {
it("every LIFECYCLE_CODES entry thrown by production path", {
timeout: 5000,
}, () => {
const source = readProductionSources();
for (const key of Object.keys(LIFECYCLE_CODES)) {
expect(
source.includes(`LIFECYCLE_CODES.${key}`),
`expected production reference to LIFECYCLE_CODES.${key}`
).toBe(true);
}
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LIFECYCLE_CODES, LifecycleError } from "./error-codes.js";
describe("error-codes", () => {
it("LIFECYCLE_CODES contains exactly the four documented codes", {
timeout: 5000,
}, () => {
expect(Object.keys(LIFECYCLE_CODES).sort()).toEqual([
"ILLEGAL_TRANSITION",
"MUTEX_HELD",
"REINSTALL_REQUIRED_VERSION_DOWNGRADE",
"RETRY_BUDGET_EXHAUSTED",
]);
});
it("every LIFECYCLE_CODES entry is prefixed with LIFECYCLE.", {
timeout: 5000,
}, () => {
for (const value of Object.values(LIFECYCLE_CODES)) {
expect(value.startsWith("LIFECYCLE.")).toBe(true);
}
});
it("LIFECYCLE_CODES is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(LIFECYCLE_CODES)).toBe(true);
});
it("LifecycleError carries the code and name", { timeout: 5000 }, () => {
const err = new LifecycleError(
LIFECYCLE_CODES.ILLEGAL_TRANSITION,
"illegal transition"
);
expect(err.code).toBe("LIFECYCLE.ILLEGAL_TRANSITION");
expect(err.name).toBe("LifecycleError");
expect(err).toBeInstanceOf(Error);
});
it("LifecycleError forwards options to base", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new LifecycleError(LIFECYCLE_CODES.MUTEX_HELD, "held", {
cause,
context: { plugin: "billing" },
});
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ plugin: "billing" });
});
});

View File

@@ -0,0 +1,52 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-lifecycle`.
*
* Every public failure path raises a {@link LifecycleError} carrying one of
* these namespaced `LIFECYCLE.*` codes. Consumers branch on the string value
* (e.g. `error.code === LIFECYCLE_CODES.ILLEGAL_TRANSITION` or the inline
* literal `"LIFECYCLE.ILLEGAL_TRANSITION"`).
*
* The four codes cover:
* - Transition-table violations (`ILLEGAL_TRANSITION`).
* - Mutex contention reported from `tryAcquire` (`MUTEX_HELD`).
* - Retry-budget exhaustion for `start_failed` or `crashed`
* (`RETRY_BUDGET_EXHAUSTED`).
* - Reinstall-criteria rejection of a manifest version downgrade
* (`REINSTALL_REQUIRED_VERSION_DOWNGRADE`).
*/
export const LIFECYCLE_CODES = defineCodes("LIFECYCLE", [
"ILLEGAL_TRANSITION",
"MUTEX_HELD",
"RETRY_BUDGET_EXHAUSTED",
"REINSTALL_REQUIRED_VERSION_DOWNGRADE",
] as const);
/**
* Type of every fully-qualified code in {@link LIFECYCLE_CODES}.
*/
export type LifecycleCode =
(typeof LIFECYCLE_CODES)[keyof typeof LIFECYCLE_CODES];
/**
* Typed error for all `@suaveplan/api-lifecycle` failures.
*
* Extends {@link SuaveplanError} so callers can discriminate either via
* `error instanceof LifecycleError` or via the stable `error.code` string.
*
* Construction always copies the `code`, message, and any cause/context
* options upward. The `name` is overridden to `"LifecycleError"` so
* stack traces are self-describing.
*/
export class LifecycleError extends SuaveplanError<LifecycleCode> {
public constructor(
code: LifecycleCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.name = "LifecycleError";
}
}

View File

@@ -0,0 +1,2 @@
export type { LifecycleCode } from "./error-codes.js";
export { LIFECYCLE_CODES, LifecycleError } from "./error-codes.js";

View File

@@ -0,0 +1,82 @@
# `fsm-factory`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Binds the `@suaveplan/api-lifecycle` definitions together — states,
transitions, side-effects, and the per-plugin mutex — into a running FSM
backed by `@suaveplan/fsm`'s `FSMInterpreter`. The factory is the single
entry point `api-host` driver imports; everything else in this
package is reachable through the returned `PluginFsm` handle.
## Features
- Builds a `FSMDefinition` from the 13-state union and the 22-edge legal
transitions table.
- Wraps every `transition(trigger)` call in a mutex acquire/release pair so
concurrent calls observe FIFO ordering.
- Pre-resolves the destination via `transitions.resolve` so illegal pairs
throw `LIFECYCLE.ILLEGAL_TRANSITION` (not the upstream `FSMError`),
keeping the error namespace clean.
- Appends the matching `SideEffectEntry` to the FSM's append-only log on
every commit; the host runtime reads the log and dispatches the ops.
- `onTransition` listener API for admin UI subscriptions; returns an
unsubscribe function.
## Usage: Basic
```ts
import { createPluginFsm } from "@suaveplan/api-lifecycle/fsm-factory";
const fsm = createPluginFsm({ plugin: "billing" });
await fsm.transition("install");
await fsm.transition("installCompleted");
console.log(fsm.state); // "installed"
```
## Usage: Advanced
Wire a custom mutex (e.g. Redis-backed) plus an external subscriber that
forwards transition snapshots to OpenTelemetry:
```ts
import { createPluginFsm, type PluginFsm } from "@suaveplan/api-lifecycle/fsm-factory";
declare const redisMutex: { acquire(p: string): Promise<{ release(): void }>; tryAcquire(p: string): { release(): void } | null };
const fsm: PluginFsm = createPluginFsm({
plugin: "billing",
initial: "uninstalled",
mutex: redisMutex,
});
const off = fsm.onTransition(({ from, to, trigger }) => {
span.addEvent("plugin.transition", { from, to, trigger });
});
await fsm.transition("install");
off();
```
## API Reference
- `createPluginFsm(options): PluginFsm` — entry point.
- `interface CreatePluginFsmOptions` — `plugin`, optional `initial`,
`transitions`, `mutex`.
- `interface PluginFsm` — `state`, `sideEffects`, `transition(trigger)`,
`onTransition(listener)`.
- `interface PluginFsmContext` — `plugin`, `sideEffects` (carried by the
underlying FSM definition for traceability).
## Implementation Notes
The factory builds the `FSMDefinition.states` map exhaustively from
`PLUGIN_STATES` so every state has a `StateDefinition` — `@suaveplan/fsm`
otherwise throws `INVALID_STATE` when entering a state that was never
declared. The transition-table iteration deliberately scans `edges()`
once per state during construction; the resulting per-state `on` map is
then served O(1) by `FSMInterpreter`. The mutex acquire happens before
the `transitions.resolve` call so a queued caller never sees the FSM
in a half-mutated state.

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LifecycleError } from "../error-codes/error-codes.js";
import { InMemoryPluginMutex } from "../mutex-contract/mutex-contract.js";
import { createPluginFsm } from "./fsm-factory.js";
describe("fsm-factory", () => {
it("fsm starts in declared initial state", { timeout: 5000 }, () => {
const fsm = createPluginFsm({ plugin: "billing" });
expect(fsm.state).toBe("uninstalled");
const explicit = createPluginFsm({
plugin: "billing",
initial: "stopped",
});
expect(explicit.state).toBe("stopped");
});
it("transition walks the install path", { timeout: 5000 }, async () => {
const fsm = createPluginFsm({ plugin: "billing" });
expect(await fsm.transition("install")).toBe("installing");
expect(await fsm.transition("installCompleted")).toBe("installed");
expect(fsm.state).toBe("installed");
});
it("transition appends the side-effect descriptor", {
timeout: 5000,
}, async () => {
const fsm = createPluginFsm({ plugin: "billing" });
await fsm.transition("install");
await fsm.transition("installCompleted");
expect(fsm.sideEffects).toHaveLength(2);
expect(fsm.sideEffects[0].from).toBe("uninstalled");
expect(fsm.sideEffects[0].to).toBe("installing");
expect(fsm.sideEffects[1].from).toBe("installing");
expect(fsm.sideEffects[1].to).toBe("installed");
});
it("transition rejects an illegal trigger with LIFECYCLE.ILLEGAL_TRANSITION", {
timeout: 5000,
}, async () => {
const fsm = createPluginFsm({ plugin: "billing" });
try {
await fsm.transition("stop");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LifecycleError);
const e = err as LifecycleError;
expect(e.code).toBe("LIFECYCLE.ILLEGAL_TRANSITION");
}
});
it("onTransition observers are notified in order", {
timeout: 5000,
}, async () => {
const seen: Array<{ from: string; to: string; trigger: string }> = [];
const fsm = createPluginFsm({ plugin: "billing" });
const off = fsm.onTransition((s) => seen.push(s));
await fsm.transition("install");
await fsm.transition("installCompleted");
off();
await fsm.transition("start");
expect(seen).toEqual([
{ from: "uninstalled", to: "installing", trigger: "install" },
{
from: "installing",
to: "installed",
trigger: "installCompleted",
},
]);
});
it("uses the supplied mutex to serialise concurrent triggers", {
timeout: 5000,
}, async () => {
const mutex = new InMemoryPluginMutex();
const fsm = createPluginFsm({ plugin: "billing", mutex });
const order: string[] = [];
const p1 = fsm.transition("install").then((s) => order.push(`1:${s}`));
const p2 = fsm
.transition("installCompleted")
.then((s) => order.push(`2:${s}`));
await Promise.all([p1, p2]);
expect(order).toEqual(["1:installing", "2:installed"]);
});
it("sideEffects gracefully tolerates transitions without an entry", {
timeout: 5000,
}, async () => {
// install_failed → installing has no side-effect descriptor by design.
const fsm = createPluginFsm({
plugin: "billing",
initial: "install_failed",
});
await fsm.transition("retryInstall");
expect(fsm.state).toBe("installing");
// No entry was registered for the retry transition.
expect(fsm.sideEffects).toHaveLength(0);
});
it("PluginFsmContext carries the plugin name into the FSM definition", {
timeout: 5000,
}, () => {
const fsm = createPluginFsm({ plugin: "inventory" });
expect(fsm.state).toBe("uninstalled");
});
});

View File

@@ -0,0 +1,163 @@
import type {
FSMDefinition,
FSMInterpreter,
StateDefinition,
TransitionDefinition,
} from "@suaveplan/fsm";
import { createMachine } from "@suaveplan/fsm";
import {
InMemoryPluginMutex,
type MutexRelease,
type PluginMutex,
} from "../mutex-contract/mutex-contract.js";
import {
getSideEffects,
type SideEffectEntry,
} from "../side-effects/side-effects.js";
import type { PluginState } from "../states/states.js";
import { PLUGIN_STATES } from "../states/states.js";
import {
defaultPluginTransitions,
type PluginTransitions,
type PluginTrigger,
} from "../transitions/transitions.js";
/**
* Context attached to the FSM interpreter. Each transition appends the
* side-effect descriptor it dispatched so the host driver can iterate
* through the captured sequence on demand.
*/
export interface PluginFsmContext {
/** Stable plugin name; carried through for error context. */
readonly plugin: string;
/** Append-only list of side-effect descriptors fired so far. */
readonly sideEffects: readonly SideEffectEntry[];
}
/**
* Options accepted by {@link createPluginFsm}.
*/
export interface CreatePluginFsmOptions {
/** Plugin name attached to error contexts. */
readonly plugin: string;
/** Starting state. Defaults to `"uninstalled"`. */
readonly initial?: PluginState;
/** Transition table. Defaults to {@link defaultPluginTransitions}. */
readonly transitions?: PluginTransitions;
/** Per-plugin mutex. Defaults to {@link InMemoryPluginMutex}. */
readonly mutex?: PluginMutex;
}
/**
* Lightweight wrapper around `@suaveplan/fsm`'s `FSMInterpreter` adding
* the per-plugin mutex contract (FIFO queue, at most one transition in
* flight) and emitting the side-effect descriptor on every commit.
*/
export interface PluginFsm {
/** Current state value. */
readonly state: PluginState;
/** Active side-effect log. */
readonly sideEffects: readonly SideEffectEntry[];
/**
* Fire a trigger. Acquires the per-plugin mutex first, dispatches
* through the FSM, appends the side-effect, and releases the mutex.
* Throws `LIFECYCLE.ILLEGAL_TRANSITION` on an unknown `(state, trigger)`.
*/
transition(trigger: PluginTrigger): Promise<PluginState>;
/**
* Subscribe to transition snapshots. Returns an unsubscribe function.
*/
onTransition(
listener: (snapshot: {
from: PluginState;
to: PluginState;
trigger: PluginTrigger;
}) => void
): () => void;
}
function buildFsmDefinition(
initial: PluginState,
transitions: PluginTransitions,
context: PluginFsmContext
): FSMDefinition<PluginFsmContext> {
const states: Record<string, StateDefinition<PluginFsmContext>> = {};
for (const stateName of PLUGIN_STATES) {
const on: Record<string, TransitionDefinition<PluginFsmContext>> = {};
for (const edge of transitions.edges()) {
if (edge.from === stateName) {
on[edge.trigger] = { target: edge.to };
}
}
states[stateName] = { on };
}
return {
id: `plugin-fsm:${context.plugin}`,
initial,
context,
states,
};
}
/**
* Build a lifecycle FSM for one plugin. Mutex is acquired before every
* `transition` and released before the call returns — concurrent callers
* therefore observe FIFO ordering.
*/
export function createPluginFsm(options: CreatePluginFsmOptions): PluginFsm {
const initial = options.initial ?? "uninstalled";
const transitions = options.transitions ?? defaultPluginTransitions;
const mutex = options.mutex ?? new InMemoryPluginMutex();
const context: PluginFsmContext = {
plugin: options.plugin,
sideEffects: [],
};
const definition = buildFsmDefinition(initial, transitions, context);
const machine = createMachine<PluginFsmContext>(
definition
) as FSMInterpreter<PluginFsmContext>;
const sideEffects: SideEffectEntry[] = [];
const listeners = new Set<
(snapshot: {
from: PluginState;
to: PluginState;
trigger: PluginTrigger;
}) => void
>();
async function transition(trigger: PluginTrigger): Promise<PluginState> {
const release: MutexRelease = await mutex.acquire(options.plugin);
try {
const from = machine.current as PluginState;
// Trigger the transition table guard first so we throw a
// LifecycleError (not an FSMError) on illegal pairs. This
// keeps the error namespace consistent for callers.
const to = transitions.resolve(from, trigger);
machine.send({ type: trigger });
const entry = getSideEffects(from, to);
if (entry !== undefined) {
sideEffects.push(entry);
}
for (const listener of listeners) {
listener({ from, to, trigger });
}
return to;
} finally {
release.release();
}
}
return {
get state(): PluginState {
return machine.current as PluginState;
},
get sideEffects(): readonly SideEffectEntry[] {
return sideEffects;
},
transition,
onTransition(listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}

View File

@@ -0,0 +1,6 @@
export type {
CreatePluginFsmOptions,
PluginFsm,
PluginFsmContext,
} from "./fsm-factory.js";
export { createPluginFsm } from "./fsm-factory.js";

View File

@@ -0,0 +1,83 @@
// Error codes
export type { LifecycleCode } from "./error-codes/error-codes.js";
export { LIFECYCLE_CODES, LifecycleError } from "./error-codes/error-codes.js";
// FSM factory
export type {
CreatePluginFsmOptions,
PluginFsm,
PluginFsmContext,
} from "./fsm-factory/fsm-factory.js";
export { createPluginFsm } from "./fsm-factory/fsm-factory.js";
// Mutex contract
export type {
MutexRelease,
PluginMutex,
} from "./mutex-contract/mutex-contract.js";
export {
assertAcquired,
InMemoryPluginMutex,
} from "./mutex-contract/mutex-contract.js";
// Reinstall criteria
export {
compareSemver,
requiresReinstall,
} from "./reinstall-criteria/reinstall-criteria.js";
// Retry policies
export type {
Clock,
CrashedPolicy,
StartFailedPolicy,
} from "./retry-policies/retry-policies.js";
export {
CRASHED_DEFAULTS,
CrashBudgetTracker,
RETRY_POLICIES,
START_FAILED_DEFAULTS,
StartFailedTracker,
} from "./retry-policies/retry-policies.js";
// Side effects
export type {
SideEffectEntry,
SideEffectOp,
} from "./side-effects/side-effects.js";
export {
DEFAULT_GRACEFUL_MS,
getSideEffects,
SIDE_EFFECTS,
} from "./side-effects/side-effects.js";
// States
export type { PluginState } from "./states/states.js";
export {
isLive,
isParked,
isPluginState,
LIVE_STATES,
PARKED_STATES,
PLUGIN_STATE_SET,
PLUGIN_STATES,
} from "./states/states.js";
// Testing harness
export type {
LifecycleDriverFactory,
LifecycleDriverLike,
} from "./testing/conformance.js";
export { runLifecycleConformance } from "./testing/conformance.js";
// Transitions
export type {
PluginTransitionEdge,
PluginTrigger,
} from "./transitions/transitions.js";
export {
defaultPluginTransitions,
PLUGIN_TRANSITIONS,
PLUGIN_TRIGGERS,
PluginTransitions,
} from "./transitions/transitions.js";

View File

@@ -0,0 +1,2 @@
export type { MutexRelease, PluginMutex } from "./mutex-contract.js";
export { assertAcquired, InMemoryPluginMutex } from "./mutex-contract.js";

View File

@@ -0,0 +1,80 @@
# `mutex-contract`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `PluginMutex` — the contract guaranteeing at most one lifecycle
transition per plugin in flight at any time — together with a reference
in-memory implementation (`InMemoryPluginMutex`) that the FSM factory uses
by default and that the conformance harness exercises end-to-end. Concurrent
`acquire(plugin)` calls queue FIFO and resolve in arrival order, matching
the semantics required by design §4's "per-plugin mutex" paragraph.
## Features
- Two-method interface: `acquire(plugin) → Promise<MutexRelease>` and
`tryAcquire(plugin) → MutexRelease | null`.
- Per-plugin scope: locks for distinct plugin names are independent, so
`billing` and `inventory` can transition concurrently.
- Idempotent `release()` — re-calling is safe.
- FIFO queueing semantics so operators see deterministic transition order
rather than scheduler-dependent races.
- `assertAcquired(plugin, handle)` helper for callers that prefer the
`LIFECYCLE.MUTEX_HELD` exception over the null-check pattern.
## Usage: Basic
```ts
import { InMemoryPluginMutex } from "@suaveplan/api-lifecycle/mutex-contract";
const mutex = new InMemoryPluginMutex();
const handle = await mutex.acquire("billing");
try {
// … perform the transition …
} finally {
handle.release();
}
```
## Usage: Advanced
Pluggable mutex backed by a distributed lock (sketch — the production driver
in `api-host` will supply a real implementation):
```ts
import type { MutexRelease, PluginMutex } from "@suaveplan/api-lifecycle/mutex-contract";
class RedisPluginMutex implements PluginMutex {
public async acquire(plugin: string): Promise<MutexRelease> {
const lock = await this.client.lock(`plugin:${plugin}`, { ttlMs: 60_000 });
return { release: () => lock.unlock() };
}
public tryAcquire(plugin: string): MutexRelease | null {
const lock = this.client.tryLock(`plugin:${plugin}`, { ttlMs: 60_000 });
return lock === null ? null : { release: () => lock.unlock() };
}
private readonly client: { lock: (k: string, o: { ttlMs: number }) => Promise<{ unlock(): void }>; tryLock: (k: string, o: { ttlMs: number }) => { unlock(): void } | null };
}
```
## API Reference
- `interface PluginMutex` — `acquire`, `tryAcquire`.
- `interface MutexRelease` — `release`.
- `class InMemoryPluginMutex` — reference impl; concrete drop-in for the
default factory and for conformance tests.
- `assertAcquired(plugin, handle)` — throws `LIFECYCLE.MUTEX_HELD` on `null`,
returns the handle otherwise.
## Implementation Notes
The reference implementation tracks holders via a `Map<string, "held">` and
maintains an array of waiter resolvers per plugin name. On release, if a
waiter is queued, the new handle is created against the same plugin name so
the next acquirer sees a fresh `released = false` flag — without that, a
stale handle's `release()` could re-fire and drain another waiter. The
`released` flag is closed-over rather than stored on the handle so the
public shape of `MutexRelease` stays minimal.

View File

@@ -0,0 +1,20 @@
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { MutexRelease, PluginMutex } from "./mutex-contract.js";
describe("mutex-contract spec-conformance", () => {
it("PluginMutex declares acquire tryAcquire", { timeout: 5000 }, () => {
expectTypeOf<PluginMutex>().toHaveProperty("acquire");
expectTypeOf<PluginMutex>().toHaveProperty("tryAcquire");
expectTypeOf<PluginMutex["acquire"]>().toEqualTypeOf<
(plugin: string) => Promise<MutexRelease>
>();
expectTypeOf<PluginMutex["tryAcquire"]>().toEqualTypeOf<
(plugin: string) => MutexRelease | null
>();
});
it("MutexRelease declares release", { timeout: 5000 }, () => {
expectTypeOf<MutexRelease>().toHaveProperty("release");
expectTypeOf<MutexRelease["release"]>().toEqualTypeOf<() => void>();
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LifecycleError } from "../error-codes/error-codes.js";
import {
assertAcquired,
InMemoryPluginMutex,
type MutexRelease,
} from "./mutex-contract.js";
describe("mutex-contract", () => {
it("acquire returns a release handle when the lock is free", {
timeout: 5000,
}, async () => {
const mutex = new InMemoryPluginMutex();
const handle = await mutex.acquire("billing");
expect(typeof handle.release).toBe("function");
handle.release();
});
it("tryAcquire returns null when held", { timeout: 5000 }, async () => {
const mutex = new InMemoryPluginMutex();
const first = await mutex.acquire("billing");
expect(mutex.tryAcquire("billing")).toBeNull();
first.release();
const second = mutex.tryAcquire("billing");
expect(second).not.toBeNull();
second?.release();
});
it("tryAcquire returns a handle for a different plugin", {
timeout: 5000,
}, async () => {
const mutex = new InMemoryPluginMutex();
const a = await mutex.acquire("billing");
const b = mutex.tryAcquire("inventory");
expect(b).not.toBeNull();
a.release();
b?.release();
});
it("concurrent triggers queue FIFO", { timeout: 5000 }, async () => {
const mutex = new InMemoryPluginMutex();
const order: string[] = [];
const first = await mutex.acquire("billing");
const second = mutex.acquire("billing").then((h) => {
order.push("second");
h.release();
});
const third = mutex.acquire("billing").then((h) => {
order.push("third");
h.release();
});
order.push("first");
first.release();
await Promise.all([second, third]);
expect(order).toEqual(["first", "second", "third"]);
});
it("release is idempotent", { timeout: 5000 }, async () => {
const mutex = new InMemoryPluginMutex();
const handle = await mutex.acquire("billing");
handle.release();
handle.release(); // no-op
const next = mutex.tryAcquire("billing");
expect(next).not.toBeNull();
next?.release();
});
it("assertAcquired throws LIFECYCLE.MUTEX_HELD on null", {
timeout: 5000,
}, () => {
try {
assertAcquired("billing", null);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LifecycleError);
const e = err as LifecycleError;
expect(e.code).toBe("LIFECYCLE.MUTEX_HELD");
expect(e.context).toEqual({ plugin: "billing" });
}
});
it("assertAcquired returns the handle on a non-null value", {
timeout: 5000,
}, () => {
const fake: MutexRelease = { release: () => undefined };
expect(assertAcquired("billing", fake)).toBe(fake);
});
});

View File

@@ -0,0 +1,119 @@
import { LIFECYCLE_CODES, LifecycleError } from "../error-codes/error-codes.js";
/**
* Handle returned by {@link PluginMutex.acquire} / `tryAcquire`. Calling
* `release()` is idempotent — subsequent calls are no-ops. The host
* driver MUST always call `release()` (typically via `try { … } finally`)
* so the queue can drain.
*/
export interface MutexRelease {
/**
* Release the held lock. Idempotent: safe to call any number of times.
*/
release(): void;
}
/**
* Per-plugin mutex contract: at most one transition per plugin in flight.
* Concurrent calls to `acquire(plugin)` queue FIFO and resolve in arrival
* order. `tryAcquire(plugin)` is the non-blocking variant — it returns
* `null` when the lock is already held (or throws `LIFECYCLE.MUTEX_HELD`
* via {@link assertAcquired} if the caller prefers an exception).
*
* This is a contract, not an implementation — the concrete in-memory mutex
* lives in `api-host`. Stage-1 owns only the interface so the FSM factory
* and the conformance harness can program against it.
*/
export interface PluginMutex {
/**
* Acquire the per-plugin lock. Resolves to a release handle. If the
* lock is already held, the returned promise resolves once preceding
* acquirers release.
*/
acquire(plugin: string): Promise<MutexRelease>;
/**
* Non-blocking variant. Returns a release handle if the lock was free;
* returns `null` if the lock was held by another acquirer.
*/
tryAcquire(plugin: string): MutexRelease | null;
}
/**
* Helper: take the result of `tryAcquire` and throw
* `LIFECYCLE.MUTEX_HELD` when it is `null`. Some callers (admin tooling)
* prefer an exception over a null check.
*/
export function assertAcquired(
plugin: string,
handle: MutexRelease | null
): MutexRelease {
if (handle === null) {
throw new LifecycleError(
LIFECYCLE_CODES.MUTEX_HELD,
`Plugin ${plugin} mutex is held by another in-flight transition`,
{ context: { plugin } }
);
}
return handle;
}
/**
* Reference in-memory implementation of {@link PluginMutex} used by
* conformance tests. The production driver in `api-host` MAY reuse this
* directly or supply its own (e.g. backed by a distributed lock).
*
* Semantics:
* - At most one acquired release per plugin name.
* - Concurrent `acquire(plugin)` calls queue FIFO and resolve in arrival
* order.
* - `tryAcquire(plugin)` returns `null` when the lock is held — never
* waits.
* - `release()` resumes the next FIFO waiter, if any.
*/
export class InMemoryPluginMutex implements PluginMutex {
private readonly holders = new Map<string, "held">();
private readonly waiters = new Map<
string,
Array<(handle: MutexRelease) => void>
>();
public async acquire(plugin: string): Promise<MutexRelease> {
if (!this.holders.has(plugin)) {
this.holders.set(plugin, "held");
return this.makeHandle(plugin);
}
return new Promise<MutexRelease>((resolve) => {
const queue = this.waiters.get(plugin) ?? [];
queue.push(resolve);
this.waiters.set(plugin, queue);
});
}
public tryAcquire(plugin: string): MutexRelease | null {
if (this.holders.has(plugin)) {
return null;
}
this.holders.set(plugin, "held");
return this.makeHandle(plugin);
}
private makeHandle(plugin: string): MutexRelease {
let released = false;
return {
release: () => {
if (released) return;
released = true;
const queue = this.waiters.get(plugin) ?? [];
const next = queue.shift();
if (next === undefined) {
this.holders.delete(plugin);
this.waiters.delete(plugin);
} else {
this.waiters.set(plugin, queue);
next(this.makeHandle(plugin));
}
},
};
}
}

View File

@@ -0,0 +1 @@
export { compareSemver, requiresReinstall } from "./reinstall-criteria.js";

View File

@@ -0,0 +1,72 @@
# `reinstall-criteria`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Decides whether a manifest update from `prev` to `next` requires a full
reinstall path (`uninstalled → installing → installed → starting → started`)
versus a hot-reload (`stopped → starting`). The criteria mirror design §4
exactly: a `schemaVersion` bump, new scopes/events/jobs/secrets, or any
`dependsOn` diff force the full path; a strict version downgrade throws
`LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` because the ledger would
then point at a higher schemaVersion than the running code can satisfy.
## Features
- Pure `requiresReinstall(prev, next): boolean` — no I/O, no global state,
no clock dependency.
- Per-field detection of added items only (removals are intentionally not a
reinstall trigger — the host can drop them in hot-reload).
- Embedded `compareSemver(a, b)` implementing loose semver compare
(`X.Y.Z` form, prerelease/build metadata ignored). The manifest
validator in `api-manifest` already rejects non-semver values, so this
is the only consumer.
- Throws `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` with `prev` /
`next` versions in `error.context` for downgrade attempts.
## Usage: Basic
```ts
import { requiresReinstall } from "@suaveplan/api-lifecycle/reinstall-criteria";
if (requiresReinstall(prev, next)) {
// walk the full install path
} else {
// hot reload via stopped → starting
}
```
## Usage: Advanced
Wrapping the call in admin tooling that wants to surface the specific
trigger reason:
```ts
import { compareSemver, requiresReinstall } from "@suaveplan/api-lifecycle/reinstall-criteria";
function explain(prev: PluginManifest, next: PluginManifest): string {
if (compareSemver(next.version, prev.version) < 0) return "version downgrade";
if ((next.database?.schemaVersion ?? 0) > (prev.database?.schemaVersion ?? 0)) {
return "schemaVersion bumped";
}
return requiresReinstall(prev, next) ? "manifest changed materially" : "hot reload OK";
}
```
## API Reference
- `requiresReinstall(prev: PluginManifest, next: PluginManifest): boolean`
- `compareSemver(a: string, b: string): -1 | 0 | 1`
## Implementation Notes
The "added-items" detection uses `Set`s built from `prev` so the comparison
is O(n) per field. `dependsOn` is compared via a key-union walk so a
removed-but-then-readded dep with a different version is still caught.
Removing items intentionally does not trigger a reinstall — design §4
defines this as a hot-reload-safe operation; the host driver drops the
corresponding capability registrations in the `installed → starting`
side-effect chain.

View File

@@ -0,0 +1,194 @@
import type { PluginManifest } from "@suaveplan/api-types/manifest";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LifecycleError } from "../error-codes/error-codes.js";
import { compareSemver, requiresReinstall } from "./reinstall-criteria.js";
function manifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
return {
name: "billing",
version: "0.1.0",
apiHost: "1.x",
isolation: "trusted",
trustLevel: "first-party",
...overrides,
};
}
describe("reinstall-criteria", () => {
describe("compareSemver", () => {
it("orders patch versions", { timeout: 5000 }, () => {
expect(compareSemver("1.0.0", "1.0.1")).toBe(-1);
expect(compareSemver("1.0.1", "1.0.0")).toBe(1);
expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
});
it("orders minor versions", { timeout: 5000 }, () => {
expect(compareSemver("1.1.0", "1.2.0")).toBe(-1);
expect(compareSemver("1.2.0", "1.1.0")).toBe(1);
});
it("orders major versions", { timeout: 5000 }, () => {
expect(compareSemver("1.0.0", "2.0.0")).toBe(-1);
});
it("ignores prerelease and build metadata", { timeout: 5000 }, () => {
expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBe(0);
expect(compareSemver("1.0.0+build.1", "1.0.0")).toBe(0);
});
it("handles missing segments as zeros", { timeout: 5000 }, () => {
expect(compareSemver("1", "1.0.0")).toBe(0);
expect(compareSemver("1.0", "1.0.1")).toBe(-1);
// Reverse: b shorter than a — exercises the `bp[i] ?? 0` branch.
expect(compareSemver("1.0.0", "1")).toBe(0);
expect(compareSemver("1.0.1", "1.0")).toBe(1);
});
it("treats non-numeric segments as zero", { timeout: 5000 }, () => {
expect(compareSemver("a.b.c", "0.0.0")).toBe(0);
});
});
describe("requiresReinstall", () => {
it("identical manifest returns false", { timeout: 5000 }, () => {
const m = manifest({ scopes: [{ name: "x" }] });
expect(requiresReinstall(m, m)).toBe(false);
});
it("schemaVersion bump returns true", { timeout: 5000 }, () => {
const prev = manifest({
database: { required: true, schemaVersion: 1 },
});
const next = manifest({
database: { required: true, schemaVersion: 2 },
});
expect(requiresReinstall(prev, next)).toBe(true);
});
it("schemaVersion unchanged returns false", { timeout: 5000 }, () => {
const prev = manifest({
database: { required: true, schemaVersion: 1 },
});
const next = manifest({
database: { required: true, schemaVersion: 1 },
});
expect(requiresReinstall(prev, next)).toBe(false);
});
it("new scope returns true", { timeout: 5000 }, () => {
const prev = manifest({ scopes: [{ name: "read" }] });
const next = manifest({
scopes: [{ name: "read" }, { name: "write" }],
});
expect(requiresReinstall(prev, next)).toBe(true);
});
it("new publish topic returns true", { timeout: 5000 }, () => {
const prev = manifest({ events: { publishes: ["a"] } });
const next = manifest({ events: { publishes: ["a", "b"] } });
expect(requiresReinstall(prev, next)).toBe(true);
});
it("new subscribe topic returns true", { timeout: 5000 }, () => {
const prev = manifest({ events: { subscribes: ["a"] } });
const next = manifest({ events: { subscribes: ["a", "b"] } });
expect(requiresReinstall(prev, next)).toBe(true);
});
it("new cron job returns true", { timeout: 5000 }, () => {
const prev = manifest({
jobs: { cron: [{ name: "x", expression: "* * * * *" }] },
});
const next = manifest({
jobs: {
cron: [
{ name: "x", expression: "* * * * *" },
{ name: "y", expression: "* * * * *" },
],
},
});
expect(requiresReinstall(prev, next)).toBe(true);
});
it("new queued job returns true", { timeout: 5000 }, () => {
const prev = manifest({
jobs: { queued: [{ name: "a" }] },
});
const next = manifest({
jobs: { queued: [{ name: "a" }, { name: "b" }] },
});
expect(requiresReinstall(prev, next)).toBe(true);
});
it("new secret returns true", { timeout: 5000 }, () => {
const prev = manifest({
secrets: [{ name: "STRIPE", required: true }],
});
const next = manifest({
secrets: [
{ name: "STRIPE", required: true },
{ name: "PAYPAL", required: false },
],
});
expect(requiresReinstall(prev, next)).toBe(true);
});
it("dependsOn change returns true", { timeout: 5000 }, () => {
const prev = manifest({ dependsOn: { core: "1.x" } });
const next = manifest({ dependsOn: { core: "2.x" } });
expect(requiresReinstall(prev, next)).toBe(true);
});
it("dependsOn additions return true", { timeout: 5000 }, () => {
const prev = manifest({ dependsOn: { core: "1.x" } });
const next = manifest({
dependsOn: { core: "1.x", auth: "1.x" },
});
expect(requiresReinstall(prev, next)).toBe(true);
});
it("version downgrade throws", { timeout: 5000 }, () => {
const prev = manifest({ version: "1.0.0" });
const next = manifest({ version: "0.9.0" });
try {
requiresReinstall(prev, next);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LifecycleError);
const e = err as LifecycleError;
expect(e.code).toBe(
"LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE"
);
expect(e.context).toEqual({ prev: "1.0.0", next: "0.9.0" });
}
});
it("no schemaVersion in either manifest treats as zero", {
timeout: 5000,
}, () => {
expect(requiresReinstall(manifest(), manifest())).toBe(false);
});
it("manifest with empty events block returns false", {
timeout: 5000,
}, () => {
const prev = manifest({
events: { publishes: [], subscribes: [] },
});
const next = manifest({
events: { publishes: [], subscribes: [] },
});
expect(requiresReinstall(prev, next)).toBe(false);
});
it("removing an item is NOT a reinstall trigger", {
timeout: 5000,
}, () => {
const prev = manifest({
scopes: [{ name: "read" }, { name: "write" }],
});
const next = manifest({ scopes: [{ name: "read" }] });
expect(requiresReinstall(prev, next)).toBe(false);
});
});
});

View File

@@ -0,0 +1,112 @@
import type { PluginManifest } from "@suaveplan/api-types/manifest";
import { LIFECYCLE_CODES, LifecycleError } from "../error-codes/error-codes.js";
/**
* Loose semver comparison. Returns -1 if `a < b`, 1 if `a > b`, 0 if equal.
* Honours the `X.Y.Z` shape and ignores prerelease/build metadata
* (everything from the first `-` or `+`).
*
* Robust enough for the manifest-version compare needed by
* {@link requiresReinstall} — Stage-2's `api-manifest` validator will
* already have rejected non-semver values.
*/
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
const norm = (s: string): number[] => {
const sep = s.search(/[-+]/);
const core = sep === -1 ? s : s.slice(0, sep);
return core.split(".").map((part) => Number.parseInt(part, 10) || 0);
};
const ap = norm(a);
const bp = norm(b);
const len = Math.max(ap.length, bp.length);
for (let i = 0; i < len; i += 1) {
const ai = (ap[i] ?? 0) as number;
const bi = (bp[i] ?? 0) as number;
if (ai < bi) return -1;
if (ai > bi) return 1;
}
return 0;
}
function setOfNames(
items: ReadonlyArray<{ readonly name: string }> | undefined
): Set<string> {
return new Set((items ?? []).map((it) => it.name));
}
function hasNewItems<T extends { readonly name: string }>(
prev: ReadonlyArray<T> | undefined,
next: ReadonlyArray<T> | undefined
): boolean {
const prevSet = setOfNames(prev);
for (const item of next ?? []) {
if (!prevSet.has(item.name)) return true;
}
return false;
}
function hasNewStrings(
prev: readonly string[] | undefined,
next: readonly string[] | undefined
): boolean {
const prevSet = new Set(prev ?? []);
for (const item of next ?? []) {
if (!prevSet.has(item)) return true;
}
return false;
}
function dependsOnDiffers(
prev: Readonly<Record<string, string>> | undefined,
next: Readonly<Record<string, string>> | undefined
): boolean {
const a = prev ?? {};
const b = next ?? {};
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
if (a[k] !== b[k]) return true;
}
return false;
}
/**
* Determines whether the transition from `prev` to `next` requires a full
* reinstall (versus a hot reload). Per design §4:
* - `next.database.schemaVersion > prev.database.schemaVersion`
* - new entries in `scopes`, `events.publishes`, `events.subscribes`,
* `jobs.cron`, `jobs.queued`, or `secrets`
* - any change to `dependsOn`
*
* If `next.version` is strictly lower than `prev.version`, throws
* `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` — downgrades are
* disallowed (the ledger would then point at a higher schemaVersion than
* the running code can satisfy).
*/
export function requiresReinstall(
prev: PluginManifest,
next: PluginManifest
): boolean {
if (compareSemver(next.version, prev.version) < 0) {
throw new LifecycleError(
LIFECYCLE_CODES.REINSTALL_REQUIRED_VERSION_DOWNGRADE,
`Cannot reinstall: next.version (${next.version}) is lower than prev.version (${prev.version})`,
{ context: { prev: prev.version, next: next.version } }
);
}
const prevSchema = prev.database?.schemaVersion ?? 0;
const nextSchema = next.database?.schemaVersion ?? 0;
if (nextSchema > prevSchema) return true;
if (hasNewItems(prev.scopes, next.scopes)) return true;
if (hasNewStrings(prev.events?.publishes, next.events?.publishes))
return true;
if (hasNewStrings(prev.events?.subscribes, next.events?.subscribes))
return true;
if (hasNewItems(prev.jobs?.cron, next.jobs?.cron)) return true;
if (hasNewItems(prev.jobs?.queued, next.jobs?.queued)) return true;
if (hasNewItems(prev.secrets, next.secrets)) return true;
if (dependsOnDiffers(prev.dependsOn, next.dependsOn)) return true;
return false;
}

View File

@@ -0,0 +1,12 @@
export type {
Clock,
CrashedPolicy,
StartFailedPolicy,
} from "./retry-policies.js";
export {
CRASHED_DEFAULTS,
CrashBudgetTracker,
RETRY_POLICIES,
START_FAILED_DEFAULTS,
StartFailedTracker,
} from "./retry-policies.js";

View File

@@ -0,0 +1,101 @@
# `retry-policies`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the retry-budget contract for the two automatic-recovery paths in
the lifecycle FSM: `start_failed` (3 attempts with exponential-backoff +
full jitter) and `crashed` (3 restarts within a rolling 60-second window
that resets after 120 seconds of healthy operation). Backed by
`@suaveplan/retry`'s `createExponentialBackoff` so the delay maths match
the rest of the codebase. Throws `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` when
either budget is depleted.
## Features
- `RETRY_POLICIES` frozen registry exposing `.startFailed` and `.crashed`
policy bundles with the configured `delay` function.
- `START_FAILED_DEFAULTS` and `CRASHED_DEFAULTS` as named constants for
callers that need the raw numeric defaults (e.g. admin UI tooltips).
- `StartFailedTracker` — per-plugin attempt counter; `recordFailure()`
throws once `maxAttempts` is reached, `markStarted()` resets.
- `CrashBudgetTracker` — per-plugin rolling-window crash counter; honours
`crashRestartWindowMs` to drop expired entries and `markHealthy()` for
the cooldown reset.
- Injectable `Clock` on `CrashBudgetTracker` so tests can drive the
rolling window deterministically without real time.
## Usage: Basic
```ts
import {
CrashBudgetTracker,
RETRY_POLICIES,
StartFailedTracker,
} from "@suaveplan/api-lifecycle/retry-policies";
const startTracker = new StartFailedTracker("billing");
try {
await plugin.start();
startTracker.markStarted();
} catch (err) {
startTracker.recordFailure(); // throws once budget exhausted
}
```
## Usage: Advanced
Custom policy for a host that wants 5 start attempts and a tighter crash
window, while keeping the default delay function:
```ts
import {
type CrashedPolicy,
type StartFailedPolicy,
RETRY_POLICIES,
} from "@suaveplan/api-lifecycle/retry-policies";
const startPolicy: StartFailedPolicy = {
...RETRY_POLICIES.startFailed,
maxAttempts: 5,
};
const crashPolicy: CrashedPolicy = {
...RETRY_POLICIES.crashed,
crashRestartMax: 5,
crashRestartWindowMs: 30_000,
};
```
Deterministic testing — feed the tracker a fake clock to step through the
rolling window in zero wall-clock time:
```ts
import { CrashBudgetTracker, RETRY_POLICIES } from "@suaveplan/api-lifecycle/retry-policies";
let now = 0;
const tracker = new CrashBudgetTracker("billing", RETRY_POLICIES.crashed, () => now);
tracker.recordCrash();
now += RETRY_POLICIES.crashed.crashRestartWindowMs + 1;
tracker.recordCrash(); // old crash dropped — counts as first
```
## API Reference
- `RETRY_POLICIES` — frozen `{ startFailed, crashed }`.
- `START_FAILED_DEFAULTS`, `CRASHED_DEFAULTS` — raw constants.
- `class StartFailedTracker` — `recordFailure`, `markStarted`, `count`.
- `class CrashBudgetTracker` — `recordCrash`, `markHealthy`, `count`.
- `type StartFailedPolicy`, `type CrashedPolicy`, `type Clock`.
## Implementation Notes
The crash tracker stores timestamps in an array — when the window grows
past 3 a `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` throw fires. The filter-on-each
-record approach is fine for the small numbers involved (max 3 + one
overflow); a ring buffer is overkill here. The trackers are deliberately
not coupled to the FSM factory — the host driver wires them together so a
custom driver can substitute its own budget policy without changing the
state-machine wiring.

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LifecycleError } from "../error-codes/error-codes.js";
import {
CRASHED_DEFAULTS,
CrashBudgetTracker,
RETRY_POLICIES,
START_FAILED_DEFAULTS,
StartFailedTracker,
} from "./retry-policies.js";
describe("retry-policies", () => {
it("RETRY_POLICIES has startFailed and crashed slots", {
timeout: 5000,
}, () => {
expect(RETRY_POLICIES.startFailed).toBeDefined();
expect(RETRY_POLICIES.crashed).toBeDefined();
expect(Object.isFrozen(RETRY_POLICIES)).toBe(true);
expect(Object.isFrozen(RETRY_POLICIES.startFailed)).toBe(true);
expect(Object.isFrozen(RETRY_POLICIES.crashed)).toBe(true);
});
it("START_FAILED_DEFAULTS pins maxAttempts to 3", { timeout: 5000 }, () => {
expect(START_FAILED_DEFAULTS.maxAttempts).toBe(3);
});
it("CRASHED_DEFAULTS pins crashRestartMax to 3", { timeout: 5000 }, () => {
expect(CRASHED_DEFAULTS.crashRestartMax).toBe(3);
});
it("startFailed delay function returns ascending values", {
timeout: 5000,
}, () => {
const d = RETRY_POLICIES.startFailed.delay;
const a = d(1);
const b = d(2);
expect(a).toBeGreaterThanOrEqual(0);
expect(b).toBeGreaterThanOrEqual(0);
// Both must respect maxDelayMs.
expect(b).toBeLessThanOrEqual(RETRY_POLICIES.startFailed.maxDelayMs);
});
it("crashed delay function respects maxDelayMs", { timeout: 5000 }, () => {
const d = RETRY_POLICIES.crashed.delay;
for (let i = 1; i <= 10; i += 1) {
expect(d(i)).toBeLessThanOrEqual(RETRY_POLICIES.crashed.maxDelayMs);
}
});
it("start_failed retried 3 times then parked", { timeout: 5000 }, () => {
const tracker = new StartFailedTracker("billing");
tracker.recordFailure();
expect(tracker.count).toBe(1);
tracker.recordFailure();
expect(tracker.count).toBe(2);
try {
tracker.recordFailure();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LifecycleError);
const e = err as LifecycleError;
expect(e.code).toBe("LIFECYCLE.RETRY_BUDGET_EXHAUSTED");
expect(e.context).toEqual({
plugin: "billing",
attempts: 3,
maxAttempts: 3,
});
}
});
it("StartFailedTracker resets on markStarted", { timeout: 5000 }, () => {
const tracker = new StartFailedTracker("billing");
tracker.recordFailure();
tracker.recordFailure();
tracker.markStarted();
expect(tracker.count).toBe(0);
// Now we should be able to record another two without throwing.
tracker.recordFailure();
tracker.recordFailure();
expect(tracker.count).toBe(2);
});
it("crashed budget resets after cooldown", { timeout: 5000 }, () => {
let now = 1_000;
const clock = () => now;
const tracker = new CrashBudgetTracker(
"billing",
RETRY_POLICIES.crashed,
clock
);
tracker.recordCrash(); // 1
tracker.recordCrash(); // 2
tracker.recordCrash(); // 3 — at max
// Healthy operation for crashRestartCooldownMs resets the budget.
tracker.markHealthy();
expect(tracker.count).toBe(0);
// After cooldown, a fresh crash is "first in window".
now += RETRY_POLICIES.crashed.crashRestartCooldownMs + 1;
tracker.recordCrash();
expect(tracker.count).toBe(1);
});
it("crashed budget rolling-window drops old crashes", {
timeout: 5000,
}, () => {
let now = 1_000;
const clock = () => now;
const tracker = new CrashBudgetTracker(
"billing",
RETRY_POLICIES.crashed,
clock
);
tracker.recordCrash(); // t=1000
now += RETRY_POLICIES.crashed.crashRestartWindowMs + 1;
// Old crash now outside window; new crash is "first in window".
tracker.recordCrash();
expect(tracker.count).toBe(1);
});
it("crashed budget throws LIFECYCLE.RETRY_BUDGET_EXHAUSTED past max", {
timeout: 5000,
}, () => {
const clock = () => 1_000;
const tracker = new CrashBudgetTracker(
"billing",
RETRY_POLICIES.crashed,
clock
);
tracker.recordCrash();
tracker.recordCrash();
tracker.recordCrash();
try {
tracker.recordCrash();
expect.fail("expected throw");
} catch (err) {
const e = err as LifecycleError;
expect(e.code).toBe("LIFECYCLE.RETRY_BUDGET_EXHAUSTED");
expect(e.context).toMatchObject({ plugin: "billing", max: 3 });
}
});
it("CrashBudgetTracker default clock uses Date.now", {
timeout: 5000,
}, () => {
const tracker = new CrashBudgetTracker("billing");
tracker.recordCrash();
expect(tracker.count).toBe(1);
});
it("StartFailedTracker default policy is RETRY_POLICIES.startFailed", {
timeout: 5000,
}, () => {
const tracker = new StartFailedTracker("billing");
expect(tracker.count).toBe(0);
});
});

View File

@@ -0,0 +1,201 @@
import type { RetryStrategy } from "@suaveplan/retry";
import { createExponentialBackoff } from "@suaveplan/retry";
/**
* Minimal monotonic clock contract — matches `@suaveplan/types`'s `Clock`
* shape but avoids adding the types-only dep just for one signature.
*/
export type Clock = () => number;
import { LIFECYCLE_CODES, LifecycleError } from "../error-codes/error-codes.js";
/**
* Defaults for the `start_failed` recovery path: 3 attempts including the
* first, exponential backoff with full jitter starting at 250ms and
* doubling.
*/
export const START_FAILED_DEFAULTS = {
maxAttempts: 3,
baseDelayMs: 250,
maxDelayMs: 5_000,
factor: 2,
} as const;
/**
* Defaults for the `crashed` auto-restart loop: 3 restarts within
* `windowMs` count against the budget. Healthy operation for `cooldownMs`
* resets the counter to zero.
*/
export const CRASHED_DEFAULTS = {
crashRestartMax: 3,
crashRestartWindowMs: 60_000,
crashRestartCooldownMs: 120_000,
baseDelayMs: 500,
maxDelayMs: 10_000,
factor: 2,
} as const;
/**
* Retry-policy bundle for the lifecycle FSM. Exposed via
* {@link RETRY_POLICIES} as a frozen pair of policy objects.
*/
export interface StartFailedPolicy {
readonly maxAttempts: number;
readonly delay: RetryStrategy;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
readonly factor: number;
}
export interface CrashedPolicy {
readonly crashRestartMax: number;
readonly crashRestartWindowMs: number;
readonly crashRestartCooldownMs: number;
readonly delay: RetryStrategy;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
readonly factor: number;
}
function buildStartFailed(): StartFailedPolicy {
return Object.freeze({
maxAttempts: START_FAILED_DEFAULTS.maxAttempts,
baseDelayMs: START_FAILED_DEFAULTS.baseDelayMs,
maxDelayMs: START_FAILED_DEFAULTS.maxDelayMs,
factor: START_FAILED_DEFAULTS.factor,
delay: createExponentialBackoff({
initial: START_FAILED_DEFAULTS.baseDelayMs,
max: START_FAILED_DEFAULTS.maxDelayMs,
base: START_FAILED_DEFAULTS.factor,
jitter: "full",
}),
});
}
function buildCrashed(): CrashedPolicy {
return Object.freeze({
crashRestartMax: CRASHED_DEFAULTS.crashRestartMax,
crashRestartWindowMs: CRASHED_DEFAULTS.crashRestartWindowMs,
crashRestartCooldownMs: CRASHED_DEFAULTS.crashRestartCooldownMs,
baseDelayMs: CRASHED_DEFAULTS.baseDelayMs,
maxDelayMs: CRASHED_DEFAULTS.maxDelayMs,
factor: CRASHED_DEFAULTS.factor,
delay: createExponentialBackoff({
initial: CRASHED_DEFAULTS.baseDelayMs,
max: CRASHED_DEFAULTS.maxDelayMs,
base: CRASHED_DEFAULTS.factor,
jitter: "full",
}),
});
}
/**
* Frozen retry-policy registry. Stage-2's host driver reads these and feeds
* them into `@suaveplan/retry`'s `retry` engine.
*/
export const RETRY_POLICIES = Object.freeze({
startFailed: buildStartFailed(),
crashed: buildCrashed(),
});
/**
* Tracker for the `crashed` auto-restart budget per plugin. The host
* driver instantiates one per plugin and calls `recordCrash` on each
* crash and `markHealthy` (via a timer) after the cooldown elapses. The
* tracker emits `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` when the budget is
* exhausted in the rolling window.
*/
export class CrashBudgetTracker {
private crashesInWindow: number[] = [];
public constructor(
private readonly plugin: string,
private readonly policy: CrashedPolicy = RETRY_POLICIES.crashed,
private readonly clock: Clock = () => Date.now()
) {}
/**
* Record a crash. Throws `LIFECYCLE.RETRY_BUDGET_EXHAUSTED` if the
* crash count within `crashRestartWindowMs` now exceeds
* `crashRestartMax`.
*/
public recordCrash(): void {
const now = this.clock();
// Drop crashes outside the rolling window.
this.crashesInWindow = this.crashesInWindow.filter(
(t) => now - t <= this.policy.crashRestartWindowMs
);
this.crashesInWindow.push(now);
if (this.crashesInWindow.length > this.policy.crashRestartMax) {
throw new LifecycleError(
LIFECYCLE_CODES.RETRY_BUDGET_EXHAUSTED,
`Plugin ${this.plugin} exceeded crashRestartMax (${this.policy.crashRestartMax}) within ${this.policy.crashRestartWindowMs}ms`,
{
context: {
plugin: this.plugin,
max: this.policy.crashRestartMax,
windowMs: this.policy.crashRestartWindowMs,
},
}
);
}
}
/**
* Reset the budget. Called by the host after `crashRestartCooldownMs`
* of uninterrupted healthy operation.
*/
public markHealthy(): void {
this.crashesInWindow = [];
}
/** Read-only view of the current crash count. */
public get count(): number {
return this.crashesInWindow.length;
}
}
/**
* Tracker for the `start_failed` retry budget per plugin. Each
* `recordFailure()` increments; the engine MUST call this before
* dispatching a retry. After `maxAttempts` attempts total, the next
* recordFailure throws `LIFECYCLE.RETRY_BUDGET_EXHAUSTED`.
*/
export class StartFailedTracker {
private attempts = 0;
public constructor(
private readonly plugin: string,
private readonly policy: StartFailedPolicy = RETRY_POLICIES.startFailed
) {}
/**
* Record a failed start attempt. Throws when the budget is exhausted.
*/
public recordFailure(): void {
this.attempts += 1;
if (this.attempts >= this.policy.maxAttempts) {
throw new LifecycleError(
LIFECYCLE_CODES.RETRY_BUDGET_EXHAUSTED,
`Plugin ${this.plugin} exhausted start_failed budget after ${this.policy.maxAttempts} attempts`,
{
context: {
plugin: this.plugin,
attempts: this.attempts,
maxAttempts: this.policy.maxAttempts,
},
}
);
}
}
/** Reset on a successful start. */
public markStarted(): void {
this.attempts = 0;
}
/** Read-only view of the current attempt count. */
public get count(): number {
return this.attempts;
}
}

View File

@@ -0,0 +1,6 @@
export type { SideEffectEntry, SideEffectOp } from "./side-effects.js";
export {
DEFAULT_GRACEFUL_MS,
getSideEffects,
SIDE_EFFECTS,
} from "./side-effects.js";

View File

@@ -0,0 +1,95 @@
# `side-effects`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes every side-effect the host runtime SHALL execute on each documented
transition — `mount-routes`, `unmount-routes` with 503 + Retry-After,
`call-user-hook` with the user-facing install/start/stop/uninstall hooks,
`dispose-capabilities` in reverse-registration order, `emit-event` for the
public `plugin.*` topics, OTel-span completion, and the migration runner's
ledger writes. The table is data; Stage 2's `api-host` is the executor.
## Features
- Discriminated-union `SideEffectOp` covering all 24 distinct effect kinds
named in design §4's table — every operation is statically typed so the
Stage-2 dispatcher can exhaustively switch.
- `SIDE_EFFECTS` map keyed `"<from>|<to>"` matching the lookup form used
by the FSM factory.
- `getSideEffects(from, to)` query helper; `undefined` for unknown pairs.
- `DEFAULT_GRACEFUL_MS = 30_000` constant pinning the design-doc default
for the `stop` hook's graceful budget.
- `disposalOrder: "reverse-registration"` field surfaced on
`stopping → stopped` for direct inspection.
## Usage: Basic
```ts
import { getSideEffects } from "@suaveplan/api-lifecycle/side-effects";
const entry = getSideEffects("installing", "installed");
for (const op of entry?.ops ?? []) {
// dispatch op …
}
```
## Usage: Advanced
Dispatch table for The host driver — exhaustively handles every
`SideEffectOp` kind. The compiler reports an error if a new effect is added
to the union without a corresponding handler:
```ts
import { type SideEffectOp } from "@suaveplan/api-lifecycle/side-effects";
async function dispatch(op: SideEffectOp, ctx: HostContext): Promise<void> {
switch (op.kind) {
case "validate-manifest": return ctx.validateManifest();
case "resolve-deps": return ctx.resolveDeps();
case "acquire-db-connection": return ctx.acquireDb();
case "run-migrations": return ctx.runMigrations(op.upTo);
case "write-ledger": return ctx.writeLedger();
case "register-scopes": return ctx.registerScopes();
case "call-user-hook": return ctx.callHook(op.hook, op.gracefulMs);
case "emit-event": return ctx.emit(op.topic);
case "complete-otel-span": return ctx.endSpan();
case "mount-routes": return ctx.mountRoutes();
case "unmount-routes": return ctx.unmountRoutes(op.status, op.retryAfter);
case "subscribe-events": return ctx.subscribe();
case "unsubscribe-events": return ctx.unsubscribe();
case "register-jobs": return ctx.registerJobs({ paused: op.paused });
case "pause-jobs": return ctx.pauseJobs();
case "unpause-jobs": return ctx.unpauseJobs();
case "mark-health": return ctx.markHealth(op.state);
case "inject-capabilities": return ctx.injectCaps();
case "dispose-capabilities": return ctx.disposeCaps(op.order);
case "force-cancel-stragglers": return ctx.forceCancel();
case "apply-data-policy": return ctx.applyDataPolicy();
case "drop-scopes": return ctx.dropScopes();
case "drop-ledger": return ctx.dropLedger();
case "rollback-to-prior-module": return ctx.rollback();
}
}
```
## API Reference
- `type SideEffectOp` — 24-arm discriminated union.
- `interface SideEffectEntry` — `{ from, to, ops, disposalOrder? }`.
- `SIDE_EFFECTS` — `ReadonlyMap<"<from>|<to>", SideEffectEntry>`.
- `getSideEffects(from, to)` — query helper.
- `DEFAULT_GRACEFUL_MS` — `30_000`.
## Implementation Notes
Every entry is `Object.freeze`-d at module load so accidental mutation
throws in strict mode. The crash-recovery (`crashed → starting`) and
hot-reload (`stopped → starting`) entries reuse the same ops as the
`installed → starting` path — the design doc specifies they MUST be
indistinguishable from the operator's perspective. `started → stopping`
and `rolled_back → stopping` are similarly aliased; the test suite asserts
that the two ops sequences are equal so a future divergence is caught.

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PluginState } from "../states/states.js";
import {
DEFAULT_GRACEFUL_MS,
getSideEffects,
SIDE_EFFECTS,
type SideEffectOp,
} from "./side-effects.js";
describe("side-effects", () => {
it("every documented side-effect is in the table", {
timeout: 5000,
}, () => {
const documented: ReadonlyArray<[PluginState, PluginState]> = [
["uninstalled", "installing"],
["installing", "installed"],
["installed", "starting"],
["starting", "started"],
["started", "stopping"],
["stopping", "stopped"],
["stopped", "uninstalling"],
["uninstalling", "uninstalled"],
];
for (const [from, to] of documented) {
expect(getSideEffects(from, to), `${from}→${to}`).toBeDefined();
}
});
it("getSideEffects returns undefined for an unknown pair", {
timeout: 5000,
}, () => {
expect(getSideEffects("uninstalled", "stopped")).toBeUndefined();
});
it("installing→installed emits plugin.installed and completes the OTel span", {
timeout: 5000,
}, () => {
const entry = getSideEffects("installing", "installed");
const kinds = entry?.ops.map((op) => op.kind);
expect(kinds).toEqual(["emit-event", "complete-otel-span"]);
const emit = entry?.ops[0] as Extract<
SideEffectOp,
{ kind: "emit-event" }
>;
expect(emit.topic).toBe("plugin.installed");
});
it("started→stopping pauses jobs, unsubs, unmounts 503, calls stop hook", {
timeout: 5000,
}, () => {
const entry = getSideEffects("started", "stopping");
const kinds = entry?.ops.map((op) => op.kind);
expect(kinds).toEqual([
"pause-jobs",
"unsubscribe-events",
"unmount-routes",
"call-user-hook",
]);
const unmount = entry?.ops[2] as Extract<
SideEffectOp,
{ kind: "unmount-routes" }
>;
expect(unmount.status).toBe(503);
expect(unmount.retryAfter).toBe("30");
const hook = entry?.ops[3] as Extract<
SideEffectOp,
{ kind: "call-user-hook" }
>;
expect(hook.hook).toBe("stop");
expect(hook.gracefulMs).toBe(DEFAULT_GRACEFUL_MS);
});
it("stopping→stopped disposes in reverse-registration order", {
timeout: 5000,
}, () => {
const entry = getSideEffects("stopping", "stopped");
expect(entry?.disposalOrder).toBe("reverse-registration");
const dispose = entry?.ops.find(
(op) => op.kind === "dispose-capabilities"
) as
| Extract<SideEffectOp, { kind: "dispose-capabilities" }>
| undefined;
expect(dispose?.order).toBe("reverse-registration");
});
it("DEFAULT_GRACEFUL_MS is 30000", { timeout: 5000 }, () => {
expect(DEFAULT_GRACEFUL_MS).toBe(30_000);
});
it("SIDE_EFFECTS entries are frozen", { timeout: 5000 }, () => {
for (const entry of SIDE_EFFECTS.values()) {
expect(Object.isFrozen(entry)).toBe(true);
}
});
it("crashed→starting and stopped→starting both call user start hook", {
timeout: 5000,
}, () => {
for (const pair of [
["crashed", "starting"] as const,
["stopped", "starting"] as const,
]) {
const entry = getSideEffects(pair[0], pair[1]);
const hook = entry?.ops.find(
(op) => op.kind === "call-user-hook"
) as Extract<SideEffectOp, { kind: "call-user-hook" }> | undefined;
expect(hook?.hook).toBe("start");
}
});
it("starting→rolled_back invokes the rollback-to-prior-module op", {
timeout: 5000,
}, () => {
const entry = getSideEffects("starting", "rolled_back");
const kinds = entry?.ops.map((op) => op.kind);
expect(kinds).toContain("rollback-to-prior-module");
expect(kinds).toContain("mark-health");
});
it("rolled_back→stopping mirrors started→stopping", {
timeout: 5000,
}, () => {
const a = getSideEffects("rolled_back", "stopping");
const b = getSideEffects("started", "stopping");
expect(a?.ops.map((op) => op.kind)).toEqual(
b?.ops.map((op) => op.kind)
);
});
});

View File

@@ -0,0 +1,225 @@
import type { PluginState } from "../states/states.js";
/**
* Default grace period (ms) supplied to user `stop` hooks before the host
* force-cancels stragglers. Aligned with design §4's "(default 30s)" line.
*/
export const DEFAULT_GRACEFUL_MS = 30_000;
/**
* Discriminated union of side-effect operations the host runtime SHALL
* execute when a given `(from, to)` transition fires. Stage 1 owns the
* data — Stage 2's `api-host` executes them.
*/
export type SideEffectOp =
| { kind: "validate-manifest" }
| { kind: "resolve-deps" }
| { kind: "acquire-db-connection" }
| { kind: "run-migrations"; upTo: "schemaVersion" }
| { kind: "write-ledger" }
| { kind: "register-scopes" }
| {
kind: "call-user-hook";
hook: "install" | "start" | "stop" | "uninstall";
gracefulMs?: number;
}
| { kind: "emit-event"; topic: string }
| { kind: "complete-otel-span" }
| { kind: "mount-routes" }
| { kind: "unmount-routes"; status: number; retryAfter: string }
| { kind: "subscribe-events" }
| { kind: "unsubscribe-events" }
| { kind: "register-jobs"; paused: boolean }
| { kind: "pause-jobs" }
| { kind: "unpause-jobs" }
| { kind: "mark-health"; state: "ready" | "draining" | "down" }
| { kind: "inject-capabilities" }
| { kind: "dispose-capabilities"; order: "reverse-registration" }
| { kind: "force-cancel-stragglers" }
| { kind: "apply-data-policy" }
| { kind: "drop-scopes" }
| { kind: "drop-ledger" }
| { kind: "rollback-to-prior-module" };
/**
* Descriptor entry for one transition: the `(from, to)` pair and the
* ordered side-effect list. The host runtime iterates `ops` in order.
*/
export interface SideEffectEntry {
readonly from: PluginState;
readonly to: PluginState;
readonly ops: readonly SideEffectOp[];
/**
* For `stopping → stopped`: pins the disposal order so the field is
* directly visible without scanning `ops`. Always `"reverse-registration"`
* when present.
*/
readonly disposalOrder?: "reverse-registration";
}
/**
* Frozen side-effect descriptor table. Every transition documented in the
* design §4 table appears here.
*
* The map is keyed on `"<from>|<to>"` to mirror the lookup shape used by
* the FSM factory.
*/
export const SIDE_EFFECTS: ReadonlyMap<string, SideEffectEntry> = (() => {
const entries: SideEffectEntry[] = [
{
from: "uninstalled",
to: "installing",
ops: [
{ kind: "validate-manifest" },
{ kind: "resolve-deps" },
{ kind: "acquire-db-connection" },
{ kind: "run-migrations", upTo: "schemaVersion" },
{ kind: "write-ledger" },
{ kind: "register-scopes" },
{ kind: "call-user-hook", hook: "install" },
],
},
{
from: "installing",
to: "installed",
ops: [
{ kind: "emit-event", topic: "plugin.installed" },
{ kind: "complete-otel-span" },
],
},
{
from: "installed",
to: "starting",
ops: [
{ kind: "mount-routes" },
{ kind: "subscribe-events" },
{ kind: "register-jobs", paused: true },
{ kind: "inject-capabilities" },
{ kind: "call-user-hook", hook: "start" },
],
},
{
from: "starting",
to: "started",
ops: [
{ kind: "unpause-jobs" },
{ kind: "mark-health", state: "ready" },
{ kind: "emit-event", topic: "plugin.started" },
],
},
{
from: "started",
to: "stopping",
ops: [
{ kind: "pause-jobs" },
{ kind: "unsubscribe-events" },
{
kind: "unmount-routes",
status: 503,
retryAfter: "30",
},
{
kind: "call-user-hook",
hook: "stop",
gracefulMs: DEFAULT_GRACEFUL_MS,
},
],
},
{
from: "rolled_back",
to: "stopping",
ops: [
{ kind: "pause-jobs" },
{ kind: "unsubscribe-events" },
{
kind: "unmount-routes",
status: 503,
retryAfter: "30",
},
{
kind: "call-user-hook",
hook: "stop",
gracefulMs: DEFAULT_GRACEFUL_MS,
},
],
},
{
from: "stopping",
to: "stopped",
ops: [
{ kind: "force-cancel-stragglers" },
{
kind: "dispose-capabilities",
order: "reverse-registration",
},
{ kind: "emit-event", topic: "plugin.stopped" },
],
disposalOrder: "reverse-registration",
},
{
from: "stopped",
to: "uninstalling",
ops: [
{ kind: "apply-data-policy" },
{ kind: "drop-scopes" },
{ kind: "drop-ledger" },
{ kind: "call-user-hook", hook: "uninstall" },
],
},
{
from: "uninstalling",
to: "uninstalled",
ops: [{ kind: "emit-event", topic: "plugin.uninstalled" }],
},
// Failure path: rolled back from a failed hot reload
{
from: "starting",
to: "rolled_back",
ops: [
{ kind: "rollback-to-prior-module" },
{ kind: "mark-health", state: "ready" },
],
},
// Crash recovery path: auto-restart
{
from: "crashed",
to: "starting",
ops: [
{ kind: "mount-routes" },
{ kind: "subscribe-events" },
{ kind: "register-jobs", paused: true },
{ kind: "inject-capabilities" },
{ kind: "call-user-hook", hook: "start" },
],
},
// Hot reload: stopped → starting (no install re-run)
{
from: "stopped",
to: "starting",
ops: [
{ kind: "mount-routes" },
{ kind: "subscribe-events" },
{ kind: "register-jobs", paused: true },
{ kind: "inject-capabilities" },
{ kind: "call-user-hook", hook: "start" },
],
},
];
const map = new Map<string, SideEffectEntry>();
for (const entry of entries) {
map.set(`${entry.from}|${entry.to}`, Object.freeze(entry));
}
return map;
})();
/**
* Returns the side-effect entry for `(from, to)`, or `undefined` when no
* entry exists. The FSM factory consults this map after the transition
* commits.
*/
export function getSideEffects(
from: PluginState,
to: PluginState
): SideEffectEntry | undefined {
return SIDE_EFFECTS.get(`${from}|${to}`);
}

View File

@@ -0,0 +1,10 @@
export type { PluginState } from "./states.js";
export {
isLive,
isParked,
isPluginState,
LIVE_STATES,
PARKED_STATES,
PLUGIN_STATE_SET,
PLUGIN_STATES,
} from "./states.js";

View File

@@ -0,0 +1,93 @@
# `states`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the runtime view of the plugin lifecycle's 13-state union. The
`PluginState` type itself is re-exported from `@suaveplan/api-types/lifecycle`
(the single contract package); this module adds the runtime tuple
`PLUGIN_STATES`, the membership set `PLUGIN_STATE_SET`, and three classifier
helpers (`isLive`, `isParked`, `isPluginState`) so the host driver, the admin
status renderer, and operator tooling all share one source of truth.
## Features
- 13-state tuple in design-doc §4 order so the ASCII diagram and the code
read identically.
- O(1) membership checks via `PLUGIN_STATE_SET`.
- Two classifier subsets — `LIVE_STATES` (`started`, `rolled_back`) and
`PARKED_STATES` (`install_failed`, `start_failed`, `uninstall_failed`,
`crashed`) — encoded as `as const` tuples so callers can derive types.
- Three pure predicates: `isLive`, `isParked`, `isPluginState`.
- `satisfies readonly PluginState[]` on every tuple so adding a state to the
union without updating the tuple is a compile-time error.
## Usage: Basic
```ts
import { isLive, PLUGIN_STATES } from "@suaveplan/api-lifecycle/states";
for (const state of PLUGIN_STATES) {
console.log(state, isLive(state));
}
```
## Usage: Advanced
Building a filter for the admin status table that only surfaces plugins that
need operator attention:
```ts
import { isParked, type PluginState } from "@suaveplan/api-lifecycle/states";
function needsOperator(rows: ReadonlyArray<{ state: PluginState }>): number {
return rows.filter((row) => isParked(row.state)).length;
}
```
Driving an exhaustive `switch` against the union (TypeScript reports a
compile error if a new state is added without a case):
```ts
import type { PluginState } from "@suaveplan/api-lifecycle/states";
function statusBadge(state: PluginState): "ok" | "warn" | "error" {
switch (state) {
case "started":
case "rolled_back":
return "ok";
case "installing":
case "starting":
case "stopping":
case "uninstalling":
case "installed":
case "stopped":
case "uninstalled":
return "warn";
case "crashed":
case "install_failed":
case "start_failed":
case "uninstall_failed":
return "error";
}
}
```
## API Reference
- `type PluginState` — re-exported from `@suaveplan/api-types/lifecycle`.
- `PLUGIN_STATES` — frozen 13-element tuple.
- `PLUGIN_STATE_SET` — `ReadonlySet<PluginState>`.
- `LIVE_STATES`, `PARKED_STATES` — narrowed `as const` subsets.
- `isLive(state)`, `isParked(state)`, `isPluginState(value)` — predicates.
## Implementation Notes
Empirically the design doc's ASCII diagram is the source of truth; this
module replicates that order rather than alphabetising for documentation
parity. The `satisfies readonly PluginState[]` clause on every tuple is
essential — without it, a divergence between the union and the runtime
tuple would slip through both the typecheck and the test suite.

View File

@@ -0,0 +1,24 @@
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { PluginState } from "./states.js";
describe("states spec-conformance", () => {
it("PluginState union matches design §4 exactly", { timeout: 5000 }, () => {
// Type-only assertion: every documented state must be assignable
// and no additional state may be assignable.
type Expected =
| "uninstalled"
| "installing"
| "installed"
| "starting"
| "started"
| "rolled_back"
| "crashed"
| "stopping"
| "stopped"
| "uninstalling"
| "install_failed"
| "start_failed"
| "uninstall_failed";
expectTypeOf<PluginState>().toEqualTypeOf<Expected>();
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
isLive,
isParked,
isPluginState,
LIVE_STATES,
PARKED_STATES,
PLUGIN_STATE_SET,
PLUGIN_STATES,
} from "./states.js";
describe("states", () => {
it("PLUGIN_STATES has exactly 13 entries", { timeout: 5000 }, () => {
expect(PLUGIN_STATES).toHaveLength(13);
});
it("PLUGIN_STATES tuple covers every union member", {
timeout: 5000,
}, () => {
expect([...PLUGIN_STATES].sort()).toEqual(
[
"crashed",
"install_failed",
"installed",
"installing",
"rolled_back",
"start_failed",
"started",
"starting",
"stopped",
"stopping",
"uninstall_failed",
"uninstalled",
"uninstalling",
].sort()
);
});
it("PLUGIN_STATE_SET is a set of every PLUGIN_STATES entry", {
timeout: 5000,
}, () => {
expect(PLUGIN_STATE_SET.size).toBe(13);
for (const s of PLUGIN_STATES) {
expect(PLUGIN_STATE_SET.has(s)).toBe(true);
}
});
it("LIVE_STATES contains started + rolled_back", { timeout: 5000 }, () => {
expect([...LIVE_STATES].sort()).toEqual(["rolled_back", "started"]);
});
it("PARKED_STATES contains four failure states", { timeout: 5000 }, () => {
expect([...PARKED_STATES].sort()).toEqual(
[
"crashed",
"install_failed",
"start_failed",
"uninstall_failed",
].sort()
);
});
it("isLive returns true for live states only", { timeout: 5000 }, () => {
expect(isLive("started")).toBe(true);
expect(isLive("rolled_back")).toBe(true);
expect(isLive("installed")).toBe(false);
expect(isLive("crashed")).toBe(false);
});
it("isParked returns true for parked states only", {
timeout: 5000,
}, () => {
expect(isParked("crashed")).toBe(true);
expect(isParked("install_failed")).toBe(true);
expect(isParked("start_failed")).toBe(true);
expect(isParked("uninstall_failed")).toBe(true);
expect(isParked("started")).toBe(false);
expect(isParked("uninstalled")).toBe(false);
});
it("isPluginState distinguishes valid vs invalid values", {
timeout: 5000,
}, () => {
expect(isPluginState("started")).toBe(true);
expect(isPluginState("not-a-state")).toBe(false);
expect(isPluginState(42)).toBe(false);
expect(isPluginState(null)).toBe(false);
expect(isPluginState(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,86 @@
import type { PluginState as ApiPluginState } from "@suaveplan/api-types/lifecycle";
/**
* Plugin lifecycle state — re-exported from `@suaveplan/api-types/lifecycle`
* so the type-level contract is owned by one package.
*
* Mirrors design doc §4 exactly: 13 states covering steady-state, failure,
* and recovery paths.
*/
export type PluginState = ApiPluginState;
/**
* Runtime tuple matching {@link PluginState}. Used by tests that need to
* enumerate states and by admin-facing tooling that needs to render a state
* picker. The order matches the §4 ASCII diagram top-to-bottom.
*/
export const PLUGIN_STATES = [
"uninstalled",
"installing",
"installed",
"starting",
"started",
"rolled_back",
"crashed",
"stopping",
"stopped",
"uninstalling",
"install_failed",
"start_failed",
"uninstall_failed",
] as const satisfies readonly PluginState[];
/**
* Set-form of {@link PLUGIN_STATES} for O(1) membership tests in hot paths
* (e.g. validating an inbound transition trigger).
*/
export const PLUGIN_STATE_SET: ReadonlySet<PluginState> = new Set(
PLUGIN_STATES
);
/**
* States from which the plugin is considered "live" — capabilities mounted,
* routes serving, jobs scheduled. Design §4 treats `rolled_back` as
* semantically equivalent to `started`.
*/
export const LIVE_STATES = [
"started",
"rolled_back",
] as const satisfies readonly PluginState[];
/**
* Terminal failure states an operator must explicitly clear (admin endpoint)
* before the FSM will resume normal transitions for the plugin.
*/
export const PARKED_STATES = [
"install_failed",
"start_failed",
"uninstall_failed",
"crashed",
] as const satisfies readonly PluginState[];
/**
* Returns `true` when the given state is in the live set
* ({@link LIVE_STATES}).
*/
export function isLive(state: PluginState): boolean {
return (LIVE_STATES as readonly PluginState[]).includes(state);
}
/**
* Returns `true` when the given state is parked and needs operator action
* to clear (`install_failed`, `start_failed`, `uninstall_failed`, `crashed`
* once the auto-restart budget is exhausted).
*/
export function isParked(state: PluginState): boolean {
return (PARKED_STATES as readonly PluginState[]).includes(state);
}
/**
* Returns `true` if `value` is a valid {@link PluginState}.
*/
export function isPluginState(value: unknown): value is PluginState {
return (
typeof value === "string" && PLUGIN_STATE_SET.has(value as PluginState)
);
}

View File

@@ -0,0 +1,75 @@
# `testing/conformance`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes `runLifecycleConformance({ factory, label })` — the reusable test
harness `api-host` lifecycle driver MUST pass. Every category in
the requirement (states, transitions, side-effects, retry-budgets, mutex,
reinstall-criteria) is exercised, so a driver that satisfies the harness
satisfies the published lifecycle contract end-to-end. The harness is
designed to drop straight into a Vitest file: the caller imports the
function, invokes it with their factory, and Vitest discovers the cases.
## Features
- Six test categories tracking the six concrete capabilities the lifecycle
contract specifies.
- Factory-based — every test starts from a fresh driver instance so leaks
between cases are impossible.
- Reuses the public `@suaveplan/api-lifecycle` APIs (`StartFailedTracker`,
`CrashBudgetTracker`, `defaultPluginTransitions`, etc.) rather than
re-implementing them, so the harness drifts in lockstep with the
surface it audits.
- No mocks: the included reference test wires up `createPluginFsm` + the
in-memory mutex so the same body of cases that gates `api-host` also
gates Stage 1 in self-test mode.
## Usage: Basic
```ts
import { runLifecycleConformance } from "@suaveplan/api-lifecycle/testing";
runLifecycleConformance({
factory: (init) => buildMyDriver(init),
label: "api-host driver",
});
```
## Usage: Advanced
Composing the harness inside a CI pipeline that wants additional
host-specific cases — `runLifecycleConformance` emits its `describe`
block at module load, so anything else in the file runs alongside it:
```ts
import { describe, it, expect } from "@suaveplan/testing/runner";
import { runLifecycleConformance } from "@suaveplan/api-lifecycle/testing";
runLifecycleConformance({ factory: buildMyDriver, label: "host" });
describe("host-specific extensions", () => {
it("metrics endpoint counts transitions", { timeout: 5000 }, () => {
// …
});
});
```
## API Reference
- `runLifecycleConformance({ factory, label }): void`
- `interface LifecycleDriverLike` — `plugin`, `state()`, `transition()`,
`mutex()`.
- `type LifecycleDriverFactory` — `({ plugin, initial? }) => driver`.
## Implementation Notes
The harness uses `describe`/`it` from `@suaveplan/testing/runner`, so it
inherits the dual-runner conditional resolution: by default it runs on
Vitest; under the `bun` condition it runs on `bun:test`. The sentinel
case `"harness covers …"` exists so the package's `spec.md` can cite a
single deterministic `it` title for the requirement — the actual coverage
comes from the nested describe blocks.

View File

@@ -0,0 +1,44 @@
import { createPluginFsm } from "../fsm-factory/fsm-factory.js";
import {
InMemoryPluginMutex,
type PluginMutex,
} from "../mutex-contract/mutex-contract.js";
import type { PluginState } from "../states/states.js";
import type { PluginTrigger } from "../transitions/transitions.js";
import {
type LifecycleDriverLike,
runLifecycleConformance,
} from "./conformance.js";
/**
* Reference driver — composes the factory-built FSM with a shared mutex
* so the conformance suite has something real to test against.
*/
function buildDriver(init: {
plugin: string;
initial?: PluginState;
}): LifecycleDriverLike {
const mutex: PluginMutex = new InMemoryPluginMutex();
const fsm = createPluginFsm({
plugin: init.plugin,
initial: init.initial,
mutex,
});
return {
plugin: init.plugin,
state(): PluginState {
return fsm.state;
},
transition(trigger: PluginTrigger): Promise<PluginState> {
return fsm.transition(trigger);
},
mutex(): PluginMutex {
return mutex;
},
};
}
runLifecycleConformance({
factory: buildDriver,
label: "reference driver (createPluginFsm + InMemoryPluginMutex)",
});

View File

@@ -0,0 +1,235 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LifecycleError } from "../error-codes/error-codes.js";
import {
InMemoryPluginMutex,
type PluginMutex,
} from "../mutex-contract/mutex-contract.js";
import {
compareSemver,
requiresReinstall,
} from "../reinstall-criteria/reinstall-criteria.js";
import {
CrashBudgetTracker,
RETRY_POLICIES,
StartFailedTracker,
} from "../retry-policies/retry-policies.js";
import { getSideEffects, SIDE_EFFECTS } from "../side-effects/side-effects.js";
import type { PluginState } from "../states/states.js";
import { isLive, isParked, PLUGIN_STATES } from "../states/states.js";
import {
defaultPluginTransitions,
type PluginTrigger,
} from "../transitions/transitions.js";
/**
* Stage-2 host driver — the object whose lifecycle behaviour the
* conformance harness exercises. Any concrete `api-host` driver
* implementation MUST pass {@link runLifecycleConformance} against this
* shape.
*/
export interface LifecycleDriverLike {
readonly plugin: string;
state(): PluginState;
transition(trigger: PluginTrigger): Promise<PluginState>;
mutex(): PluginMutex;
}
/**
* Factory the harness uses to build fresh driver instances per test —
* each test starts from a clean slate.
*/
export type LifecycleDriverFactory = (init: {
plugin: string;
initial?: PluginState;
}) => LifecycleDriverLike;
/**
* Runs the lifecycle conformance suite against an `api-host` driver.
*
* The harness uses `describe`/`it` from `@suaveplan/testing/runner`, so
* callers invoke it inside their test file — Vitest discovers the
* generated cases automatically.
*
* Test categories: states, transitions, side-effects, retry-budgets,
* mutex, reinstall-criteria.
*/
export function runLifecycleConformance({
factory,
label,
}: {
factory: LifecycleDriverFactory;
label: string;
}): void {
describe(`lifecycle conformance — ${label}`, () => {
it("harness covers states transitions side-effects retry-budgets mutex reinstall-criteria", {
timeout: 5000,
}, () => {
// Sentinel: the categories below all run as part of this describe block.
expect(true).toBe(true);
});
describe("states", () => {
it("PLUGIN_STATES covers 13 entries", { timeout: 5000 }, () => {
expect(PLUGIN_STATES.length).toBe(13);
});
it("isLive distinguishes started/rolled_back", {
timeout: 5000,
}, () => {
expect(isLive("started")).toBe(true);
expect(isLive("crashed")).toBe(false);
});
it("isParked covers all parked states", { timeout: 5000 }, () => {
expect(isParked("install_failed")).toBe(true);
expect(isParked("crashed")).toBe(true);
expect(isParked("started")).toBe(false);
});
});
describe("transitions", () => {
it("driver starts in uninstalled by default", {
timeout: 5000,
}, () => {
const driver = factory({ plugin: "harness-default" });
expect(driver.state()).toBe("uninstalled");
});
it("driver walks install path", { timeout: 5000 }, async () => {
const driver = factory({ plugin: "harness-walk" });
await driver.transition("install");
expect(driver.state()).toBe("installing");
await driver.transition("installCompleted");
expect(driver.state()).toBe("installed");
});
it("default transition table contains 22 edges", {
timeout: 5000,
}, () => {
expect(defaultPluginTransitions.edges().length).toBe(22);
});
});
describe("side-effects", () => {
it("SIDE_EFFECTS has an entry for every primary transition", {
timeout: 5000,
}, () => {
expect(
getSideEffects("uninstalled", "installing")
).toBeDefined();
expect(getSideEffects("installing", "installed")).toBeDefined();
expect(getSideEffects("started", "stopping")).toBeDefined();
expect(SIDE_EFFECTS.size).toBeGreaterThanOrEqual(8);
});
});
describe("retry-budgets", () => {
it("StartFailedTracker honours maxAttempts=3", {
timeout: 5000,
}, () => {
const t = new StartFailedTracker("harness");
t.recordFailure();
t.recordFailure();
let captured: unknown;
try {
t.recordFailure();
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(LifecycleError);
expect((captured as LifecycleError).code).toBe(
"LIFECYCLE.RETRY_BUDGET_EXHAUSTED"
);
});
it("CrashBudgetTracker honours crashRestartMax=3 inside window", {
timeout: 5000,
}, () => {
const clock = () => 1_000;
const t = new CrashBudgetTracker(
"harness",
RETRY_POLICIES.crashed,
clock
);
t.recordCrash();
t.recordCrash();
t.recordCrash();
let captured: unknown;
try {
t.recordCrash();
} catch (err) {
captured = err;
}
expect(captured).toBeInstanceOf(LifecycleError);
expect((captured as LifecycleError).code).toBe(
"LIFECYCLE.RETRY_BUDGET_EXHAUSTED"
);
});
});
describe("mutex", () => {
it("InMemoryPluginMutex queues concurrent acquires FIFO", {
timeout: 5000,
}, async () => {
const mutex = new InMemoryPluginMutex();
const order: number[] = [];
const first = await mutex.acquire("harness");
const p2 = mutex.acquire("harness").then((h) => {
order.push(2);
h.release();
});
order.push(1);
first.release();
await p2;
expect(order).toEqual([1, 2]);
});
it("driver exposes a usable mutex", { timeout: 5000 }, () => {
const driver = factory({ plugin: "harness-mutex" });
const mutex = driver.mutex();
expect(typeof mutex.acquire).toBe("function");
expect(typeof mutex.tryAcquire).toBe("function");
});
});
describe("reinstall-criteria", () => {
const baseManifest = {
name: "harness",
version: "1.0.0",
apiHost: "1.x",
isolation: "trusted" as const,
trustLevel: "first-party" as const,
};
it("identical manifests do not require reinstall", {
timeout: 5000,
}, () => {
expect(requiresReinstall(baseManifest, baseManifest)).toBe(
false
);
});
it("schemaVersion bump requires reinstall", {
timeout: 5000,
}, () => {
expect(
requiresReinstall(
{
...baseManifest,
database: { required: true, schemaVersion: 1 },
},
{
...baseManifest,
database: { required: true, schemaVersion: 2 },
}
)
).toBe(true);
});
it("compareSemver orders versions correctly", {
timeout: 5000,
}, () => {
expect(compareSemver("1.0.0", "1.0.1")).toBe(-1);
});
});
});
}

View File

@@ -0,0 +1,5 @@
export type {
LifecycleDriverFactory,
LifecycleDriverLike,
} from "./conformance.js";
export { runLifecycleConformance } from "./conformance.js";

View File

@@ -0,0 +1,7 @@
export type { PluginTransitionEdge, PluginTrigger } from "./transitions.js";
export {
defaultPluginTransitions,
PLUGIN_TRANSITIONS,
PLUGIN_TRIGGERS,
PluginTransitions,
} from "./transitions.js";

View File

@@ -0,0 +1,94 @@
# `transitions`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encodes the complete legal-transition graph of the plugin lifecycle FSM —
every `(from, to, trigger)` edge documented in design doc §4 is present in
`PLUGIN_TRANSITIONS`, and the `PluginTransitions` query wrapper supplies the
runtime lookups (`resolve`, `isLegalPair`, `assertLegalPair`) used by the FSM
factory, the admin status renderer, and the conformance harness. Anything not
in the table throws `LIFECYCLE.ILLEGAL_TRANSITION` — there is no "permissive"
mode.
## Features
- 22-edge frozen `PLUGIN_TRANSITIONS` array covering install, start, crash,
stop, hot-reload, and uninstall paths plus the four operator recovery
triggers (`retryInstall`, `retryStart`, `clearRollback`, `forceUninstall`,
`repair`).
- 19-member `PluginTrigger` string union plus the matching `PLUGIN_TRIGGERS`
runtime tuple.
- `PluginTransitions.resolve(from, trigger)` — strict `(from, trigger) → to`
lookup; throws on illegal edges with the offending pair in `error.context`.
- `PluginTransitions.isLegalPair(from, to)` / `.assertLegalPair(from, to)`
for pair-form validation when no trigger is available.
- Constructor accepts an alternative edge table for test scenarios that need
to exercise the lookup against a synthetic graph.
- `defaultPluginTransitions` singleton — most callers should use this.
## Usage: Basic
```ts
import { defaultPluginTransitions } from "@suaveplan/api-lifecycle/transitions";
const next = defaultPluginTransitions.resolve("uninstalled", "install");
// "installing"
```
## Usage: Advanced
Driving an admin-side state change UI that needs to know whether a manually
typed pair is legal:
```ts
import { defaultPluginTransitions, type PluginTrigger } from "@suaveplan/api-lifecycle/transitions";
import { LifecycleError, LIFECYCLE_CODES } from "@suaveplan/api-lifecycle/error-codes";
function dispatch(from: PluginState, trigger: PluginTrigger): PluginState {
try {
return defaultPluginTransitions.resolve(from, trigger);
} catch (err) {
if (err instanceof LifecycleError && err.code === LIFECYCLE_CODES.ILLEGAL_TRANSITION) {
// Render a user-friendly error in the admin UI rather than crashing.
throw new Error(`Cannot ${trigger} from ${from}: not a legal transition`);
}
throw err;
}
}
```
Building a custom transition table for a host that wants to forbid the
hot-reload path (`stopped → starting`):
```ts
import { PLUGIN_TRANSITIONS, PluginTransitions } from "@suaveplan/api-lifecycle/transitions";
const stricter = new PluginTransitions(
PLUGIN_TRANSITIONS.filter(
(edge) => !(edge.from === "stopped" && edge.to === "starting" && edge.trigger === "start"),
),
);
```
## API Reference
- `type PluginTrigger` — 19-member string union.
- `type PluginTransitionEdge` — `{ from, to, trigger }`.
- `PLUGIN_TRANSITIONS` — frozen edge array.
- `PLUGIN_TRIGGERS` — runtime tuple.
- `class PluginTransitions` — `new (edges?)`, `.resolve`, `.isLegalPair`,
`.assertLegalPair`, `.edges()`.
- `defaultPluginTransitions` — singleton.
## Implementation Notes
The table uses two parallel indices: `byFromTrigger` keyed on
`"<from>|<trigger>"` for the strict `(from, trigger) → to` lookup, and
`fromToPairs` keyed on `"<from>|<to>"` for pair-form validation. Both are
constructed once per `PluginTransitions` instance and cached on the
instance — `resolve` is O(1). The string-concatenation join (`|`) is safe
because no `PluginState` or `PluginTrigger` value contains a pipe character.

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LifecycleError } from "../error-codes/error-codes.js";
import type { PluginState } from "../states/states.js";
import {
defaultPluginTransitions,
PLUGIN_TRANSITIONS,
PLUGIN_TRIGGERS,
PluginTransitions,
} from "./transitions.js";
describe("transitions", () => {
it("PLUGIN_TRIGGERS lists exactly 19 triggers", { timeout: 5000 }, () => {
expect(PLUGIN_TRIGGERS).toHaveLength(19);
});
it("PLUGIN_TRANSITIONS is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(PLUGIN_TRANSITIONS)).toBe(true);
});
it("every documented transition is legal", { timeout: 5000 }, () => {
const expected: ReadonlyArray<[PluginState, PluginState]> = [
["uninstalled", "installing"],
["installing", "installed"],
["installing", "install_failed"],
["install_failed", "installing"],
["installed", "starting"],
["starting", "started"],
["starting", "start_failed"],
["starting", "rolled_back"],
["start_failed", "starting"],
["rolled_back", "starting"],
["started", "crashed"],
["rolled_back", "crashed"],
["crashed", "starting"],
["started", "stopping"],
["rolled_back", "stopping"],
["stopping", "stopped"],
["stopped", "starting"],
["stopped", "uninstalling"],
["uninstalling", "uninstalled"],
["uninstalling", "uninstall_failed"],
["uninstall_failed", "uninstalling"],
["uninstall_failed", "uninstalled"],
];
for (const [from, to] of expected) {
expect(defaultPluginTransitions.isLegalPair(from, to)).toBe(true);
}
});
it("undocumented transition rejected", { timeout: 5000 }, () => {
try {
defaultPluginTransitions.assertLegalPair("installed", "stopped");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LifecycleError);
const e = err as LifecycleError;
expect(e.code).toBe("LIFECYCLE.ILLEGAL_TRANSITION");
expect(e.context).toEqual({ from: "installed", to: "stopped" });
}
});
it("resolve returns the documented destination state", {
timeout: 5000,
}, () => {
expect(defaultPluginTransitions.resolve("uninstalled", "install")).toBe(
"installing"
);
expect(
defaultPluginTransitions.resolve("starting", "startCompleted")
).toBe("started");
expect(
defaultPluginTransitions.resolve("crashed", "crashRestart")
).toBe("starting");
});
it("resolve throws for illegal (from, trigger) combinations", {
timeout: 5000,
}, () => {
try {
defaultPluginTransitions.resolve("uninstalled", "stop");
expect.fail("expected throw");
} catch (err) {
const e = err as LifecycleError;
expect(e.code).toBe("LIFECYCLE.ILLEGAL_TRANSITION");
expect(e.context).toEqual({ from: "uninstalled", trigger: "stop" });
}
});
it("edges() returns the full PLUGIN_TRANSITIONS array", {
timeout: 5000,
}, () => {
expect(defaultPluginTransitions.edges()).toBe(PLUGIN_TRANSITIONS);
});
it("custom edge table is honoured by constructor", {
timeout: 5000,
}, () => {
const custom = new PluginTransitions([
{ from: "uninstalled", to: "uninstalled", trigger: "repair" },
]);
expect(custom.resolve("uninstalled", "repair")).toBe("uninstalled");
expect(custom.isLegalPair("uninstalled", "uninstalled")).toBe(true);
});
it("isLegalPair returns false for unreachable pairs", {
timeout: 5000,
}, () => {
expect(
defaultPluginTransitions.isLegalPair("uninstalled", "stopped")
).toBe(false);
});
it("assertLegalPair is a no-op on legal pairs", { timeout: 5000 }, () => {
// Doesn't throw — the false branch of the isLegalPair guard.
expect(() =>
defaultPluginTransitions.assertLegalPair(
"uninstalled",
"installing"
)
).not.toThrow();
});
});

View File

@@ -0,0 +1,192 @@
import { LIFECYCLE_CODES, LifecycleError } from "../error-codes/error-codes.js";
import type { PluginState } from "../states/states.js";
/**
* Operator/runtime-initiated triggers that drive the lifecycle FSM. The
* names are mechanically derived from design §4's transition table: each
* `start`-side trigger has a matching `Completed` / `Failed` outcome, plus
* the four recovery triggers (`crashDetected`, `crashRestart`,
* `clearRollback`, `repair`) and the retry/force variants.
*/
export type PluginTrigger =
| "install"
| "installCompleted"
| "installFailed"
| "start"
| "startCompleted"
| "startFailed"
| "rollbackTriggered"
| "stop"
| "stopCompleted"
| "uninstall"
| "uninstallCompleted"
| "uninstallFailed"
| "crashDetected"
| "crashRestart"
| "clearRollback"
| "retryInstall"
| "retryStart"
| "forceUninstall"
| "repair";
/**
* Runtime tuple form of {@link PluginTrigger} for enumeration in tests and
* admin tooling.
*/
export const PLUGIN_TRIGGERS = [
"install",
"installCompleted",
"installFailed",
"start",
"startCompleted",
"startFailed",
"rollbackTriggered",
"stop",
"stopCompleted",
"uninstall",
"uninstallCompleted",
"uninstallFailed",
"crashDetected",
"crashRestart",
"clearRollback",
"retryInstall",
"retryStart",
"forceUninstall",
"repair",
] as const satisfies readonly PluginTrigger[];
/**
* Single (from, to, trigger) edge in the legal-transition graph.
*/
export interface PluginTransitionEdge {
readonly from: PluginState;
readonly to: PluginState;
readonly trigger: PluginTrigger;
}
/**
* The full, exhaustive legal-transition table mirroring design §4's ASCII
* diagram and side-effect table. Frozen at module load. Every edge here is
* legal; everything else throws `LIFECYCLE.ILLEGAL_TRANSITION`.
*/
export const PLUGIN_TRANSITIONS: readonly PluginTransitionEdge[] =
Object.freeze([
// Install path
{ from: "uninstalled", to: "installing", trigger: "install" },
{ from: "installing", to: "installed", trigger: "installCompleted" },
{ from: "installing", to: "install_failed", trigger: "installFailed" },
{ from: "install_failed", to: "installing", trigger: "retryInstall" },
// Start path
{ from: "installed", to: "starting", trigger: "start" },
{ from: "starting", to: "started", trigger: "startCompleted" },
{ from: "starting", to: "start_failed", trigger: "startFailed" },
{ from: "starting", to: "rolled_back", trigger: "rollbackTriggered" },
{ from: "start_failed", to: "starting", trigger: "retryStart" },
{ from: "rolled_back", to: "starting", trigger: "clearRollback" },
// Crash path — only reachable from started for isolated/sandboxed tiers
{ from: "started", to: "crashed", trigger: "crashDetected" },
{ from: "rolled_back", to: "crashed", trigger: "crashDetected" },
{ from: "crashed", to: "starting", trigger: "crashRestart" },
// Stop path
{ from: "started", to: "stopping", trigger: "stop" },
{ from: "rolled_back", to: "stopping", trigger: "stop" },
{ from: "stopping", to: "stopped", trigger: "stopCompleted" },
// Hot-reload from stopped back to starting (no install re-run)
{ from: "stopped", to: "starting", trigger: "start" },
// Uninstall path
{ from: "stopped", to: "uninstalling", trigger: "uninstall" },
{
from: "uninstalling",
to: "uninstalled",
trigger: "uninstallCompleted",
},
{
from: "uninstalling",
to: "uninstall_failed",
trigger: "uninstallFailed",
},
{ from: "uninstall_failed", to: "uninstalling", trigger: "repair" },
{
from: "uninstall_failed",
to: "uninstalled",
trigger: "forceUninstall",
},
]);
/**
* Wrapper around {@link PLUGIN_TRANSITIONS} exposing query helpers — the
* runtime FSM, admin tooling, and the conformance harness all need to ask
* "is (from, to) legal?" without scanning the array each time.
*/
export class PluginTransitions {
private readonly byFromTrigger: ReadonlyMap<string, PluginState>;
private readonly fromToPairs: ReadonlySet<string>;
public constructor(
edges: readonly PluginTransitionEdge[] = PLUGIN_TRANSITIONS
) {
const byFromTrigger = new Map<string, PluginState>();
const fromToPairs = new Set<string>();
for (const edge of edges) {
byFromTrigger.set(`${edge.from}|${edge.trigger}`, edge.to);
fromToPairs.add(`${edge.from}|${edge.to}`);
}
this.byFromTrigger = byFromTrigger;
this.fromToPairs = fromToPairs;
}
/**
* Returns the destination state for `(from, trigger)`, or throws
* `LIFECYCLE.ILLEGAL_TRANSITION` if no such edge exists. Used by the
* FSM factory's transition guard.
*/
public resolve(from: PluginState, trigger: PluginTrigger): PluginState {
const to = this.byFromTrigger.get(`${from}|${trigger}`);
if (to === undefined) {
throw new LifecycleError(
LIFECYCLE_CODES.ILLEGAL_TRANSITION,
`No legal transition for (${from}, ${trigger})`,
{ context: { from, trigger } }
);
}
return to;
}
/**
* Returns `true` when `(from, to)` is reachable via any trigger.
* Useful for admin-side validation; the FSM factory uses `resolve`.
*/
public isLegalPair(from: PluginState, to: PluginState): boolean {
return this.fromToPairs.has(`${from}|${to}`);
}
/**
* Throws `LIFECYCLE.ILLEGAL_TRANSITION` if `(from, to)` is not in the
* table. Pair-form variant used by tools that don't have a trigger
* (e.g. operator hand-edits in admin UI).
*/
public assertLegalPair(from: PluginState, to: PluginState): void {
if (!this.isLegalPair(from, to)) {
throw new LifecycleError(
LIFECYCLE_CODES.ILLEGAL_TRANSITION,
`No legal transition from ${from} to ${to}`,
{ context: { from, to } }
);
}
}
/**
* Returns every legal `(from, to, trigger)` edge in declaration order
* — used by the conformance harness and admin tooling.
*/
public edges(): readonly PluginTransitionEdge[] {
return PLUGIN_TRANSITIONS;
}
}
/**
* Default singleton instance built from {@link PLUGIN_TRANSITIONS}. Most
* callers should use this; tests that need a custom table pass their own
* `edges` to the constructor.
*/
export const defaultPluginTransitions = new PluginTransitions();

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_JSON = JSON.parse(
readFileSync(resolve(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
describe("dep-graph", () => {
it("declares the four required runtime suaveplan deps", {
timeout: 5000,
}, () => {
const deps = PKG_JSON.dependencies ?? {};
expect(deps["@suaveplan/api-types"]).toBeDefined();
expect(deps["@suaveplan/error"]).toBeDefined();
expect(deps["@suaveplan/events"]).toBeDefined();
expect(deps["@suaveplan/fsm"]).toBeDefined();
expect(deps["@suaveplan/retry"]).toBeDefined();
});
it("uses workspace ranges for internal deps", { timeout: 5000 }, () => {
const deps = PKG_JSON.dependencies ?? {};
for (const [name, range] of Object.entries(deps)) {
if (name.startsWith("@suaveplan/")) {
expect(range, `${name} should use workspace range`).toMatch(
/^workspace:/
);
}
}
});
it("root barrel has no export-star", { timeout: 5000 }, () => {
const indexSrc = readFileSync(
resolve(__dirname, "..", "src", "index.ts"),
"utf8"
);
// Forbid `export *` per CLAUDE.md rule 10. Namespace re-exports
// (`export * as X`) are not used here.
expect(/export\s+\*\s+from/.test(indexSrc)).toBe(false);
expect(/export\s+\*\s+as/.test(indexSrc)).toBe(false);
});
});

View File

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

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-lifecycle",
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\//,
/^node:/,
"tslib",
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
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/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});