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,109 @@
# @suaveplan/api-2pc - Two-phase commit coordinator for the Suaveplan API plugin system.
Two-phase commit coordinator for the Suaveplan API plugin system. Drives Postgres PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED across registered participants with a host-owned ledger in suaveplan_2pc_transactions. Refuses non-preparable engines as writers (SQLite, SurrealDB) but accepts them as read-only voters. Default timeoutMs 5000, hard ceiling 30000, default outstanding-prepared cap 256, all operator-tunable. Ships boot-time orphan resolution via pg_prepared_xacts, the full TWOPC_* error namespace, OTel transaction/prepare/commit/rollback spans plus prepare_duration_ms / commit_duration_ms histograms / transactions_total counter / outstanding_prepared gauge, and run2pcParticipantConformance on the ./testing subpath. Server-tier — Bun + Node.
> **Postgres-only writer surface.** SQLite and SurrealDB cannot honour
> `PREPARE TRANSACTION` and are refused as writers at install time.
> They MAY participate as read-only voters that contribute a
> `commit`/`abort` vote based on their own consistency check, but the
> coordinator never sends them prepare / commit / rollback SQL.
<!-- begin:auto-docs -->
## Documentation tree
- [coordinator](./src/coordinator/coordinator.md) — Top-level orchestration for the 2PC protocol. `createTwoPcCoordinator` builds a fully-wired coordinator that holds a pa…
- [coordinator](./src/coordinator/coordinator.md) — Top-level orchestration for the 2PC protocol. `createTwoPcCoordinator` builds a fully-wired coordinator that holds a pa…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `TWOPC_*` error-code namespace and the `TwoPcError` concrete class for the `@suaveplan/api-2pc` pa…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the `TWOPC_*` error-code namespace and the `TwoPcError` concrete class for the `@suaveplan/api-2pc` pa…
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument registry the 2PC coordinator emits against on every transaction. Each instrument's name foll…
- [instruments](./src/instruments/instruments.md) — Builds the OTel instrument registry the 2PC coordinator emits against on every transaction. Each instrument's name foll…
- [outstanding-cap](./src/limits/outstanding-cap.md) — Admission control for in-flight 2PC transactions. The coordinator holds a fixed-size budget of "outstanding prepared tr…
- [timeout-ceiling](./src/limits/timeout-ceiling.md) — Enforces the hard 30 000 ms ceiling on caller-supplied `timeoutMs` values. The check fires at the public entry point of…
- [options](./src/options/options.md) — Defines the boundary-validated option shape consumed by `createTwoPcCoordinator`. Every tunable that affects coordinato…
- [options](./src/options/options.md) — Defines the boundary-validated option shape consumed by `createTwoPcCoordinator`. Every tunable that affects coordinato…
- [persistence](./src/persistence/persistence.md) — Persists every coordinator state transition into the `suaveplan_2pc_transactions` ledger table. The table is owned by `…
- [persistence](./src/persistence/persistence.md) — Persists every coordinator state transition into the `suaveplan_2pc_transactions` ledger table. The table is owned by `…
- [commit](./src/phases/commit.md) — Phase-2 commit for a single participant. Once every participant has returned `commit` from phase 1 and the coordinator…
- [gid](./src/phases/gid.md) — Builds and parses the global transaction id (gid) string passed to Postgres' `PREPARE TRANSACTION '<gid>'`. The format…
- [prepare](./src/phases/prepare.md) — Implements phase 1 of the 2PC protocol for a single participant. The coordinator iterates participants and calls this m…
- [rollback](./src/phases/rollback.md) — Phase-2 rollback for a single participant. Used both by the coordinator's abort path (vote_abort or prepare failure) an…
- [recovery](./src/recovery/recovery.md) — Boot-time orphan resolution for the 2PC coordinator. When a host crashes between phase 1 and phase 2, prepared transact…
- [recovery](./src/recovery/recovery.md) — Boot-time orphan resolution for the 2PC coordinator. When a host crashes between phase 1 and phase 2, prepared transact…
- [registry](./src/registry/participant-registry.md) — The participant registry is the install-time admission control between plugins and the 2PC coordinator. Plugins declare…
- [participant-registry](./src/registry/participant-registry.md) — The participant registry is the install-time admission control between plugins and the 2PC coordinator. Plugins declare…
- [testing](./src/testing/conformance.md) — Provides `run2pcParticipantConformance(factory)` — a runnable test suite that pins the contract every 2PC writer partic…
- [conformance](./src/testing/conformance.md) — Provides `run2pcParticipantConformance(factory)` — a runnable test suite that pins the contract every 2PC writer partic…
<!-- end:auto-docs -->
## Why
Some cross-database workflows demand atomic visibility — no partial
state, no eventual convergence. Per the API Plugin Foundation design
doc §9.2 the coordinator runs phase-1 prepare across every registered
participant, persists the vote tally to `suaveplan_2pc_transactions`
on the host's metadata DB, then issues `COMMIT PREPARED` or
`ROLLBACK PREPARED` against every writer participant. Recovery on the
next boot resolves any prepared transactions left over from a host
crash.
## Modules
- `coordinator/` — `createTwoPcCoordinator` + `run2pc` factory and
state machine.
- `registry/` — `TwoPcParticipantRegistry` (admission control).
- `phases/` — `preparePhaseOne`, `commitPhaseTwo`, `rollbackPhaseTwo`,
`gid` helpers.
- `persistence/` — `TwoPcLedgerStore` over the host-owned
`suaveplan_2pc_transactions` table.
- `recovery/` — boot-time orphan resolution via `pg_prepared_xacts`.
- `limits/` — `OutstandingPreparedCap` (admission), `assertTimeoutBelowCeiling`
(per-call ceiling).
- `instruments/` — OTel meter/tracer bundle.
- `options/` — Zod-validated coordinator options + defaults.
- `error-codes/` — 10 `TWOPC_*` codes + `TwoPcError`.
- `types/` — `Run2PcInput`, `Run2PcOutcome`, participant + state types.
- `testing/` — `run2pcParticipantConformance(factory)` suite.
## Quick start
```ts
import { createTwoPcCoordinator } from "@suaveplan/api-2pc";
const coordinator = createTwoPcCoordinator({
options: { hostInstanceId: "host-01" },
registry,
ledgerDb,
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
});
const outcome = await coordinator.run2pc({
participants: [
{ id: "billing", input: { invoiceId: "inv-1" } },
{ id: "inventory", input: { sku: "abc-9" } },
],
});
// outcome.state is "committed" | "aborted" | "in_doubt"
```
## Defaults
- `timeoutMs`: **5000** (operator-tunable per-call up to the
30000 ms ceiling).
- `outstandingPreparedCap`: **256** (operator-tunable via host
config).
## Error namespace
Every production code path throws `TwoPcError` with one of the ten
`TWOPC_*` codes. See `src/error-codes/error-codes.md` for the full
catalogue.
## Conformance
Every adapter implementing a writer participant MUST run
`run2pcParticipantConformance({ create: ... })` in a
`tests/conformance/run-2pc-participant.test.ts` file. The harness
pins prepare / commit / rollback semantics and the idempotency
contract.

View File

