Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,131 @@
{
"name": "@suaveplan/api-loader",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-1 contract package for the Suaveplan API plugin foundation. 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 live in @suaveplan/api-host (Stage 2).",
"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"
}
},
"./cache-bust": {
"import": {
"types": "./dist/cache-bust/index.d.ts",
"default": "./dist/cache-bust/index.js"
},
"require": {
"types": "./dist/cache-bust/index.d.ts",
"default": "./dist/cache-bust/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
},
"require": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./journal-entry": {
"import": {
"types": "./dist/journal-entry/index.d.ts",
"default": "./dist/journal-entry/index.js"
},
"require": {
"types": "./dist/journal-entry/index.d.ts",
"default": "./dist/journal-entry/index.js"
}
},
"./native-binding-detector": {
"import": {
"types": "./dist/native-binding-detector/index.d.ts",
"default": "./dist/native-binding-detector/index.js"
},
"require": {
"types": "./dist/native-binding-detector/index.d.ts",
"default": "./dist/native-binding-detector/index.js"
}
},
"./resolver": {
"import": {
"types": "./dist/resolver/index.d.ts",
"default": "./dist/resolver/index.js"
},
"require": {
"types": "./dist/resolver/index.d.ts",
"default": "./dist/resolver/index.js"
}
},
"./rollback-journal": {
"import": {
"types": "./dist/rollback-journal/index.d.ts",
"default": "./dist/rollback-journal/index.js"
},
"require": {
"types": "./dist/rollback-journal/index.d.ts",
"default": "./dist/rollback-journal/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
},
"require": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"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:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"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"
]
}

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,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { bustSpecifier } from "./cache-bust.js";
describe("bustSpecifier", () => {
it("appends ?v=N to a clean file URL", { timeout: 5000 }, () => {
expect(bustSpecifier("file:///abs/x.js", 1)).toBe(
"file:///abs/x.js?v=1"
);
});
it("appends ?v=N to a clean https URL", { timeout: 5000 }, () => {
expect(bustSpecifier("https://cdn.example/x.js", 42)).toBe(
"https://cdn.example/x.js?v=42"
);
});
it("re-busting replaces the version (does not concatenate)", {
timeout: 5000,
}, () => {
const once = bustSpecifier("file:///abs/x.js", 1);
const twice = bustSpecifier(once, 2);
expect(twice).toBe("file:///abs/x.js?v=2");
expect(twice.match(/v=/g)?.length).toBe(1);
});
it("preserves unrelated query parameters", { timeout: 5000 }, () => {
const out = bustSpecifier("file:///abs/x.js?keep=1", 7);
const url = new URL(out);
expect(url.searchParams.get("keep")).toBe("1");
expect(url.searchParams.get("v")).toBe("7");
});
it("repeated bust replaces only the v param", { timeout: 5000 }, () => {
const a = bustSpecifier("file:///abs/x.js?keep=foo&v=3", 9);
const url = new URL(a);
expect(url.searchParams.get("v")).toBe("9");
expect(url.searchParams.get("keep")).toBe("foo");
});
it("monotonic version sequence produces strictly different specifiers", {
timeout: 5000,
}, () => {
const seen = new Set<string>();
for (let i = 1; i <= 20; i++) {
seen.add(bustSpecifier("file:///abs/x.js", i));
}
expect(seen.size).toBe(20);
});
it("supports zero version (boundary)", { timeout: 5000 }, () => {
expect(bustSpecifier("file:///abs/x.js", 0)).toBe(
"file:///abs/x.js?v=0"
);
});
it("handles a hash fragment without losing it", { timeout: 5000 }, () => {
const out = bustSpecifier("file:///abs/x.js#frag", 3);
const url = new URL(out);
expect(url.hash).toBe("#frag");
expect(url.searchParams.get("v")).toBe("3");
});
});

View File

@@ -0,0 +1,22 @@
/**
* Append (or replace) a `?v=<version>` cache-bust suffix on an ESM specifier.
*
* Bun's ESM cache is URL-keyed: `file:///x.js` and `file:///x.js?v=2` are
* distinct cache entries even though they resolve to the same disk file.
* The loader uses this to force a fresh evaluation of a module subgraph
* during hot reload (design §8.2).
*
* The function is **pure** and side-effect-free. Repeated busts replace the
* version rather than concatenate: `bustSpecifier(bustSpecifier(u, 1), 2)`
* yields `${u}?v=2`, never `${u}?v=1?v=2`.
*
* @param url - The base specifier (typically a `file://` URL but any
* absolute URL works). Both `https://` and `file://` are supported.
* @param version - Monotonically-increasing reload counter for the plugin.
* @returns The specifier with a single `?v=<version>` suffix.
*/
export function bustSpecifier(url: string, version: number): string {
const parsed = new URL(url);
parsed.searchParams.set("v", String(version));
return parsed.toString();
}

View File

