first commit
This commit is contained in:
82
docs/api-stack/api/api-lifecycle/README.md
Normal file
82
docs/api-stack/api/api-lifecycle/README.md
Normal 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` |
|
||||
|
||||
@@ -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"`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
93
docs/api-stack/api/api-lifecycle/src/states/states.md
Normal file
93
docs/api-stack/api/api-lifecycle/src/states/states.md
Normal 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.
|
||||
75
docs/api-stack/api/api-lifecycle/src/testing/conformance.md
Normal file
75
docs/api-stack/api/api-lifecycle/src/testing/conformance.md
Normal 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user