@@ -0,0 +1,150 @@
# `coordinator/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level orchestration for the 2PC protocol. `createTwoPcCoordinator`
builds a fully-wired coordinator that holds a participant registry, a
ledger store, an admission counter, and an OTel instrument bundle.
The `run2pc({ participants, timeoutMs? })` entry point owns the full
state machine described in design doc §9.2:
1. Admission via the outstanding-prepared cap.
2. Ledger insert at `voting`.
3. Phase-1 prepare per participant, with the
`2pc.prepare_duration_ms` histogram and a `2pc.prepare` span per
participant.
4. Either unanimous commit → `markAllVotedCommit` → phase-2
`COMMIT PREPARED` per writer; OR any abort/error →
`markAborting` → phase-2 `ROLLBACK PREPARED` per prepared writer.
5. Terminal ledger stamp (`committed` / `aborted` / `in_doubt`).
The coordinator is the only path application code ever touches.
Every smaller module (phases, persistence, recovery, limits) is
internal plumbing this factory composes.
## Features
- Single public surface: `createTwoPcCoordinator(deps)` returns
`{ run2pc, outstandingCount, options }`.
- Default `timeoutMs` from options (5000), per-call override accepted
up to the 30000 ceiling (enforced via
`limits/timeout-ceiling`).
- Admission cap (default 256) enforced via
`limits/outstanding-cap`; over-cap calls fail with
`TWOPC_OUTSTANDING_LIMIT_EXCEEDED` before any participant is
contacted.
- Hooks every documented OTel instrument (`2pc.transaction` span,
`2pc.prepare` / `2pc.commit` / `2pc.rollback` child spans,
`2pc.transactions_total` counter, `prepare`/`commit_duration_ms`
histograms, `outstanding_prepared` gauge).
- Ledger persistence is bidirectional — every state transition writes
through `TwoPcLedgerStore`, so the recovery runner has a full audit
trail on restart.
- Abort handling: vote_abort, prepare_failed, and timeout all funnel
to the rollback branch; rollback errors escalate to `in_doubt`
rather than failing the rollback path itself.
## Usage: Basic
```ts
import { createTwoPcCoordinator } from "@suaveplan/api-2pc/coordinator";
const coordinator = createTwoPcCoordinator({
options: { hostInstanceId: "host-01" },
registry,
ledgerDb,
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
});
const outcome = await coordinator.run2pc({
participants: [
{ id: "billing", input: { invoiceId: "inv-1" } },
{ id: "inventory", input: { sku: "abc-9" } },
],
});
console.log(outcome.state); // "committed" | "aborted" | "in_doubt"
```
## Usage: Advanced
Inject a custom instruments bundle to feed a hand-rolled
MeterProvider, override the default timeout per call, and consume the
per-participant vote summary on the outcome:
```ts
import { createTwoPcCoordinator } from "@suaveplan/api-2pc/coordinator";
import { createTwoPcInstruments } from "@suaveplan/api-2pc/instruments";
const instruments = createTwoPcInstruments({
meterProvider,
outstandingPreparedSnapshot: () => coordinator.outstandingCount(),
});
const coordinator = createTwoPcCoordinator({
options: {
hostInstanceId: "host-01",
defaultTimeoutMs: 10_000,
outstandingPreparedCap: 64,
},
registry,
ledgerDb,
clock: () => Date.now(),
idGenerator: () => crypto.randomUUID(),
instruments,
});
const outcome = await coordinator.run2pc({
participants: [
{ id: "billing", input: { invoiceId } },
{ id: "audit", input: { event: "invoice.created" } },
],
timeoutMs: 2_500,
});
for (const v of outcome.votes) {
auditLog.append({
txId: outcome.txId,
participant: v.participantId,
vote: v.vote,
});
}
```
## API Reference
- `interface TwoPcCoordinatorDeps`
- `options: TwoPcCoordinatorOptions`
- `registry: TwoPcParticipantRegistry`
- `ledgerDb: LedgerDb`
- `clock: Clock`
- `idGenerator: IdGenerator`
- `instruments?: TwoPcInstruments`
- `interface TwoPcCoordinator`
- `run2pc(input: Run2PcInput): Promise<Run2PcOutcome>`
- `outstandingCount(): number`
- `options: ResolvedTwoPcCoordinatorOptions`
- `createTwoPcCoordinator(deps): TwoPcCoordinator` — factory.
## Implementation Notes
The function is structured as one large `try` / `finally` so cleanup
(span end, deadline timer clear, admission release) runs regardless
of which branch fires. The pattern keeps the happy path linear and
the failure paths exhaustive without nested try blocks.
The "any commit failure → in_doubt" escalation is deliberate. Once
the coordinator has issued `COMMIT PREPARED` against at least one
participant, the only correct follow-up is to keep trying — but the
hot path cannot block on that. Instead, the row is marked `in_doubt`,
the OTel counter ticks the `in_doubt` outcome, and the recovery
runner picks it up on the next boot.
Vote-abort and prepare-failed paths both call `ac.abort()` BEFORE
running phase 2, so any prepared participants whose subsequent SQL
hits the signal map to `TWOPC_TIMEOUT` rather than getting stuck. The
deadline timer is cleared in the `finally` so a successful commit
does not leave a dangling abort handle on the AbortController.

View File