@@ -0,0 +1 @@
export { bustSpecifier } from "./cache-bust.js";

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,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOADER_CODES } from "./error-codes.js";
describe("LOADER_CODES", () => {
it("contains exactly the four documented codes", { timeout: 5000 }, () => {
expect(Object.keys(LOADER_CODES).sort()).toEqual([
"JOURNAL_ENTRY_INVALID",
"JOURNAL_FULL",
"MODULE_IMPORT_FAILED",
"NATIVE_BINDING_HELD",
]);
});
it("each entry is namespaced under LOADER", { timeout: 5000 }, () => {
for (const value of Object.values(LOADER_CODES)) {
expect(value.startsWith("LOADER.")).toBe(true);
}
});
it("each code is the dotted form of its reason", { timeout: 5000 }, () => {
expect(LOADER_CODES.NATIVE_BINDING_HELD).toBe(
"LOADER.NATIVE_BINDING_HELD"
);
expect(LOADER_CODES.MODULE_IMPORT_FAILED).toBe(
"LOADER.MODULE_IMPORT_FAILED"
);
expect(LOADER_CODES.JOURNAL_FULL).toBe("LOADER.JOURNAL_FULL");
expect(LOADER_CODES.JOURNAL_ENTRY_INVALID).toBe(
"LOADER.JOURNAL_ENTRY_INVALID"
);
});
it("registry is frozen at runtime", { timeout: 5000 }, () => {
expect(Object.isFrozen(LOADER_CODES)).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
import { defineCodes } from "@suaveplan/error";
/**
* Frozen registry of `LOADER.*` error codes thrown by the loader contract
* and the concrete host-side implementations that fulfil it (see
* `@suaveplan/api-host` Stage 2).
*
* Codes:
* - `NATIVE_BINDING_HELD` — `walkNativeBindings` reported one or more `.node`
* files reachable from the plugin entry. Hot reload is refused; the
* operator must restart the host.
* - `MODULE_IMPORT_FAILED` — `import(specifier)` threw. The Zod-validated
* wire form attaches the underlying cause via `SuaveplanError`'s
* cause-chain.
* - `JOURNAL_FULL` — sentinel reserved for journal implementations that
* opt into a hard cap rather than ring-buffer eviction. The default
* in-memory implementation (api-host Stage 2) uses eviction, but the
* ledger-backed implementation may surface this when persisting fails.
* - `JOURNAL_ENTRY_INVALID` — the persisted-entry Zod schema rejected a
* record read back from disk.
*/
export const LOADER_CODES = defineCodes("LOADER", [
"NATIVE_BINDING_HELD",
"MODULE_IMPORT_FAILED",
"JOURNAL_FULL",
"JOURNAL_ENTRY_INVALID",
] as const);
export type LoaderCode = (typeof LOADER_CODES)[keyof typeof LOADER_CODES];

View File

@@ -0,0 +1 @@
export { LOADER_CODES, type LoaderCode } from "./error-codes.js";

View File

@@ -0,0 +1,26 @@
export type { RollbackJournalEntry } from "@suaveplan/api-types/rollback-journal";
export { bustSpecifier } from "./cache-bust/cache-bust.js";
export { LOADER_CODES, type LoaderCode } from "./error-codes/error-codes.js";
export {
type PersistedRollbackEntry,
rollbackJournalEntryPersistedSchema,
} from "./journal-entry/journal-entry.js";
export {
assertNoNativeBindings,
LoaderError,
type NativeBindingDetector,
} from "./native-binding-detector/native-binding-detector.js";
export type {
ModuleHandle,
ModuleResolver,
ModuleResolverFactory,
ResolveOptions,
} from "./resolver/resolver.types.js";
export {
createRollbackJournal,
DEFAULT_ROLLBACK_JOURNAL_DEPTH,
} from "./rollback-journal/rollback-journal.js";
export type {
RollbackJournal,
RollbackJournalFactory,
} from "./rollback-journal/rollback-journal.types.js";

View File

@@ -0,0 +1,6 @@
// Re-export the canonical entry type from api-types for ergonomics.
export type { RollbackJournalEntry } from "@suaveplan/api-types/rollback-journal";
export {
type PersistedRollbackEntry,
rollbackJournalEntryPersistedSchema,
} from "./journal-entry.js";

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,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { rollbackJournalEntryPersistedSchema } from "./journal-entry.js";
const validBase = {
specifier: "file:///opt/plugin/index.js?v=1",
manifestHash: `sha256:${"a".repeat(64)}`,
migrationCursor: 4,
capturedAt: "2026-05-25T08:14:22.014Z",
version: "1.4.2",
};
describe("rollbackJournalEntryPersistedSchema", () => {
it("persisted shape parses without moduleRef", { timeout: 5000 }, () => {
const result = rollbackJournalEntryPersistedSchema.parse(validBase);
expect(result).toEqual(validBase);
expect("moduleRef" in result).toBe(false);
});
it("rejects an entry with moduleRef (strict mode)", {
timeout: 5000,
}, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
moduleRef: null,
})
).toThrow();
});
it("rejects unknown extra keys", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
extra: "nope",
})
).toThrow();
});
it("rejects missing specifier", { timeout: 5000 }, () => {
const { specifier: _omit, ...rest } = validBase;
expect(() => rollbackJournalEntryPersistedSchema.parse(rest)).toThrow();
});
it("rejects missing manifestHash", { timeout: 5000 }, () => {
const { manifestHash: _omit, ...rest } = validBase;
expect(() => rollbackJournalEntryPersistedSchema.parse(rest)).toThrow();
});
it("rejects manifestHash without sha256: prefix", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
manifestHash: "a".repeat(64),
})
).toThrow();
});
it("rejects manifestHash with non-hex characters", {
timeout: 5000,
}, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
manifestHash: `sha256:${"g".repeat(64)}`,
})
).toThrow();
});
it("rejects manifestHash of wrong length", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
manifestHash: `sha256:${"a".repeat(63)}`,
})
).toThrow();
});
it("rejects negative migrationCursor", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
migrationCursor: -1,
})
).toThrow();
});
it("rejects non-integer migrationCursor", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
migrationCursor: 1.5,
})
).toThrow();
});
it("rejects empty specifier", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
specifier: "",
})
).toThrow();
});
it("rejects empty version", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
version: "",
})
).toThrow();
});
it("rejects non-ISO capturedAt", { timeout: 5000 }, () => {
expect(() =>
rollbackJournalEntryPersistedSchema.parse({
...validBase,
capturedAt: "2026-05-25",
})
).toThrow();
});
it("accepts migrationCursor === 0 (boundary)", { timeout: 5000 }, () => {
expect(
rollbackJournalEntryPersistedSchema.parse({
...validBase,
migrationCursor: 0,
}).migrationCursor
).toBe(0);
});
});

