first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
# @suaveplan/api-loader — contract package for the Suaveplan API plugin system. Owns the abstract ModuleResolver interface, the RollbackJournal ring-buffer contract (with a reference in-memory implementation), the canonical cache-bust algorithm (bustSpecifier), the persisted-entry Zod schema, the native-binding-detector contract, the LOADER.* error-code registry, and the runLoaderConformance / runRollbackJournalConformance harnesses. Concrete resolver and journal implementations .
Stage-1 contract package for the Suaveplan API plugin system.
This package owns the abstract types, algorithms, and conformance harnesses
the host (`@suaveplan/api-host`, Stage 2) implements. It carries no
file-watching, no admin-API wiring, and no concrete `ModuleResolver`
implementation — those land in the host. Keeping the contracts here makes
the loader testable in isolation, lets adapter packages depend on the
contract without pulling in the host runtime, and prevents the circular
dependency that would otherwise form between the host and its plugins.
<!-- begin:auto-docs -->
## Documentation tree
- [cache-bust](./src/cache-bust/cache-bust.md) — Compute the cache-busted ESM specifier used by the API plugin host during a hot reload. Bun's ESM cache is keyed on the…
- [cache-bust](./src/cache-bust/cache-bust.md) — Compute the cache-busted ESM specifier used by the API plugin host during a hot reload. Bun's ESM cache is keyed on the…
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `LOADER.*` error code surfaced by the plugin-loader contract. Downstream consumers (`@…
- [error-codes](./src/error-codes/error-codes.md) — Stable, frozen registry of every `LOADER.*` error code surfaced by the plugin-loader contract. Downstream consumers (`@…
- [journal-entry](./src/journal-entry/journal-entry.md) — Zod schema covering the **persisted subset** of a `RollbackJournalEntry`. The full entry (defined in `@suaveplan/api-ty…
- [journal-entry](./src/journal-entry/journal-entry.md) — Zod schema covering the **persisted subset** of a `RollbackJournalEntry`. The full entry (defined in `@suaveplan/api-ty…
- [native-binding-detector](./src/native-binding-detector/native-binding-detector.md) — Hot reload is the heart of the API plugin system, but it has one non-negotiable boundary: native bindings (`.node`…
- [native-binding-detector](./src/native-binding-detector/native-binding-detector.md) — Hot reload is the heart of the API plugin system, but it has one non-negotiable boundary: native bindings (`.node`…
- [resolver](./src/resolver/resolver.md) — `ModuleResolver` is the API plugin host's abstraction over `import()`. It imports a plugin entry under a cache-busted E…
- [resolver](./src/resolver/resolver.md) — `ModuleResolver` is the API plugin host's abstraction over `import()`. It imports a plugin entry under a cache-busted E…
- [rollback-journal](./src/rollback-journal/rollback-journal.md) — `RollbackJournal` is the per-plugin bounded ring buffer the API plugin host uses to retain the last N known-good module…
- [rollback-journal](./src/rollback-journal/rollback-journal.md) — `RollbackJournal` is the per-plugin bounded ring buffer the API plugin host uses to retain the last N known-good module…
- [testing](./src/testing/loader-conformance.md) — Reusable conformance harnesses for downstream packages that ship concrete implementations of the loader's `ModuleResolv…
- [loader-conformance](./src/testing/loader-conformance.md) — Reusable conformance harnesses for downstream packages that ship concrete implementations of the loader's `ModuleResolv…
<!-- end:auto-docs -->
## Modules
- [`cache-bust`](./src/cache-bust/cache-bust.md) — `bustSpecifier(url,
version)` pure helper that produces the cache-busted ESM specifier the
host uses on every reload (design §8.2).
- [`error-codes`](./src/error-codes/error-codes.md) — `LOADER_CODES`
registry: `NATIVE_BINDING_HELD`, `MODULE_IMPORT_FAILED`, `JOURNAL_FULL`,
`JOURNAL_ENTRY_INVALID`.
- [`journal-entry`](./src/journal-entry/journal-entry.md) — Zod schema for
the persisted subset of a `RollbackJournalEntry` (the fields that survive
host restart).
- [`native-binding-detector`](./src/native-binding-detector/native-binding-detector.md)
— `NativeBindingDetector` function type and `assertNoNativeBindings`
helper that throws `LOADER.NATIVE_BINDING_HELD` when a plugin's import
subgraph contains `.node` files.
- [`resolver`](./src/resolver/resolver.md) — types-only `ModuleResolver`
contract.
- [`rollback-journal`](./src/rollback-journal/rollback-journal.md) — the
`RollbackJournal` interface plus the reference in-memory
`createRollbackJournal(depth)` implementation (default depth 3).
- [`testing`](./src/testing/loader-conformance.md) — `runLoaderConformance`
and `runRollbackJournalConformance` reusable harnesses.
## Subpath exports
```ts
import { bustSpecifier } from "@suaveplan/api-loader/cache-bust";
import { LOADER_CODES } from "@suaveplan/api-loader/error-codes";
import { rollbackJournalEntryPersistedSchema } from "@suaveplan/api-loader/journal-entry";
import { assertNoNativeBindings } from "@suaveplan/api-loader/native-binding-detector";
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
import { createRollbackJournal } from "@suaveplan/api-loader/rollback-journal";
import { runLoaderConformance } from "@suaveplan/api-loader/testing";
```
## Design intent
Source: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`,
sections 8 (Hot reload), 8.1a (RollbackJournal), 8.2 (cache busting).
## Status
- Version: `0.1.0`
- Wave: Stage 1 (Contracts)
- Consumers: `@suaveplan/api-host` (Stage 2) implements the contracts here.

View File

@@ -0,0 +1,91 @@
# `bustSpecifier`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Compute the cache-busted ESM specifier used by the API plugin host during a
hot reload. Bun's ESM cache is keyed on the full specifier string (including
query parameters), so `file:///plugin/index.js` and
`file:///plugin/index.js?v=2` are distinct cache entries even though both
resolve to the same file on disk. `bustSpecifier` is the canonical helper
the host calls on every reload to produce a fresh specifier — bumping the
importing module's URL forces Bun to re-evaluate the entire transitive
dependency subgraph without paying for an explicit cache invalidation API.
The function is pure and idempotent under monotonically-increasing version
inputs: repeated calls **replace** the existing `?v=N` segment rather than
concatenate, so a long-lived host can bust the same plugin thousands of
times without growing the specifier string. This is the canonical algorithm
referenced by design §8.2.
## Features
- Replaces (never concatenates) the `?v=N` query suffix.
- Preserves unrelated query parameters and hash fragments.
- Works with `file://`, `http://`, `https://`, and `data:` URLs.
- Side-effect-free, allocation-light (one `URL` instance per call).
- Strict input contract: throws synchronously on a malformed URL.
## Usage: Basic
```ts
import { bustSpecifier } from "@suaveplan/api-loader/cache-bust";
const specifier = bustSpecifier("file:///opt/plugin/index.js", 1);
// → "file:///opt/plugin/index.js?v=1"
const next = bustSpecifier(specifier, 2);
// → "file:///opt/plugin/index.js?v=2" (NOT "?v=1?v=2")
const mod = await import(next);
```
## Usage: Advanced
Within a reload loop, the host keeps a monotonically-increasing counter and
busts the specifier on every flip. Other parameters (e.g. an integrity hash)
survive the rewrite:
```ts
import { bustSpecifier } from "@suaveplan/api-loader/cache-bust";
let reload = 0;
const base = "file:///opt/plugin/index.js?integrity=abc";
function nextSpecifier(): string {
reload += 1;
return bustSpecifier(base, reload);
}
// 1st call → "file:///opt/plugin/index.js?integrity=abc&v=1"
// 2nd call → "file:///opt/plugin/index.js?integrity=abc&v=2"
```
## API Reference
```ts
export function bustSpecifier(url: string, version: number): string;
```
- `url`: an absolute URL (a `file://` URL is typical inside the loader, but
any URL supported by the WHATWG `URL` parser works).
- `version`: a non-negative integer reload counter. Zero is permitted as a
boundary case (used by the host on the initial load).
Returns the input URL with its `v` query parameter set to the string form of
`version`. Other query parameters, the path, the fragment, and the
authority are preserved verbatim.
## Implementation Notes
The implementation uses the WHATWG `URL` API (`URL` and `URLSearchParams`)
because it round-trips correctly: `URLSearchParams.set("v", ...)` replaces
any existing `v` entry rather than appending a duplicate. Hand-rolled string
concatenation (`${url}?v=${version}`) was rejected because it cannot detect
an existing suffix — repeated busts under that approach would grow the
specifier without bound (`?v=1?v=2?v=3`), and Bun's cache would treat each
mutation as a distinct module forever, defeating the GC contract the
rollback journal relies on.
The function throws `TypeError` from the WHATWG parser on a malformed URL
input; the host's reload path catches and rewraps as
`LOADER.MODULE_IMPORT_FAILED`.

View File

@@ -0,0 +1,94 @@
# `LOADER_CODES`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable, frozen registry of every `LOADER.*` error code surfaced by the
plugin-loader contract. Downstream consumers (`@suaveplan/api-host` Stage 2
implementations, host telemetry, operator dashboards) read these strings as
load-bearing identifiers — they must never change between releases without
an OpenSpec migration plan. `LOADER_CODES` is the single source of truth
defining those strings and the only place new loader-namespace codes are
added.
The registry is built with `defineCodes("LOADER", [...])` from
`@suaveplan/error`, which yields a literal-typed, deeply-frozen record. The
literal types flow into every `SuaveplanError` thrown by the loader so a
caller can `switch` on `err.code` against `LOADER_CODES.NATIVE_BINDING_HELD`
with full compile-time exhaustiveness checking.
## Features
- Exactly four codes — `NATIVE_BINDING_HELD`, `MODULE_IMPORT_FAILED`,
`JOURNAL_FULL`, `JOURNAL_ENTRY_INVALID`.
- Literal-typed values: `LOADER_CODES.NATIVE_BINDING_HELD` has the type
`"LOADER.NATIVE_BINDING_HELD"` (not the wider `string`).
- Deep-frozen at runtime (`Object.isFrozen` returns `true`).
- Re-exported `LoaderCode` union for use as a discriminator type.
## Usage: Basic
```ts
import { LOADER_CODES } from "@suaveplan/api-loader/error-codes";
import { SuaveplanError } from "@suaveplan/error";
throw new SuaveplanError({
code: LOADER_CODES.NATIVE_BINDING_HELD,
message: "Plugin entry imports a native .node binding; reload refused.",
});
```
## Usage: Advanced
Pattern-match on the union for exhaustive error handling at host call sites:
```ts
import { LOADER_CODES, type LoaderCode } from "@suaveplan/api-loader/error-codes";
function describe(code: LoaderCode): string {
switch (code) {
case LOADER_CODES.NATIVE_BINDING_HELD:
return "Native binding present; full restart required.";
case LOADER_CODES.MODULE_IMPORT_FAILED:
return "ESM import threw; see cause chain.";
case LOADER_CODES.JOURNAL_FULL:
return "Rollback journal at capacity; ledger persist failed.";
case LOADER_CODES.JOURNAL_ENTRY_INVALID:
return "Persisted journal entry failed Zod validation.";
default: {
const exhaustive: never = code;
throw new Error(`unhandled LOADER code: ${exhaustive as string}`);
}
}
}
```
## API Reference
```ts
export const LOADER_CODES: {
readonly NATIVE_BINDING_HELD: "LOADER.NATIVE_BINDING_HELD";
readonly MODULE_IMPORT_FAILED: "LOADER.MODULE_IMPORT_FAILED";
readonly JOURNAL_FULL: "LOADER.JOURNAL_FULL";
readonly JOURNAL_ENTRY_INVALID: "LOADER.JOURNAL_ENTRY_INVALID";
};
export type LoaderCode = (typeof LOADER_CODES)[keyof typeof LOADER_CODES];
```
## Implementation Notes
The registry is built via `defineCodes`, which `Object.freeze`s the
returned record. The `as const` on the reasons array is load-bearing — it
narrows the keys to their literal forms so consumers can switch on the
value with exhaustive checking.
The four codes were chosen against design §8.1a / §8.2 and the loader's
contract surface in `proposal.md`. Adding a new code is an spec change
(it widens the public API); removing or renaming one is a major-version
break. Both `JOURNAL_FULL` and `JOURNAL_ENTRY_INVALID` are reserved here
even though the concrete journal implementation lives in `api-host` — the
host throws under this package's namespace, so the registry has to own the
identifiers.

View File

@@ -0,0 +1,102 @@
# `rollbackJournalEntryPersistedSchema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schema covering the **persisted subset** of a `RollbackJournalEntry`.
The full entry (defined in `@suaveplan/api-types/rollback-journal`) carries
a `moduleRef: WeakRef<object> | null` field that is by definition in-memory
only — a `WeakRef` cannot be JSON-serialised. The remaining five fields
(`specifier`, `manifestHash`, `migrationCursor`, `capturedAt`, `version`)
are written to the plugin ledger so a host restart can recover the rollback
history. On boot, the host reads each persisted record, validates it with
this schema, and re-imports the `specifier` to repopulate `moduleRef` on
demand.
The schema is `.strict()` — any unknown key (including a stray `moduleRef`
left over from an earlier serialisation bug) causes parsing to fail loudly.
This is intentional: a journal entry that drifts in shape is a silent
corruption hazard, and the cost of forgetting to migrate the ledger on a
schema change should be a clean error rather than partial state on the
floor.
## Features
- Zod-validated input boundary at the persisted-entry deserialisation
point.
- `.strict()` mode — rejects unknown keys.
- Manifest-hash regex pins the canonical `sha256:<64-hex>` form (the same
shape `@suaveplan/api-manifest`'s `hashManifest` emits).
- `migrationCursor` constrained to non-negative integers; matches the
`schemaVersion` semantics of the manifest.
- `capturedAt` validated as ISO-8601.
- Inferred `PersistedRollbackEntry` type ready for consumption.
## Usage: Basic
```ts
import { rollbackJournalEntryPersistedSchema } from "@suaveplan/api-loader/journal-entry";
const record = JSON.parse(rawFromLedger);
const persisted = rollbackJournalEntryPersistedSchema.parse(record);
// persisted has no `moduleRef`; the host re-imports `persisted.specifier`
// when rollback is requested.
```
## Usage: Advanced
Use `.safeParse` plus the Zod issue tree for operator-facing diagnostics:
```ts
import {
rollbackJournalEntryPersistedSchema,
type PersistedRollbackEntry,
} from "@suaveplan/api-loader/journal-entry";
import { LOADER_CODES } from "@suaveplan/api-loader/error-codes";
import { SuaveplanError } from "@suaveplan/error";
function readPersisted(record: unknown): PersistedRollbackEntry {
const result = rollbackJournalEntryPersistedSchema.safeParse(record);
if (!result.success) {
throw new SuaveplanError({
code: LOADER_CODES.JOURNAL_ENTRY_INVALID,
message: "Persisted rollback-journal entry failed Zod validation.",
details: { issues: result.error.issues },
});
}
return result.data;
}
```
## API Reference
```ts
export const rollbackJournalEntryPersistedSchema: z.ZodObject<{
specifier: z.ZodString;
manifestHash: z.ZodString;
migrationCursor: z.ZodNumber;
capturedAt: z.ZodString;
version: z.ZodString;
}>;
export type PersistedRollbackEntry = z.infer<
typeof rollbackJournalEntryPersistedSchema
>;
```
## Implementation Notes
The `manifestHash` constraint uses a regex (`/^sha256:[0-9a-f]{64}$/`)
rather than a `.refine`. Regex validation produces clearer Zod issue paths
(`['manifestHash']` with a single `invalid_string` issue rather than a
custom-refine description) and lets the schema serialise to a JSON Schema
form cleanly should the loader ever publish its persisted shape.
The schema is intentionally **not** transmissible to a JSON Schema generator
that strips `.strict()` — every consumer of the persisted shape must enforce
strictness because the rollback flow is the host's last line of defence.
Adding new persisted fields is permitted (forward-compat) but must arrive
through an OpenSpec change that includes a ledger-migration plan.

View File

@@ -0,0 +1,102 @@
# `NativeBindingDetector` + `assertNoNativeBindings`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hot reload is the heart of the API plugin system, but it has one
non-negotiable boundary: native bindings (`.node` files compiled to a
specific Node-API version) cannot be reloaded. Bun's ESM cache-bust trick
re-imports a fresh JS module under a new specifier, but the underlying
native dynamic library is loaded into the process's address space exactly
once — re-running `dlopen` would either be ignored or, worse, corrupt the
existing handle. The loader has to refuse the reload before it tries.
This module owns two pieces: the `NativeBindingDetector` function type
(host-injected — `@suaveplan/api-host` ships a Bun-native implementation
that walks the import subgraph), and the `assertNoNativeBindings` helper
that throws the canonical `LOADER.NATIVE_BINDING_HELD` error when the
detector reports a non-empty result. Per design doc §8.2: "Native bindings
(`.node` files) cannot be reloaded; loader walks the dep graph at install
time and refuses reload if any are present."
## Features
- Host-injected `NativeBindingDetector` function type — runtime-portable
across Bun, Node, and a future Deno port.
- `assertNoNativeBindings(bindings)` synchronous helper — throws
`LOADER.NATIVE_BINDING_HELD` when the array is non-empty, no-ops
otherwise.
- `LoaderError` subclass widening the protected `SuaveplanError`
constructor to public for the `LOADER.*` namespace.
- Defensive copy on the offending paths: mutating the input after the throw
cannot corrupt the error's `context.bindings`.
## Usage: Basic
```ts
import {
type NativeBindingDetector,
assertNoNativeBindings,
} from "@suaveplan/api-loader/native-binding-detector";
declare const detector: NativeBindingDetector;
const bindings = await detector("file:///opt/plugin/index.js");
assertNoNativeBindings(bindings); // safe pass-through when bindings.length === 0
```
## Usage: Advanced
Combine with the resolver to wrap the install-time reload-eligibility check:
```ts
import { assertNoNativeBindings } from "@suaveplan/api-loader/native-binding-detector";
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
async function assertReloadable(
resolver: ModuleResolver,
specifier: string
): Promise<void> {
const bindings = await resolver.walkNativeBindings(specifier);
assertNoNativeBindings(bindings);
}
```
## API Reference
```ts
export type NativeBindingDetector = (
specifier: string
) => Promise<readonly string[]>;
export class LoaderError extends SuaveplanError<LoaderCode> {
constructor(
code: LoaderCode,
messageKey: string,
options?: SuaveplanErrorOptions
);
}
export function assertNoNativeBindings(bindings: readonly string[]): void;
```
## Implementation Notes
The thrown `LoaderError` attaches the offending paths via `context.bindings`,
copying the input array (`[...bindings]`) so a caller mutating their array
after the throw cannot retroactively change the error envelope. The
copied form is `string[]` (mutable) but the type annotation on the input is
`readonly string[]` — callers are encouraged to pass an immutable array,
but the helper is defensive regardless.
The `messageKey` (`"loader.native_binding_held"`) is the i18n catalogue key
used by `SuaveplanError`'s resolver; when the host's i18n catalogue is
unwired, the resolver falls back to returning the key verbatim, which is
adequate for the operator-facing logs the §8.7 failure-mode table cites.
`assertNoNativeBindings` is intentionally synchronous: it operates on the
already-resolved output of `walkNativeBindings`, so adding an `await` would
buy nothing. Callers usually wrap it in `async` code anyway because the
detector itself is async.

View File

@@ -0,0 +1,107 @@
# `ModuleResolver`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ModuleResolver` is the API plugin host's abstraction over `import()`. It
imports a plugin entry under a cache-busted ESM specifier, returns an opaque
`ModuleHandle`, and releases the host's strong reference when `dispose` is
called so the previous module becomes eligible for GC. The contract lives
here in `@suaveplan/api-loader`; concrete implementations (Bun-native ESM,
ZIP-backed plugins, sandboxed worker proxy) land in `@suaveplan/api-host`
Stage 2 and conform to the `runLoaderConformance` harness exported from
`@suaveplan/api-loader/testing`. Design intent: design doc §8.1 and §8.2.
The contract is types-only. No runtime ships from this module beyond the
type re-export shim — the type definitions exist so the lifecycle code in
`api-host` and the spec for §8 have a single home that downstream tests can
`expectTypeOf` against.
## Features
- `resolve({ specifier, version })` — imports the plugin entry under
`bustSpecifier(specifier, version)` and returns a `ModuleHandle<M>`.
- `dispose(handle)` — releases the host's strong ref to the handle so the
previous module can be collected.
- `walkNativeBindings(specifier)` — returns paths of `.node` files reachable
from the entry's import subgraph; the host uses this at install to gate
hot-reload eligibility.
- Generic over the resolved module type `M` so consumers can pin a strict
plugin-entry shape (e.g. `{ default: PluginFactory }`).
## Usage: Basic
```ts
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
declare const resolver: ModuleResolver<{ default: () => void }>;
const handle = await resolver.resolve({
specifier: "file:///opt/plugin/index.js",
version: 1,
});
handle.module.default();
await resolver.dispose(handle);
```
## Usage: Advanced
Combining `walkNativeBindings` with `assertNoNativeBindings` at install to
gate hot-reload eligibility:
```ts
import type { ModuleResolver } from "@suaveplan/api-loader/resolver";
import { assertNoNativeBindings } from "@suaveplan/api-loader/native-binding-detector";
declare const resolver: ModuleResolver;
const bindings = await resolver.walkNativeBindings(
"file:///opt/plugin/index.js"
);
assertNoNativeBindings(bindings); // throws LOADER.NATIVE_BINDING_HELD if non-empty
```
## API Reference
```ts
export interface ResolveOptions {
readonly specifier: string;
readonly version: number;
}
export interface ModuleHandle<M = unknown> {
readonly specifier: string;
readonly version: number;
readonly module: M;
readonly resolvedAt: string;
}
export interface ModuleResolver<M = unknown> {
resolve(options: ResolveOptions): Promise<ModuleHandle<M>>;
dispose(handle: ModuleHandle<M>): Promise<void>;
walkNativeBindings(specifier: string): Promise<readonly string[]>;
}
export type ModuleResolverFactory<M = unknown> = () => ModuleResolver<M>;
```
## Implementation Notes
`resolve` returns the *evaluated namespace*, not a thenable wrapping it —
the host's lifecycle FSM treats a successful return as a synchronous
"module loaded" signal even though the call itself is async. Concrete
implementations are free to memoise nothing (the cache-bust strategy in
§8.2 makes memoisation incorrect) and to allocate a fresh `ModuleHandle` on
every call.
`dispose` is `Promise<void>` rather than `void` because the in-host
implementation may need to await a `gc()` round-trip in test environments;
the contract surface does not assume synchronous release.
`walkNativeBindings` is `async` because the canonical implementation reads
the dependency graph from a manifest or via a static traversal of `import`
statements — both can involve disk I/O. The return type is
`readonly string[]` (paths of `.node` files) rather than a single boolean so
the operator-facing error envelope can show the exact bindings that blocked
the reload.

View File

@@ -0,0 +1,110 @@
# `RollbackJournal`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RollbackJournal` is the per-plugin bounded ring buffer the API plugin host
uses to retain the last N known-good module activations. When a hot reload
fails its post-start health probe, the host re-activates the previous
specifier from the journal — making rollback atomic and almost free at
steady state. The contract lives here (in `@suaveplan/api-loader`); the
host-side concrete implementations (in-memory + durable ledger-backed)
land in `@suaveplan/api-host` Stage 2. Design intent: design doc §8.1a.
This module ships both the abstract interface (`RollbackJournal` type) and
a small in-memory reference implementation (`createRollbackJournal`). The
reference implementation is enough for downstream packages that need a
local journal without pulling in the full host (e.g. unit tests, sandboxed
plugin runners, the loader-conformance harness). Production hosts replace
it with their own implementation that persists the durable fields to the
plugin ledger so a restart can resume.
## Features
- Bounded FIFO ring buffer with configurable depth (default `3`).
- `push` evicts the oldest entry once depth is reached — never grows
unbounded.
- `peek(n)` returns up to `n` entries newest-first; defensive copy.
- `latest()` returns the most recent entry without allocating an array.
- `clear()` empties the journal; subsequent pushes resume normally.
- Strict argument validation: non-positive or non-integer depth throws
`RangeError`; non-integer or negative `peek(n)` throws `RangeError`.
## Usage: Basic
```ts
import { createRollbackJournal } from "@suaveplan/api-loader/rollback-journal";
const journal = createRollbackJournal();
journal.push({
specifier: "file:///opt/plugin/index.js?v=1",
manifestHash: "sha256:abc...",
moduleRef: null,
migrationCursor: 4,
capturedAt: "2026-05-25T08:14:22.014Z",
version: "1.4.2",
});
const previous = journal.latest();
if (previous !== null) {
await import(previous.specifier);
}
```
## Usage: Advanced
Custom depth + reading history newest-first to display a rollback menu:
```ts
import { createRollbackJournal } from "@suaveplan/api-loader/rollback-journal";
const journal = createRollbackJournal(10);
// ... many pushes happen ...
const history = journal.peek(5);
for (const entry of history) {
console.log(`${entry.capturedAt} ${entry.version} ${entry.manifestHash}`);
}
```
## API Reference
```ts
export interface RollbackJournal {
readonly length: number;
readonly depth: number;
push(entry: RollbackJournalEntry): void;
peek(n: number): readonly RollbackJournalEntry[];
latest(): RollbackJournalEntry | null;
clear(): void;
}
export const DEFAULT_ROLLBACK_JOURNAL_DEPTH: 3;
export function createRollbackJournal(depth?: number): RollbackJournal;
export type RollbackJournalFactory = (depth: number) => RollbackJournal;
```
The `RollbackJournalEntry` shape is owned by `@suaveplan/api-types` and
re-exported here for ergonomics (see `journal-entry/`).
## Implementation Notes
The reference implementation uses a plain `Array<RollbackJournalEntry>` with
`shift()` eviction — the journal is small (default depth 3) so the O(n)
shift is dominated by the ESM module-import cost the host pays on every
flip. A circular-buffer-with-head implementation would be marginally faster
but harder to audit; we accept the constant-factor cost.
`peek` returns a freshly-allocated array on every call; we explicitly do
**not** memo because the host calls `peek` once per reload, and the entries
themselves are immutable. The defensive copy guarantees that callers who
hold the returned array cannot corrupt the journal's internal state by
mutating it (a regression test pins this invariant).
Argument validation uses synchronous `RangeError` throws rather than
`SuaveplanError` because mis-construction of the journal indicates a
host-internal bug, not a recoverable runtime condition. The `LOADER.*` code
namespace (`error-codes`) is reserved for fault paths that the host's
operator UX needs to discriminate.

View File

@@ -0,0 +1,104 @@
# `runLoaderConformance` + `runRollbackJournalConformance`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reusable conformance harnesses for downstream packages that ship concrete
implementations of the loader's `ModuleResolver` or `RollbackJournal`
contracts. `@suaveplan/api-host` (Stage 2) will use both harnesses to
certify its Bun-native resolver and its ledger-backed journal; the contract
package owning the interface MUST also own the harness so the surface is
proven to be testable from the same source of truth.
Each harness takes a factory plus a human-readable label and emits a
`describe()` block per implementation. The block exercises every documented
behavior — `resolve`, `dispose`, `walkNativeBindings`, native-binding
refusal, import-failure error code on the loader side; push, peek-newest-
first, capacity eviction, latest, clear on the journal side. Implementations
that pass both harnesses can claim conformance.
## Features
- `runLoaderConformance({ label, factory, importableSpecifier, ... })` —
exercises a `ModuleResolver<M>` against the contract.
- `runRollbackJournalConformance({ label, factory })` — exercises a
ring-buffer implementation against the contract.
- Optional branches for native-binding subgraphs and failing imports — the
harness skips a branch when the implementation declares it has no path
to test.
- Returned arrays from `peek` are validated to be newest-first.
- Capacity test is parameterised on depth 3 (the design-doc default).
## Usage: Basic
```ts
import { runLoaderConformance } from "@suaveplan/api-loader/testing";
import { createBunResolver } from "@suaveplan/api-host/internal/bun-resolver";
runLoaderConformance({
label: "BunResolver",
factory: () => createBunResolver(),
importableSpecifier: "file:///opt/fixtures/hello-plugin.js",
});
```
## Usage: Advanced
Exercise every branch — including native-binding refusal and import
failure — by handing the harness optional specifiers:
```ts
import { runLoaderConformance } from "@suaveplan/api-loader/testing";
import { createBunResolver } from "@suaveplan/api-host/internal/bun-resolver";
runLoaderConformance({
label: "BunResolver (full)",
factory: () => createBunResolver(),
importableSpecifier: "file:///opt/fixtures/hello-plugin.js",
nativeBindingSpecifier: "file:///opt/fixtures/plugin-with-native.js",
failingSpecifier: "file:///nonexistent/plugin.js",
});
```
## API Reference
```ts
export interface LoaderConformanceOptions<M = unknown> {
readonly label: string;
readonly factory: ModuleResolverFactory<M>;
readonly importableSpecifier: string;
readonly nativeBindingSpecifier?: string;
readonly failingSpecifier?: string;
}
export function runLoaderConformance<M = unknown>(
options: LoaderConformanceOptions<M>
): void;
export interface JournalConformanceOptions {
readonly label: string;
readonly factory: (depth: number) => RollbackJournal;
}
export function runRollbackJournalConformance(
options: JournalConformanceOptions
): void;
```
## Implementation Notes
The harness emits its own `describe()` block, so downstream packages call it
from the top level of a `.test.ts` file rather than wrapping it in their
own `describe()`. This mirrors `@suaveplan/i18n-shared`'s
`runBackendConformance` pattern and keeps the test-runner output flat.
The factory pattern (rather than a single instance) is deliberate: every
test gets a fresh implementation so a leaky journal or a stateful resolver
cannot bleed assertions between tests. This is the same invariant the
`testing` package's `FixtureManager` enforces.
The harness deliberately does not depend on Vitest or Bun-test directly —
it imports from `@suaveplan/testing/runner`, the conditional-export wrapper
that resolves to whichever runner is active. This is mandatory per
CLAUDE.md rule 11.