Initial Commit
This commit is contained in:
82
packages/api-stack/api/api-lifecycle/README.md
Normal file
82
packages/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` |
|
||||
|
||||
117
packages/api-stack/api/api-lifecycle/package.json
Normal file
117
packages/api-stack/api/api-lifecycle/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { LifecycleCode } from "./error-codes.js";
|
||||
export { LIFECYCLE_CODES, LifecycleError } from "./error-codes.js";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
CreatePluginFsmOptions,
|
||||
PluginFsm,
|
||||
PluginFsmContext,
|
||||
} from "./fsm-factory.js";
|
||||
export { createPluginFsm } from "./fsm-factory.js";
|
||||
83
packages/api-stack/api/api-lifecycle/src/index.ts
Normal file
83
packages/api-stack/api/api-lifecycle/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { MutexRelease, PluginMutex } from "./mutex-contract.js";
|
||||
export { assertAcquired, InMemoryPluginMutex } from "./mutex-contract.js";
|
||||
@@ -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,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>();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { compareSemver, requiresReinstall } from "./reinstall-criteria.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { SideEffectEntry, SideEffectOp } from "./side-effects.js";
|
||||
export {
|
||||
DEFAULT_GRACEFUL_MS,
|
||||
getSideEffects,
|
||||
SIDE_EFFECTS,
|
||||
} from "./side-effects.js";
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
10
packages/api-stack/api/api-lifecycle/src/states/index.ts
Normal file
10
packages/api-stack/api/api-lifecycle/src/states/index.ts
Normal 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";
|
||||
93
packages/api-stack/api/api-lifecycle/src/states/states.md
Normal file
93
packages/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.
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
86
packages/api-stack/api/api-lifecycle/src/states/states.ts
Normal file
86
packages/api-stack/api/api-lifecycle/src/states/states.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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,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)",
|
||||
});
|
||||
235
packages/api-stack/api/api-lifecycle/src/testing/conformance.ts
Normal file
235
packages/api-stack/api/api-lifecycle/src/testing/conformance.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
LifecycleDriverFactory,
|
||||
LifecycleDriverLike,
|
||||
} from "./conformance.js";
|
||||
export { runLifecycleConformance } from "./conformance.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { PluginTransitionEdge, PluginTrigger } from "./transitions.js";
|
||||
export {
|
||||
defaultPluginTransitions,
|
||||
PLUGIN_TRANSITIONS,
|
||||
PLUGIN_TRIGGERS,
|
||||
PluginTransitions,
|
||||
} from "./transitions.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
45
packages/api-stack/api/api-lifecycle/tests/dep-graph.test.ts
Normal file
45
packages/api-stack/api/api-lifecycle/tests/dep-graph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-lifecycle/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-lifecycle/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
35
packages/api-stack/api/api-lifecycle/vite.config.ts
Normal file
35
packages/api-stack/api/api-lifecycle/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-lifecycle/vitest.config.ts
Normal file
30
packages/api-stack/api/api-lifecycle/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user