View File

@@ -0,0 +1,36 @@
import z from "zod";
/**
* Zod schema for the **persisted subset** of a `RollbackJournalEntry`.
*
* `RollbackJournalEntry` (defined in `@suaveplan/api-types/rollback-journal`)
* has a `moduleRef: WeakRef<object> | null` field that is in-memory only —
* a `WeakRef` cannot be serialised. The remaining five fields survive host
* restart by being written to the plugin ledger; on restart the host reads
* them back, validates them with this schema, and re-imports the specifier
* to repopulate `moduleRef`.
*
* Per design doc §8.1a:
* > "Persisted entries (manifest hash, migration cursor, version) survive
* > host restart by being written to the plugin ledger; the `moduleRef`
* > is in-memory only and lost on restart."
*/
export const rollbackJournalEntryPersistedSchema = z
.object({
specifier: z.string().min(1),
manifestHash: z
.string()
.regex(
/^sha256:[0-9a-f]{64}$/,
"manifestHash must be sha256:<64-hex>"
),
migrationCursor: z.number().int().nonnegative(),
capturedAt: z.string().datetime({ offset: false }),
version: z.string().min(1),
})
.strict();
/** Parsed shape — the persisted subset, with no `moduleRef`. */
export type PersistedRollbackEntry = z.infer<
typeof rollbackJournalEntryPersistedSchema
>;

View File

@@ -0,0 +1,5 @@
export {
assertNoNativeBindings,
LoaderError,
type NativeBindingDetector,
} from "./native-binding-detector.js";

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,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOADER_CODES } from "../error-codes/error-codes.js";
import {
assertNoNativeBindings,
LoaderError,
} from "./native-binding-detector.js";
describe("assertNoNativeBindings", () => {
it("pure-JS subgraph passes through (empty array)", {
timeout: 5000,
}, () => {
expect(() => assertNoNativeBindings([])).not.toThrow();
});
it("native binding in subgraph triggers refusal", { timeout: 5000 }, () => {
expect(() => assertNoNativeBindings(["/x/y.node"])).toThrow(
LoaderError
);
});
it("thrown error carries LOADER.NATIVE_BINDING_HELD code", {
timeout: 5000,
}, () => {
try {
assertNoNativeBindings(["/x/y.node"]);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(LoaderError);
expect((err as LoaderError).code).toBe(
LOADER_CODES.NATIVE_BINDING_HELD
);
}
});
it("thrown error context.bindings echoes the offending paths", {
timeout: 5000,
}, () => {
try {
assertNoNativeBindings(["/a.node", "/b.node"]);
expect.fail("should have thrown");
} catch (err) {
expect((err as LoaderError).context).toEqual({
bindings: ["/a.node", "/b.node"],
});
}
});
it("multiple bindings all reported", { timeout: 5000 }, () => {
try {
assertNoNativeBindings([
"/lib/a.node",
"/lib/b.node",
"/lib/c.node",
]);
expect.fail("should have thrown");
} catch (err) {
const bindings = (err as LoaderError).context?.bindings as string[];
expect(bindings.length).toBe(3);
}
});
it("defensive copy: caller mutating their input after throw does not corrupt context", {
timeout: 5000,
}, () => {
const input = ["/a.node"];
try {
assertNoNativeBindings(input);
} catch (err) {
input.push("/sneaky.node");
const stored = (err as LoaderError).context?.bindings as string[];
expect(stored).toEqual(["/a.node"]);
}
});
});
describe("LoaderError class", () => {
it("subclasses SuaveplanError with literal LoaderCode type", {
timeout: 5000,
}, () => {
const err = new LoaderError(
LOADER_CODES.MODULE_IMPORT_FAILED,
"loader.module_import_failed"
);
expect(err.code).toBe("LOADER.MODULE_IMPORT_FAILED");
});
});

View File

