Initial Commit
This commit is contained in:
76
packages/api-stack/api/api-loader/README.md
Normal file
76
packages/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.
|
||||
131
packages/api-stack/api/api-loader/package.json
Normal file
131
packages/api-stack/api/api-loader/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { bustSpecifier } from "./cache-bust.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -0,0 +1 @@
|
||||
export { LOADER_CODES, type LoaderCode } from "./error-codes.js";
|
||||
26
packages/api-stack/api/api-loader/src/index.ts
Normal file
26
packages/api-stack/api/api-loader/src/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
assertNoNativeBindings,
|
||||
LoaderError,
|
||||
type NativeBindingDetector,
|
||||
} from "./native-binding-detector.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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] } }
|
||||
);
|
||||
}
|
||||
6
packages/api-stack/api/api-loader/src/resolver/index.ts
Normal file
6
packages/api-stack/api/api-loader/src/resolver/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
ModuleHandle,
|
||||
ModuleResolver,
|
||||
ModuleResolverFactory,
|
||||
ResolveOptions,
|
||||
} from "./resolver.js";
|
||||
107
packages/api-stack/api/api-loader/src/resolver/resolver.md
Normal file
107
packages/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,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>>
|
||||
>();
|
||||
});
|
||||
});
|
||||
11
packages/api-stack/api/api-loader/src/resolver/resolver.ts
Normal file
11
packages/api-stack/api/api-loader/src/resolver/resolver.ts
Normal 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";
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createRollbackJournal,
|
||||
DEFAULT_ROLLBACK_JOURNAL_DEPTH,
|
||||
} from "./rollback-journal.js";
|
||||
export type {
|
||||
RollbackJournal,
|
||||
RollbackJournalFactory,
|
||||
} from "./rollback-journal.types.js";
|
||||
@@ -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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
6
packages/api-stack/api/api-loader/src/testing/index.ts
Normal file
6
packages/api-stack/api/api-loader/src/testing/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type JournalConformanceOptions,
|
||||
type LoaderConformanceOptions,
|
||||
runLoaderConformance,
|
||||
runRollbackJournalConformance,
|
||||
} from "./loader-conformance.js";
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
27
packages/api-stack/api/api-loader/tests/dep-graph.test.ts
Normal file
27
packages/api-stack/api/api-loader/tests/dep-graph.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
18
packages/api-stack/api/api-loader/tsconfig.lib.json
Normal file
18
packages/api-stack/api/api-loader/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
48
packages/api-stack/api/api-loader/vite.config.ts
Normal file
48
packages/api-stack/api/api-loader/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-loader/vitest.config.ts
Normal file
33
packages/api-stack/api/api-loader/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user