first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# `plugin-ledger.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
// 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.