@@ -0,0 +1,67 @@
import { SuaveplanError } from "@suaveplan/error";
import { LOADER_CODES, type LoaderCode } from "../error-codes/error-codes.js";
/** Construction options for `LoaderError`. */
export interface LoaderErrorOptions {
/** Structured fields attached to the error. */
readonly context?: Readonly<Record<string, unknown>>;
/** Underlying cause — preserved for `walkCauses`. */
readonly cause?: unknown;
/** Trace correlation id, propagated to OTel span events. */
readonly traceId?: string;
/** Time source. Defaults to `() => Date.now()`. */
readonly clock?: () => number;
}
/**
* Concrete `SuaveplanError` subclass for the loader namespace. Widens the
* protected base constructor to public so the loader and host can throw
* `LOADER.*` codes directly.
*/
export class LoaderError extends SuaveplanError<LoaderCode> {
public constructor(
code: LoaderCode,
messageKey: string,
options: LoaderErrorOptions = {}
) {
super(code, messageKey, options);
}
}
/**
* Function shape for a host-side native-binding detector. Concrete
* implementations walk the import subgraph of `specifier` and return the
* absolute paths of every `.node` file reached. An empty array means the
* plugin is hot-reload-safe.
*
* The detector is host-injected — `@suaveplan/api-host` ships a Bun-native
* implementation that walks `import.meta`'s resolved graph; alternative
* environments (e.g. a Deno port) can provide their own.
*/
export type NativeBindingDetector = (
specifier: string
) => Promise<readonly string[]>;
/**
* Throw `LOADER.NATIVE_BINDING_HELD` when one or more `.node` files were
* found in a plugin's import subgraph. The host calls this at install to
* decide whether the plugin is reload-safe; native bindings cannot be
* reloaded (design §8.2) so the operator must restart the host to upgrade.
*
* Safe pass-through when `bindings` is empty (the normal pure-JS case).
*
* @param bindings - The result of `ModuleResolver.walkNativeBindings()`.
* @throws `LoaderError` with code `LOADER.NATIVE_BINDING_HELD`. The error's
* `context.bindings` carries the offending paths so operator-facing
* tooling can surface them.
*/
export function assertNoNativeBindings(bindings: readonly string[]): void {
if (bindings.length === 0) {
return;
}
throw new LoaderError(
LOADER_CODES.NATIVE_BINDING_HELD,
"loader.native_binding_held",
{ context: { bindings: [...bindings] } }
);
}

View File

@@ -0,0 +1,6 @@
export type {
ModuleHandle,
ModuleResolver,
ModuleResolverFactory,
ResolveOptions,
} from "./resolver.js";

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,72 @@
/**
* Spec-conformance tests for `ModuleResolver`. The contract is types-only;
* runtime conformance is exercised through the `runLoaderConformance`
* harness against concrete implementations. These tests pin the type-level
* scenario so a structural drift breaks at compile time.
*/
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type {
ModuleHandle,
ModuleResolver,
ResolveOptions,
} from "./resolver.js";
describe("ModuleResolver type-level contract", () => {
it("declares resolve, dispose, walkNativeBindings", {
timeout: 5000,
}, () => {
expectTypeOf<ModuleResolver>().toHaveProperty("resolve");
expectTypeOf<ModuleResolver>().toHaveProperty("dispose");
expectTypeOf<ModuleResolver>().toHaveProperty("walkNativeBindings");
});
it("resolve returns Promise<ModuleHandle>", { timeout: 5000 }, () => {
type Fn = ModuleResolver["resolve"];
expectTypeOf<Fn>().parameter(0).toEqualTypeOf<ResolveOptions>();
expectTypeOf<Fn>().returns.toEqualTypeOf<
Promise<ModuleHandle<unknown>>
>();
});
it("dispose accepts a ModuleHandle and returns Promise<void>", {
timeout: 5000,
}, () => {
type Fn = ModuleResolver["dispose"];
expectTypeOf<Fn>().parameter(0).toEqualTypeOf<ModuleHandle<unknown>>();
expectTypeOf<Fn>().returns.toEqualTypeOf<Promise<void>>();
});
it("walkNativeBindings returns Promise<readonly string[]>", {
timeout: 5000,
}, () => {
type Fn = ModuleResolver["walkNativeBindings"];
expectTypeOf<Fn>().parameter(0).toEqualTypeOf<string>();
expectTypeOf<Fn>().returns.toEqualTypeOf<Promise<readonly string[]>>();
});
it("ModuleHandle exposes specifier, version, module, resolvedAt", {
timeout: 5000,
}, () => {
expectTypeOf<ModuleHandle>().toHaveProperty("specifier");
expectTypeOf<ModuleHandle>().toHaveProperty("version");
expectTypeOf<ModuleHandle>().toHaveProperty("module");
expectTypeOf<ModuleHandle>().toHaveProperty("resolvedAt");
});
it("ResolveOptions carries specifier + version", { timeout: 5000 }, () => {
expectTypeOf<ResolveOptions["specifier"]>().toEqualTypeOf<string>();
expectTypeOf<ResolveOptions["version"]>().toEqualTypeOf<number>();
});
it("ModuleResolver is generic over the resolved module type", {
timeout: 5000,
}, () => {
interface PluginEntry {
default: () => void;
}
type Resolver = ModuleResolver<PluginEntry>;
expectTypeOf<Resolver["resolve"]>().returns.toEqualTypeOf<
Promise<ModuleHandle<PluginEntry>>
>();
});
});

View File

@@ -0,0 +1,11 @@
// Types-only re-export shim. The `ModuleResolver` contract has no canonical
// runtime here — concrete impls live in `@suaveplan/api-host` Stage 2.
// This file exists so `./resolver` has a stable module identity and so the
// CI gate `check-feature-layout` (which expects a `<feature>.ts` next to the
// barrel) is satisfied.
export type {
ModuleHandle,
ModuleResolver,
ModuleResolverFactory,
ResolveOptions,
} from "./resolver.types.js";

View File

