Files
skills/docs/api-stack/api/api-host/src/plugin-ledger/plugin-ledger.md
2026-05-29 08:20:56 +01:00

4.3 KiB

plugin-ledger.ts

(no submodules)

Purpose

PluginLedger is the host-owned metadata store backing every plugin record the API host orchestrates. It persists the §4 plugin row (state, version, manifest hash, isolation tier, crash + install-failure counters, capability set) and the §8.1a rollback-journal subset (ring buffer of (specifier, manifestHash, migrationCursor, capturedAt, version) tuples). The store is the source of truth that survives host restart — the in-memory WeakRef<Module> from @suaveplan/api-loader stays in memory, but the persisted subset round-trips through this ledger so a post-restart rollback only costs one extra import().

The ledger is intentionally driver-agnostic: it consumes a structural LedgerDb (a SQLite-shaped prepare(sql).run(...) / .all(...) / .get(...)) seam so bun:sqlite, better-sqlite3, or any thin wrapper can wire in without recompilation. Forward-only migrations install the host-owned tables idempotently — re-instantiating a PluginLedger against an already-migrated DB is a no-op.

Features

  • Forward-only idempotent migration runner (HostMigrationRunner).
  • Schema covers the five §4 host-owned tables — the active plugin ledger + rollback journal, plus reserved schemas for Stage-6 saga + 2PC.
  • Ring-buffer trim with (plugin_name, position) composite PK so the oldest entry is overwritten when depth (default 3) is exceeded.
  • Adapter views (asLifecycleLedger, asRollbackPersistence) hand the lifecycle + reload drivers their narrow ports.
  • Zod-validated journal rows — corrupt entries raise LEDGER.INVALID_ROW.

Usage: Basic

import { Database } from "bun:sqlite";
import { PluginLedger } from "@suaveplan/api-host/plugin-ledger";

const db = new Database(":memory:");
const ledger = new PluginLedger({ db });

ledger.upsertPlugin({
    name: "@acme/billing",
    version: "1.4.2",
    manifestHash: "sha256:abc",
    isolation: "trusted",
    state: "started",
    lastTransitionAt: null,
    installFailures: 0,
    crashCount: 0,
    crashBudgetResetsAt: null,
    capabilities: [{ id: "audit-logger", version: "1.0.0" }],
});

const all = ledger.listPlugins();

Usage: Advanced

// Hand the rollback driver its persisted journal seam.
import { ReloadDriver } from "@suaveplan/api-host/hot-reload";

const driver = new ReloadDriver({
    // ...,
    adapters: {
        // ...,
        persistence: ledger.asRollbackPersistence(),
    },
});

// Push a journal entry directly (e.g. on initial activation).
ledger.pushJournalEntry("@acme/billing", {
    specifier: "file:///opt/plugins/billing/dist/index.js?v=1",
    manifestHash: "sha256:abc",
    migrationCursor: 7,
    capturedAt: new Date().toISOString(),
    version: "1.4.2",
});

const latest = ledger.latestJournal("@acme/billing");

API Reference

  • new PluginLedger({ db, journalDepth?, now?, autoMigrate? }) — opens the ledger and runs migrations unless autoMigrate === false.
  • upsertPlugin(record): PluginLedgerRow — insert-or-replace.
  • getPlugin(name): PluginLedgerRow | null
  • listPlugins(): readonly PluginLedgerRow[]
  • removePlugin(name): boolean
  • pushJournalEntry(plugin, entry) — appends with ring-buffer trim.
  • peekJournal(plugin, n): readonly PersistedRollbackEntry[]
  • latestJournal(plugin): PersistedRollbackEntry | null
  • journalLength(plugin): number
  • asLifecycleLedger(): LedgerAdapter
  • asRollbackPersistence(): RollbackPersistence

Implementation Notes

The ring-buffer trim chooses the oldest entry by (captured_at, position) so ties (same timestamp on two pushes) deterministically pick the lower position. The composite primary key (plugin_name, position) lets the upsert ON CONFLICT clause overwrite the chosen slot atomically.

asLifecycleLedger().write preserves the existing isolation tier and counter values when the lifecycle driver writes a partial row. The caller is expected to call upsertPlugin with the full record once the manifest is parsed.

materialiseRow re-parses the JSON-serialised capability set on every read so the in-memory shape is always validated. A malformed capabilities_json raises LEDGER.INVALID_ROW rather than passing through a half-typed array — operators see the corrupt row early.