Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# `persistence`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SagaPersistence` is the sole path through which the orchestrator
|
||||
mutates the host's saga tables (`suaveplan_saga_instances` and
|
||||
`suaveplan_saga_steps` from `api-host` migration 2). The class wraps
|
||||
the host's `LedgerDb` view with prepared statements and funnels every
|
||||
state transition through the `state/state-machine` guard so an
|
||||
in-flight bug in the orchestrator cannot bypass the lifecycle
|
||||
invariants — a forbidden transition raises
|
||||
`SAGA.INVALID_STATE_TRANSITION` before the DB row is touched.
|
||||
|
||||
## Features
|
||||
|
||||
- Prepared-statement CRUD on `suaveplan_saga_instances` keyed by
|
||||
`(id)` and `suaveplan_saga_steps` keyed by `(saga_id, step_index)`.
|
||||
- Per-instance payload bag (`PersistedInstancePayload`) carrying the
|
||||
saga name, tenant ids, declared steps, principal, and
|
||||
`currentStepIndex` so recovery can resume after a host restart.
|
||||
- Per-step payload bag (`PersistedStepPayload`) carrying the input,
|
||||
output, last error, attempt counters, and compensation attempts.
|
||||
- State transitions guarded — every `transitionInstance` /
|
||||
`transitionStep` call runs through `assertInstanceTransition` /
|
||||
`assertStepTransition`. Same-state transitions are accepted as
|
||||
idempotent no-ops.
|
||||
- Multi-axis filtering on `listInstances` (state, sagaName, tenantId,
|
||||
pluginName) so the recovery runner can scope its replay to
|
||||
`state IN (running, compensating)`.
|
||||
- Defensive JSON parsing — any malformed `payload_json` /
|
||||
`compensation_payload_json` row surfaces
|
||||
`SAGA.PERSISTENCE_FAILED` with the row id in context.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SagaPersistence } from "@suaveplan/api-saga/persistence/persistence";
|
||||
|
||||
const persistence = new SagaPersistence({ db: host.ledgerDb });
|
||||
const instance = persistence.createInstance({
|
||||
id: "s_1",
|
||||
pluginName: "billing",
|
||||
state: "running",
|
||||
payload: {
|
||||
sagaName: "checkout",
|
||||
tenantId: "acme",
|
||||
initiatorTenantId: "acme",
|
||||
principalId: "u_42",
|
||||
steps: [
|
||||
{ plugin: "billing", action: "charge", compensate: "refund" },
|
||||
],
|
||||
input: { amount: 1000 },
|
||||
currentStepIndex: 0,
|
||||
},
|
||||
});
|
||||
|
||||
persistence.transitionInstance(instance.id, "completed");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Recovery replay walks every non-terminal instance and resumes from
|
||||
its persisted `currentStepIndex`. Step rows already in `completed`
|
||||
are skipped; an `in_flight` row is re-emitted (the idempotency guard
|
||||
deduplicates the underlying action call):
|
||||
|
||||
```ts
|
||||
import { SagaPersistence } from "@suaveplan/api-saga";
|
||||
|
||||
const persistence = new SagaPersistence({ db });
|
||||
|
||||
const inflight = persistence.listInstances({ state: "running" });
|
||||
for (const inst of inflight) {
|
||||
const steps = persistence.listSteps(inst.id);
|
||||
const next = steps.find((s) => s.state !== "completed");
|
||||
if (next === undefined) {
|
||||
persistence.transitionInstance(inst.id, "completed");
|
||||
continue;
|
||||
}
|
||||
if (next.state === "in_flight") {
|
||||
orchestrator.replayStep(inst, next);
|
||||
} else {
|
||||
orchestrator.dispatchStep(inst, next);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SagaPersistence({ db, now? })` — construct against an already
|
||||
migrated `LedgerDb`.
|
||||
- Instance: `createInstance`, `getInstance`, `requireInstance`,
|
||||
`listInstances(filter)`, `transitionInstance(id, to)`,
|
||||
`updateInstancePayload(id, payload)`, `deleteInstance(id)`.
|
||||
- Step: `upsertStep`, `getStep`, `listSteps`, `transitionStep`,
|
||||
`updateStepPayload`.
|
||||
- Filter type `SagaInstanceListFilter` accepts any subset of
|
||||
`{ state, sagaName, tenantId, pluginName }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`api-host` migration 2 already ships the two tables — this package
|
||||
does NOT own migrations; it consumes the host-owned schema. The
|
||||
`payload_json` and `compensation_payload_json` columns are typed
|
||||
`TEXT` in SQLite; we JSON-encode the matching TS types and validate
|
||||
on read so a hand-edited row that drops a required field surfaces
|
||||
loud rather than silently undefined-ing through the orchestrator.
|
||||
|
||||
Same-state transitions intentionally short-circuit before the DB
|
||||
write so a replay pass over an already-correctly-stored saga is a
|
||||
no-op rather than an unnecessary row touch — critical for the
|
||||
recovery runner's "boot resume" path that may re-assert state for
|
||||
many instances at once.
|
||||
Reference in New Issue
Block a user