@@ -0,0 +1,68 @@
/**
* Abstract `ModuleResolver` contract — types-only. Concrete implementations
* (Bun-native, ZIP-backed, sandboxed worker proxy) land in
* `@suaveplan/api-host` Stage 2.
*
* Design intent: design doc §8.1 (blue-green primitive) and §8.2 (cache
* busting). The resolver is the loader's only abstraction over `import()`:
* it imports a plugin's entry under a cache-busted ESM specifier, hands the
* caller back an opaque `ModuleHandle`, and releases the host's strong ref
* when `dispose` is called so the previous module can be collected.
*/
/** Opaque resolved-module handle. The host holds the strong ref. */
export interface ModuleHandle<M = unknown> {
/** The specifier that was imported (post-cache-bust). */
readonly specifier: string;
/** The reload counter that produced this handle. */
readonly version: number;
/** The evaluated module namespace object. */
readonly module: M;
/** ISO-8601 timestamp at which `resolve` returned. */
readonly resolvedAt: string;
}
/** Argument shape for `ModuleResolver.resolve`. */
export interface ResolveOptions {
/** Base specifier (typically `file:///abs/path/dist/index.js`). */
readonly specifier: string;
/** Monotonically-increasing reload counter; combined into a `?v=` suffix. */
readonly version: number;
}
/**
* Abstract module resolver. The host holds exactly one `ModuleResolver` per
* plugin and calls into it on every reload.
*/
export interface ModuleResolver<M = unknown> {
/**
* Import the plugin's entry under a cache-busted specifier. Returns a
* `ModuleHandle` whose `module` is the evaluated namespace.
*
* Throws `LOADER.MODULE_IMPORT_FAILED` (wrapping the underlying cause)
* when the import fails. The host's reload path catches this and falls
* back to the previous handle from the rollback journal.
*/
resolve(options: ResolveOptions): Promise<ModuleHandle<M>>;
/**
* Release the host's strong reference to a previously-resolved handle.
* Concrete implementations may downgrade the strong ref to a `WeakRef`
* (so the previous module remains eligible for GC) and/or call into
* runtime APIs to drop module-cache entries.
*/
dispose(handle: ModuleHandle<M>): Promise<void>;
/**
* Walk the import subgraph reachable from the supplied specifier and
* return the paths of every native binding (`.node` file) found. Used
* at install to decide whether the plugin is reload-safe.
*
* Returns an empty array when the subgraph is pure-JS. Returns a
* non-empty array of absolute paths when one or more `.node` files are
* present — the caller invokes
* `assertNoNativeBindings(...)` to throw `LOADER.NATIVE_BINDING_HELD`.
*/
walkNativeBindings(specifier: string): Promise<readonly string[]>;
}
/** Factory signature for the loader-conformance harness. */
export type ModuleResolverFactory<M = unknown> = () => ModuleResolver<M>;

View File

@@ -0,0 +1,8 @@
export {
createRollbackJournal,
DEFAULT_ROLLBACK_JOURNAL_DEPTH,
} from "./rollback-journal.js";
export type {
RollbackJournal,
RollbackJournalFactory,
} from "./rollback-journal.types.js";

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,157 @@
import type { RollbackJournalEntry } from "@suaveplan/api-types/rollback-journal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createRollbackJournal,
DEFAULT_ROLLBACK_JOURNAL_DEPTH,
} from "./rollback-journal.js";
function entry(version: string): RollbackJournalEntry {
return {
specifier: `file:///opt/plugin/index.js?v=${version}`,
manifestHash: `sha256:${version.padStart(64, "0")}`,
moduleRef: null,
migrationCursor: Number.parseInt(version, 10),
capturedAt: "2026-05-25T00:00:00.000Z",
version,
};
}
describe("createRollbackJournal", () => {
it("default depth is 3 (design §8.1a)", { timeout: 5000 }, () => {
expect(DEFAULT_ROLLBACK_JOURNAL_DEPTH).toBe(3);
expect(createRollbackJournal().depth).toBe(3);
});
it("starts empty: length 0 and latest() === null", {
timeout: 5000,
}, () => {
const j = createRollbackJournal();
expect(j.length).toBe(0);
expect(j.latest()).toBeNull();
expect(j.peek(3)).toEqual([]);
});
it("push increments length up to depth", { timeout: 5000 }, () => {
const j = createRollbackJournal(3);
j.push(entry("1"));
expect(j.length).toBe(1);
j.push(entry("2"));
expect(j.length).toBe(2);
j.push(entry("3"));
expect(j.length).toBe(3);
});
it("peek returns entries newest-first", { timeout: 5000 }, () => {
const j = createRollbackJournal(3);
j.push(entry("1"));
j.push(entry("2"));
j.push(entry("3"));
const out = j.peek(3);
expect(out.map((e) => e.version)).toEqual(["3", "2", "1"]);
});
it("push beyond capacity drops oldest (FIFO eviction)", {
timeout: 5000,
}, () => {
const j = createRollbackJournal(3);
j.push(entry("1"));
j.push(entry("2"));
j.push(entry("3"));
j.push(entry("4"));
expect(j.length).toBe(3);
expect(j.peek(3).map((e) => e.version)).toEqual(["4", "3", "2"]);
expect(j.latest()?.version).toBe("4");
});
it("latest returns null on empty journal", { timeout: 5000 }, () => {
expect(createRollbackJournal().latest()).toBeNull();
});
it("latest returns the most-recently-pushed entry", {
timeout: 5000,
}, () => {
const j = createRollbackJournal();
j.push(entry("1"));
j.push(entry("2"));
expect(j.latest()?.version).toBe("2");
});
it("peek(0) returns an empty array", { timeout: 5000 }, () => {
const j = createRollbackJournal();
j.push(entry("1"));
expect(j.peek(0)).toEqual([]);
});
it("peek(n) clamps to length when n exceeds entries", {
timeout: 5000,
}, () => {
const j = createRollbackJournal(3);
j.push(entry("1"));
j.push(entry("2"));
expect(j.peek(10).map((e) => e.version)).toEqual(["2", "1"]);
});
it("clear empties the journal", { timeout: 5000 }, () => {
const j = createRollbackJournal();
j.push(entry("1"));
j.push(entry("2"));
j.clear();
expect(j.length).toBe(0);
expect(j.latest()).toBeNull();
expect(j.peek(3)).toEqual([]);
});
it("clear allows fresh pushes afterwards", { timeout: 5000 }, () => {
const j = createRollbackJournal();
j.push(entry("1"));
j.clear();
j.push(entry("2"));
expect(j.length).toBe(1);
expect(j.latest()?.version).toBe("2");
});
it("rejects non-positive depth", { timeout: 5000 }, () => {
expect(() => createRollbackJournal(0)).toThrow(RangeError);
expect(() => createRollbackJournal(-1)).toThrow(RangeError);
});
it("rejects non-integer depth", { timeout: 5000 }, () => {
expect(() => createRollbackJournal(1.5)).toThrow(RangeError);
});
it("rejects negative peek", { timeout: 5000 }, () => {
const j = createRollbackJournal();
expect(() => j.peek(-1)).toThrow(RangeError);
});
it("rejects non-integer peek", { timeout: 5000 }, () => {
const j = createRollbackJournal();
expect(() => j.peek(1.5)).toThrow(RangeError);
});
it("custom depth respected", { timeout: 5000 }, () => {
const j = createRollbackJournal(5);
expect(j.depth).toBe(5);
for (let i = 1; i <= 6; i++) {
j.push(entry(String(i)));
}
expect(j.length).toBe(5);
expect(j.peek(5).map((e) => e.version)).toEqual([
"6",
"5",
"4",
"3",
"2",
]);
});
it("peek returns a defensive immutable view", { timeout: 5000 }, () => {
const j = createRollbackJournal();
j.push(entry("1"));
j.push(entry("2"));
const view = j.peek(2);
// Mutating the returned array MUST NOT affect subsequent reads.
(view as RollbackJournalEntry[]).push(entry("99"));
expect(j.peek(2).map((e) => e.version)).toEqual(["2", "1"]);
});
});

