4.9 KiB
@suaveplan/api-loader — abstract module resolver + RollbackJournal interface
Package: @suaveplan/api-loader
Status: PROPOSED
Type: new-package
Stage: 1 (Contracts) — see umbrella openspec/changes/api-plugin-foundation/
Why
Hot reload (design §8) needs two things from the loader: (a) a ModuleResolver that imports a plugin's entry under a fresh ESM specifier (file:///...?v=N+1) so Bun's URL-keyed cache treats it as a different module; (b) a RollbackJournal (design §8.1a) — a per-plugin ring buffer (default depth 3) recording the last known-good specifiers so a failed reload can atomically re-activate the previous module. The concrete implementations land in api-host (Stage 2). This package owns the interfaces, the cache-bust algorithm signature, and the rollback-journal contract — so the host's lifecycle code and the spec for §8.1a have a single home that downstream tests (api-host's) can expectTypeOf against.
Per design §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."
What changes
Net-new package. Pure interfaces + the canonical cache-bust algorithm spec. Coverage is N/A for types-only files; the canonical algorithm helper has full coverage.
- ADDED:
./resolver— abstractModuleResolver:resolve({ specifier, version }): Promise<ModuleHandle>;dispose(handle): Promise<void>;walkNativeBindings(specifier): readonly string[](returns paths of any.nodenative bindings reachable from the entry — used at install to refuse reload when natives are present). - ADDED:
./cache-bust—bustSpecifier(url: string, version: number): stringreturning${url}?v=${version}. Pure function, fully tested. - ADDED:
./rollback-journal— abstractRollbackJournalring-buffer interface:push(entry: RollbackJournalEntry),peek(n: number): readonly RollbackJournalEntry[](newest first),length: number,clear(): void,latest(): RollbackJournalEntry | null. Default depth3; constructor parameter. - ADDED:
./journal-entry— re-exportsRollbackJournalEntryfrom@suaveplan/api-typesAND owns the Zod schema for parsing persisted entries (the parts that survive restart:specifier,manifestHash,migrationCursor,capturedAt,version). The in-memorymoduleRef: WeakRefis type-only — not serialised. - ADDED:
./native-binding-detector—walkNativeBindingscontract; throwsLOADER.NATIVE_BINDING_HELDwhen invoked on a module subgraph containing.nodefiles (the host calls this at install to gate reload). - ADDED:
./error-codes— frozenLOADER_CODES:NATIVE_BINDING_HELD,MODULE_IMPORT_FAILED,JOURNAL_FULL,JOURNAL_ENTRY_INVALID. - ADDED:
./testing—runLoaderConformance(factory)reusable harness;runRollbackJournalConformance(factory).
Out of scope
- Concrete
ModuleResolverimpl — lives inapi-host(Stage 2). - Concrete
RollbackJournal(in-memory + ledger-backed) impl — lives inapi-host(Stage 2). - Hot-reload orchestration (§8.1 blue-green flip) — lives in
api-host. - File-watching for dev mode (§8.3) — lives in
api-host.
Source mapping
Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.
| Requirement | Design doc section |
|---|---|
bustSpecifier ?v=N cache-bust |
§8.2 |
RollbackJournal ring buffer (depth 3) |
§8.1a |
RollbackJournalEntry shape |
§8.1a |
| Native-binding detection + reload refusal | §8.2 ("Native bindings cannot be reloaded; loader walks the dep graph at install time and refuses reload") |
| Persisted vs in-memory entries | §8.1a |
Requirements with no source line
| Requirement | Justification |
|---|---|
runLoaderConformance + runRollbackJournalConformance harnesses |
Stage-5 deliverable (§13.4); api-host's concrete impls verify against these. |
Verification approach
src/cache-bust/cache-bust.test.ts— KAT forbustSpecifier(url, 1) === url + "?v=1"; second bust replaces (bust(bust(url, 1), 2) === url + "?v=2", noturl + "?v=1?v=2").src/resolver/resolver.spec-conformance.test.ts— interface shape.src/rollback-journal/rollback-journal.test.ts— push beyond capacity drops oldest;peek(n)returns newest first; default depth 3.src/journal-entry/journal-entry.test.ts— Zod parses every persisted field; rejects unknown keys.src/native-binding-detector/native-binding-detector.test.ts—.nodein subgraph → throwsLOADER.NATIVE_BINDING_HELD; pure-JS subgraph → returns clean.tests/dep-graph.test.ts— deps:@suaveplan/api-types,@suaveplan/error,@suaveplan/types,zod.
Migration impact
None — additive only.
Impact
Affected dependents
- (none) — [x] no action needed (reason: net-new; first consumer is
api-hostin Stage 2)