first commit
This commit is contained in:
76
docs/api-stack/api/api-loader/README.md
Normal file
76
docs/api-stack/api/api-loader/README.md
Normal 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.
|
||||
91
docs/api-stack/api/api-loader/src/cache-bust/cache-bust.md
Normal file
91
docs/api-stack/api/api-loader/src/cache-bust/cache-bust.md
Normal 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`.
|
||||
94
docs/api-stack/api/api-loader/src/error-codes/error-codes.md
Normal file
94
docs/api-stack/api/api-loader/src/error-codes/error-codes.md
Normal 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.
|
||||
102
docs/api-stack/api/api-loader/src/journal-entry/journal-entry.md
Normal file
102
docs/api-stack/api/api-loader/src/journal-entry/journal-entry.md
Normal 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.
|
||||
@@ -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.
|
||||
107
docs/api-stack/api/api-loader/src/resolver/resolver.md
Normal file
107
docs/api-stack/api/api-loader/src/resolver/resolver.md
Normal 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.
|
||||
@@ -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.
|
||||
104
docs/api-stack/api/api-loader/src/testing/loader-conformance.md
Normal file
104
docs/api-stack/api/api-loader/src/testing/loader-conformance.md
Normal 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.
|
||||
Reference in New Issue
Block a user