View File

@@ -0,0 +1,75 @@
import type { RollbackJournalEntry } from "@suaveplan/api-types/rollback-journal";
import type { RollbackJournal } from "./rollback-journal.types.js";
/** Default ring-buffer depth from design doc §8.1a. */
export const DEFAULT_ROLLBACK_JOURNAL_DEPTH = 3;
class InMemoryRollbackJournal implements RollbackJournal {
readonly depth: number;
private readonly entries: RollbackJournalEntry[] = [];
constructor(depth: number) {
if (!Number.isInteger(depth) || depth < 1) {
throw new RangeError(
`RollbackJournal depth must be a positive integer; got ${depth}`
);
}
this.depth = depth;
}
get length(): number {
return this.entries.length;
}
push(entry: RollbackJournalEntry): void {
if (this.entries.length === this.depth) {
this.entries.shift();
}
this.entries.push(entry);
}
peek(n: number): readonly RollbackJournalEntry[] {
if (!Number.isInteger(n) || n < 0) {
throw new RangeError(
`RollbackJournal.peek(n): n must be a non-negative integer; got ${n}`
);
}
if (n === 0 || this.entries.length === 0) {
return [];
}
const wanted = Math.min(n, this.entries.length);
const out: RollbackJournalEntry[] = new Array(wanted);
for (let i = 0; i < wanted; i++) {
out[i] = this.entries[
this.entries.length - 1 - i
] as RollbackJournalEntry;
}
return out;
}
latest(): RollbackJournalEntry | null {
if (this.entries.length === 0) {
return null;
}
return this.entries[this.entries.length - 1] as RollbackJournalEntry;
}
clear(): void {
this.entries.length = 0;
}
}
/**
* Create an in-memory `RollbackJournal` ring buffer. This is the **reference
* implementation** of the contract — concrete host implementations
* (`@suaveplan/api-host` Stage 2) may layer durable persistence on top, but
* the runtime semantics every consumer relies on are defined here.
*
* @param depth - Maximum number of entries retained. Defaults to
* `DEFAULT_ROLLBACK_JOURNAL_DEPTH` (3, per design doc §8.1a).
*/
export function createRollbackJournal(
depth: number = DEFAULT_ROLLBACK_JOURNAL_DEPTH
): RollbackJournal {
return new InMemoryRollbackJournal(depth);
}

View File

@@ -0,0 +1,50 @@
import type { RollbackJournalEntry } from "@suaveplan/api-types/rollback-journal";
/**
* Abstract `RollbackJournal` interface — types-only contract owned by
* `@suaveplan/api-loader`. Concrete implementations (in-memory + ledger-
* backed) land in `@suaveplan/api-host` (Stage 2).
*
* The journal is a per-plugin bounded ring buffer (default depth `3`,
* configurable via the constructor) that keeps the last N known-good module
* activations so a failed hot-reload can roll back atomically. Design intent:
* design doc §8.1a.
*
* This is a **richer superset** of the bare-bones `RollbackJournal` exported
* from `@suaveplan/api-types/rollback-journal`. The api-types version is the
* minimal cross-package shape; api-loader's version adds:
* - `peek(n)` returns a `readonly RollbackJournalEntry[]` (newest first)
* rather than a single entry — the host's blue-green flip uses a slice of
* the history, not just the immediate predecessor.
* - `latest()` returns the newest entry or `null` (equivalent to
* `peek(1)[0] ?? null` but provided as a convenience to avoid array
* allocation on the host's hot path).
*/
export interface RollbackJournal {
/** Current number of entries (always `<= depth`). */
readonly length: number;
/** Maximum number of entries retained; constructor parameter. */
readonly depth: number;
/**
* Append a new entry. If `length === depth`, the oldest entry is evicted
* before insertion so `length` never exceeds `depth`.
*/
push(entry: RollbackJournalEntry): void;
/**
* Read up to `n` of the most recent entries, newest first.
* Returns at most `min(n, length)` entries. Returns an empty array when
* the journal is empty.
*/
peek(n: number): readonly RollbackJournalEntry[];
/** Return the most-recently-pushed entry, or `null` if empty. */
latest(): RollbackJournalEntry | null;
/** Remove every entry. `length === 0` afterwards. */
clear(): void;
}
/**
* Factory signature for the loader-conformance harness. Concrete journal
* implementations export a `createJournal(depth)` constructor that the
* harness drives against the documented invariants.
*/
export type RollbackJournalFactory = (depth: number) => RollbackJournal;

