first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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,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,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,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,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,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.