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 unlessautoMigrate === false.upsertPlugin(record): PluginLedgerRow— insert-or-replace.getPlugin(name): PluginLedgerRow | nulllistPlugins(): readonly PluginLedgerRow[]removePlugin(name): booleanpushJournalEntry(plugin, entry)— appends with ring-buffer trim.peekJournal(plugin, n): readonly PersistedRollbackEntry[]latestJournal(plugin): PersistedRollbackEntry | nulljournalLength(plugin): numberasLifecycleLedger(): LedgerAdapterasRollbackPersistence(): 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.