View File

@@ -0,0 +1,6 @@
export {
type JournalConformanceOptions,
type LoaderConformanceOptions,
runLoaderConformance,
runRollbackJournalConformance,
} from "./loader-conformance.js";

View File

@@ -0,0 +1,7 @@
import { createRollbackJournal } from "../rollback-journal/rollback-journal.js";
import { runRollbackJournalConformance } from "./loader-conformance.js";
runRollbackJournalConformance({
label: "reference in-memory journal",
factory: (depth) => createRollbackJournal(depth),
});

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.

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOADER_CODES } from "../error-codes/error-codes.js";
import { LoaderError } from "../native-binding-detector/native-binding-detector.js";
import type {
ModuleHandle,
ModuleResolver,
ResolveOptions,
} from "../resolver/resolver.types.js";
import { runLoaderConformance } from "./loader-conformance.js";
interface StubModule {
readonly ok: true;
}
class StubResolver implements ModuleResolver<StubModule> {
private nativePaths: readonly string[] = [];
private shouldFailFor: string | null = null;
setNativeBindings(paths: readonly string[]): this {
this.nativePaths = paths;
return this;
}
setFailingSpecifier(specifier: string): this {
this.shouldFailFor = specifier;
return this;
}
async resolve(options: ResolveOptions): Promise<ModuleHandle<StubModule>> {
if (
this.shouldFailFor !== null &&
options.specifier === this.shouldFailFor
) {
throw new LoaderError(
LOADER_CODES.MODULE_IMPORT_FAILED,
"loader.module_import_failed"
);
}
return {
specifier: `${options.specifier}?v=${options.version}`,
version: options.version,
module: { ok: true },
resolvedAt: new Date(0).toISOString(),
};
}
async dispose(_: ModuleHandle<StubModule>): Promise<void> {
return;
}
async walkNativeBindings(specifier: string): Promise<readonly string[]> {
if (specifier === "native://present") {
return this.nativePaths;
}
return [];
}
}
// Drive the harness against a fully-conforming stub.
runLoaderConformance({
label: "StubResolver (happy path)",
factory: () => new StubResolver(),
importableSpecifier: "stub://entry",
});
runLoaderConformance({
label: "StubResolver (native + failing branches)",
factory: () =>
new StubResolver()
.setNativeBindings(["/lib/a.node"])
.setFailingSpecifier("stub://broken"),
importableSpecifier: "stub://entry",
nativeBindingSpecifier: "native://present",
failingSpecifier: "stub://broken",
});
describe("runLoaderConformance options surface", () => {
it("accepts a minimum options object", { timeout: 5000 }, () => {
// Type-only assertion that the harness compiles with the smallest
// viable options bag.
const factory = () => new StubResolver();
const noop = (): void => {
runLoaderConformance({
label: "noop",
factory,
importableSpecifier: "stub://entry",
});
};
expect(typeof noop).toBe("function");
});
});

View File