@@ -0,0 +1,97 @@
# `error-codes/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the `TWOPC_*` error-code namespace and the `TwoPcError`
concrete class for the `@suaveplan/api-2pc` package. The registry is the
canonical contract every production code path in the coordinator throws
against — observability dashboards, support runbooks, and integrating
plugins all join on these stable string codes.
The codes are intentionally enumerated up-front rather than grown
ad-hoc: a missing code is a spec deviation that a CI gate can detect,
and the closed set lets operators write alerting rules without
constantly chasing schema drift.
## Features
- Ten stable codes (`TIMEOUT`, `VOTE_ABORT`, `PREPARE_FAILED`,
`COMMIT_FAILED`, `ROLLBACK_FAILED`, `IN_DOUBT`,
`PARTICIPANT_NOT_PREPARABLE`, `READ_ONLY_PARTICIPANT_CANNOT_WRITE`,
`OUTSTANDING_LIMIT_EXCEEDED`, `TIMEOUT_CEILING_EXCEEDED`).
- Each code is exposed via `TWOPC_CODES.<NAME>` returning the prefixed
string `"TWOPC.<NAME>"` produced by `defineCodes`.
- `TwoPcError` extends `SuaveplanError` so cause-chain traversal,
Zod-validated wire format, and the OTel span-event adapter from
`@suaveplan/error` all apply unchanged.
## Usage: Basic
```ts
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
throw new TwoPcError(
TWOPC_CODES.TIMEOUT,
"Phase-1 deadline elapsed",
{ context: { txId, timeoutMs: 5000 } }
);
```
## Usage: Advanced
Branching on error code at the boundary between coordinator and caller
is the recommended consumer pattern. The narrowed `twoPcCode` field
gives an exhaustive switch the compiler can check:
```ts
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
try {
await run2pc({ participants, timeoutMs: 5000 }, ctx);
} catch (err) {
if (err instanceof TwoPcError) {
switch (err.twoPcCode) {
case TWOPC_CODES.TIMEOUT:
metrics.incr("billing.2pc.timeout");
return retryWithBackoff();
case TWOPC_CODES.VOTE_ABORT:
return surfaceUserError();
case TWOPC_CODES.IN_DOUBT:
pageOncall(err);
return;
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `TWOPC_CODES` — frozen object `{ TIMEOUT: "TWOPC.TIMEOUT", ... }`.
- `type TwoPcErrorCode` — string-literal union of every value in
`TWOPC_CODES`.
- `class TwoPcError extends SuaveplanError<TwoPcErrorCode>` — exposes
`twoPcCode` as a same-typed accessor for ergonomic narrowing without
re-asserting at every catch site.
## Implementation Notes
`defineCodes` from `@suaveplan/error` produces the prefixed values at
runtime, so every code string starts with `"TWOPC."`. This matches the
prefix convention used by sibling api-stack packages (`HOST.*`,
`PLUGIN.*`, `RELOAD.*`, `LEDGER.*`). The `TwoPcError` class carries a
redundant `twoPcCode` field — duplicating `code` — purely to give
callers a name they don't have to import the generic
`SuaveplanError<C>` type to narrow against. The duplication is
deliberate and harmless because the base class makes `code` readonly.
The registry is closed; adding a code requires an OpenSpec change so
every consumer dashboard and runbook stays in sync. Coverage assertions
ensure every code is actually thrown by a production path before this
package can graduate to `1.0.0`.

View File

@@ -0,0 +1,105 @@
# `instruments/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the OTel instrument registry the 2PC coordinator emits against
on every transaction. Each instrument's name follows design doc §9.2
exactly so a missing entry is a spec deviation a CI gate could detect.
Centralising creation keeps the coordinator code free of meter / tracer
plumbing — every phase module simply records against the frozen
bundle returned here.
The observable gauge wires into the coordinator's outstanding-prepared
counter via a snapshot callback, so dashboards see the live admission
state without the coordinator pushing on every change.
## Features
- One span: `2pc.transaction` per transaction lifetime; per-phase
child spans `2pc.prepare`, `2pc.commit`, `2pc.rollback` (started by
the phase modules themselves, using the tracer this bundle exposes).
- One counter: `2pc.transactions_total` labelled `{ outcome }`.
- Two histograms: `2pc.prepare_duration_ms`, `2pc.commit_duration_ms`
labelled `{ outcome }`.
- One observable gauge: `2pc.outstanding_prepared` wired to a
snapshot callback supplied at construction.
- Provider defaults to the OTel globals (no-op when no SDK is
installed) so unit tests do not have to wire a provider.
## Usage: Basic
```ts
import { createTwoPcInstruments } from "@suaveplan/api-2pc/instruments";
const instruments = createTwoPcInstruments({
outstandingPreparedSnapshot: () => coordinator.outstandingCount(),
});
instruments.transactionsTotal.add(1, { outcome: "committed" });
```
## Usage: Advanced
Inject a real OTel SDK and a custom snapshot to feed a live dashboard:
```ts
import { metrics, trace } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { createTwoPcInstruments } from "@suaveplan/api-2pc/instruments";
const meterProvider = new MeterProvider({ /* readers/exporters */ });
metrics.setGlobalMeterProvider(meterProvider);
const instruments = createTwoPcInstruments({
meterProvider,
tracerProvider: trace.getTracerProvider(),
outstandingPreparedSnapshot: () => coordinator.outstandingCount(),
});
const span = instruments.tracer.startSpan("2pc.transaction", {
attributes: { "tx.id": txId, participant_count: 2 },
});
try {
// ...
instruments.prepareDurationMs.record(prepareElapsedMs, {
outcome: "committed",
});
} finally {
span.end();
}
```
## API Reference
- `type TwoPcOutcome = "committed" | "aborted" | "in_doubt"` — values
attached to the `outcome` attribute on every counter / histogram
observation.
- `interface TwoPcInstrumentsConfig`
- `meterProvider?: MeterProvider`
- `tracerProvider?: TracerProvider`
- `outstandingPreparedSnapshot: () => number`
- `interface TwoPcInstruments` — frozen bundle exposing `meter`,
`tracer`, `transactionsTotal`, `prepareDurationMs`,
`commitDurationMs`, `outstandingPreparedGauge`.
- `createTwoPcInstruments(config): TwoPcInstruments` — factory.
## Implementation Notes
The observable gauge's callback fires whenever the SDK collects metrics
(typically on a fixed interval driven by the exporter). The snapshot
returned by `outstandingPreparedSnapshot` is therefore an
eventually-consistent value — operators must not treat it as a strict
upper bound for admission decisions. The strict upper bound is enforced
inside `limits/outstanding-cap.ts`; the gauge is purely a dashboard
signal.
Histogram unit is set explicitly to `"ms"` per OTel naming convention.
The two histograms are named after the phases they measure so a single
dashboard panel can render side-by-side latency for prepare and commit
without re-labelling — operators flagging long prepares typically
care more about phase-1 latency because that is when row-level locks
are held on every participant.

View File

