Files
2026-05-27 23:29:53 +01:00

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 — abstract ModuleResolver: resolve({ specifier, version }): Promise<ModuleHandle>; dispose(handle): Promise<void>; walkNativeBindings(specifier): readonly string[] (returns paths of any .node native bindings reachable from the entry — used at install to refuse reload when natives are present).
  • ADDED: ./cache-bust — bustSpecifier(url: string, version: number): string returning ${url}?v=${version}. Pure function, fully tested.
  • ADDED: ./rollback-journal — abstract RollbackJournal ring-buffer interface: push(entry: RollbackJournalEntry), peek(n: number): readonly RollbackJournalEntry[] (newest first), length: number, clear(): void, latest(): RollbackJournalEntry | null. Default depth 3; constructor parameter.
  • ADDED: ./journal-entry — re-exports RollbackJournalEntry from @suaveplan/api-types AND owns the Zod schema for parsing persisted entries (the parts that survive restart: specifier, manifestHash, migrationCursor, capturedAt, version). The in-memory moduleRef: WeakRef is type-only — not serialised.
  • ADDED: ./native-binding-detector — walkNativeBindings contract; throws LOADER.NATIVE_BINDING_HELD when invoked on a module subgraph containing .node files (the host calls this at install to gate reload).
  • ADDED: ./error-codes — frozen LOADER_CODES: NATIVE_BINDING_HELD, MODULE_IMPORT_FAILED, JOURNAL_FULL, JOURNAL_ENTRY_INVALID.
  • ADDED: ./testing — runLoaderConformance(factory) reusable harness; runRollbackJournalConformance(factory).

Out of scope

  • Concrete ModuleResolver impl — lives in api-host (Stage 2).
  • Concrete RollbackJournal (in-memory + ledger-backed) impl — lives in api-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 for bustSpecifier(url, 1) === url + "?v=1"; second bust replaces (bust(bust(url, 1), 2) === url + "?v=2", not url + "?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 — .node in subgraph → throws LOADER.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-host in Stage 2)