@@ -0,0 +1,192 @@
import type { RollbackJournalEntry } from "@suaveplan/api-types/rollback-journal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
assertNoNativeBindings,
LoaderError,
} from "../native-binding-detector/native-binding-detector.js";
import type { ModuleResolverFactory } from "../resolver/resolver.types.js";
export interface LoaderConformanceOptions<M = unknown> {
/** Human-readable label included in every describe() block. */
readonly label: string;
/**
* Factory returning a fresh `ModuleResolver<M>` for each test. The
* harness exercises the contract independently of any shared state.
*/
readonly factory: ModuleResolverFactory<M>;
/**
* A specifier the resolver knows how to import. The harness imports it
* and asserts the returned `ModuleHandle` carries the expected shape.
*/
readonly importableSpecifier: string;
/**
* Optional specifier whose subgraph the resolver should report as
* containing native bindings. When omitted, the native-binding test is
* skipped (the implementation declares it has no path to test).
*/
readonly nativeBindingSpecifier?: string;
/**
* Optional specifier guaranteed to throw on import. When omitted, the
* `resolve` failure path is skipped.
*/
readonly failingSpecifier?: string;
}
/**
* Conformance harness for `ModuleResolver` implementations. Concrete
* implementations in `@suaveplan/api-host` and downstream packages MUST
* pass this harness — it is the only way to certify a resolver against
* the contract.
*/
export function runLoaderConformance<M = unknown>(
options: LoaderConformanceOptions<M>
): void {
const { label, factory, importableSpecifier } = options;
describe(`ModuleResolver conformance: ${label}`, () => {
it("resolve returns a ModuleHandle with specifier + version + module + resolvedAt", {
timeout: 5000,
}, async () => {
const resolver = factory();
const handle = await resolver.resolve({
specifier: importableSpecifier,
version: 1,
});
expect(typeof handle.specifier).toBe("string");
expect(handle.specifier.includes("v=1")).toBe(true);
expect(handle.version).toBe(1);
expect(handle.module).not.toBeUndefined();
expect(typeof handle.resolvedAt).toBe("string");
await resolver.dispose(handle);
});
it("dispose returns Promise<void> without throwing", {
timeout: 5000,
}, async () => {
const resolver = factory();
const handle = await resolver.resolve({
specifier: importableSpecifier,
version: 1,
});
const result = resolver.dispose(handle);
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toBeUndefined();
});
it("walkNativeBindings returns a readonly string[]", {
timeout: 5000,
}, async () => {
const resolver = factory();
const result =
await resolver.walkNativeBindings(importableSpecifier);
expect(Array.isArray(result)).toBe(true);
});
if (options.nativeBindingSpecifier !== undefined) {
const native = options.nativeBindingSpecifier;
it("native binding subgraph triggers refusal via assertNoNativeBindings", {
timeout: 5000,
}, async () => {
const resolver = factory();
const bindings = await resolver.walkNativeBindings(native);
expect(bindings.length).toBeGreaterThan(0);
expect(() => assertNoNativeBindings(bindings)).toThrow(
LoaderError
);
});
}
if (options.failingSpecifier !== undefined) {
const failing = options.failingSpecifier;
it("resolve surfaces import failures by throwing", {
timeout: 5000,
}, async () => {
const resolver = factory();
await expect(
resolver.resolve({ specifier: failing, version: 1 })
).rejects.toBeDefined();
});
}
});
}
export interface JournalConformanceOptions {
readonly label: string;
readonly factory: (depth: number) => {
readonly length: number;
readonly depth: number;
push(entry: RollbackJournalEntry): void;
peek(n: number): readonly RollbackJournalEntry[];
latest(): RollbackJournalEntry | null;
clear(): void;
};
}
function entry(version: string): RollbackJournalEntry {
return {
specifier: `file:///opt/plugin/index.js?v=${version}`,
manifestHash: `sha256:${version.padStart(64, "0")}`,
moduleRef: null,
migrationCursor: Number.parseInt(version, 10),
capturedAt: "2026-05-25T00:00:00.000Z",
version,
};
}
/**
* Conformance harness for `RollbackJournal` implementations. Concrete
* implementations MUST pass this harness — it pins push, peek, capacity,
* latest, and clear semantics.
*/
export function runRollbackJournalConformance(
options: JournalConformanceOptions
): void {
const { label, factory } = options;
describe(`RollbackJournal conformance: ${label}`, () => {
it("starts empty", { timeout: 5000 }, () => {
const j = factory(3);
expect(j.length).toBe(0);
expect(j.latest()).toBeNull();
expect(j.peek(3)).toEqual([]);
});
it("push increments length", { timeout: 5000 }, () => {
const j = factory(3);
j.push(entry("1"));
expect(j.length).toBe(1);
});
it("peek returns entries newest-first", { timeout: 5000 }, () => {
const j = factory(3);
j.push(entry("1"));
j.push(entry("2"));
expect(j.peek(2).map((e) => e.version)).toEqual(["2", "1"]);
});
it("push beyond capacity drops oldest", { timeout: 5000 }, () => {
const j = factory(3);
j.push(entry("1"));
j.push(entry("2"));
j.push(entry("3"));
j.push(entry("4"));
expect(j.length).toBe(3);
expect(j.peek(3).map((e) => e.version)).toEqual(["4", "3", "2"]);
});
it("latest returns the newest entry", { timeout: 5000 }, () => {
const j = factory(3);
j.push(entry("1"));
j.push(entry("2"));
expect(j.latest()?.version).toBe("2");
});
it("clear empties the journal", { timeout: 5000 }, () => {
const j = factory(3);
j.push(entry("1"));
j.clear();
expect(j.length).toBe(0);
expect(j.latest()).toBeNull();
});
});
}

View File

@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("runtime deps are exactly the expected sorted set", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@suaveplan/api-types",
"@suaveplan/error",
"tslib",
"zod",
]);
});
it("no peer-deps (leaf-like contract)", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { peerDependencies?: Record<string, string> };
expect(pkg.peerDependencies ?? {}).toEqual({});
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,36 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
const s = statSync(full);
if (s.isDirectory()) {
walk(full, out);
} else if (
s.isFile() &&
(full.endsWith(".ts") || full.endsWith(".tsx"))
) {
out.push(full);
}
}
return out;
}
describe("zod import form", () => {
it("every src file uses default-import z", { timeout: 5000 }, () => {
const root = join(__dirname, "..", "src");
const offenders: string[] = [];
for (const file of walk(root)) {
const text = readFileSync(file, "utf8");
// Forbid `import { z } from "zod"` and `import { z, ... } from "zod"`.
if (
/import\s*\{[^}]*\bz\b[^}]*\}\s*from\s*["']zod["']/.test(text)
) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,18 @@
{
"$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",
"src/**/*.spec-conformance.test.ts",
"src/**/*.bench.ts",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-loader",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"cache-bust/index": "src/cache-bust/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"journal-entry/index": "src/journal-entry/index.ts",
"native-binding-detector/index":
"src/native-binding-detector/index.ts",
"resolver/index": "src/resolver/index.ts",
"rollback-journal/index": "src/rollback-journal/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
/^@suaveplan\//,
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.spec-conformance.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});