@@ -0,0 +1,104 @@
# `limits/outstanding-cap`
## Purpose
Admission control for in-flight 2PC transactions. The coordinator
holds a fixed-size budget of "outstanding prepared transactions"; once
the budget is exhausted new `run2pc` calls refuse rather than queue.
The default is 256 (design doc §14's open question, pinned by this
package's spec) and operators can tune it via host config.
Refusing rather than queuing is intentional: Postgres holds row-level
locks for every prepared transaction, so a backed-up coordinator
would otherwise convert a transient overload into a global lock
storm. Failing fast lets callers route to a degraded path
(degraded-write, retry queue, etc.).
## Features
- `OutstandingPreparedCap` — synchronous counter with `admit()`,
`release()`, `snapshot()`, `capacity()`.
- Constructor validates that the cap is a positive integer (rejects
zero, negatives, fractions, `NaN`).
- `release()` is idempotent below zero — a buggy double-release in
caller code cannot silently expand the real cap.
- Synchronous-only — relies on the event-loop atomicity guarantee
between `admit` / `release` pairs in JavaScript runtimes.
## Usage: Basic
```ts
import { OutstandingPreparedCap } from "@suaveplan/api-2pc/limits";
const cap = new OutstandingPreparedCap(256);
cap.admit();
try {
// run the 2PC transaction
} finally {
cap.release();
}
```
## Usage: Advanced
Wrap admission inside the coordinator's public entry to centralise the
release in one `finally`. The snapshot fuels the OTel gauge:
```ts
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
import { OutstandingPreparedCap } from "@suaveplan/api-2pc/limits";
class MiniCoordinator {
private readonly cap = new OutstandingPreparedCap(256);
snapshot() {
return this.cap.snapshot();
}
async run<T>(fn: () => Promise<T>): Promise<T> {
try {
this.cap.admit();
} catch (err) {
if (
err instanceof TwoPcError &&
err.twoPcCode === TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED
) {
// route to degraded path
throw err;
}
throw err;
}
try {
return await fn();
} finally {
this.cap.release();
}
}
}
```
## API Reference
- `class OutstandingPreparedCap`
- `constructor(cap: number)` — throws
`TWOPC_OUTSTANDING_LIMIT_EXCEEDED` on invalid `cap`.
- `admit(): void` — throws
`TWOPC_OUTSTANDING_LIMIT_EXCEEDED` when at capacity.
- `release(): void` — idempotent below zero.
- `snapshot(): number` — current outstanding count.
- `capacity(): number` — configured cap.
## Implementation Notes
Construction-time validation re-uses the same error code as the
runtime cap-exceeded path. This is deliberate: operators tuning the
cap via host config want one error string to alert on, regardless of
whether the misconfiguration was a fractional value at startup or a
real overload at steady state. The context object on the thrown
`TwoPcError` distinguishes the two cases (`cap` only vs.
`cap` + `outstanding`).
The class is intentionally tiny — no async, no internal queue, no
external dependencies. Adding a queue here would re-introduce the
lock-storm risk the cap exists to prevent.

View File

@@ -0,0 +1,77 @@
# `limits/timeout-ceiling`
## Purpose
Enforces the hard 30 000 ms ceiling on caller-supplied `timeoutMs`
values. The check fires at the public entry point of `run2pc` so
above-ceiling calls never contact a participant; the operational
warning in design doc §9.2 is that long prepares hold row-level locks,
so surfacing the misuse loudly is more useful than silently clamping.
This module is intentionally tiny — one function — because the
behaviour is small but the gate is one of the most-asserted contracts
in the spec.
## Features
- `assertTimeoutBelowCeiling(timeoutMs)` — throws
`TWOPC_TIMEOUT_CEILING_EXCEEDED` for above-ceiling, non-positive,
non-finite, or `NaN` inputs.
- Re-uses the `TIMEOUT_CEILING_MS` constant from `options/` so the
ceiling stays a single source of truth.
## Usage: Basic
```ts
import { assertTimeoutBelowCeiling } from "@suaveplan/api-2pc/limits";
assertTimeoutBelowCeiling(5000); // ok
assertTimeoutBelowCeiling(30001); // throws TWOPC_TIMEOUT_CEILING_EXCEEDED
```
## Usage: Advanced
The function is intentionally side-effect-free so callers can wrap it
in a per-tenant override path:
```ts
import { TIMEOUT_CEILING_MS } from "@suaveplan/api-2pc/options";
import { assertTimeoutBelowCeiling } from "@suaveplan/api-2pc/limits";
function resolveTimeout(
requested: number | undefined,
tenantMax: number
): number {
const effective = requested ?? DEFAULT_TIMEOUT_MS;
assertTimeoutBelowCeiling(effective);
if (effective > tenantMax) {
return tenantMax;
}
if (effective > TIMEOUT_CEILING_MS) {
// unreachable given the assert above — included only to
// illustrate that the ceiling acts as the strict upper bound.
return TIMEOUT_CEILING_MS;
}
return effective;
}
```
## API Reference
- `function assertTimeoutBelowCeiling(timeoutMs: number): void`
- Throws `TwoPcError(TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED)` for any
of: `NaN`, `Infinity`, `-Infinity`, values `<= 0`, values
`> TIMEOUT_CEILING_MS`.
## Implementation Notes
The check rejects non-positive values for the same reason it rejects
above-ceiling ones: an immediate or backwards-in-time deadline would
collapse phase 1 to a single tick and the rollback path would fire
before any participant could finish a real prepare. Treating that as
the same misuse class as "30 minutes" is a deliberate ergonomic
choice — operators get one error code to grep for.
`Number.isFinite` is used in preference to `Number.isNaN` because the
former covers `Infinity` and `-Infinity` in addition to `NaN`. The
explicit `<= 0` check then excludes zero and negative-finite values.

View File

@@ -0,0 +1,96 @@
# `options/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the boundary-validated option shape consumed by
`createTwoPcCoordinator`. Every tunable that affects coordinator
behaviour (host instance id, outstanding-prepared cap, default phase-1
timeout) lives here, with Zod validation at the seam and explicit
defaults pinned to the design doc §9.2 numbers.
Centralising option resolution in one module keeps the coordinator
free of conditional fallback logic — by the time it sees the resolved
shape every field is concrete, so subsequent code never has to
re-check `undefined`.
## Features
- `DEFAULT_TIMEOUT_MS = 5000` — phase-1 deadline default per design
doc §9.2.
- `TIMEOUT_CEILING_MS = 30000` — hard ceiling enforced at call time
(the coordinator's `limits/timeout-ceiling` module imports this).
- `DEFAULT_OUTSTANDING_PREPARED_CAP = 256` — admission-control default
per design doc §14.
- `twoPcCoordinatorOptionsSchema` — Zod schema, `strict()` so typos
surface as Zod errors instead of being silently ignored.
- `resolveTwoPcCoordinatorOptions(raw)` — validates and fills in
defaults; returns the fully-populated `ResolvedTwoPcCoordinatorOptions`
shape the coordinator consumes.
## Usage: Basic
```ts
import {
DEFAULT_TIMEOUT_MS,
resolveTwoPcCoordinatorOptions,
} from "@suaveplan/api-2pc/options";
const resolved = resolveTwoPcCoordinatorOptions({
hostInstanceId: "host-01",
});
console.log(resolved.defaultTimeoutMs === DEFAULT_TIMEOUT_MS); // true
```
## Usage: Advanced
Override every default — useful for canary hosts where operators want
a tighter admission cap and a longer prepare window:
```ts
import { resolveTwoPcCoordinatorOptions } from "@suaveplan/api-2pc/options";
const resolved = resolveTwoPcCoordinatorOptions({
hostInstanceId: "canary-eu-west-1",
outstandingPreparedCap: 64,
defaultTimeoutMs: 15_000,
});
```
Calls supplying `defaultTimeoutMs` above `TIMEOUT_CEILING_MS` raise a
Zod error at this boundary — the coordinator's runtime ceiling check
in `limits/timeout-ceiling.ts` then surfaces the misuse on per-call
overrides as well.
## API Reference
- `const DEFAULT_TIMEOUT_MS: number` — 5000.
- `const TIMEOUT_CEILING_MS: number` — 30000.
- `const DEFAULT_OUTSTANDING_PREPARED_CAP: number` — 256.
- `interface TwoPcCoordinatorOptions` — caller-supplied input shape.
- `interface ResolvedTwoPcCoordinatorOptions` — internal shape after
defaults.
- `const twoPcCoordinatorOptionsSchema: z.ZodType` — strict-shape
Zod schema.
- `function resolveTwoPcCoordinatorOptions(raw): ResolvedTwoPcCoordinatorOptions`
— validates and resolves defaults.
## Implementation Notes
The schema uses `.strict()` so unknown keys fail validation rather than
silently being dropped. The default for `defaultTimeoutMs` is applied
post-parse with `?? DEFAULT_TIMEOUT_MS` rather than via
`z.default(...)` so the schema continues to reject above-ceiling
values regardless of whether the caller supplied the field — Zod's
`default` would short-circuit `.max()` checks when the field is
omitted, which is the wrong shape for a ceiling-enforced option.
The module deliberately does NOT re-throw Zod failures as
`TwoPcError` — that's the responsibility of the coordinator's
public entry points. Keeping this module pure of cross-package error
mapping makes it reusable from tests that want a raw Zod surface for
property-based shape testing.

View File

@@ -0,0 +1,114 @@
# `persistence/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Persists every coordinator state transition into the
`suaveplan_2pc_transactions` ledger table. The table is owned by
`@suaveplan/api-host` (migration id 2, `saga-2pc-reserved`); this
module only reads and writes rows so the host stays the single source
of truth for schema evolution.
The ledger is what makes 2PC durable across coordinator restarts.
Without it, a host that crashes mid-prepare would lose track of which
participants were `PREPARE TRANSACTION`'d and could not safely choose
between commit and rollback on the next boot.
## Features
- `TwoPcLedgerStore` — CRUD facade over `suaveplan_2pc_transactions`
with statements for every state transition (`insert`,
`markAllVotedCommit`, `markAborting`, `markCommitted`, `markAborted`,
`markInDoubt`).
- `findById` — single-row lookup for state inspection.
- `findRecoverable` — scan returning every non-terminal row for the
current host instance id. Drives `recovery/`.
- `TwoPcLedgerStore.decodeParticipants` — static helper that parses the
`participants_json` blob back into a typed array.
- Structural `LedgerDb` / `LedgerStatement` interfaces so the store
works with `bun:sqlite`, `@suaveplan/db-sqlite`'s thin wrapper, and
`better-sqlite3` without any concrete dependency.
## Usage: Basic
```ts
import { Database } from "bun:sqlite";
import { TwoPcLedgerStore } from "@suaveplan/api-2pc/persistence";
const db = new Database(":memory:");
// Schema is created by api-host's migration runner — see migrations.ts
// migration id 2 (`saga-2pc-reserved`). In tests, run the host runner
// against the same database.
const store = new TwoPcLedgerStore(db as never);
store.insert({
id: "tx-1",
coordinator: "host-instance-01",
participants: [
{ participantId: "billing", voterMode: "writer", vote: null },
],
});
```
## Usage: Advanced
Walk the recovery candidates and decode each blob — the typical shape
used inside `recovery/recovery.ts`:
```ts
import { TwoPcLedgerStore } from "@suaveplan/api-2pc/persistence";
function listRecoverable(store: TwoPcLedgerStore, hostId: string) {
const rows = store.findRecoverable(hostId);
return rows.map((row) => ({
id: row.id,
state: row.state,
participants: TwoPcLedgerStore.decodeParticipants(row),
}));
}
```
The static `decodeParticipants` is deliberately exposed so callers
working with raw rows (e.g. ad-hoc admin scripts) do not have to
recreate the JSON contract themselves.
## API Reference
- `interface LedgerStatement` / `interface LedgerDb` — structural
SQLite-shaped views.
- `interface TwoPcLedgerRow` — exact row shape returned by `findById`
/ `findRecoverable`.
- `interface LedgerParticipantEntry` — per-participant entry in
`participants_json`.
- `class TwoPcLedgerStore`
- `constructor(db: LedgerDb)`
- `insert({ id, coordinator, participants })`
- `markAllVotedCommit({ id, participants, preparedAt })`
- `markAborting({ id, participants })`
- `markCommitted(id, committedAt)`
- `markAborted(id, abortedAt)`
- `markInDoubt(id)`
- `findById(id): TwoPcLedgerRow | null`
- `findRecoverable(coordinator): readonly TwoPcLedgerRow[]`
- `static decodeParticipants(row): readonly LedgerParticipantEntry[]`
## Implementation Notes
Every UPDATE uses a single statement with the new `state` value
matched against `TwoPcCoordinatorState`'s string-literal union — the
`satisfies TwoPcCoordinatorState` annotation catches typos at compile
time. The store does NOT enforce a state machine on top of the table;
that responsibility belongs to the coordinator. Keeping the
persistence layer state-machine-free lets recovery use the same store
without conflating "what changed in this lifetime" with "what the row
looked like after a crash".
The `participants_json` blob is stored as a JSON string rather than a
side table — the coordinator never queries by participant id, only by
transaction id, so a JSON blob has zero indexing cost and survives
schema evolution painlessly. `decodeParticipants` returns an empty
array when the blob is not an array — defensive against rows
hand-written by ad-hoc admin scripts.

View File

@@ -0,0 +1,94 @@
# `phases/commit`
## Purpose
Phase-2 commit for a single participant. Once every participant has
returned `commit` from phase 1 and the coordinator has persisted the
`all-voted-commit` state to the ledger, this module emits
`COMMIT PREPARED '<gid>'` against each writer participant and records
`2pc.commit_duration_ms`.
Phase 2 is intentionally unbounded by timeout — the protocol cannot
tolerate giving up on a commit-prepared mid-flight, because doing so
would leave the system in `in_doubt` state and require the recovery
runner to resolve it on the next boot.
## Features
- Skips participants that did not actually prepare (`outcome.prepared
=== false`) — read-only voters and abort-voters never see commit
SQL.
- Emits a `2pc.commit` span with `tx.id`, `participant.id`, and `gid`
attributes.
- Records `2pc.commit_duration_ms` tagged with `outcome: "committed"`
on success.
- Failure preserves the underlying cause on a thrown
`TwoPcError(TWOPC_COMMIT_FAILED)`; the coordinator escalates to
`TWOPC_IN_DOUBT` once it has tried every participant.
## Usage: Basic
```ts
import { commitPhaseTwo } from "@suaveplan/api-2pc/phases";
await commitPhaseTwo({
txId,
participant,
outcome: preparedOutcome,
instruments,
});
```
## Usage: Advanced
The coordinator collects per-participant failures rather than
short-circuiting — every participant gets attempted exactly once so
operators can see the full picture in the ledger:
```ts
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
import { commitPhaseTwo } from "@suaveplan/api-2pc/phases";
const failures: TwoPcError[] = [];
for (const outcome of preparedOutcomes) {
if (!outcome.prepared) continue;
try {
await commitPhaseTwo({
txId,
participant: registry.get(outcome.participantId)!,
outcome,
instruments,
});
} catch (err) {
if (err instanceof TwoPcError) failures.push(err);
else throw err;
}
}
if (failures.length > 0) {
// ledger.markInDoubt(txId); recovery resolves on next boot
}
```
## API Reference
- `interface CommitPhaseInput`
- `txId: string`
- `participant: RegisteredTwoPcParticipant`
- `outcome: ParticipantPrepareOutcome` — the phase-1 result.
- `instruments: TwoPcInstruments`
- `function commitPhaseTwo(input): Promise<void>`
- no-op for `outcome.prepared === false`
- throws `TwoPcError(TWOPC_COMMIT_FAILED)` on SQL failure.
## Implementation Notes
The gid stored on the prepare outcome is the single source of truth —
this module never rebuilds it. Reusing the prepare-time value
defends against the unlikely case where a host-config change between
phases would have produced a different gid: the SQL the coordinator
needs to send is exactly the one it prepared with.
The SQL escape (`replace(/'/g, "''")`) matches the prepare-phase
treatment so a gid containing the unlikely quote character would
still produce valid SQL. The escape is cheap; the defence-in-depth
costs nothing on the happy path.

View File

@@ -0,0 +1,85 @@
# `phases/gid`
## Purpose
Builds and parses the global transaction id (gid) string passed to
Postgres' `PREPARE TRANSACTION '<gid>'`. The format embeds the host
instance id so the recovery scanner can filter `pg_prepared_xacts` to
only the rows this coordinator owns — without that anchor, two
coordinators sharing a Postgres host would clobber each other's
recovery decisions.
The module is intentionally pure: no SQL, no client. Centralising the
gid format here makes the recovery filter and the per-participant
prepare path share a single source of truth.
## Features
- `buildGid(hostInstanceId, txId, participantId)` — produces
`2pc-<host>-<tx>-<participant>`.
- `parseGid(gid, hostInstanceId)` — extracts `{ txId, participantId }`
for gids written by the supplied host; returns `null` otherwise so
recovery skips foreign prepared transactions silently. The
hostInstanceId is supplied by the caller (the recovery runner)
because the host id itself may contain hyphens and the parser cannot
recover it unambiguously from the gid alone.
- `gidPrefixFor(hostInstanceId)` — the prefix the recovery SQL filter
uses; centralised so SQL and parser never drift.
## Usage: Basic
```ts
import { buildGid, parseGid } from "@suaveplan/api-2pc/phases";
const gid = buildGid("host-01", "tx-abc", "billing");
// "2pc-host-01-tx-abc-billing"
const parts = parseGid(gid, "host-01");
// { txId: "tx-abc", participantId: "billing" }
```
## Usage: Advanced
Recovery uses the prefix filter to ignore prepared transactions
written by other components sharing the database:
```ts
import { gidPrefixFor, parseGid } from "@suaveplan/api-2pc/phases";
const prefix = gidPrefixFor("host-01");
const rows = await db.run<{ gid: string }>(
`SELECT gid FROM pg_prepared_xacts WHERE gid LIKE $1`,
[`${prefix}%`]
);
for (const row of rows) {
const parts = parseGid(row.gid, "host-01");
if (parts === null) continue; // foreign prepared transaction
// ...
}
```
## API Reference
- `buildGid(hostInstanceId: string, txId: string, participantId: string): string`
- `parseGid(gid: string, hostInstanceId: string): { txId, participantId } | null`
- `gidPrefixFor(hostInstanceId: string): string`
## Implementation Notes
`parseGid` takes the caller-supplied `hostInstanceId` and strips the
prefix `2pc-<host>-` before extracting `txId` and `participantId`.
The host instance id is supplied explicitly because hyphens are
allowed inside it (production hosts often look like `host-eu-west-1`)
and the parser cannot recover the boundary from the gid alone. The
recovery runner always knows its own host id, so this constraint is
ergonomic in practice.
Participant ids are constrained by the registry (and by host config)
to not contain hyphens, so the LAST hyphen after the prefix
unambiguously delimits the `participantId` from the `txId`.
The function returns `null` rather than throwing on unrecognised gids
because the recovery scan iterates over rows that may include prepared
transactions written by code outside this package's control — Postgres
has no participant-tag column, only the gid string itself, so the
shared-database scenario is a legitimate concern.

View File

@@ -0,0 +1,105 @@
# `phases/prepare`
## Purpose
Implements phase 1 of the 2PC protocol for a single participant. The
coordinator iterates participants and calls this module for each;
unanimous commit votes trigger phase 2, any abort or prepare failure
short-circuits to rollback. This module is the only place the
`PREPARE TRANSACTION '<gid>'` SQL is emitted, so the gid format
(`phases/gid`) and the SQL emission point stay co-located.
Phase 1 is the latency-sensitive half of 2PC: every participant holds
row-level locks for the duration. The module records
`2pc.prepare_duration_ms` so operators can spot creeping lock holds
before they cause throughput regressions.
## Features
- Runs the participant's `prepare` callback, then the
`PREPARE TRANSACTION` SQL when the vote is `commit` AND the
participant is a writer.
- Skips the SQL for read-only voters — they participate in the vote
only.
- Refuses a read-only voter that returned `writeIntent: true` with
`TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`.
- Honours an `AbortSignal` for the phase-1 deadline; aborted SQL is
remapped to `TWOPC_TIMEOUT`.
- Emits a `2pc.prepare` span around the whole call and records a
`2pc.prepare_duration_ms` observation tagged with the outcome.
- Failure cases preserve the underlying cause on the thrown
`TwoPcError`.
## Usage: Basic
```ts
import { preparePhaseOne } from "@suaveplan/api-2pc/phases";
const outcome = await preparePhaseOne({
hostInstanceId: "host-01",
txId,
participant,
payload: { invoiceId: "inv-1" },
instruments,
signal: ac.signal,
});
if (outcome.vote === "abort") {
// run rollback for every participant that voted commit
}
```
## Usage: Advanced
The coordinator runs this in a loop. Read-only voters interleave
freely with writers — the outcome's `prepared` flag tells phase 2
whether to send commit/rollback SQL:
```ts
import { preparePhaseOne } from "@suaveplan/api-2pc/phases";
const outcomes: ParticipantPrepareOutcome[] = [];
for (const p of registry.list()) {
const o = await preparePhaseOne({
hostInstanceId,
txId,
participant: p,
payload: payloadFor(p),
instruments,
signal,
});
outcomes.push(o);
if (o.vote === "abort") break; // short-circuit
}
// phase 2: commit only those with prepared === true
const writers = outcomes.filter((o) => o.prepared);
```
## API Reference
- `interface ParticipantPrepareOutcome` — `{ participantId, vote,
prepared, gid }`.
- `interface PreparePhaseInput` — `{ hostInstanceId, txId,
participant, payload, instruments, signal }`.
- `function preparePhaseOne(input): Promise<ParticipantPrepareOutcome>`
- throws `TWOPC_PREPARE_FAILED` when the participant callback or
SQL raises (signal not aborted),
- throws `TWOPC_TIMEOUT` when the signal aborted around the SQL,
- throws `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE` when a read-only
voter returned `writeIntent: true`.
## Implementation Notes
The SQL escape strategy is conservative: gids are built from the host
instance id, transaction id, and participant id — all three are
already validated at host config / id-generator / registry time — but
the function still calls `.replace(/'/g, "''")` before interpolating
into the `PREPARE TRANSACTION` statement. The runtime cost is zero on
the happy path and the defence-in-depth is cheap. The coordinator
never accepts user-supplied SQL fragments.
The span is started before the prepare callback so the participant's
own work shows up in the trace as a child of the `2pc.prepare` span if
the participant opens its own spans against the same tracer. The
`finally`-block `span.end()` guarantees the span is closed regardless
of which branch fires.

View File

@@ -0,0 +1,92 @@
# `phases/rollback`
## Purpose
Phase-2 rollback for a single participant. Used both by the
coordinator's abort path (vote_abort or prepare failure) and by the
recovery runner (orphan with no `all-voted-commit` ledger row).
The function is idempotent at the protocol level: Postgres returns
`42704` (`undefined_object`, "prepared transaction does not exist")
for a gid that was already rolled back, and this module catches that
specific error and treats it as a no-op. That property is what makes
recovery safe to re-run on every boot.
## Features
- Emits `ROLLBACK PREPARED '<gid>'` against the participant's
connection.
- Idempotent against Postgres `42704` — a second rollback against the
same gid is a no-op.
- Skips participants that did not actually prepare (`outcome.prepared
=== false`).
- Emits a `2pc.rollback` span with `tx.id`, `participant.id`, and
`gid` attributes.
- Failure preserves the underlying cause on a thrown
`TwoPcError(TWOPC_ROLLBACK_FAILED)`.
- Exports `isUnknownPreparedTransaction(cause)` so the recovery
runner can apply the same idempotency check without duplicating the
SQLSTATE sniff.
## Usage: Basic
```ts
import { rollbackPhaseTwo } from "@suaveplan/api-2pc/phases";
await rollbackPhaseTwo({
txId,
participant,
outcome: preparedOutcome,
instruments,
});
```
## Usage: Advanced
Recovery uses the same idempotency check directly to short-circuit
when the row was concurrently resolved:
```ts
import {
isUnknownPreparedTransaction,
rollbackPhaseTwo,
} from "@suaveplan/api-2pc/phases";
try {
await rollbackPhaseTwo({ txId, participant, outcome, instruments });
} catch (err) {
if (
err instanceof TwoPcError &&
err.twoPcCode === TWOPC_CODES.ROLLBACK_FAILED &&
isUnknownPreparedTransaction(err.cause)
) {
// race: another process already rolled this back — treat as success
} else {
throw err;
}
}
```
## API Reference
- `interface RollbackPhaseInput` — same fields as `CommitPhaseInput`.
- `function rollbackPhaseTwo(input): Promise<void>`
- no-op for `outcome.prepared === false`
- throws `TwoPcError(TWOPC_ROLLBACK_FAILED)` on non-idempotency SQL
failure.
- `function isUnknownPreparedTransaction(cause: unknown): boolean`
- matches SQLSTATE `42704` AND the substring `"does not exist"` as
a fallback for drivers that omit the SQLSTATE.
## Implementation Notes
The idempotency check sniffs both `cause.code` and `cause.message`
because the two postgres clients in use (`postgres`/porsager and
`pg.Client`) surface the SQLSTATE differently. Using both lets the
module work against either driver without a wrapper layer.
The function is split into a small public `rollbackPhaseTwo` and an
exported `isUnknownPreparedTransaction` helper so recovery can re-use
the idempotency predicate without re-implementing it. Recovery sees
this exact race regularly: the coordinator and the recovery runner
both target the same prepared gid when a host restarts mid-rollback.

View File

@@ -0,0 +1,133 @@
# `recovery/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Boot-time orphan resolution for the 2PC coordinator. When a host
crashes between phase 1 and phase 2, prepared transactions remain
held in `pg_prepared_xacts` on every writer participant. The recovery
runner sweeps those rows on the next boot, consults the host-owned
ledger, and either commits or rolls back each one — guaranteeing the
"once prepared, eventually resolved" half of the 2PC durability
property.
The runner is the only path that should ever resolve an in-doubt
transaction. The coordinator's hot path NEVER auto-recovers; it
escalates to `TWOPC_IN_DOUBT` and lets the next-boot recovery sweep
handle it. That separation keeps the hot path's failure handling
small and the recovery path testable in isolation.
## Features
- `resolveRecovery({ hostInstanceId, ledger, probes, onResolved })` —
the entry point. Returns a `RecoveryReport` enumerating committed,
rolled-back, skipped, and errored gids.
- Idempotent — re-running the function on a host with no outstanding
prepared transactions is a no-op (the inner `queryOrphans` returns
an empty list).
- Treats Postgres `42704` ("does not exist") as success during
rollback — covers the race where another process already resolved
the orphan.
- Marks rows as `in_doubt` on unresolvable errors so the next sweep
can retry; errored gids are surfaced on the report rather than
thrown.
- `buildProbeFromRegistry(registry)` — convenience adapter that
spins up one probe per writer participant.
## Usage: Basic
```ts
import {
buildProbeFromRegistry,
resolveRecovery,
} from "@suaveplan/api-2pc/recovery";
const report = await resolveRecovery({
hostInstanceId: "host-01",
ledger,
probes: buildProbeFromRegistry(registry),
});
console.log(
`committed=${report.committed.length} ` +
`rolledBack=${report.rolledBack.length} ` +
`errors=${report.errors.length}`
);
```
## Usage: Advanced
Wire a custom probe to feed an alternate identifier convention or a
replicated-read connection — useful when the recovery sweep should
hit a read replica to avoid contending with the live writer:
```ts
import { gidPrefixFor } from "@suaveplan/api-2pc/phases";
import { resolveRecovery, type RecoveryProbe } from "@suaveplan/api-2pc/recovery";
const probes: RecoveryProbe[] = [
{
participantId: "billing",
async queryOrphans(prefix) {
const rows = await readReplica.unsafe<{ gid: string }[]>(
`SELECT gid FROM pg_prepared_xacts WHERE gid LIKE $1`,
[`${prefix}%`]
);
return rows.map((r) => r.gid);
},
async commit(gid) {
await primary.unsafe(`COMMIT PREPARED $1`, [gid]);
},
async rollback(gid) {
await primary.unsafe(`ROLLBACK PREPARED $1`, [gid]);
},
},
];
const report = await resolveRecovery({
hostInstanceId: "host-01",
ledger,
probes,
onResolved: (e) => log.info(e),
});
```
## API Reference
- `interface RecoveryProbe`
- `participantId: string`
- `queryOrphans(prefix: string): Promise<readonly string[]>`
- `commit(gid: string): Promise<void>`
- `rollback(gid: string): Promise<void>`
- `interface RecoveryReport`
- `committed: readonly string[]`
- `rolledBack: readonly string[]`
- `skipped: readonly string[]`
- `errors: readonly { gid; error: TwoPcError }[]`
- `interface RecoveryInput`
- `hostInstanceId, ledger, probes, onResolved?`
- `function resolveRecovery(input): Promise<RecoveryReport>`
- `function buildProbeFromRegistry(registry): readonly RecoveryProbe[]`
## Implementation Notes
The ledger lookup uses the gid's parsed `txId` as the primary key —
the gid format from `phases/gid.ts` is what makes that mapping
unambiguous. Rows whose ledger state is `committing` OR `committed`
trigger `COMMIT PREPARED`; everything else (including missing rows)
triggers `ROLLBACK PREPARED`. That asymmetric default ("rollback by
default") is the safe choice — committing a row whose ledger does
not explicitly say "all voted commit" would violate the design
guarantee.
The `42704` swallow during rollback is what makes the runner safe to
re-execute: if the previous sweep crashed AFTER rolling back the
prepared transaction but BEFORE updating the ledger, the second
sweep will see the orphan listed in `pg_prepared_xacts` (or not, if
truly gone) and will not throw on the "does not exist" follow-up.
Errors that cannot be classified as idempotency are captured per-gid
and surfaced on the report — operators page-on `errors.length > 0`,
not on a generic throw.

View File

@@ -0,0 +1,111 @@
# `registry/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The participant registry is the install-time admission control between
plugins and the 2PC coordinator. Plugins declare
`twoPhaseCommit.participates: true` in their manifest; the host
registers them with this registry; the registry refuses any writer
whose bound database engine cannot honour `PREPARE TRANSACTION`.
This is where the design doc §9.2 rule "SQLite and SurrealDB MUST NOT
participate as writers" is enforced. The capability check is injected
as a callback so the registry stays decoupled from
`@suaveplan/api-host`'s concrete `CapabilityRegistry`.
## Features
- `TwoPcParticipantRegistry.register(participant)` — refuses writers
without `db.preparable`; accepts read-only voters regardless of
engine.
- `get(id)` / `list()` / `size()` / `unregister(id)` — standard
registry surface.
- Injection seam: `PreparableCapabilityCheck` is a plain
`(id: string) => boolean` callback — zero coupling to api-host.
## Usage: Basic
```ts
import { TwoPcParticipantRegistry } from "@suaveplan/api-2pc/registry";
const isPreparable = (pluginId: string) =>
pluginId === "billing" || pluginId === "inventory";
const registry = new TwoPcParticipantRegistry(isPreparable);
registry.register({
id: "billing",
voterMode: "writer",
connection: { engine: "postgres", run: pg.unsafe.bind(pg) },
prepare: billingPrepareCallback,
});
```
## Usage: Advanced
Refusing a SQLite-backed plugin as a writer happens at register-time
— wrap the registration in a try/catch to surface the failure to the
plugin loader without crashing the host boot:
```ts
import { TWOPC_CODES, TwoPcError } from "@suaveplan/api-2pc/error-codes";
import { TwoPcParticipantRegistry } from "@suaveplan/api-2pc/registry";
function safeRegisterParticipants(
registry: TwoPcParticipantRegistry,
participants: ReadonlyArray<RegisteredTwoPcParticipant>
) {
const failures: { id: string; reason: string }[] = [];
for (const p of participants) {
try {
registry.register(p);
} catch (err) {
if (
err instanceof TwoPcError &&
err.twoPcCode === TWOPC_CODES.PARTICIPANT_NOT_PREPARABLE
) {
failures.push({ id: p.id, reason: err.message });
continue;
}
throw err;
}
}
return failures;
}
```
The same plugin can then re-register as a `read-only` voter — the
registry accepts read-only voters regardless of engine, because the
coordinator never sends them prepare/commit SQL.
## API Reference
- `type PreparableCapabilityCheck = (participantId: string) => boolean`
— capability resolver injected at construction.
- `class TwoPcParticipantRegistry`
- `constructor(isPreparable: PreparableCapabilityCheck)`
- `register(participant): void` — throws
`TWOPC_PARTICIPANT_NOT_PREPARABLE` on writer refusal.
- `get(id): RegisteredTwoPcParticipant | undefined`
- `list(): readonly RegisteredTwoPcParticipant[]`
- `size(): number`
- `unregister(id): boolean`
## Implementation Notes
The registry refuses a writer at `register`-time rather than at
`run2pc`-time. This is deliberate — admission failures should surface
during plugin install, not when the first 2PC transaction is attempted
in production. The capability check fires only for writers; read-only
voters are accepted unconditionally because the coordinator drops their
connection from phase-2 SQL traffic. The second-half guard against
read-only voters that try to write a payload lives in
`phases/prepare.ts` (raising `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`
when the participant returns `writeIntent: true`).
`unregister` exists for hot-reload paths: when a plugin is removed
from the running host, the registry drops its participant entry so
subsequent `run2pc` calls cannot accidentally route to it.

View File

@@ -0,0 +1,100 @@
# `testing/`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `run2pcParticipantConformance(factory)` — a runnable test
suite that pins the contract every 2PC writer participant MUST honour.
Adapter authors drop a single import + invocation into a
`tests/conformance/run-2pc-participant.test.ts` file in their package;
the harness handles the rest.
Shipping the harness on this package's `./testing` subpath keeps
participants accountable to the same prepare/commit/rollback semantics
the coordinator depends on at runtime. Without this harness, drift
between participant implementations would only surface during
integration testing — too late.
## Features
- Six pinned invariants (engine identifier, voter mode, prepare-then-
prepare-SQL, commit-prepared, rollback-prepared, idempotency-on-
second-rollback).
- Per-scenario factory: every test starts from a fresh participant +
teardown so the suite is isolation-safe under parallel runs.
- Uses `@suaveplan/testing/runner` — works under both Vitest and
Bun:test without re-importing.
- Generates unique gids per scenario so re-runs do not collide.
## Usage: Basic
```ts
// In an adapter package's tests/conformance/run-2pc-participant.test.ts
import { run2pcParticipantConformance } from "@suaveplan/api-2pc/testing";
import { createMyParticipant, teardownMyParticipant } from "../helpers";
run2pcParticipantConformance({
async create() {
const participant = await createMyParticipant();
return {
participant,
teardown: () => teardownMyParticipant(participant),
};
},
});
```
## Usage: Advanced
The harness exposes no surface beyond `run2pcParticipantConformance` —
deliberately. Adapter-specific assertions belong in adapter test
files, not in the conformance harness. If your adapter needs to
exercise behaviour the suite does not cover (e.g. cancellation,
custom serialization), add a sibling test file:
```ts
import { describe, it } from "@suaveplan/testing/runner";
import { run2pcParticipantConformance } from "@suaveplan/api-2pc/testing";
describe("my-participant", () => {
run2pcParticipantConformance({ create: createMyParticipant });
it("cancels a stuck prepare on signal abort", { timeout: 30_000 }, async () => {
// adapter-specific assertion
});
});
```
## API Reference
- `interface TwoPcParticipantFactory`
- `create(): Promise<{ participant: RegisteredTwoPcParticipant; teardown: () => Promise<void> }>`
- `function run2pcParticipantConformance(factory): void`
- Registers six `describe`/`it` blocks against the surrounding
runner. Returns nothing.
## Implementation Notes
The harness deliberately does NOT use a shared Postgres connection
across scenarios. Each scenario calls `factory.create()` to get a
fresh participant; the factory is expected to wire its own connection
(directly to a Postgres container, against a shared pool, or against
an `env-bridge` passthrough URL). This isolation prevents one
scenario's leftover prepared transactions from contaminating the
next.
Gids embed `Date.now()` + a random suffix so re-runs within the same
millisecond never collide on the underlying `pg_prepared_xacts`
table. The cleanup `ROLLBACK PREPARED` at the end of the
prepare-only scenario is best-effort: failure to roll back leaves
the gid prepared until the next teardown sweep, but the harness's
own teardown handle is the primary cleanup path.
The harness is intentionally NOT exported from the package root (`./`
subpath) — only `./testing`. This mirrors the convention used by
sibling packages (`@suaveplan/i18n-shared/testing`,
`@suaveplan/api-host/testing`) so production code can not accidentally
depend on test-only symbols.

View File

@@ -0,0 +1,108 @@
# `types/`
## Purpose
Shared cross-module type definitions for the 2PC coordinator. Holds
the `TwoPcCoordinatorState` state-machine alphabet, the
`PreparableConnection` and `RegisteredTwoPcParticipant` participant
shapes, the `Run2PcInput` / `Run2PcOutcome` public input/output
contracts, and the `TwoPcPrepareResult` per-participant prepare
return type.
Centralising these types here prevents the cyclic-import smell that
would otherwise emerge from `coordinator/`, `phases/`, `recovery/`,
and `registry/` all needing to know the same wire shapes. The
sibling modules import from this barrel and stay decoupled from each
other at the type level.
## Features
- `TwoPcCoordinatorState` — string-literal union covering
`voting`, `committing`, `aborting`, `committed`, `aborted`,
`in_doubt`.
- `PreparableConnection` — structural connection shape exposing
`engine` (a `DatabaseEngine` from `@suaveplan/api-types/db`) and
`run(sql, params?)` returning a `Promise<R>`.
- `RegisteredTwoPcParticipant` — coordinator-side participant view
carrying `id`, `voterMode`, `connection`, and an async
`prepare(txId, payload) => TwoPcPrepareResult` callback.
- `TwoPcPrepareResult` — `{ vote, writeIntent }` returned by a
participant's `prepare` callback.
- `Run2PcInput` / `Run2PcOutcome` — public input/output contracts
accepted and returned by `run2pc`.
## Usage: Basic
```ts
import type {
Run2PcInput,
RegisteredTwoPcParticipant,
} from "@suaveplan/api-2pc";
const input: Run2PcInput = {
participants: [{ id: "billing", input: { invoiceId: "inv-1" } }],
timeoutMs: 5000,
};
const billing: RegisteredTwoPcParticipant = {
id: "billing",
voterMode: "writer",
connection: { engine: "postgres", run: pg.unsafe.bind(pg) },
prepare: async (_txId) => ({ vote: "commit", writeIntent: true }),
};
```
## Usage: Advanced
Read-only voter typing — combine `voterMode: "read-only"` with a
`writeIntent: false` invariant on the `prepare` return shape:
```ts
import type {
RegisteredTwoPcParticipant,
TwoPcPrepareResult,
} from "@suaveplan/api-2pc";
async function auditPrepare(): Promise<TwoPcPrepareResult> {
const ok = await checkConsistency();
return { vote: ok ? "commit" : "abort", writeIntent: false };
}
const audit: RegisteredTwoPcParticipant = {
id: "audit",
voterMode: "read-only",
connection: { engine: "surrealdb", run: surrealUnsafe },
prepare: () => auditPrepare(),
};
```
The coordinator refuses a read-only voter whose `prepare` returns
`writeIntent: true` with `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`
— the static type doesn't enforce that pairing, but the runtime
boundary does.
## API Reference
- `type TwoPcCoordinatorState`
- `interface PreparableConnection`
- `interface RegisteredTwoPcParticipant`
- `interface TwoPcPrepareResult`
- `interface Run2PcInput`
- `interface Run2PcOutcome`
## Implementation Notes
Every interface is `readonly` on every field. This is deliberate —
the coordinator caches inputs (participant lookups, ledger entries)
and a mutation by accident would corrupt in-flight state. The
strictness is enforced at the type level via `readonly` plus
`ReadonlyArray<...>` and at the runtime level by the boundary Zod
schemas in `options/` (for caller-facing inputs).
The `Run2PcOutcome.state` field uses `Extract<TwoPcCoordinatorState,
"committed" | "aborted" | "in_doubt">` so callers do not have to
exhaustively switch over the in-flight states (`voting`,
`committing`, `aborting`) that never appear on a returned outcome.
The structural typing of `PreparableConnection` keeps the package
independent of `pg` and `postgres` (porsager) — both clients can be
adapted at the seam with a one-line wrapper.