Initial Commit
This commit is contained in:
862
packages/README.md
Normal file
862
packages/README.md
Normal file
@@ -0,0 +1,862 @@
|
||||
# `packages`
|
||||
|
||||
Suaveplan package tree. Every `@suaveplan/*` library lives under a category (`packages/<category>/`) and a subcategory (`packages/<category>/<subcategory>/`).
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Categories
|
||||
|
||||
18 categories, 466 packages.
|
||||
|
||||
### `api-stack`
|
||||
|
||||
API infrastructure, plugin framework, and HTTP orchestration layer for Suaveplan.
|
||||
|
||||
#### `api`
|
||||
|
||||
API orchestration, plugin lifecycle, routing, workers, and tenant resolution.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/api`](./api-stack/api/api/README.md) | The thin Hono-based application orchestrator for the Suaveplan API stack. Composes the four sibling packages — `@suaveplan/api-plugin`, `@suaveplan/api-tenant`… |
|
||||
| [`@suaveplan/api-2pc`](./api-stack/api/api-2pc/README.md) | Two-phase commit coordinator for the Suaveplan API plugin system. Drives Postgres PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED across registered p… |
|
||||
| [`@suaveplan/api-audit-file`](./api-stack/api/api-audit-file/README.md) | File-rotating, HMAC-signed audit sink for `@suaveplan/api-host`. Implements `RequestAuditSink` from `@suaveplan/api-host/request-pipeline`: writes one JSON lin… |
|
||||
| [`@suaveplan/api-audit-stdout`](./api-stack/api/api-audit-stdout/README.md) | Stdout audit sink for `@suaveplan/api-host`. Writes JSON-per-line audit records to a `LineWriter` (default `process.stdout`) with an HMAC-SHA-256 signature app… |
|
||||
| [`@suaveplan/api-auth`](./api-stack/api/api-auth/README.md) | **$2** for the Suaveplan API plugin system. Defines the abstract session-resolver shape (`Request → Principal \| null`) used by the host on every request, plus… |
|
||||
| [`@suaveplan/api-auth-anonymous`](./api-stack/api/api-auth-anonymous/README.md) | Stage-4 trivial session resolver for `@suaveplan/api`. Always returns `null` for every request. Use this as the tail of resolver chains for public endpoints, a… |
|
||||
| [`@suaveplan/api-auth-bearer-static`](./api-stack/api/api-auth-bearer-static/README.md) | Stage-4 service-to-service `SessionResolver` adapter for the `@suaveplan/api` plugin system. Matches an inbound `Authorization: Bearer <token>` header against… |
|
||||
| [`@suaveplan/api-auth-better-auth`](./api-stack/api/api-auth-better-auth/README.md) | Stage-6 Better-Auth adapter for `@suaveplan/api`. Ships two **independently-wireable** capability surfaces — `createBetterAuthSession` (implements `auth.sessio… |
|
||||
| [`@suaveplan/api-auth-jwt`](./api-stack/api/api-auth-jwt/README.md) | Stage-4 concrete `SessionResolver` for the `@suaveplan/api` plugin system. Verifies a JWT presented on the `Authorization: Bearer <jwt>` header (or a configure… |
|
||||
| [`@suaveplan/api-auth-provider`](./api-stack/api/api-auth-provider/README.md) | Abstract account-lifecycle contract for the Suaveplan API plugin system (contracts). This package is OPTIONAL: hosted-IdP deployments (Clerk, Auth0, Cognito) i… |
|
||||
| [`@suaveplan/api-auth-rolled`](./api-stack/api/api-auth-rolled/README.md) | Stage-8.12 rolled session-token resolver for `@suaveplan/api`. Issues RS256-signed JWTs with rotating `kid` slots, verifies tokens through the rotator's active… |
|
||||
| [`@suaveplan/api-auth-static-principal`](./api-stack/api/api-auth-static-principal/README.md) | Fixed-`Principal` session resolver for the `@suaveplan/api` plugin system. Returns the configured `Principal` verbatim on every request — no token decoding, no… |
|
||||
| [`@suaveplan/api-db-adapter`](./api-stack/api/api-db-adapter/README.md) | Stage-4 bridge from the `@suaveplan/db` engine adapters to the plugin `AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces exposed by `@suaveplan/api-type… |
|
||||
| [`@suaveplan/api-db-sqlite-memory`](./api-stack/api/api-db-sqlite-memory/README.md) | Stage-5 in-memory SQLite database adapter for the `@suaveplan/api` plugin host testing kit. Wraps `bun:sqlite` (in `:memory:` mode by default, or a file path f… |
|
||||
| [`@suaveplan/api-db-worker-proxy`](./api-stack/api/api-db-worker-proxy/README.md) | Worker-side proxy for the api `db` capability. Marshals `AdminDatabaseLike` and `TenantScopedDatabaseLike` calls — including transactions and cursor streams —… |
|
||||
| [`@suaveplan/api-events-in-memory`](./api-stack/api/api-events-in-memory/README.md) | In-memory events bus adapter for the `@suaveplan/api` testing kit. Wraps `@suaveplan/events`' production `EventBus` with a per-topic recording layer so tests c… |
|
||||
| [`@suaveplan/api-events-worker-proxy`](./api-stack/api/api-events-worker-proxy/README.md) | Worker-tier proxy over a main-process `@suaveplan/events` `EventBus`. |
|
||||
| [`@suaveplan/api-graphql`](./api-stack/api/api-graphql/README.md) | Stage-1 abstract GraphQL contract for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-graphql-apollo`](./api-stack/api/api-graphql-apollo/README.md) | Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API plugin system. Implements the `GraphqlEngine` contract from `@suaveplan/api-graphql` and pas… |
|
||||
| [`@suaveplan/api-graphql-ui-altair`](./api-stack/api/api-graphql-ui-altair/README.md) | Altair GraphQL UI plugin for the Suaveplan API plugin system. Structural sibling of `@suaveplan/api-graphql-ui-graphiql`. |
|
||||
| [`@suaveplan/api-graphql-ui-graphiql`](./api-stack/api/api-graphql-ui-graphiql/README.md) | GraphiQL UI mount for the Suaveplan API plugin system. Stage 7 of the umbrella plan. |
|
||||
| [`@suaveplan/api-graphql-yoga`](./api-stack/api/api-graphql-yoga/README.md) | Stage-7 GraphQL Yoga engine adapter. **Default GraphQL engine** for the Suaveplan API plugin system per design doc §10.5 — Bun-friendly, fastest for our usage… |
|
||||
| [`@suaveplan/api-host`](./api-stack/api/api-host/README.md) | Host orchestration core for the Suaveplan API plugin system. Provides the stable error-code registry (HOST/PLUGIN/RELOAD/CAPABILITY/ADMIN/LEDGER), the author-facing definePlugin DX, the runtime CapabilityRegistry, the LifecycleDriver + ReloadDriver + PluginLedger + admin Hono sub-router, the ErrorBoundary, the §6 11-step RequestPipeline, the top-level createHost(config) entry point composing every internal driver, and the HostInstruments registry covering the §12.3 metric surface. The ./testing subpath is reserved for test utilities. |
|
||||
| [`@suaveplan/api-jobs-worker-proxy`](./api-stack/api/api-jobs-worker-proxy/README.md) | Bidirectional `MessagePort` proxy that lets a worker-isolated API plugin participate in the host's main-process job scheduler. The worker calls `registerCron`… |
|
||||
| [`@suaveplan/api-lifecycle`](./api-stack/api/api-lifecycle/README.md) | Stage-1 contract package for the Suaveplan API plugin system: the plugin-lifecycle finite state machine definition, the legal-transition table, the side-effect… |
|
||||
| [`@suaveplan/api-loader`](./api-stack/api/api-loader/README.md) | Stage-1 contract package for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-logger-worker-proxy`](./api-stack/api/api-logger-worker-proxy/README.md) | Stage-3 worker → main proxy for `@suaveplan/logger`. Lets a worker-isolated API plugin call a normal `LoggerLike` surface while the records cross a `MessagePor… |
|
||||
| [`@suaveplan/api-manifest`](./api-stack/api/api-manifest/README.md) | Stage-1 contract package of the Suaveplan API plugin system: Zod schemas, parser, cross-field validators, dependency-graph resolver, capability resolver, and c… |
|
||||
| [`@suaveplan/api-migrations`](./api-stack/api/api-migrations/README.md) | Database-agnostic migration runner for the Suaveplan API. Ships a narrow `MigrationAdapter` surface (six methods), three shipped adapters (`surrealql`, `postgr… |
|
||||
| [`@suaveplan/api-openapi`](./api-stack/api/api-openapi/README.md) | OpenAPI 3.1 registry, typed `RouteBuilder`, schema registry, and document merger for the Suaveplan API. Single source of truth that wires into Hono via `OpenAP… |
|
||||
| [`@suaveplan/api-openapi-ui-redoc`](./api-stack/api/api-openapi-ui-redoc/README.md) | Stage-8 ReDoc UI adapter for the Suaveplan api-host. Mounts the ReDoc HTML shell at `/docs/ui/redoc` (or any caller-chosen path) via `createRedocUiHandler` and… |
|
||||
| [`@suaveplan/api-openapi-ui-scalar`](./api-stack/api/api-openapi-ui-scalar/README.md) | Scalar UI adapter for the Suaveplan OpenAPI plugin system. |
|
||||
| [`@suaveplan/api-openapi-ui-swagger`](./api-stack/api/api-openapi-ui-swagger/README.md) | Swagger UI adapter for the `/docs/ui/swagger` endpoint of the Suaveplan API plugin system (Stage 8.8). Returns a pure HTTP handler whose response is a `text/ht… |
|
||||
| [`@suaveplan/api-plugin`](./api-stack/api/api-plugin/README.md) | Plugin-system foundation for the Suaveplan API. Universal — runs unchanged in Bun, Node, and the browser. |
|
||||
| [`@suaveplan/api-plugin-audit`](./api-stack/api/api-plugin-audit/README.md) | Hono plugin that wraps `@suaveplan/audit-logging` as a write-through audit trail for `@suaveplan/api`. Installs a global per-request audit middleware (method /… |
|
||||
| [`@suaveplan/api-plugin-auth`](./api-stack/api/api-plugin-auth/README.md) | Better-Auth-first authentication plugin for `@suaveplan/api`. The canonical exemplar of the 3-way plug-and-play contract every `@suaveplan/api-plugin-*` packag… |
|
||||
| [`@suaveplan/api-plugin-campaigns`](./api-stack/api/api-plugin-campaigns/README.md) | Campaigns + suppression + delivery-webhooks HTTP plugin for `@suaveplan/api`. Mounts the `/campaigns`, `/suppression`, and `/webhooks` route trees on a Hono `O… |
|
||||
| [`@suaveplan/api-plugin-cms`](./api-stack/api/api-plugin-cms/README.md) | Headless content-management primitives for `@suaveplan/api`. Ships an in-memory `CmsEngine` that manages flexible content models, content entries with a `draft… |
|
||||
| [`@suaveplan/api-plugin-database`](./api-stack/api/api-plugin-database/README.md) | SurrealDB-backed database plugin for `@suaveplan/api`. Pools `@suaveplan/surrealdb`'s `SurrealAdapter` (implementing `@suaveplan/db`'s `DatabaseAdapter`), runs… |
|
||||
| [`@suaveplan/api-plugin-event-bus`](./api-stack/api/api-plugin-event-bus/README.md) | Typed event-bus plugin for `@suaveplan/api`. Wraps `@suaveplan/events` (pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener… |
|
||||
| [`@suaveplan/api-plugin-events`](./api-stack/api/api-plugin-events/README.md) | Event-management plugin for `@suaveplan/api`. Ships a SurrealDB-backed `EventRepository` + `RsvpRepository` pair, ten Hono OpenAPI routes (list / get / create… |
|
||||
| [`@suaveplan/api-plugin-example`](./api-stack/api/api-plugin-example/README.md) | Reference plugin for the `@suaveplan/api-host` plugin system. The package is a load-bearing integration target, not a tutorial: every contract surface the host… |
|
||||
| [`@suaveplan/api-plugin-export`](./api-stack/api/api-plugin-export/README.md) | Data-export plugin for `@suaveplan/api`. Turns any registered resource into a CSV (RFC 4180), JSON, or XLSX (SpreadsheetML XML) blob through three self-contain… |
|
||||
| [`@suaveplan/api-plugin-file-upload`](./api-stack/api/api-plugin-file-upload/README.md) | Multipart and chunked file-upload plugin for `@suaveplan/api`. Ships six OpenAPI-typed Hono routes, a pluggable storage + session-store contract, SHA-256 hash… |
|
||||
| [`@suaveplan/api-plugin-graphql`](./api-stack/api/api-plugin-graphql/README.md) | GraphQL description + resolver-composition layer for `@suaveplan/api`. Server-tier — runs in Bun and Node. |
|
||||
| [`@suaveplan/api-plugin-import`](./api-stack/api/api-plugin-import/README.md) | Tabular-data ingest plugin for `@suaveplan/api`. Parses CSV / JSON / TSV-XLSX payloads, applies declarative column mappings + per-field validation + computed f… |
|
||||
| [`@suaveplan/api-plugin-notifications`](./api-stack/api/api-plugin-notifications/README.md) | SurrealDB-backed notifications + push plugin for `@suaveplan/api`. Ships a `NotificationRepository` + `DeviceTokenRepository` pair, an `ApnService` with a plug… |
|
||||
| [`@suaveplan/api-plugin-payment`](./api-stack/api/api-plugin-payment/README.md) | Payment-lifecycle plugin for `@suaveplan/api`. Wraps `@suaveplan/payment` + a caller-injected `PaymentProvider` (Stripe / Adyen / PayPal / Square / Mollie / cu… |
|
||||
| [`@suaveplan/api-plugin-rate-limit`](./api-stack/api/api-plugin-rate-limit/README.md) | Rate-limit plugin for `@suaveplan/api`. Wraps `@suaveplan/rate-limiter` as a `definePlugin` descriptor exposing per-route + per-tenant configuration, parameter… |
|
||||
| [`@suaveplan/api-plugin-search`](./api-stack/api/api-plugin-search/README.md) | Full-text search plugin for `@suaveplan/api`. Brings TF-weighted relevance scoring, four built-in text analyzers (`standard`, `simple`, `whitespace`, `keyword`… |
|
||||
| [`@suaveplan/api-plugin-site`](./api-stack/api/api-plugin-site/README.md) | Multi-site / multi-domain management plugin for `@suaveplan/api`. Stores `site` records (name, slug, theme, settings, push config) in SurrealDB, resolves incom… |
|
||||
| [`@suaveplan/api-plugin-sse`](./api-stack/api/api-plugin-sse/README.md) | Server-Sent Events plugin for `@suaveplan/api`. Registers a typed `sseService` in the host `ServiceRegistry`, mounts a Hono streaming handler at `GET /sse/:cha… |
|
||||
| [`@suaveplan/api-plugin-tenant`](./api-stack/api/api-plugin-tenant/README.md) | Multi-tenancy plugin for `@suaveplan/api`. Wraps `@suaveplan/api-tenant`'s resolver chain (header / subdomain / domain / path / JWT / query / cookie) as a firs… |
|
||||
| [`@suaveplan/api-plugin-user`](./api-stack/api/api-plugin-user/README.md) | User-profile CRUD plugin for `@suaveplan/api`. Mounts the canonical `/users/me` GET/PATCH/DELETE surface on top of `@suaveplan/api-plugin-auth`, registers a ty… |
|
||||
| [`@suaveplan/api-plugin-vault`](./api-stack/api/api-plugin-vault/README.md) | Per-user vault HTTP plugin for `@suaveplan/api`. Third instance of the 3-way plug-and-play contract every `@suaveplan/api-plugin-*` package follows: the plugin… |
|
||||
| [`@suaveplan/api-plugin-webhook`](./api-stack/api/api-plugin-webhook/README.md) | Signed outbound webhook delivery plugin for `@suaveplan/api`. Wraps `@suaveplan/webhook-client` behind a `definePlugin` descriptor, registers a typed `webhookS… |
|
||||
| [`@suaveplan/api-rate-limit`](./api-stack/api/api-rate-limit/README.md) | Per-route / per-principal / per-tenant / per-IP token-bucket rate-limit middleware for `@suaveplan/api`. Implements the `RequestRateLimiter` contract consumed… |
|
||||
| [`@suaveplan/api-rbac`](./api-stack/api/api-rbac/README.md) | Scope + rule-based authorisation middleware for the Suaveplan API plugin system. Produces a framework-agnostic `Middleware` that consumes the resolved `Princip… |
|
||||
| [`@suaveplan/api-router`](./api-stack/api/api-router/README.md) | Stage-1 contract package for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-router-hono`](./api-stack/api/api-router-hono/README.md) | Stage-4 concrete Hono router adapter for `@suaveplan/api`. Implements the abstract `Router` contract from `@suaveplan/api-router` on top of a Hono `Hono` insta… |
|
||||
| [`@suaveplan/api-router-in-memory`](./api-stack/api/api-router-in-memory/README.md) | Stage-5 in-memory router adapter for `@suaveplan/api`. Implements both the abstract `Router` contract from `@suaveplan/api-router` AND the `PipelineRouter` con… |
|
||||
| [`@suaveplan/api-router-worker-bridge`](./api-stack/api/api-router-worker-bridge/README.md) | Stage-3 HTTP bridge that marshals Fetch `Request` and `Response` objects across the worker boundary. Pairs a main-side `dispatch` with a worker- side handler o… |
|
||||
| [`@suaveplan/api-saga`](./api-stack/api/api-saga/README.md) | Saga orchestrator for the Suaveplan API plugin system. Drives manifest-declared multi-plugin workflows in declared order with reverse-order compensation on fai… |
|
||||
| [`@suaveplan/api-sandbox-permissions`](./api-stack/api/api-sandbox-permissions/README.md) | Permission model for sandboxed-tier API plugins. Centralises the `permissions.fs[]` / `permissions.fetch[]` allowlists, the `permissions.budgets` enforcement,… |
|
||||
| [`@suaveplan/api-sandbox-vm`](./api-stack/api/api-sandbox-vm/README.md) | `vm.Context`-based sandbox host for Suaveplan API plugins running in the sandboxed tier. The package owns the `vm.Context`+frozen-globals half of the design do… |
|
||||
| [`@suaveplan/api-sandbox-vm-host-bridge`](./api-stack/api/api-sandbox-vm-host-bridge/README.md) | Host-integration bridge that lets a `VmSandboxHandle` from `@suaveplan/api-sandbox-vm` install into `@suaveplan/api-host` as a first-class plugin. |
|
||||
| [`@suaveplan/api-sandbox-wasm`](./api-stack/api/api-sandbox-wasm/README.md) | Stage-8 WebAssembly sandbox host. Instantiates a sandboxed-tier plugin WASM module against a WASI snapshot preview1 implementation derived from the plugin's `P… |
|
||||
| [`@suaveplan/api-sandbox-wasm-host-bridge`](./api-stack/api/api-sandbox-wasm-host-bridge/README.md) | Host-integration layer that wraps a `@suaveplan/api-sandbox-wasm` `WasmSandboxHandle` as a `@suaveplan/api-host` `PluginModule`. Sandboxed-tier plugins compile… |
|
||||
| [`@suaveplan/api-schemas`](./api-stack/api/api-schemas/README.md) | Shared Zod schemas describing Suaveplan API request/response shapes. The single source of truth for server plugins, React data hooks, and generated OpenAPI doc… |
|
||||
| [`@suaveplan/api-secrets`](./api-stack/api/api-secrets/README.md) | Abstract Secrets contract for the Suaveplan API plugin system — per-plugin secret access typed against `manifest.secrets[]`, canonical redaction marker, frozen… |
|
||||
| [`@suaveplan/api-secrets-bun`](./api-stack/api/api-secrets-bun/README.md) | Stage-8 `SecretsLike` adapter backed by `Bun.secrets` (macOS Keychain, Linux libsecret, Windows Credential Manager) with per-plugin allow-list enforcement, OS-… |
|
||||
| [`@suaveplan/api-secrets-env`](./api-stack/api/api-secrets-env/README.md) | Stage-4 `SecretsLike` adapter backed by `process.env` / `Bun.env` with per-plugin allowed-keys enforcement. |
|
||||
| [`@suaveplan/api-secrets-vault`](./api-stack/api/api-secrets-vault/README.md) | Stage-8 `SecretsLike` adapter backed by `@suaveplan/vault`. The vault is passed in already unlocked by the host's startup sequence; the adapter never sees the… |
|
||||
| [`@suaveplan/api-secrets-worker-proxy`](./api-stack/api/api-secrets-worker-proxy/README.md) | Stage-3 worker → main `SecretsLike` proxy for the API plugin system. |
|
||||
| [`@suaveplan/api-sse`](./api-stack/api/api-sse/README.md) | Abstract Server-Sent Events contracts for `@suaveplan/api`. Stage-1 contract package: defines the engine-side `SseConnection`, `SseBroker`, `ReplayBuffer` (Las… |
|
||||
| [`@suaveplan/api-sse-bun`](./api-stack/api/api-sse-bun/README.md) | `Bun.serve`-backed Server-Sent Events engine for the `@suaveplan/api` plugin system. Implements the abstract `@suaveplan/api-sse` contract and passes `runSseEn… |
|
||||
| [`@suaveplan/api-tenant`](./api-stack/api/api-tenant/README.md) | Multi-tenant resolution + scoping for the Suaveplan API. Server-tier — runs on Bun and Node, depends on `hono` and `node:async_hooks`. |
|
||||
| [`@suaveplan/api-tenant-provider`](./api-stack/api/api-tenant-provider/README.md) | Abstract `TenantProvider` contract for the Suaveplan API plugin system. Enumerates the universe of tenants known to a host so per-tenant cron / queued jobs (ma… |
|
||||
| [`@suaveplan/api-tenant-provider-memory`](./api-stack/api/api-tenant-provider-memory/README.md) | In-memory reference implementation of the `@suaveplan/api-tenant-provider` `TenantProvider` contract. Backed by an insertion-ordered `Map<TenantId, TenantRecor… |
|
||||
| [`@suaveplan/api-tenant-resolver-header`](./api-stack/api/api-tenant-resolver-header/README.md) | Stage-8 tenant resolver. Implements the `TenantResolver` contract from `@suaveplan/api-types/tenant-resolver` by reading the tenant identifier from a configura… |
|
||||
| [`@suaveplan/api-tenant-resolver-jwt`](./api-stack/api/api-tenant-resolver-jwt/README.md) | Stage-4 tenant resolver for the Suaveplan API plugin system. Reads the already-resolved `Principal.tenantId` (attached by an earlier `api-auth-*` chain step) a… |
|
||||
| [`@suaveplan/api-tenant-resolver-subdomain`](./api-stack/api/api-tenant-resolver-subdomain/README.md) | Stage-8 tenant resolver for the Suaveplan API plugin system. Reads the inbound `Host` header (and optionally a CIDR-trusted `X-Forwarded-Host`), strips a confi… |
|
||||
| [`@suaveplan/api-types`](./api-stack/api/api-types/README.md) | Root contract-types package for the Suaveplan API plugin system. Defines every cross-cutting type the foundation packages (`api-manifest`, `api-router`, `api-h… |
|
||||
| [`@suaveplan/api-worker-host`](./api-stack/api/api-worker-host/README.md) | Worker-side runtime that hosts exactly one Suaveplan API plugin inside an isolated `Worker` thread. The main-process counterpart is `@suaveplan/api-host`; this… |
|
||||
| [`@suaveplan/api-worker-rpc`](./api-stack/api/api-worker-rpc/README.md) | MessagePort-based RPC protocol for worker-isolated API plugins. Provides the shared protocol — Zod-validated wire frames, monotonic correlation ids, AbortSigna… |
|
||||
| [`@suaveplan/api-ws`](./api-stack/api/api-ws/README.md) | Abstract WebSocket engine contract for the Suaveplan API foundation — Connection, ConnectionRegistry, SubscriptionBus, WsBroker, reconnect-token envelope, backpressure + heartbeat policies, cross-tenant capability gate, and a runWsEngineConformance harness. Stage-1 contracts only; concrete engines (api-ws-bun) and brokers (api-ws-broker-redis / -in-memory) plug behind these interfaces. |
|
||||
| [`@suaveplan/api-ws-broker-in-memory`](./api-stack/api/api-ws-broker-in-memory/README.md) | Single-host in-process implementation of the `@suaveplan/api-ws` `WsBroker` contract. Suitable for production single-host deployments AND as the default broker… |
|
||||
| [`@suaveplan/api-ws-broker-redis`](./api-stack/api/api-ws-broker-redis/README.md) | Cross-host Redis pub/sub broker for `@suaveplan/api-ws` and `@suaveplan/api-sse`. |
|
||||
| [`@suaveplan/api-ws-bun`](./api-stack/api/api-ws-bun/README.md) | Bun.serve-backed WebSocket engine for the `@suaveplan/api-ws` Stage-1 abstract contract. The engine composes the per-connection handshake pipeline, registry, s… |
|
||||
|
||||
### `commerce`
|
||||
|
||||
Booking engines, contact management, payment plugins, and commerce primitives.
|
||||
|
||||
#### `booking`
|
||||
|
||||
Universal booking engine with availability, conflict detection, and pricing.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/booking`](./commerce/booking/booking/README.md) | Universal, JSON-configurable booking engine with pluggable strategies for availability resolution, conflict detection, pricing calculation, and rule evalu… |
|
||||
|
||||
#### `contacts`
|
||||
|
||||
Contact and group management with deduplication and import/export.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/api-plugin-contacts`](./commerce/contacts/api-plugin-contacts/README.md) | Contacts and Groups HTTP plugin for `@suaveplan/api`. Mounts the `/contacts` and `/groups` route trees on a Hono `OpenAPIHono`, backed by SurrealDB implementat… |
|
||||
| [`@suaveplan/audience-rules`](./commerce/contacts/audience-rules/README.md) | Universal audience segmentation: rule-based filtering on contact data with 14 comparison operators, AND/OR chaining, dot-notation field resolution includi… |
|
||||
| [`@suaveplan/contacts`](./commerce/contacts/contacts/README.md) | Universal contact and group management with hierarchical nesting, audience resolution, contact merger, and CSV/JSON import-export. Multi-tenant, channel-a… |
|
||||
| [`@suaveplan/macos-contacts`](./commerce/contacts/macos-contacts/README.md) | Universal bridge to macOS Contacts.app via AppleScript. Reads contacts and groups through the platform `osascript` binary, parses the output into typed re… |
|
||||
|
||||
### `communication`
|
||||
|
||||
Email delivery, messaging channels, and webhook clients.
|
||||
|
||||
#### `emails`
|
||||
|
||||
Email delivery, DMARC parsing, templates, campaigns, and suppression lists.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/campaigns`](./communication/emails/campaigns/README.md) | Universal campaign orchestration. |
|
||||
| [`@suaveplan/delivery-tracking`](./communication/emails/delivery-tracking/README.md) | Channel-agnostic delivery event tracking for campaigns and messages: lifecycle dedupe, in-memory + SurrealDB stores, time-series aggregation, tracking pixel an… |
|
||||
| [`@suaveplan/dmarc`](./communication/emails/dmarc/README.md) | DMARC report parser for `@suaveplan` — parses aggregate (RUA) and forensic (RUF) DMARC XML reports into validated, strongly-typed structures. Accepts raw XML s… |
|
||||
| [`@suaveplan/domain-normalizer`](./communication/emails/domain-normalizer/README.md) | Normalisation, matching, and alias-canonicalisation for internet domain names. Given heterogeneous URL or domain input — including common mobile/CDN subdomains… |
|
||||
| [`@suaveplan/email-processing`](./communication/emails/email-processing/README.md) | Universal MIME parsing and email processing for the Suaveplan . Wraps `postal-mime` and `libmime` with structured errors, OpenTelemetry instrumentation, a… |
|
||||
| [`@suaveplan/email-templates`](./communication/emails/email-templates/README.md) | Universal, React-free email template engine. Produces email-safe HTML strings with inline styles via VNode factories — runs identically in Bun, Node, and… |
|
||||
| [`@suaveplan/emailer`](./communication/emails/emailer/README.md) | Universal email service with pluggable transports. Ships `EmailService`, three transports (Dummy / SMTP via injectable `SmtpClientLike` / Gmail REST via i… |
|
||||
| [`@suaveplan/suppression`](./communication/emails/suppression/README.md) | Email suppression list management for Suaveplan. Stores and checks email addresses that should not receive outgoing mail — unsubscribes, hard/soft bounces, com… |
|
||||
|
||||
#### `messaging`
|
||||
|
||||
Messaging adapters — Discord, Slack, Telegram, SMS, iMessage, and notifications.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/discord-client`](./communication/messaging/discord-client/README.md) | Generic Discord API client for webhooks, bot messaging, and embeds. Runtime-agnostic via injectable `fetch` and `clock`. Maps every documented Discord failure… |
|
||||
| [`@suaveplan/imessage-adapter`](./communication/messaging/imessage-adapter/README.md) | macOS iMessage SQLite chat.db watcher and `MessageSourceAdapter` implementation. Polls Apple's `~/Library/Messages/chat.db`, decodes Apple-epoch / nanosecond t… |
|
||||
| [`@suaveplan/notification-channel`](./communication/messaging/notification-channel/README.md) | Multi-channel notification router with templates, sliding-window rate limiting, retry + dead-letter, scheduler, preferences, hooks, OpenTelemetry instrumentati… |
|
||||
| [`@suaveplan/slack-client`](./communication/messaging/slack-client/README.md) | Typed, deterministic Slack client — Web API + incoming-webhook surface — with Zod-validated boundaries, OpenTelemetry-instrumented hot paths, injectable `Clock… |
|
||||
| [`@suaveplan/sms`](./communication/messaging/sms/README.md) | Universal SMS core: provider-agnostic send pipeline with strict E.164 validation, Zod boundaries, template rendering, opt-out registry, token-bucket rate limit… |
|
||||
| [`@suaveplan/sms-providers`](./communication/messaging/sms-providers/README.md) | Universal SMS provider implementations for @suaveplan/sms. Real HTTP clients for Twilio (Basic auth + form-encoded + X-Twilio-Signature HMAC-SHA1), Vonage (HMAC-SHA256 webhook), MessageBird (AccessKey + JWT HS256 webhook), Plivo (Basic auth + X-Plivo-Signature-V3 HMAC-SHA256), and AWS SNS (inline SigV4 + RSA-SHA1 SNS notification verifier). Ships ProviderRegistry, httpRequest over injectable FetchLike, composeAbortSignal, SMS_PROVIDERS_CODES + SmsProvidersError, per-provider mapStatus, parseInbound, parseOptOutKeyword, and five OTel instruments. No provider SDK deps; every HTTP call flows through an injectable FetchLike seam. |
|
||||
| [`@suaveplan/telegram-client`](./communication/messaging/telegram-client/README.md) | Telegram Bot API client: send/edit/delete messages, six media variants, inline + custom keyboards, typed errors, injectable `fetch` + `clock`, and OpenTelemetr… |
|
||||
|
||||
#### `webhook-client`
|
||||
|
||||
Webhook delivery client with retry logic and HMAC verification.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/webhook-client`](./communication/webhook-client/webhook-client/README.md) | Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking. Runtime-agnostic (injectable f… |
|
||||
|
||||
### `devtools`
|
||||
|
||||
Developer tooling, documentation generators, Docker helpers, and build utilities.
|
||||
|
||||
#### `docker`
|
||||
|
||||
Docker helpers and GPU runtime configuration.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/docker-nvidia`](./devtools/docker/docker-nvidia/README.md) | A collection of docker / docker-compose scripts configured for use with WSL / Nvidia |
|
||||
|
||||
#### `docs`
|
||||
|
||||
Documentation utilities, SVG compression, PDF handling, and sitemap generation.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/compress-svg`](./devtools/docs/compress-svg/README.md) | SVG optimisation and minification for TypeScript. Pure TypeScript — no DOMParser, no external SVG library. Runs in Bun, Node.js, browsers, and edge runtimes. |
|
||||
| [`@suaveplan/docs`](./devtools/docs/docs/README.md) | Server-tier markdown documentation toolkit for Suaveplan. Discovers every `.md` file under a repo root, parses each via `gray-matter` + `remark` into a structu… |
|
||||
| [`@suaveplan/pdf`](./devtools/docs/pdf/README.md) | Server-tier PDF 1.4 generation toolkit. **Chunk A foundation** — pure utilities + cross-cutting wiring (typed error registry, Zod boundary schemas, OpenTelemet… |
|
||||
| [`@suaveplan/sitemap`](./devtools/docs/sitemap/README.md) | Sitemap generation and parsing for the sitemaps.org XML protocol. Produces valid `sitemap.xml`, sitemap index documents, and parses existing sitemaps. Supports… |
|
||||
|
||||
#### `tooling`
|
||||
|
||||
CLI frameworks, installers, and build tooling.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cli`](./devtools/tooling/cli/README.md) | Enterprise-grade CLI framework for Node.js. Composable fluent API with subcommand routing, option parsing, OTel tracing, and a full set of terminal UI primitiv… |
|
||||
| [`@suaveplan/electrobun`](./devtools/tooling/electrobun/README.md) | Electrobun desktop-app generator and orchestrator for the SuavePlan ecosystem. Scaffolds a fresh Electrobun project from a layered config + prompt flow, drives… |
|
||||
| [`@suaveplan/install-agent-skills`](./devtools/tooling/install-agent-skills/README.md) | Installer plugin that bundles every Suaveplan skill (`SKILL.md` + assets) and installs them into a target project via `@suaveplan/installer`. |
|
||||
| [`@suaveplan/installer`](./devtools/tooling/installer/README.md) | Plugin host for project boilerplating and asset installation. Pipelines plugins through `detect → prompts → plan → apply → postInstall` with transactional roll… |
|
||||
| [`@suaveplan/linter`](./devtools/tooling/linter/README.md) | Unified markdown + biome linting pipeline for Suaveplan: a remark-based markdown fixer covering MD012/022/025/029/031/032/035/036/040/044, an injectable `Comma… |
|
||||
| [`@suaveplan/mcp`](./devtools/tooling/mcp/README.md) | Universal Model Context Protocol (MCP) server + client with a pluggable transport stack, JSON-RPC dispatcher, AbortSignal cancellation, progress notificat… |
|
||||
| [`@suaveplan/overview`](./devtools/tooling/overview/README.md) | Machine-readable Suaveplan project manifest: typed PackageRecord / SuavePlanManifest / WorkspaceGraph, Zod schemas, tier inference, filesystem discovery (server sub-path), graph utilities (topo, cycle detection, transitive closure, leaves, roots), plugin/category/tier roll-ups, six OVERVIEW.* error codes, three OTel counters + one histogram. |
|
||||
| [`@suaveplan/profiler`](./devtools/tooling/profiler/README.md) | Universal runtime performance profiler. |
|
||||
| [`@suaveplan/readline`](./devtools/tooling/readline/README.md) | AsyncGenerator line reader and interactive prompt interface for Node.js streams, file paths, and Web Streams. |
|
||||
| [`@suaveplan/sandbox`](./devtools/tooling/sandbox/README.md) | Server-tier sandboxed code execution for `@suaveplan` plugin hosts and agent runtimes. Ships four `SandboxEngine` implementations (a `node:vm` default, real `n… |
|
||||
| [`@suaveplan/test-vite`](./devtools/tooling/test-vite/README.md) | Vite + Vitest configuration factories and validators for Suaveplan: `createViteLibraryConfig`, `createViteAppConfig`, `createVitestConfig`, `createVitestBrowse… |
|
||||
|
||||
### `foundation`
|
||||
|
||||
Foundational primitives — types, errors, configuration, data structures, and cross-cutting utilities.
|
||||
|
||||
#### `barcode`
|
||||
|
||||
1-D barcode generation and parsing.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/barcode`](./foundation/barcode/barcode/README.md) | Runtime-agnostic 1D barcode generation for seven symbologies — Code 128B, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, and ITF. Outputs inline SVG strings and binary… |
|
||||
| [`@suaveplan/qrcode`](./foundation/barcode/qrcode/README.md) | QR code generation producing SVG, pixel matrix, terminal (Unicode blocks), and data URL representations. Implements QR code versions 1-10, error correction lev… |
|
||||
|
||||
#### `bitpacker`
|
||||
|
||||
Bit-level binary packing, schemas, compression, and streaming codecs.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/bitpacker`](./foundation/bitpacker/bitpacker/README.md) | Core bit-level binary packing library. Provides `BitWriter`, `BitReader`, `BitPacker`, and a pluggable strategy system for schema-driven serialization. |
|
||||
| [`@suaveplan/bitpacker-compressed`](./foundation/bitpacker/bitpacker-compressed/README.md) | Compression pipeline integration for bitpacker. |
|
||||
| [`@suaveplan/bitpacker-crypto`](./foundation/bitpacker/bitpacker-crypto/README.md) | Bit-packer-aware crypto primitives. The shared interfaces (`Authority`, `DomainTag`, `AuthorityVerifier`, `AeadOps`) live in `@suaveplan/types`; the canonical… |
|
||||
| [`@suaveplan/bitpacker-delta`](./foundation/bitpacker/bitpacker-delta/README.md) | Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values. |
|
||||
| [`@suaveplan/bitpacker-game`](./foundation/bitpacker/bitpacker-game/README.md) | Game state utilities: quantization, prediction, interpolation, and client-server reconciliation. |
|
||||
| [`@suaveplan/bitpacker-network`](./foundation/bitpacker/bitpacker-network/README.md) | Network simulation utilities for testing bitpacker under realistic network conditions. |
|
||||
| [`@suaveplan/bitpacker-schema`](./foundation/bitpacker/bitpacker-schema/README.md) | Schema registry and validation for bitpacker. |
|
||||
| [`@suaveplan/bitpacker-strategies`](./foundation/bitpacker/bitpacker-strategies/README.md) | Built-in packing strategies for `@suaveplan/bitpacker`. Provides strategies for numbers, strings, booleans, objects, arrays, enums, optional fields, oneOf unio… |
|
||||
| [`@suaveplan/bitpacker-streaming`](./foundation/bitpacker/bitpacker-streaming/README.md) | Streaming APIs for bitpacker including chunked packing/unpacking and true TransformStream support with framing. |
|
||||
| [`@suaveplan/bitpacker-validation`](./foundation/bitpacker/bitpacker-validation/README.md) | Safety limits and validation reports for bitpacker schemas. |
|
||||
|
||||
#### `core`
|
||||
|
||||
Core foundation — types, errors, configuration, schema registry, and utilities.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/config`](./foundation/core/config/README.md) | Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the r… |
|
||||
| [`@suaveplan/env`](./foundation/core/env/README.md) | Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and rece… |
|
||||
| [`@suaveplan/error`](./foundation/core/error/README.md) | Foundation error substrate for Suaveplan. Every non-testing `@suaveplan/*` package extends this library's `SuaveplanError` base class and uses its stable code… |
|
||||
| [`@suaveplan/schema-registry`](./foundation/core/schema-registry/README.md) | Versioned schema registry with forward/backward/full compatibility checking for evolving data contracts. Register schemas by subject name, enforce evolution ru… |
|
||||
| [`@suaveplan/types`](./foundation/core/types/README.md) | Types-only foundation package. Zero runtime code, zero dependencies, zero bundle bytes. Provides the canonical shared interfaces that allow every `@suaveplan/*… |
|
||||
| [`@suaveplan/typescript-config`](./foundation/core/typescript-config/README.md) | Shared TypeScript configuration presets for Suaveplan. This package ships composable JSON presets consumed via `"extends"` across every workspace package — no… |
|
||||
| [`@suaveplan/utils`](./foundation/core/utils/README.md) | Zero-external-dependency utility library for Suaveplan. Covers debug helpers, deep object manipulation, platform detection, IEEE 754 float packing, pseudo-rand… |
|
||||
|
||||
#### `data`
|
||||
|
||||
Data manipulation, masking, deduplication, diffing, and byte utilities.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/bytes`](./foundation/data/bytes/README.md) | Low-level byte utilities for Suaveplan: lossless hex/base64/UTF-8 encoding, `Uint8Array` manipulation, and UUID conversion. No native addons; depends only on p… |
|
||||
| [`@suaveplan/clipboard`](./foundation/data/clipboard/README.md) | Enterprise Clipboard API abstraction for browser-tier consumers. Wraps the async Clipboard API with a textarea + `execCommand` fallback, a paste-event read fal… |
|
||||
| [`@suaveplan/data-masking`](./foundation/data/data-masking/README.md) | PII/sensitive data masking for objects and strings with field-level rules, nested traversal, eight masking strategies, and OpenTelemetry instrumentation. |
|
||||
| [`@suaveplan/dedup`](./foundation/data/dedup/README.md) | Entity deduplication engine for detecting and merging duplicate records in any data set. Given a list of objects with varying representations of the same real-… |
|
||||
| [`@suaveplan/diff`](./foundation/data/diff/README.md) | Zero-dependency RFC 6902 JSON Patch engine for computing, applying, and inverting structural differences between JSON documents. The `diff(a, b)` function prod… |
|
||||
| [`@suaveplan/json`](./foundation/data/json/README.md) | Safe, enterprise-grade JSON utilities with prototype-pollution protection, circular-reference detection, and OpenTelemetry instrumentation. |
|
||||
| [`@suaveplan/media`](./foundation/data/media/README.md) | Universal media detection, metadata extraction, and constraint validation. Combines a pluggable magic-byte `SignatureRegistry` with the MIME database from… |
|
||||
| [`@suaveplan/mime`](./foundation/data/mime/README.md) | Structured MIME-type detection, parsing, and registry for the Suaveplan platform. Provides RFC 7231 Content-Type parsing, magic-byte file signature detection,… |
|
||||
| [`@suaveplan/packer`](./foundation/data/packer/README.md) | Binary packing/unpacking utilities. Two modes: |
|
||||
| [`@suaveplan/packer-browser`](./foundation/data/packer-browser/README.md) | Browser-tier runtime for `@suaveplan/packer` archives. Ships a Service Worker interceptor, a `PackedResourceManager` (LRU + Blob-URL bookkeeping + ref-count +… |
|
||||
| [`@suaveplan/pagination`](./foundation/data/pagination/README.md) | Universal pagination primitives for cursor-based, offset-based, and keyset-based (seek) pagination strategies. The package covers the full pagination surface:… |
|
||||
| [`@suaveplan/pwa`](./foundation/data/pwa/README.md) | Enterprise browser-tier PWA primitives for Suaveplan applications. |
|
||||
| [`@suaveplan/react-packer`](./foundation/data/react-packer/README.md) | React 19 companion for `@suaveplan/packer-browser`. Ships `usePackedResource`, `usePackedImage`, `usePackedAudio`, `usePackedVideo`, and `usePackedFont` hooks… |
|
||||
| [`@suaveplan/serialize`](./foundation/data/serialize/README.md) | Round-trip-preserving structured serialization for JavaScript's rich type system. The standard `JSON.stringify`/`JSON.parse` pair silently drops `Date` objects… |
|
||||
| [`@suaveplan/templates`](./foundation/data/templates/README.md) | A plugin-based string template engine for TypeScript. Resolves `{variable}` and `{variable:transform}` tokens in strings using a priority-sorted, regex-scan pi… |
|
||||
| [`@suaveplan/validation`](./foundation/data/validation/README.md) | Runtime schema validation with a Zod-compatible API. This package is a self-contained parse engine — it does not import Zod or any other validation library — s… |
|
||||
| [`@suaveplan/web-workers`](./foundation/data/web-workers/README.md) | Enterprise browser-tier runtime for managing Web Workers, SharedWorkers, and fixed-size worker pools with typed RPC, AbortSignal-aware cancellation, transfer-l… |
|
||||
|
||||
#### `etl`
|
||||
|
||||
Extract-Transform-Load pipeline engine with typed stages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/etl`](./foundation/etl/etl/README.md) | Extract-Transform-Load pipeline engine with typed stages, error handling, observable progress, and OTel instrumentation. |
|
||||
|
||||
#### `feature-flags`
|
||||
|
||||
Feature-flag evaluation engine with percentage and attribute targeting.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/feature-flags`](./foundation/feature-flags/feature-flags/README.md) | Provider-agnostic feature flag evaluation engine for controlling feature rollout without code deploys. The package ships a `FlagManager` high-level façade, a `… |
|
||||
|
||||
#### `flow`
|
||||
|
||||
Finite state machines, CRDTs, idempotency, and CBOR serialization.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cbor`](./foundation/flow/cbor/README.md) | RFC 8949 (CBOR) encoder + decoder, RFC 8742 (CBOR Sequences), pull-style streaming decoder (`AsyncIterable<Uint8Array>` / `ReadableStream<Uint8Array>`), RFC 90… |
|
||||
| [`@suaveplan/crdt`](./foundation/flow/crdt/README.md) | Conflict-free Replicated Data Types (CRDTs) for distributed systems. Provides eight composable CRDT primitives plus a vector-clock and a sync manager that coor… |
|
||||
| [`@suaveplan/fsm`](./foundation/flow/fsm/README.md) | Typed, generic finite state machine (FSM) for TypeScript. Ships a synchronous-by-default interpreter with guard predicates, ordered transition actions, context… |
|
||||
| [`@suaveplan/idempotency`](./foundation/flow/idempotency/README.md) | Idempotency key guard and deduplication for async operations. The package provides a complete `pending → completed \| failed` state machine, three store backend… |
|
||||
| [`@suaveplan/job-scheduler`](./foundation/flow/job-scheduler/README.md) | Universal in-process job scheduler with cron + queued runs, retries, timeouts, concurrency control, and OpenTelemetry observability. Built on `@suaveplan/… |
|
||||
| [`@suaveplan/queue`](./foundation/flow/queue/README.md) | Enterprise-grade queue management: memory/priority/delayed queues, concurrent workers with retry and dead-letter forwarding, job state machines, and OTel instr… |
|
||||
| [`@suaveplan/queue-distribution`](./foundation/flow/queue-distribution/README.md) | Worker-pool distribution layer: load-balancing strategies, worker pool management, manager with retry and health checks, in-process `LocalWorker`. Transport-ag… |
|
||||
| [`@suaveplan/queue-distribution-http`](./foundation/flow/queue-distribution-http/README.md) | HTTP transport plugin for `@suaveplan/queue-distribution`. Ships `HttpWorkerCommunicator` (a fetch-backed `WorkerCommunicator` with a Zod-validated wire envelo… |
|
||||
| [`@suaveplan/queue-distribution-worker-thread`](./foundation/flow/queue-distribution-worker-thread/README.md) | Web-Worker / Node `worker_threads` transport plugin for `@suaveplan/queue-distribution`. Ships `WorkerThreadCommunicator` (a `ThreadWorkerLike`-based message b… |
|
||||
| [`@suaveplan/rate-limiter`](./foundation/flow/rate-limiter/README.md) | Enterprise rate limiter with three algorithms, rule-based routing, tenant multipliers, pluggable storage, and OTel instrumentation. |
|
||||
| [`@suaveplan/retry`](./foundation/flow/retry/README.md) | Retry strategies and circuit-breaker for async operations. The package provides constant, linear, and exponential backoff with four jitter modes (full, equal,… |
|
||||
| [`@suaveplan/rules`](./foundation/flow/rules/README.md) | Universal rule engine + validation system. Comprehensive `RuleEngine` with async evaluation, depth limit, AbortSignal, plugin registration; fluent `RuleBu… |
|
||||
| [`@suaveplan/rules-hono`](./foundation/flow/rules-hono/README.md) | Hono middleware wrapping `@suaveplan/rules`. Six middleware factories — `ruleMiddleware`, `validationMiddleware`, `accessControl`, `responseValidation`, `rateL… |
|
||||
| [`@suaveplan/streams`](./foundation/flow/streams/README.md) | WHATWG-Streams-based primitives: backpressure-first transforms, zero-copy `Uint8Array` operations, `AbortSignal` integration end-to-end, structured errors, OTe… |
|
||||
| [`@suaveplan/streams-chunk-queue`](./foundation/flow/streams-chunk-queue/README.md) | Universal out-of-order chunk reconstitution. Accepts chunks for a file in any order and exposes a sequential `ReadableStream<Uint8Array>` consumer surface… |
|
||||
| [`@suaveplan/streams-metadata`](./foundation/flow/streams-metadata/README.md) | Universal stream transforms that detect MIME type and extract EXIF/IPTC/XMP image metadata from buffered `Uint8Array` payloads. Three composable transform… |
|
||||
| [`@suaveplan/undo`](./foundation/flow/undo/README.md) | Enterprise-grade undo/redo engine with branching history, async commands, history compression, selective undo, and command merging. Built for collaborative edi… |
|
||||
| [`@suaveplan/workflow`](./foundation/flow/workflow/README.md) | Workflow and pipeline orchestration: sequential and parallel step execution, conditional branching with guard functions, retry strategies, timeouts, saga-patte… |
|
||||
|
||||
#### `random`
|
||||
|
||||
Pseudo-random generators, noise functions, and deterministic sequences.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/noise`](./foundation/random/noise/README.md) | Seeded spatial noise fields for procedural content generation. Provides six field types — Perlin, Simplex (2D/3D/4D), Value, Worley/cellular (F1/F2/F2-F1 modes… |
|
||||
| [`@suaveplan/pseudo-random`](./foundation/random/pseudo-random/README.md) | Deterministic, seeded pseudo-random number generators (PRNGs) conforming to the `Rng` interface from `@suaveplan/types`. Ships four algorithms — Mulberry32, SF… |
|
||||
| [`@suaveplan/random`](./foundation/random/random/README.md) | High-level cryptographically secure randomness for application use. This is the everyday random package — it wraps the CSPRNG with ergonomic helpers for every… |
|
||||
|
||||
#### `timing`
|
||||
|
||||
Cron, stopwatches, throttling, debouncing, and scheduling.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cron`](./foundation/timing/cron/README.md) | Universal cron expression parser, next-fire calculator with timezone + DST awareness, and an opt-in lightweight in-process scheduler. Zero external cron d… |
|
||||
| [`@suaveplan/datetime`](./foundation/timing/datetime/README.md) | Timezone-aware date/time utilities covering the most common application-layer date operations that the native `Date` API leaves unaddressed. The package provid… |
|
||||
| [`@suaveplan/timing`](./foundation/timing/timing/README.md) | Timing and scheduling utilities for application-layer time management. The package covers measurement (stopwatch with lap recording), rate control (throttle an… |
|
||||
|
||||
### `gaming`
|
||||
|
||||
Game engines, entity-component systems, and simulation libraries.
|
||||
|
||||
#### `games`
|
||||
|
||||
Game controllers, entity-component systems, and game-server logic.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/controller`](./gaming/games/controller/README.md) | Universal input controller foundation. Ships the unified `ControllerInput` shape (37 normalised fields in `[0, 255]`), `BaseController` abstract base with… |
|
||||
| [`@suaveplan/ecs`](./gaming/games/ecs/README.md) | Archetype-based Entity Component System with `Uint32`-backed bitset queries, phase-ordered system scheduling, entity-ID recycling, Zod-validated component sche… |
|
||||
| [`@suaveplan/game-server`](./gaming/games/game-server/README.md) | Universal game-server framework. Migrated from `@suaveplan/game-server` on the reference branch `claude/review-game-engine-arch-Kaykr` (main repo `packages/uni… |
|
||||
|
||||
### `geospatial`
|
||||
|
||||
Geospatial data, mapping, planetary coordinates, and logistics utilities.
|
||||
|
||||
#### `geo`
|
||||
|
||||
Geospatial coordinates, planetary data, and solar-system modelling.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/geo`](./geospatial/geo/geo/README.md) | Abstract spherical and ellipsoidal geometry — pure math with no planet-specific constants. Every operation that needs a body's shape takes an explicit `Ellipso… |
|
||||
| [`@suaveplan/geo-earth`](./geospatial/geo/geo-earth/README.md) | Earth configuration and legacy geo utilities. |
|
||||
| [`@suaveplan/geo-planet`](./geospatial/geo/geo-planet/README.md) | Configurable planet surface geometry from `PlanetConfig` — ellipsoid, atmosphere, satellites, and orbital elements. |
|
||||
| [`@suaveplan/solar-system`](./geospatial/geo/solar-system/README.md) | Solar system provider with astronomy engine. |
|
||||
| [`@suaveplan/stars`](./geospatial/geo/stars/README.md) | Star catalog provider. |
|
||||
| [`@suaveplan/tides`](./geospatial/geo/tides/README.md) | Tidal prediction providers. |
|
||||
| [`@suaveplan/universe`](./geospatial/geo/universe/README.md) | Observer model, coordinate frames, wire protocol, and provider registry. |
|
||||
|
||||
#### `logistics`
|
||||
|
||||
Port data and logistics primitives.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/ports`](./geospatial/logistics/ports/README.md) | Global port database with shipping-lane routing, ETA estimation, and UN/LOCODE port lookups for sea ports, airports, and rail terminals. |
|
||||
|
||||
### `identity`
|
||||
|
||||
Authentication, authorization, identity resolution, and credential management.
|
||||
|
||||
#### `auth`
|
||||
|
||||
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/auth`](./identity/auth/auth/README.md) | Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware —… |
|
||||
| [`@suaveplan/better-auth-surrealdb`](./identity/auth/better-auth-surrealdb/README.md) | Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised SurrealQL, auto… |
|
||||
| [`@suaveplan/jwt-tenant`](./identity/auth/jwt-tenant/README.md) | JWT-based multi-tenant token issuance and verification for Suaveplan. Ships an Ed25519 / ECDSA-P256 signing pipeline, in-memory JWKS store with rotation and ex… |
|
||||
| [`@suaveplan/macos-keychain`](./identity/auth/macos-keychain/README.md) | macOS Keychain bridge for generic-password credentials. Wraps the system `/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors, AbortS… |
|
||||
| [`@suaveplan/oauth-client`](./identity/auth/oauth-client/README.md) | Universal OAuth 2.0 / OIDC client for Node.js, browsers, Bun, Deno, and edge runtimes. |
|
||||
| [`@suaveplan/permissions`](./identity/auth/permissions/README.md) | Full RBAC/ABAC permissions engine for Suaveplan. Ships role management, policy evaluation, CASL-inspired ability builder, TTL permission cache, condition evalu… |
|
||||
| [`@suaveplan/webauthn`](./identity/auth/webauthn/README.md) | W3C WebAuthn Level 3 client + parser + verifier for Suaveplan. Browser-tier registration / authentication entry points sit on the package root; framework-agnos… |
|
||||
|
||||
### `localization`
|
||||
|
||||
Internationalization, localization, locale detection, and translation infrastructure.
|
||||
|
||||
#### `i18n`
|
||||
|
||||
Internationalization core, backends, detectors, and locale resolution.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/i18n`](./localization/i18n/i18n/README.md) | Slim integration core for the i18n family. After the Wave 6 modular split, this package owns the `MessageCatalog`, the `createI18n` / `I18nInstance` plugin slo… |
|
||||
| [`@suaveplan/i18n-backend-chained`](./localization/i18n/i18n-backend-chained/README.md) | Universal chained backend orchestrator for the `@suaveplan/i18n` family. Composes N backends into a single `Backend` with read-fallthrough on `BACKEND_LOC… |
|
||||
| [`@suaveplan/i18n-backend-fs-multifile`](./localization/i18n/i18n-backend-fs-multifile/README.md) | Server-tier multi-file Node fs/promises translation backend for the @suaveplan/i18n family. Reads <root>/<locale>/<namespace>.<ext> (nested), <root>/<locale>.<namespace>.<ext> (flat), or a caller-supplied glob pattern. JSON built-in; YAML and JSON5 auto-detected via dynamic import. Optional fs.watch loop emits loaded events through the catalog event bus. AbortSignal cancellation, OpenTelemetry instrumentation under i18n.backend.fs_multifile.*. Server-only — uses node:fs/promises; browser consumers should use @suaveplan/i18n-backend-http or @suaveplan/i18n-backend-storage. |
|
||||
| [`@suaveplan/i18n-backend-http`](./localization/i18n/i18n-backend-http/README.md) | Universal translation backend for the `@suaveplan/i18n` family, backed by an injectable `fetch`. Read-only by design: maps HTTP 404 to `BACKEND_LOCALE_NOT… |
|
||||
| [`@suaveplan/i18n-backend-memory`](./localization/i18n/i18n-backend-memory/README.md) | Universal in-memory translation backend for the `@suaveplan/i18n` family. Backed by `Map<locale, Map<namespace, TranslationRecord>>` plus a modification l… |
|
||||
| [`@suaveplan/i18n-backend-storage`](./localization/i18n/i18n-backend-storage/README.md) | Universal translation backend for the `@suaveplan/i18n` family, backed by any `AbstractStorage` implementation from `@suaveplan/storage`. Implements the f… |
|
||||
| [`@suaveplan/i18n-detector`](./localization/i18n/i18n-detector/README.md) | Universal locale-detection chain for the `@suaveplan/i18n` family. Ships the chain orchestrator, the BCP-47 default negotiator, the `getTextDirection` / `… |
|
||||
| [`@suaveplan/i18n-formatter-collator`](./localization/i18n/i18n-formatter-collator/README.md) | Wrapper around `Intl.Collator` for the `@suaveplan/i18n` family — locale-aware string comparison and sorting (sensitivity, numeric-aware natural sort, case-fir… |
|
||||
| [`@suaveplan/i18n-formatter-currency`](./localization/i18n/i18n-formatter-currency/README.md) | Locale-aware currency formatting backed by `Intl.NumberFormat` with `style: "currency"`. Wave 3 of the 14-package i18n modular split. Provides both a class (`C… |
|
||||
| [`@suaveplan/i18n-formatter-date`](./localization/i18n/i18n-formatter-date/README.md) | Wrapper around `Intl.DateTimeFormat` for the `@suaveplan/i18n` family — 8-preset table (short/medium/long/full × date+time/time-only/date-only), range renderin… |
|
||||
| [`@suaveplan/i18n-formatter-display-name`](./localization/i18n/i18n-formatter-display-name/README.md) | Wrapper around `Intl.DisplayNames` for the `@suaveplan/i18n` family — locale-aware language, region, script, currency, and calendar display names. Universal-ti… |
|
||||
| [`@suaveplan/i18n-formatter-list`](./localization/i18n/i18n-formatter-list/README.md) | Locale-aware list joining backed by `Intl.ListFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`ListFormatter`) and a standalone he… |
|
||||
| [`@suaveplan/i18n-formatter-number`](./localization/i18n/i18n-formatter-number/README.md) | Locale-aware number formatting backed by `Intl.NumberFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`NumberFormatter`) and a stan… |
|
||||
| [`@suaveplan/i18n-formatter-relative-time`](./localization/i18n/i18n-formatter-relative-time/README.md) | Locale-aware relative-time formatting backed by `Intl.RelativeTimeFormat`. Wave 3 of the 14-package i18n modular split. Provides both a class (`RelativeTimeFor… |
|
||||
| [`@suaveplan/i18n-formatter-segmenter`](./localization/i18n/i18n-formatter-segmenter/README.md) | Wrapper around `Intl.Segmenter` for the `@suaveplan/i18n` family — locale-aware text segmentation by grapheme, word, or sentence. Compound emoji count as a sin… |
|
||||
| [`@suaveplan/i18n-icu-parser`](./localization/i18n/i18n-icu-parser/README.md) | ICU MessageFormat parser, `{{var}}` interpolation engine, and 9-language CLDR pluralization for the `@suaveplan/i18n` family. Universal leaf package — dep… |
|
||||
| [`@suaveplan/i18n-shared`](./localization/i18n/i18n-shared/README.md) | Shared runtime atoms for the entire `@suaveplan/i18n*` package family — locale constants, BCP-47 normalisation map, RTL set, locale descriptors, interpolation… |
|
||||
| [`@suaveplan/locale-data`](./localization/i18n/locale-data/README.md) | Static ISO 3166-1 country data and ISO 639-1 language data with typed lookup functions, OTel instrumentation, and Zod schemas. |
|
||||
| [`@suaveplan/react-i18n`](./localization/i18n/react-i18n/README.md) | React bindings for `@suaveplan/i18n`. Ships the `<TranslationProvider>`, twelve hooks, four headless components, the class-component HOC, the nine built-in loc… |
|
||||
|
||||
### `media`
|
||||
|
||||
Audio, video, image, and font processing libraries.
|
||||
|
||||
#### `audio`
|
||||
|
||||
Audio processing, playback, analysis, and effect plugins.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/audio-analysis-beat`](./media/audio/audio-analysis-beat/README.md) | Universal beat, tempo, and onset detection plugin for the `@suaveplan/audio-*` family. Detects BPM, beat locations, and onset times from `AudioDocument` s… |
|
||||
| [`@suaveplan/audio-analysis-loudness`](./media/audio/audio-analysis-loudness/README.md) | Universal ITU-R BS.1770-4 loudness analyser. Drop-in `AnalysisOp` plugin for the `@suaveplan/audio-*` family. |
|
||||
| [`@suaveplan/audio-analysis-pitch`](./media/audio/audio-analysis-pitch/README.md) | Universal pitch, key, and chord detection Analysis Op plugins for the `@suaveplan/audio-*` family. Three detectors: |
|
||||
| [`@suaveplan/audio-analysis-spectrum`](./media/audio/audio-analysis-spectrum/README.md) | Universal spectral analysis for the Suaveplan audio stack: real FFT, Hann/Hamming/Blackman windows, mel filterbank, DCT-II, and three high-level analyzers… |
|
||||
| [`@suaveplan/audio-cli`](./media/audio/audio-cli/README.md) | Server-tier `audio` command-line tool. Thin shell over `@suaveplan/audio-core`, `@suaveplan/audio-codecs`, and `@suaveplan/audio-engine-server`: every audio tr… |
|
||||
| [`@suaveplan/audio-codec-ffmpeg-native`](./media/audio/audio-codec-ffmpeg-native/README.md) | Server-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` that shells out to the system `ffmpeg` binary via `fluent-ffmpeg` and streams audio through the s… |
|
||||
| [`@suaveplan/audio-codec-ffmpeg-wasm`](./media/audio/audio-codec-ffmpeg-wasm/README.md) | Browser-tier `AudioCodec` plugin for `@suaveplan/audio-codecs` backed by `@ffmpeg/ffmpeg` running inside a dedicated Web Worker. Decodes and encodes `wav`, `mp… |
|
||||
| [`@suaveplan/audio-codecs`](./media/audio/audio-codecs/README.md) | Universal audio codec registry: decode/encode plugin loader with magic-byte format detection, progressive streaming decode, native WAV/AIFF/CAF codecs, and laz… |
|
||||
| [`@suaveplan/audio-controllers`](./media/audio/audio-controllers/README.md) | Universal MIDI hardware controller plugins. Built on `@suaveplan/audio-core`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/fsm`, and `@s… |
|
||||
| [`@suaveplan/audio-core`](./media/audio/audio-core/README.md) | Universal audio data model and processing primitives. Provides a paged `AudioDocument`, a non-destructive `PlanEngine`, a block-level `ProcessPipeline`, and a… |
|
||||
| [`@suaveplan/audio-decode-wav`](./media/audio/audio-decode-wav/README.md) | Universal WAV decoder wrapper over the upstream `@audio/decode-wav` library. Exposes a stable, OTel-instrumented, AbortSignal-honouring contract over a fr… |
|
||||
| [`@suaveplan/audio-engine-browser`](./media/audio/audio-engine-browser/README.md) | Browser-tier `TimelineAdapter` implementation for the `@suaveplan/audio-*` family. Hosts Web Audio rendering, `AudioWorklet` loading, and the `AudioFxPlugin.cr… |
|
||||
| [`@suaveplan/audio-engine-server`](./media/audio/audio-engine-server/README.md) | Server-tier audio I/O layer for the `@suaveplan/audio-*` family. Provides the thinnest possible bridge between universal `AudioDocument` primitives and a Node.… |
|
||||
| [`@suaveplan/audio-fingerprint`](./media/audio/audio-fingerprint/README.md) | High-accuracy audio fingerprinting service. The package is dual-runtime: a Python FastAPI backend (`src/fingerprint.py` + `src/api/app.py`) implements chromagr… |
|
||||
| [`@suaveplan/audio-metadata`](./media/audio/audio-metadata/README.md) | Universal audio metadata extractor that parses ID3v1/v2, MP4/M4A atoms, FLAC Vorbis comments, Ogg Vorbis, WAV RIFF, and AIFF/AIFC containers, returning a norma… |
|
||||
| [`@suaveplan/audio-sprite-packer`](./media/audio/audio-sprite-packer/README.md) | Universal audio sprite packer with injectable adapter, Zod-validated boundaries, and OpenTelemetry instrumentation. Zero browser or Node.js dependencies. |
|
||||
| [`@suaveplan/audio-sprite-player`](./media/audio/audio-sprite-player/README.md) | Universal audio sprite playback engine. Consumes manifests produced by `@suaveplan/audio-sprite-packer`, validates them, schedules voices, runs voice-stea… |
|
||||
| [`@suaveplan/audio-timeline`](./media/audio/audio-timeline/README.md) | Headless DAW timeline engine with multi-track arrangements, clips, playback, loop regions, selection, zoom, per-track mixer state, undo/redo, and a pluggable `… |
|
||||
| [`@suaveplan/audio-transport`](./media/audio/audio-transport/README.md) | Universal musical clock and transport: sample-accurate scheduling with PPQ, BPM automation, swing, time signatures, and loop regions. Zero browser or Node depe… |
|
||||
| [`@suaveplan/control-surface-schema`](./media/audio/control-surface-schema/README.md) | Universal canonical schema for DAW-style control surfaces. Owns the `ControlSurface` root document, the `ControlNode = Group \| Control` discriminated tree, the… |
|
||||
| [`@suaveplan/midi`](./media/audio/midi/README.md) | Universal MIDI primitives for the Suaveplan platform. Owns the canonical types, byte parsers, message constructors, control-surface dispatchers (NRPN, RPN… |
|
||||
| [`@suaveplan/third-party-elevenlabs`](./media/audio/third-party-elevenlabs/README.md) | <!-- begin:auto-docs --> |
|
||||
| [`@suaveplan/voice`](./media/audio/voice/README.md) | Provider-agnostic voice core for Suaveplan. Defines the abstract interfaces, shared types, Zod schemas, and structured error hierarchy that every voice provide… |
|
||||
| [`@suaveplan/voice-elevenlabs`](./media/audio/voice-elevenlabs/README.md) | ElevenLabs domain adapter for the `@suaveplan/voice` interface family. |
|
||||
|
||||
#### `audio-fx`
|
||||
|
||||
Real-time audio effects and DSP plugins.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/audio-fx-plugin-audio-analyzer`](./media/audio-fx/audio-fx-plugin-audio-analyzer/README.md) | audio-analyzer plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-audio-player`](./media/audio-fx/audio-fx-plugin-audio-player/README.md) | audio-player plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-auto-filter`](./media/audio-fx/audio-fx-plugin-auto-filter/README.md) | auto-filter plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-base`](./media/audio-fx/audio-fx-plugin-base/README.md) | Foundation for the `@suaveplan/audio-fx-plugin-*` family. Universal (browser / server / offline / worklet identical). Zero `@suaveplan/audio-fx-plugin-*`… |
|
||||
| [`@suaveplan/audio-fx-plugin-chorus`](./media/audio-fx/audio-fx-plugin-chorus/README.md) | chorus plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-compressor`](./media/audio-fx/audio-fx-plugin-compressor/README.md) | compressor plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-delay`](./media/audio-fx/audio-fx-plugin-delay/README.md) | delay plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-distortion`](./media/audio-fx/audio-fx-plugin-distortion/README.md) | distortion plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-eq`](./media/audio-fx/audio-fx-plugin-eq/README.md) | Parametric / shelving / high-pass / low-pass equaliser for the `@suaveplan/audio-fx-plugin-*` family. Multi-band biquad implementation, sample-accurate paramet… |
|
||||
| [`@suaveplan/audio-fx-plugin-filter`](./media/audio-fx/audio-fx-plugin-filter/README.md) | filter plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-flanger`](./media/audio-fx/audio-fx-plugin-flanger/README.md) | flanger plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-game-audio`](./media/audio-fx/audio-fx-plugin-game-audio/README.md) | game-audio plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-gate`](./media/audio-fx/audio-fx-plugin-gate/README.md) | gate plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-glados`](./media/audio-fx/audio-fx-plugin-glados/README.md) | glados plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-limiter`](./media/audio-fx/audio-fx-plugin-limiter/README.md) | limiter plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-microphone`](./media/audio-fx/audio-fx-plugin-microphone/README.md) | microphone plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-mixer`](./media/audio-fx/audio-fx-plugin-mixer/README.md) | mixer plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-phaser`](./media/audio-fx/audio-fx-plugin-phaser/README.md) | phaser plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-pitch-shifter`](./media/audio-fx/audio-fx-plugin-pitch-shifter/README.md) | pitch-shifter plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-reverb`](./media/audio-fx/audio-fx-plugin-reverb/README.md) | reverb plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-sampler`](./media/audio-fx/audio-fx-plugin-sampler/README.md) | sampler plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-spatial`](./media/audio-fx/audio-fx-plugin-spatial/README.md) | spatial plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-spessasynth`](./media/audio-fx/audio-fx-plugin-spessasynth/README.md) | SF2/SF3 SoundFont synthesizer plugin for the `@suaveplan/audio-fx-plugin-*` family. Wraps the MIT-licensed SpessaSynth engine as a standard `AudioFxPlugin`, so… |
|
||||
| [`@suaveplan/audio-fx-plugin-synthesizer`](./media/audio-fx/audio-fx-plugin-synthesizer/README.md) | synthesizer plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-tremolo`](./media/audio-fx/audio-fx-plugin-tremolo/README.md) | tremolo plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
| [`@suaveplan/audio-fx-plugin-widener`](./media/audio-fx/audio-fx-plugin-widener/README.md) | widener plugin for the @suaveplan/audio-fx-plugin-* family. |
|
||||
|
||||
#### `audio-ir`
|
||||
|
||||
Impulse-response capture, loading, and room-acoustic plugins.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/audio-ir-core`](./media/audio-ir/audio-ir-core/README.md) | Foundation contract for the `@suaveplan/audio-ir-*` family. Universal; zero `@suaveplan/audio-*` runtime deps. Every sibling package (`audio-ir-loader`, `… |
|
||||
| [`@suaveplan/audio-ir-loader`](./media/audio-ir/audio-ir-loader/README.md) | Pipeline package for the `@suaveplan/audio-ir-*` family. Composes `IrRegistry` (from `@suaveplan/audio-ir-core`), a `StorageUriResolver` (from `@suaveplan/stor… |
|
||||
| [`@suaveplan/audio-ir-room-plugin-club`](./media/audio-ir/audio-ir-room-plugin-club/README.md) | Original audio-ir content package. Ships two bundled room impulse responses (`club1.wav`, `club2.wav`) wired through the `@suaveplan/audio-ir-*` family. |
|
||||
| [`@suaveplan/audio-ir-room-plugin-pool`](./media/audio-ir/audio-ir-room-plugin-pool/README.md) | Original audio-ir content package. Ships a single `room`-family impulse response — `pool-outdoor-swimming-pool` — sourced from the outdoor-swimming-pool captur… |
|
||||
|
||||
#### `fonts`
|
||||
|
||||
Font packing, compression, subsetting, and conversion tools.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/font-compress`](./media/fonts/font-compress/README.md) | BTF (Binary TypeFace) binary packing and unpacking. Converts `TypefaceFont` (typeface.js JSON format) to a compact `Uint8Array` using dictionary-based integer… |
|
||||
| [`@suaveplan/font-convert`](./media/fonts/font-convert/README.md) | Converts TTF/OTF/WOFF/WOFF2 font files to the typeface.js JSON format used by Three.js `FontLoader`. Also defines the canonical `TypefaceFont`, `TypefaceGlyph`… |
|
||||
| [`@suaveplan/font-decompress`](./media/fonts/font-decompress/README.md) | BTF (Binary TypeFace) binary unpacking. Reverses `@suaveplan/font-compress`'s `BinaryFontPacker` output into a typeface.js JSON shape ready for Three.js / R3F… |
|
||||
| [`@suaveplan/font-subset`](./media/fonts/font-subset/README.md) | Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature… |
|
||||
|
||||
#### `image`
|
||||
|
||||
Image compression, colour conversion, EXIF handling, and optimisation.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/caddy-imgproxy`](./media/image/caddy-imgproxy/README.md) | Universal TypeScript port of the Caddy imgproxy URL rewriter. Parses the compact `/u<op><gravity><dims><modifiers>/<path>.<format>` grammar, resolves aspe… |
|
||||
| [`@suaveplan/colour-convert-adapt`](./media/image/colour-convert-adapt/README.md) | Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's appearance so that it looks the same under different light so… |
|
||||
| [`@suaveplan/colour-convert-cieluv`](./media/image/colour-convert-cieluv/README.md) | CIELUV, xyY, LMS, and Bradford chromatic adaptation conversions. This plugin provides colour-space transforms between RGB, CIELUV, xyY, and LMS cone-response s… |
|
||||
| [`@suaveplan/colour-convert-cmyk`](./media/image/colour-convert-cmyk/README.md) | Bidirectional conversion between RGB and CMYK colour spaces using the standard Euclidean algorithm with black generation. CMYK is the subtractive model used in… |
|
||||
| [`@suaveplan/colour-convert-hsluv`](./media/image/colour-convert-hsluv/README.md) | HSLuv perceptually-uniform colour space with gamut-mapped ray intersection for sRGB. This plugin provides bidirectional RGB↔HSLuv/HPLuv conversions and uniform… |
|
||||
| [`@suaveplan/colour-filter-cct`](./media/image/colour-filter-cct/README.md) | Correlated Colour Temperature (CCT) conversions using the McCamy formula and black-body RGB approximation. Convert any RGB colour to its approximate colour tem… |
|
||||
| [`@suaveplan/colour-filter-cmc`](./media/image/colour-filter-cmc/README.md) | CMC l:c Delta-E colour difference metric with hue-dependent chroma scaling and low-luminance branch handling. This plugin computes perceptual distance between… |
|
||||
| [`@suaveplan/colour-filter-greyscale`](./media/image/colour-filter-greyscale/README.md) | Converts any RGB colour to its perceptual greyscale equivalent using the ITU-R BT.709 luminance coefficients. Unlike simple channel averaging, this formula wei… |
|
||||
| [`@suaveplan/colour-filter-sepia`](./media/image/colour-filter-sepia/README.md) | Applies a photographic sepia tone filter to RGB colours using the standard W3C CSS Filters 3×3 matrix. This plugin converts any colour into a warm brownish ton… |
|
||||
| [`@suaveplan/colours`](./media/image/colours/README.md) | Universal colour manipulation library: parsing, conversion, analysis, mixing, blending, and palette generation. Parses hex (3/4/6/8 digit), CSS `rgb()`, `… |
|
||||
| [`@suaveplan/compress-image`](./media/image/compress-image/README.md) | Runtime-agnostic image compression utilities with pluggable adapters for Node.js (Sharp) and testing (MemoryAdapter). Supports resize, quality reduction, forma… |
|
||||
| [`@suaveplan/emblem-solver`](./media/image/emblem-solver/README.md) | Minimal-rectangle SVG converter for pixelated sprite emblems. |
|
||||
| [`@suaveplan/exif`](./media/image/exif/README.md) | Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images. Parses JPEG segment chains to locate and decode… |
|
||||
| [`@suaveplan/image`](./media/image/image/README.md) | Universal image metadata extraction, format detection, resize math, crop validation, and transform pipeline planning. Zero browser or Node dependencies —… |
|
||||
| [`@suaveplan/image-quantisation`](./media/image/image-quantisation/README.md) | Image complexity analysis and adaptive colour quantisation with pool detection, spatial-aware anti-aliasing cleanup, and multiple quantisation algorithms. Desi… |
|
||||
|
||||
#### `video`
|
||||
|
||||
Video encoding, decoding, and thumbnail generation.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/video`](./media/video/video/README.md) | Universal video metadata extraction, magic-byte format detection, resolution math, transform-pipeline planning, and a fluent pipeline builder. Zero browse… |
|
||||
| [`@suaveplan/video-thumbnails`](./media/video/video-thumbnails/README.md) | Video thumbnail extraction and sprite-sheet generation backed by an injectable `FfmpegRunner`. The default runner — `FluentFfmpegRunner` — wraps `fluent-ffmpeg… |
|
||||
|
||||
### `money`
|
||||
|
||||
Payments, currencies, cryptocurrency, and financial primitives.
|
||||
|
||||
#### `finance`
|
||||
|
||||
Currency conversion, cryptocurrency utilities, and exchange-rate providers.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cryptocurrency`](./money/finance/cryptocurrency/README.md) | Universal cryptocurrency utilities for wallet address validation, unit conversion, chain registry, gas estimation, ERC-20 token registry, calldata / funct… |
|
||||
| [`@suaveplan/currency`](./money/finance/currency/README.md) | Universal currency conversion engine — pluggable `CurrencyProvider` interface, TTL-bounded `RateCache` with direct / inverse / triangulated lookup, primar… |
|
||||
| [`@suaveplan/currency-provider-binance`](./money/finance/currency-provider-binance/README.md) | Binance public-ticker crypto-rates provider for `@suaveplan/currency`. Sibling package in the foundation-with-sibling-plugins shape: implements the `CurrencyPr… |
|
||||
| [`@suaveplan/currency-provider-coingecko`](./money/finance/currency-provider-coingecko/README.md) | Universal CoinGecko sibling for `@suaveplan/currency-providers`. Implements the `CurrencyProvider` contract from `@suaveplan/currency` against the public… |
|
||||
| [`@suaveplan/currency-provider-coinmarketcap`](./money/finance/currency-provider-coinmarketcap/README.md) | CoinMarketCap crypto-rates sibling provider for `@suaveplan/currency`. One of the nine `@suaveplan/currency-provider-<name>` sibling packages spun out of the 2… |
|
||||
| [`@suaveplan/currency-provider-cryptocompare`](./money/finance/currency-provider-cryptocompare/README.md) | Universal CryptoCompare crypto-rates provider for `@suaveplan/currency`. Sibling provider package on top of the slim `@suaveplan/currency-providers` core… |
|
||||
| [`@suaveplan/currency-provider-ecb`](./money/finance/currency-provider-ecb/README.md) | Universal `@suaveplan/currency` rate provider for the European Central Bank's daily exchange-rates XML feed. Free, no API key required. Built on the slim… |
|
||||
| [`@suaveplan/currency-provider-fixer`](./money/finance/currency-provider-fixer/README.md) | Fixer.io fiat-rates sibling provider for the slim `@suaveplan/currency-providers` core. Implements the `CurrencyProvider` contract from `@suaveplan/currency`,… |
|
||||
| [`@suaveplan/currency-provider-kraken`](./money/finance/currency-provider-kraken/README.md) | Universal Kraken public-ticker rate provider for `@suaveplan/currency`. Wraps Kraken's free, no-API-key `/0/public/Ticker` endpoint into a `CurrencyProvid… |
|
||||
| [`@suaveplan/currency-provider-open-exchange-rates`](./money/finance/currency-provider-open-exchange-rates/README.md) | Universal `@suaveplan/currency` provider for openexchangerates.org. Routes `/latest.json` through the slim-core `httpRequest()` helper, validates the upst… |
|
||||
| [`@suaveplan/currency-provider-virtual`](./money/finance/currency-provider-virtual/README.md) | In-memory, programmable virtual / game currency provider for `@suaveplan/currency`. Sibling of the `@suaveplan/currency-providers` slim plugin core. No HTTP, n… |
|
||||
| [`@suaveplan/currency-providers`](./money/finance/currency-providers/README.md) | Slim plugin core for `@suaveplan/currency` rate providers. Ships the `CurrencyProviderRegistry` (name-keyed lookup), the `defineCurrencyProvider` factory, the… |
|
||||
| [`@suaveplan/money`](./money/finance/money/README.md) | Universal precise currency and money arithmetic using integer minor-unit representation. All operations use `bigint` so consumers never hit floating-point… |
|
||||
|
||||
#### `payment`
|
||||
|
||||
Payment intent lifecycle, provider adapters, and fraud detection.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/payment`](./money/payment/payment/README.md) | Universal payment intent lifecycle, webhook signature verification, receipt generation, fraud scoring, refund tracking, tax calculation, retry orchestrati… |
|
||||
| [`@suaveplan/payment-provider-adyen`](./money/payment/payment-provider-adyen/README.md) | Universal **Adyen Checkout API v71 adapter** for `@suaveplan/payment`. Implements the `PaymentProvider` interface via the slim `@suaveplan/payment-provide… |
|
||||
| [`@suaveplan/payment-provider-mollie`](./money/payment/payment-provider-mollie/README.md) | Universal Mollie Payments API v2 adapter for `@suaveplan/payment`. Sibling provider package on the slim `@suaveplan/payment-providers` core: implements `P… |
|
||||
| [`@suaveplan/payment-provider-paypal`](./money/payment/payment-provider-paypal/README.md) | PayPal sibling adapter for the slim `@suaveplan/payment-providers` foundation. Ships `PayPalProvider` — a `PaymentProvider` implementation against the PayPal O… |
|
||||
| [`@suaveplan/payment-provider-square`](./money/payment/payment-provider-square/README.md) | Universal Square Payments API adapter for `@suaveplan/payment`. Ships the `SquareProvider` class (implements the structural `PaymentProvider` contract wit… |
|
||||
| [`@suaveplan/payment-provider-stripe`](./money/payment/payment-provider-stripe/README.md) | Universal Stripe adapter for `@suaveplan/payment`. Implements `PaymentProvider` against the Stripe REST API using the slim `@suaveplan/payment-providers`… |
|
||||
| [`@suaveplan/payment-providers`](./money/payment/payment-providers/README.md) | Universal **foundation** for the `@suaveplan/payment-provider-*` sibling-package family. This package ships only the shared abstractions every concrete ad… |
|
||||
|
||||
### `networking`
|
||||
|
||||
Distributed networking, event buses, drive mesh, and network discovery.
|
||||
|
||||
#### `drive`
|
||||
|
||||
Virtual filesystem, mesh networking, and chunk-addressed storage plugins.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/drive`](./networking/drive/drive/README.md) | Modular virtual filesystem core for Suaveplan. File-level operations over `AbstractStorage` with a plugin slot for middleware, chunking, encryption, and P2P me… |
|
||||
| [`@suaveplan/drive-mesh`](./networking/drive/drive-mesh/README.md) | P2P mesh orchestrator over the drive chunks plugin. Consumes `ChunkSource` (from `@suaveplan/drive-plugin-chunks`) and one or more `DriveTransport` implementat… |
|
||||
| [`@suaveplan/drive-mesh-protocol`](./networking/drive/drive-mesh-protocol/README.md) | Wire protocol for chunk discovery, request, and delivery across the mesh. Three envelope shapes (`cd:request`, `cd:advertise`, `cd:response`) with three reques… |
|
||||
| [`@suaveplan/drive-plugin-cache`](./networking/drive/drive-plugin-cache/README.md) | Read-through cache middleware for `@suaveplan/drive`. Accepts a caller-supplied `AbstractCache<Uint8Array>` (from `@suaveplan/cache`) and intercepts every `dri… |
|
||||
| [`@suaveplan/drive-plugin-chunks`](./networking/drive/drive-plugin-chunks/README.md) | Chunk-addressing plugin for `@suaveplan/drive`. Splits files into signed, encrypted chunks using the locked `version 0x0001` binary format, with server-signed… |
|
||||
| [`@suaveplan/drive-react-sharing`](./networking/drive/drive-react-sharing/README.md) | React-tier UI surface for `@suaveplan/drive-sharing`. Three controlled components plus a real-engine-backed hook. |
|
||||
| [`@suaveplan/drive-sharing`](./networking/drive/drive-sharing/README.md) | Partitioned drive sharing engine for the Suaveplan ecosystem. Ships envelope encryption with mandatory AAD, per-user key wrapping, partition policies with dete… |
|
||||
| [`@suaveplan/drive-storage-archive-core`](./networking/drive/drive-storage-archive-core/README.md) | Shared archive-mounting primitives for the Suaveplan drive: magic-byte format sniffing, split-volume sibling resolution, password hook contract, mutation-mode… |
|
||||
| [`@suaveplan/drive-storage-zip`](./networking/drive/drive-storage-zip/README.md) | ZIP archive adapter for `@suaveplan/drive`. Reads and writes standards-compliant zip archives through the structural `DriveStorage` view; supports ZIP64, split… |
|
||||
| [`@suaveplan/drive-template`](./networking/drive/drive-template/README.md) | Server-signed drive templates for the dehydrated-drive workflow. A `DriveTemplate` describes a drive's full folder structure and per-file metadata; the actual… |
|
||||
| [`@suaveplan/drive-transport-https`](./networking/drive/drive-transport-https/README.md) | HTTPS-relay-backed `TransportLike<MessageEnvelope>` for the Suaveplan drive mesh. Implements long-poll receive, stateless POST send, exponential backoff with e… |
|
||||
| [`@suaveplan/drive-transport-webrtc`](./networking/drive/drive-transport-webrtc/README.md) | WebRTC datachannel transport for the drive mesh. Implements the canonical `DriveTransport` interface from `@suaveplan/drive-mesh` over a `@suaveplan/drive-webr… |
|
||||
| [`@suaveplan/drive-uri`](./networking/drive/drive-uri/README.md) | URI plugins (`drive://`, `drive+mesh://`, `drive+template://`) for `@suaveplan/storage-uri`'s `StorageUriResolver`. Wraps a caller-supplied `Drive` instance in… |
|
||||
| [`@suaveplan/drive-webrtc`](./networking/drive/drive-webrtc/README.md) | Browser-tier WebRTC P2P chunk-distribution plugin for `@suaveplan/drive`. Provides a complete mesh networking stack — signalling client, ICE-aware WebRTC clien… |
|
||||
|
||||
#### `events`
|
||||
|
||||
Typed event bus, event sourcing, metrics, and transport bridges.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/broadcast-channel`](./networking/events/broadcast-channel/README.md) | Typed cross-tab messaging built on the Web `BroadcastChannel` API. Browser-tier package — leader election, tab presence and heartbeats, message-type handler re… |
|
||||
| [`@suaveplan/event-sourcing`](./networking/events/event-sourcing/README.md) | Event sourcing patterns for Suaveplan: typed aggregate root, in-memory event store with optimistic concurrency, snapshot support, projections, upcasting, and O… |
|
||||
| [`@suaveplan/events`](./networking/events/events/README.md) | Typed event-bus substrate for Suaveplan. Pub/sub with deterministic FIFO delivery, interceptor API, bounded-queue backpressure, idempotent listeners, Zod-valid… |
|
||||
| [`@suaveplan/events-metrics`](./networking/events/events-metrics/README.md) | OTel metrics companion for `@suaveplan/events`. Attaches via the public interceptor API to record dispatch totals, listener error counts, and latency histogram… |
|
||||
| [`@suaveplan/events-otel`](./networking/events/events-otel/README.md) | OpenTelemetry tracing companion for `@suaveplan/events`. Wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span e… |
|
||||
| [`@suaveplan/events-schema`](./networking/events/events-schema/README.md) | Runtime schema-validation companion for `@suaveplan/events`. Validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and… |
|
||||
| [`@suaveplan/events-transport`](./networking/events/events-transport/README.md) | Cross-context event bridges for `@suaveplan/events`: `MessageChannel` (at-least-once with ack/retry) and `BroadcastChannel` (at-most-once, fan-out) transports… |
|
||||
|
||||
#### `network`
|
||||
|
||||
Mesh networking, WebSocket transports, bootstrap, and discovery protocols.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/network`](./networking/network/network/README.md) | Core network interfaces and runtime for the mesh system. |
|
||||
| [`@suaveplan/network-bootstrap`](./networking/network/network-bootstrap/README.md) | Pluggable peer-discovery chain, persistent peer cache, and `NetworkPlugin` wrapper for the `@suaveplan/network` mesh. Provides the canonical way for a freshly… |
|
||||
| [`@suaveplan/network-discovery-crdt`](./networking/network/network-discovery-crdt/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`. Wraps an awareness-document CRDT (Yjs *or* Automerge — chosen at construction) and produce… |
|
||||
| [`@suaveplan/network-discovery-gundb`](./networking/network/network-discovery-gundb/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains. Wraps a GunDB instance — gossip-over- WebSocket decentralised graph database — and… |
|
||||
| [`@suaveplan/network-discovery-hybrid`](./networking/network/network-discovery-hybrid/README.md) | Browser-tier multi-strategy DiscoveryStrategy orchestrator: composes any subset of the five Wave-2 sibling strategies (peerjs / crdt / trystero / webtorrent / gundb) into a single HybridDiscoveryManager with priority-based peer selection, per-strategy health tracking, automatic fallback, de-duplicated peer set with PeerSource provenance, and unified lifecycle. Implements the network-bootstrap DiscoveryStrategy contract so it can itself be chained. |
|
||||
| [`@suaveplan/network-discovery-peerjs`](./networking/network/network-discovery-peerjs/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` that wraps a PeerJS signalling-server client + WebRTC `DataConnection`s into a deduped peer… |
|
||||
| [`@suaveplan/network-discovery-trystero`](./networking/network/network-discovery-trystero/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`. Serverless WebRTC peer discovery via Trystero, with four interchangeable transports — BitT… |
|
||||
| [`@suaveplan/network-discovery-webtorrent`](./networking/network/network-discovery-webtorrent/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains. Wraps a `p2pt` instance — WebTorrent-tracker-driven WebRTC peer discovery — and exp… |
|
||||
| [`@suaveplan/network-gundb-server`](./networking/network/network-gundb-server/README.md) | Server-tier GunDB relay daemon. Hosts a Gun relay peer plus standard `/health` / `/ready` / `/live` probes on a single Node `http.Server`. Browser-tier clients… |
|
||||
| [`@suaveplan/network-libp2p-server`](./networking/network/network-libp2p-server/README.md) | Server-tier libp2p relay daemon paired with `@suaveplan/network-transport-libp2p`. Hosts a libp2p node configured as a bootstrap + circuit-relay-v2 server alon… |
|
||||
| [`@suaveplan/network-plugin-assignment`](./networking/network/network-plugin-assignment/README.md) | Mesh auto-assignment and load balancing: live `MeshCapacityTracker`, weighted-multi-criteria `AssignmentEngine`, imbalance-detecting `MeshBalancer` planner, an… |
|
||||
| [`@suaveplan/network-plugin-audit-mesh`](./networking/network/network-plugin-audit-mesh/README.md) | Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh. `SignedLog` produces hash-chained `LogEntry` records keyed by mon… |
|
||||
| [`@suaveplan/network-plugin-auth`](./networking/network/network-plugin-auth/README.md) | Mesh membership authentication and cross-mesh relay authorization. Bridges `@suaveplan/auth` into the mesh: peer join verification, capability-tier-bound token… |
|
||||
| [`@suaveplan/network-plugin-circuit-breaker`](./networking/network/network-plugin-circuit-breaker/README.md) | Mesh-level circuit breakers: per-path state machine (`CLOSED → OPEN → HALF_OPEN`), registry, half-open probe loop, and relay middleware integration. Fast-fails… |
|
||||
| [`@suaveplan/network-plugin-content-delivery`](./networking/network/network-plugin-content-delivery/README.md) | Mesh-as-CDN core for the Suaveplan network. `ChunkIndex` maintains a distributed holder map keyed by chunk hash with gossip-style merge over `ChunkAdvertisemen… |
|
||||
| [`@suaveplan/network-plugin-crypto-messages`](./networking/network/network-plugin-crypto-messages/README.md) | Cryptographic integrity layer for the Suaveplan mesh. Ed25519-backed `MessageSigner` (sign on send, verify against the registry on receive); `SessionTokenAutho… |
|
||||
| [`@suaveplan/network-plugin-devtools`](./networking/network/network-plugin-devtools/README.md) | Developer-tools plugin for the Suaveplan mesh. Bundles four subsystems behind a single `NetworkPlugin` lifecycle so engineers can inspect, visualise, and profi… |
|
||||
| [`@suaveplan/network-plugin-failover`](./networking/network/network-plugin-failover/README.md) | Mesh failover plugin: per-peer heartbeat manager, alive→suspect→dead→recovering health monitor, debounced promoter that triggers emergency elections, and a coo… |
|
||||
| [`@suaveplan/network-plugin-governance`](./networking/network/network-plugin-governance/README.md) | Election engine for the Suaveplan mesh: candidate scoring, reputation tracking, voting, and host transitions. |
|
||||
| [`@suaveplan/network-plugin-intra-relay`](./networking/network/network-plugin-intra-relay/README.md) | Intra-mesh peer-to-peer relay for the Suaveplan network. When direct WebRTC fails (symmetric NAT, restrictive firewall), `IntraRelayPlugin` routes the message… |
|
||||
| [`@suaveplan/network-plugin-middleware`](./networking/network/network-plugin-middleware/README.md) | Priority-ordered middleware chain for the Suaveplan mesh. Inbound and outbound message flow each pass through the same five-band pipeline (CRITICAL → HIGH → NO… |
|
||||
| [`@suaveplan/network-plugin-migration`](./networking/network/network-plugin-migration/README.md) | Peer state migration between meshes: 8-step `MigrationCoordinator`, snapshot serialize/deserialize via `StateTransferProtocol`, graceful-handoff `ForwardingTun… |
|
||||
| [`@suaveplan/network-plugin-monitor`](./networking/network/network-plugin-monitor/README.md) | Production monitoring + distributed tracing for the Suaveplan mesh. Collects metrics across four categories (node / connection / message / hierarchy), supports… |
|
||||
| [`@suaveplan/network-plugin-partition`](./networking/network/network-plugin-partition/README.md) | Split-brain detection and resolution for the mesh: epoch-based `PartitionDetector` (deterministic partition-id from sorted reachable set), `QuorumResolver` wit… |
|
||||
| [`@suaveplan/network-plugin-protocol`](./networking/network/network-plugin-protocol/README.md) | Network protocol plugin for the mesh system. |
|
||||
| [`@suaveplan/network-plugin-qos`](./networking/network/network-plugin-qos/README.md) | Bandwidth management and quality-of-service for the mesh: bandwidth-quota enforcer, message-priority classifier, QoS router (bypass / drop / queue / defer), co… |
|
||||
| [`@suaveplan/network-plugin-registry`](./networking/network/network-plugin-registry/README.md) | Fixed-capacity peer registry with bitpacker-based binary encoding. |
|
||||
| [`@suaveplan/network-plugin-relay`](./networking/network/network-plugin-relay/README.md) | Cross-mesh relay — the federation layer that lets Tier 0 hosts of different meshes route messages to each other. Composes `RelayRouter`, `HostBackboneTransport… |
|
||||
| [`@suaveplan/network-plugin-server-bridge`](./networking/network/network-plugin-server-bridge/README.md) | Tier-0 host ↔ central-server bridge for the Suaveplan mesh. |
|
||||
| [`@suaveplan/network-plugin-session`](./networking/network/network-plugin-session/README.md) | Hierarchical session management for the Suaveplan mesh. `SessionTree` maintains a parent-child session graph rooted at a single auto-created root; `SessionMana… |
|
||||
| [`@suaveplan/network-plugin-simulation`](./networking/network/network-plugin-simulation/README.md) | Deterministic virtual-network simulation framework for the Suaveplan mesh. `NetworkSimulator` runs scenarios against a seeded RNG with simulated time (accelera… |
|
||||
| [`@suaveplan/network-plugin-state`](./networking/network/network-plugin-state/README.md) | Mesh-wide synchronized state for the Suaveplan network. Path-based `StateManager` (versioned history with sequence numbers + per-path subscriptions); `DeltaCom… |
|
||||
| [`@suaveplan/network-plugin-topology`](./networking/network/network-plugin-topology/README.md) | Tiered mesh topology plugin: 3-tier `HierarchicalTopology` (Host / SubHost / Leaf) with promote/demote, latency-bucket-based `LatencyAssigner`, `GroupManager`,… |
|
||||
| [`@suaveplan/network-plugin-transport-manager`](./networking/network/network-plugin-transport-manager/README.md) | Multi-transport orchestration for the Suaveplan mesh. Maintains a pool of registered transports per peer, negotiates capability bitmaps during handshake, route… |
|
||||
| [`@suaveplan/network-testing`](./networking/network/network-testing/README.md) | <!-- begin:auto-docs --> |
|
||||
| [`@suaveplan/network-transport-hyperswarm`](./networking/network/network-transport-hyperswarm/README.md) | Server-tier Hyperswarm DHT transport for `@suaveplan/network`. Provides hole-punched peer-to-peer streams discovered via Kademlia DHT topics. |
|
||||
| [`@suaveplan/network-transport-libp2p`](./networking/network/network-transport-libp2p/README.md) | Universal libp2p `Transport` for `@suaveplan/network`. Composes WebSockets or WebRTC sub-transports with Noise + Yamux, identify + ping + bootstrap + circ… |
|
||||
| [`@suaveplan/network-transport-standby`](./networking/network/network-transport-standby/README.md) | Pre-emptive WebRTC standby transport for `@suaveplan/network`. Maintains a pool of warm `RTCPeerConnection`-like slots so peer attach completes in ~10 ms inste… |
|
||||
| [`@suaveplan/network-transport-webrtc`](./networking/network/network-transport-webrtc/README.md) | WebRTC transport plugin for mesh networking. |
|
||||
| [`@suaveplan/network-transport-websockets`](./networking/network/network-transport-websockets/README.md) | WebSocket client utilities with typed message framing, connection lifecycle management, heartbeat, backpressure queue, and injectable transport adapters. |
|
||||
| [`@suaveplan/network-trystero-server`](./networking/network/network-trystero-server/README.md) | Server-tier Trystero signaling daemon. Hosts an MQTT-style or Nostr-style WebSocket broker shim plus standard `/health` / `/ready` / `/live` probes on a single… |
|
||||
| [`@suaveplan/react-network-libp2p-demo`](./networking/network/react-network-libp2p-demo/README.md) | React-tier demo UI for `@suaveplan/network-transport-libp2p`. Splits the upstream 749-LOC monolithic demo into per-concern subcomponents (`<NodeStatusPanel>`,… |
|
||||
| [`@suaveplan/service-discovery`](./networking/network/service-discovery/README.md) | Universal service-registry primitives. Provides the shared `ServiceDescriptor`, `ServiceQuery`, `RegistryEvent`, and `ServiceRegistry` contract consumed b… |
|
||||
| [`@suaveplan/websocket-gateway`](./networking/network/websocket-gateway/README.md) | Server-tier WebSocket gateway daemon with rooms, presence tracking, and channel-multiplexed pub/sub. Pairs with `@suaveplan/network-transport-websockets` on th… |
|
||||
|
||||
### `observability`
|
||||
|
||||
Monitoring, logging, health checks, audit trails, and analytics.
|
||||
|
||||
#### `monitor`
|
||||
|
||||
Logging, health checks, audit logging, and analytics instrumentation.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/analytics-browser`](./observability/monitor/analytics-browser/README.md) | Browser-tier client-side product analytics — `AnalyticsTracker`, `BatchTransport`, `BeaconTransport`, and `SessionManager` with consent gate, PII scrubber, hea… |
|
||||
| [`@suaveplan/audit-logging`](./observability/monitor/audit-logging/README.md) | Tamper-proof, GDPR-compliant audit logging for Suaveplan. Provides a cryptographic chain-integrity engine, pluggable storage adapters, GDPR data export, config… |
|
||||
| [`@suaveplan/health-check`](./observability/monitor/health-check/README.md) | Server-tier health-check core for `/health`, `/ready`, `/live` endpoints with dependency checks (HTTP, TCP, custom), aggregated status, caching, and typed life… |
|
||||
| [`@suaveplan/logger`](./observability/monitor/logger/README.md) | Enterprise-grade structured logger for Suaveplan. Provides a full pipeline: level filtering → OTel trace enrichment → sampling → redaction → sinks. Supports mu… |
|
||||
| [`@suaveplan/opentelemetry`](./observability/monitor/opentelemetry/README.md) | OpenTelemetry SDK setup and configuration helpers for Suaveplan. Provides batteries-included OTel initialization helpers, resource detection, ergonomic span/me… |
|
||||
|
||||
### `persistence`
|
||||
|
||||
Caching, database adapters, search engines, and storage abstractions.
|
||||
|
||||
#### `cache`
|
||||
|
||||
Caching abstractions, memory caches, and Redis backends.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/cache`](./persistence/cache/cache/README.md) | Type-safe caching abstraction for Suaveplan. Provides `MemoryCache` with LRU/LFU/FIFO/TTL eviction, `NamespacedCache` for shared-storage isolation, a `CacheFac… |
|
||||
| [`@suaveplan/cache-redis`](./persistence/cache/cache-redis/README.md) | `AbstractCache<T>` backend over a minimal `RedisLike` interface — native `SET ... EX`, `GET`, `DEL`, `EXISTS`, `TTL`, `SCAN`. Reuses `@suaveplan/cache-storage`… |
|
||||
| [`@suaveplan/cache-storage`](./persistence/cache/cache-storage/README.md) | `AbstractCache<T>` backend that bridges any `@suaveplan/storage` `AbstractStorage` into a typed cache via a 32-byte fixed-width TTL header and pluggable `Codec… |
|
||||
|
||||
#### `database`
|
||||
|
||||
Database adapters, migrations, seeding, and SQLite support.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/db`](./persistence/database/db/README.md) | Universal relational database adapter interface for Suaveplan. Provides the `DatabaseAdapter` contract (extending `DatabaseLike` from `@suaveplan/types`), inje… |
|
||||
| [`@suaveplan/db-sqlite`](./persistence/database/db-sqlite/README.md) | Universal SQLite adapter for Suaveplan. Runs on `bun:sqlite` on the Bun server and `@sqlite.org/sqlite-wasm` (Web Worker) in the browser, unified behind a sing… |
|
||||
| [`@suaveplan/migration`](./persistence/database/migration/README.md) | Database-agnostic schema migration runner with ledger tracking, checksum validation, dry-run reporting, distributed locking, event bus integration, and OTel in… |
|
||||
| [`@suaveplan/seed`](./persistence/database/seed/README.md) | Universal declarative database seeding and fixture framework. Define seed data once, let the runner handle dependency ordering, environment filtering, ide… |
|
||||
| [`@suaveplan/surrealdb`](./persistence/database/surrealdb/README.md) | Universal SurrealDB client wrapper for Suaveplan. Implements `@suaveplan/db`'s `DatabaseAdapter` interface with connection lifecycle management, typed CRUD, sc… |
|
||||
|
||||
#### `search`
|
||||
|
||||
Full-text search, BM25, fuzzy matching, and faceted search.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/search`](./persistence/search/search/README.md) | Full-text and fuzzy search primitives with in-memory BM25 engine, trigram fuzzy matching, faceted search, and pagination support. |
|
||||
|
||||
#### `storage`
|
||||
|
||||
Storage abstractions, browser backends, S3, CDN, and URI routing.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/storage`](./persistence/storage/storage/README.md) | Enterprise key-value storage abstractions for Suaveplan. Provides `AbstractStorage` (implementing `StorageLike`), `MemoryStorage` with TTL and zero-copy stream… |
|
||||
| [`@suaveplan/storage-browser-indexeddb`](./persistence/storage/storage-browser-indexeddb/README.md) | Browser-tier `AbstractStorage` adapter backed by the W3C IndexedDB API (`globalThis.indexedDB`). Provides persistent, origin-scoped key-value storage for brows… |
|
||||
| [`@suaveplan/storage-browser-localstorage`](./persistence/storage/storage-browser-localstorage/README.md) | Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storag… |
|
||||
| [`@suaveplan/storage-browser-opfs`](./persistence/storage/storage-browser-opfs/README.md) | Browser-tier `AbstractStorage` adapter backed by the Origin Private File System (OPFS). Provides persistent, origin-scoped key-value storage for browser consum… |
|
||||
| [`@suaveplan/storage-cdn`](./persistence/storage/storage-cdn/README.md) | Types-only foundation for CDN-flavoured `@suaveplan/storage` adapters. Declares `CdnAdapter` (an abstract base class extending `AbstractStorage` with `purge`,… |
|
||||
| [`@suaveplan/storage-cdn-bunnycdn`](./persistence/storage/storage-cdn-bunnycdn/README.md) | BunnyCDN Storage Zone + Pull Zone provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`set`/`delete`/`exists`/`getMe… |
|
||||
| [`@suaveplan/storage-cdn-rackspace`](./persistence/storage/storage-cdn-rackspace/README.md) | Rackspace Cloud Files (OpenStack Swift) origin + Rackspace CDN provider for `@suaveplan/storage-cdn`. Full `AbstractStorage` and `CdnAdapter` surface — `get`/`… |
|
||||
| [`@suaveplan/storage-file`](./persistence/storage/storage-file/README.md) | Node.js filesystem `AbstractStorage` adapter for `@suaveplan/storage`. Implements atomic writes via temp-file-then-rename, AbortSignal threaded through every a… |
|
||||
| [`@suaveplan/storage-http`](./persistence/storage/storage-http/README.md) | Universal HTTP/CDN-backed `AbstractStorage` adapter for `@suaveplan/storage`. Reads via `GET`/`HEAD`/`Range` against any `<baseUrl><key>` origin, with optional… |
|
||||
| [`@suaveplan/storage-plugin-cache`](./persistence/storage/storage-plugin-cache/README.md) | Read-through cache decorator for `AbstractStorage`. Wraps any `@suaveplan/storage` instance with an injectable `AbstractCache<Uint8Array>`; hit/miss/populate o… |
|
||||
| [`@suaveplan/storage-s3`](./persistence/storage/storage-s3/README.md) | S3-compatible object storage adapter for `@suaveplan/storage`. Extends `AbstractStorage` backed by `@aws-sdk/client-s3` with presigned URL generation, multipar… |
|
||||
| [`@suaveplan/storage-uri`](./persistence/storage/storage-uri/README.md) | Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters. |
|
||||
| [`@suaveplan/storage-uri-plugin-bundle`](./persistence/storage/storage-uri-plugin-bundle/README.md) | Per-package `bundle://` URI scheme for `@suaveplan/storage-uri`. |
|
||||
| [`@suaveplan/storage-uri-plugin-fallback`](./persistence/storage/storage-uri-plugin-fallback/README.md) | Universal `fallback://` URI plugin for `@suaveplan/storage-uri`. Ships ordered multi-source resolution with cycle detection, depth cap, mirror writes, list uni… |
|
||||
|
||||
### `security-stack`
|
||||
|
||||
Cryptography, vaults, security utilities, and threat-detection primitives.
|
||||
|
||||
#### `crypto`
|
||||
|
||||
Cryptographic primitives, ciphers, hashes, curves, and key management.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/crypto`](./security-stack/crypto/crypto/README.md) | Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited @noble… |
|
||||
| [`@suaveplan/crypto-bin`](./security-stack/crypto/crypto-bin/README.md) | Suaveplan password-hashing CLI. Ships the `suaveplan-hash-password` binary — Argon2id (default) and BLAKE2b password hashing + verification with PHC/MCF-format… |
|
||||
| [`@suaveplan/crypto-browser`](./security-stack/crypto/crypto-browser/README.md) | WebCrypto-backed engine implementations for `@suaveplan/crypto` — AES-256-GCM, Ed25519, ECDSA-P256/P384, X25519, ECDH-P256/P384, RSA, AES-KW, and non-extractab… |
|
||||
| [`@suaveplan/crypto-cipher`](./security-stack/crypto/crypto-cipher/README.md) | Low-level AEAD (Authenticated Encryption with Associated Data) cipher primitives for the `@suaveplan/crypto` stack. Provides three algorithms — AES-256-GCM, Ch… |
|
||||
| [`@suaveplan/crypto-cli`](./security-stack/crypto/crypto-cli/README.md) | Suaveplan keychain CLI — root + server identity lifecycle (init, roll, revoke, inspect, verify, export-public, restore) |
|
||||
| [`@suaveplan/crypto-core`](./security-stack/crypto/crypto-core/README.md) | Universal cryptographic primitives — engine-pluggable AEAD, Ed25519/ECDSA-P256 signing, X25519 key agreement, sealed-box, and auth-box. Built on audited @noble… |
|
||||
| [`@suaveplan/crypto-curve`](./security-stack/crypto/crypto-curve/README.md) | Low-level elliptic-curve building blocks for the `@suaveplan/crypto` stack. Exposes four curves — secp256k1, Ed25519, X25519, and NIST P-256 — each as a standa… |
|
||||
| [`@suaveplan/crypto-hash`](./security-stack/crypto/crypto-hash/README.md) | Pure-TypeScript hashing substrate. SHA-2 digests, HMAC, HKDF, PBKDF2, Argon2id, and hex/Base64 encoding — no WASM, no browser globals, fully tree-shakeable. |
|
||||
| [`@suaveplan/crypto-keys`](./security-stack/crypto/crypto-keys/README.md) | Hierarchical keychain chain-signing for the crypto ecosystem. Universal, store-agnostic, free of `node:` / `bun:` imports. |
|
||||
| [`@suaveplan/crypto-rsa`](./security-stack/crypto/crypto-rsa/README.md) | RSA primitives for Suaveplan — RFC 8017 PKCS #1 v2.2, RFC 5208 / RFC 5958 PKCS #8, RFC 5280 SPKI, RFC 7517 / RFC 7518 JWK, RFC 7468 PEM. Universal package… |
|
||||
| [`@suaveplan/crypto-sodium`](./security-stack/crypto/crypto-sodium/README.md) | libsodium-backed engine implementations for `@suaveplan/crypto`. Provides AEAD encryption, Ed25519 signing, X25519 key agreement, sealed-box, auth-box, determi… |
|
||||
| [`@suaveplan/crypto-utils`](./security-stack/crypto/crypto-utils/README.md) | Shared cryptographic utilities consumed internally by all other `@suaveplan/crypto-*` packages. Provides the low-level primitives that every crypto implementat… |
|
||||
| [`@suaveplan/crypto-vault`](./security-stack/crypto/crypto-vault/README.md) | Cryptographic foundation for the Vaulted password-manager ecosystem. Provides BIP39 mnemonic generation, OWASP-grade Argon2id derivation, two-secret key deriva… |
|
||||
|
||||
#### `security`
|
||||
|
||||
Have-I-Been-Pwned checks, JWS, licensing, and OTP utilities.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/hibp`](./security-stack/security/hibp/README.md) | Privacy-preserving Have I Been Pwned v3 client. Universal — works in Node, Bun, Deno, browsers, edge workers. The only required runtime primitive is `fetc… |
|
||||
| [`@suaveplan/jws`](./security-stack/security/jws/README.md) | RFC 7515 **JSON Web Signature** (verify + sign). Pure TypeScript over `Uint8Array` + `DataView` plus the workspace crypto primitives (`@… |
|
||||
| [`@suaveplan/license`](./security-stack/security/license/README.md) | Compact, offline-verifiable, secp256k1-signed software-license-key format. Bit-for-bit wire-format compatible with upstream `@suaveplan/license`. runs unchange… |
|
||||
| [`@suaveplan/otp`](./security-stack/security/otp/README.md) | RFC 4226 (HOTP) + RFC 6238 (TOTP) + RFC 6287 (OCRA) + Google Authenticator Key URI Format + Base32 (RFC 4648 §6) + recovery codes + clock-drift detector — zero browser/Node deps |
|
||||
| [`@suaveplan/password-formats`](./security-stack/security/password-formats/README.md) | Universal import/export layer for common password-manager export formats. Parses Apple Passwords CSV, Bitwarden JSON, Chrome CSV, and KeePass XML into a normal… |
|
||||
| [`@suaveplan/password-generator`](./security-stack/security/password-generator/README.md) | Cryptographically-strong password and passphrase generator — char-class, pronounceable, EFF Diceware, and PIN modes with bias-free RNG, real entropy estimates,… |
|
||||
| [`@suaveplan/password-strength`](./security-stack/security/password-strength/README.md) | zxcvbn-style password strength estimator — pattern-matching + dictionary cracker model per Wheeler 2016. Universal; zero browser/Node/React deps. Re-imple… |
|
||||
| [`@suaveplan/security-codes`](./security-stack/security/security-codes/README.md) | Detect and sanitise sensitive security-code leakage in strings. Handles API keys, JWTs, credit cards, GitHub tokens, Stripe keys, SSH keys, PEM blocks, AWS acc… |
|
||||
| [`@suaveplan/server-keys`](./security-stack/security/server-keys/README.md) | Operator-facing CLI bundle for the Suaveplan server-key lifecycle: 18 bins covering root + server keychain generation (including hierarchical `ChainedKeySigner… |
|
||||
| [`@suaveplan/slsa`](./security-stack/security/slsa/README.md) | SLSA L1-L2 supply-chain security for Suaveplan. Generates and verifies in-toto / SLSA v1.0 provenance, CycloneDX 1.5 SBOMs, DSSE attestation envelopes, and RFC… |
|
||||
| [`@suaveplan/srp`](./security-stack/security/srp/README.md) | SRP-6a zero-knowledge password proof per RFC 5054 / RFC 2945. Universal — client and server roles in one package, bundled 2048-bit safe-prime group, optio… |
|
||||
| [`@suaveplan/tpm-struct`](./security-stack/security/tpm-struct/README.md) | Universal, zero-runtime-dep TPM 2.0 attestation-structure parser and serialiser. Decodes `TPMS_ATTEST` (TCG TPM 2.0 Library Part 2 §10.12), decodes `TPMT_… |
|
||||
| [`@suaveplan/vault`](./security-stack/security/vault/README.md) | Vault orchestration layer for the Vaulted password-manager ecosystem. Provides a drive-backed encrypted SQLite-style container, a strict `locked` ↔ `unlocked`… |
|
||||
| [`@suaveplan/vault-sync`](./security-stack/security/vault-sync/README.md) | Domain-specific sync and conflict-resolution engine for the Vaulted password-manager ecosystem. Builds on `@suaveplan/crdt` and `@suaveplan/diff` (RFC 6902 JSO… |
|
||||
| [`@suaveplan/x509`](./security-stack/security/x509/README.md) | RFC 5280 X.509 v3 certificate parser and certification-path validator. Universal (zero browser, Node, or React deps). Pure TypeScript over `Uint8Array` /… |
|
||||
|
||||
### `testkit`
|
||||
|
||||
Testing frameworks, A/B testing, and quality-assurance utilities.
|
||||
|
||||
#### `testing`
|
||||
|
||||
Testing runners, DOM helpers, E2E harnesses, and A/B testing.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/ab-testing`](./testkit/testing/ab-testing/README.md) | Enterprise A/B testing and experimentation engine with deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, result aggrega… |
|
||||
| [`@suaveplan/testing`](./testkit/testing/testing/README.md) | Shared test infrastructure for Suaveplan. Provides runner primitives (Vitest or bun-test via conditional exports), structured builders, a deterministic FakeClo… |
|
||||
| [`@suaveplan/testing-dom`](./testkit/testing/testing-dom/README.md) | DOM and React component testing utilities for Suaveplan. Provides a single install that covers vitest config generation with WSL2-safe defaults, `@testing-libr… |
|
||||
| [`@suaveplan/testing-e2e`](./testkit/testing/testing-e2e/README.md) | Playwright E2E testing utilities for Suaveplan, with the default `browser` worker fixture launching `cloakbrowser` — a real Chromium binary with 49 source-leve… |
|
||||
| [`@suaveplan/testing-integration`](./testkit/testing/testing-integration/README.md) | Core integration test infrastructure for Vitest: fixture lifecycle management, health polling, and the global-setup bridge. |
|
||||
| [`@suaveplan/testing-integration-garage-s3`](./testkit/testing/testing-integration-garage-s3/README.md) | Garage S3 v2.0.0 testcontainers fixture for `@suaveplan/testing-integration`. |
|
||||
| [`@suaveplan/testing-integration-mailpit`](./testkit/testing/testing-integration-mailpit/README.md) | Mailpit testcontainers fixture + `assertMail()` helper for `@suaveplan/testing-integration`. |
|
||||
| [`@suaveplan/testing-integration-oauth-mock`](./testkit/testing/testing-integration-oauth-mock/README.md) | Real-HTTP mock OAuth 2.0 Identity Provider for integration tests. |
|
||||
| [`@suaveplan/testing-integration-postgres`](./testkit/testing/testing-integration-postgres/README.md) | PostgreSQL 16 testcontainers fixture for `@suaveplan/testing-integration`. |
|
||||
| [`@suaveplan/testing-integration-redis`](./testkit/testing/testing-integration-redis/README.md) | Redis 7 testcontainers fixture for `@suaveplan/testing-integration`. |
|
||||
| [`@suaveplan/testing-integration-step-ca`](./testkit/testing/testing-integration-step-ca/README.md) | A `ServiceFixture` for `smallstep/step-ca` that gives any integration test suite a real, ephemeral certificate authority — root cert, JWK provisioner, and an `… |
|
||||
| [`@suaveplan/testing-integration-surrealdb`](./testkit/testing/testing-integration-surrealdb/README.md) | SurrealDB v3 testcontainers fixture for `@suaveplan/testing-integration`. |
|
||||
| [`@suaveplan/testing-integration-typesense`](./testkit/testing/testing-integration-typesense/README.md) | Typesense 27 testcontainers fixture for `@suaveplan/testing-integration`. |
|
||||
|
||||
### `threejs`
|
||||
|
||||
Three.js and React Three Fiber integrations.
|
||||
|
||||
#### `r3f`
|
||||
|
||||
React Three Fiber networking and scene components.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/r3f-networking`](./threejs/r3f/r3f-networking/README.md) | R3F-tier multiplayer state-synchronisation for React-Three-Fiber scenes. Composes any `@suaveplan/network` `Transport` (canonical: `@suaveplan/network-transpor… |
|
||||
|
||||
### `ui`
|
||||
|
||||
React components, forms, and user-interface primitives.
|
||||
|
||||
#### `forms`
|
||||
|
||||
Form builders, validation, and React form components.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/form-builder`](./ui/forms/form-builder/README.md) | Universal dynamic form schema engine. Renderer-agnostic — no DOM, no React, no Node-specific globals. Drives every dynamic form surface in the Suaveplan. |
|
||||
| [`@suaveplan/react-forms`](./ui/forms/react-forms/README.md) | React forms library for the Suaveplan ecosystem. Wraps react-hook-form + Zod with a primitives + factory + recipes architecture: every user-facing string is ty… |
|
||||
|
||||
#### `react`
|
||||
|
||||
React hooks and components for audio, auth, and UI surfaces.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/react-audio`](./ui/react/react-audio/README.md) | React surface for the Suaveplan audio family. **Phase 1 of 3 — foundation** (provider + hooks + stores + error codes + a11y helpers). Components ship in Phase… |
|
||||
| [`@suaveplan/react-audio-fingerprint`](./ui/react/react-audio-fingerprint/README.md) | React 19 surface for client-side audio fingerprinting and identity matching. The package implements the classic Shazam-style constellation algorithm (2D-peak d… |
|
||||
| [`@suaveplan/react-audio-player`](./ui/react/react-audio-player/README.md) | Track-style media-player surface for the Suaveplan audio family. The package ships two kinds of exports: |
|
||||
| [`@suaveplan/react-audio-stream`](./ui/react/react-audio-stream/README.md) | React 19 surface for live audio streaming sessions: a Provider, six hooks, and four accessible components. Chunk C of the four-chunk `enterprise-react-audio-st… |
|
||||
| [`@suaveplan/react-auth`](./ui/react/react-auth/README.md) | React authentication core for Suaveplan apps — a typed wrapper around `better-auth/client`, an `AuthProvider` React context, an `AuthGuard` route component, `u… |
|
||||
| [`@suaveplan/react-auth-whitelabel`](./ui/react/react-auth-whitelabel/README.md) | White-label authentication React components with tenant branding wired through the `@suaveplan/react-ui` runtime theme-token mutator. |
|
||||
| [`@suaveplan/react-control-surface`](./ui/react/react-control-surface/README.md) | React 19 atomic-control surface renderer for `@suaveplan/control-surface-schema`. |
|
||||
| [`@suaveplan/react-midi`](./ui/react/react-midi/README.md) | React 19 adapter for the Web MIDI API. Provides a strict-mode-safe `<MidiProvider>` that requests `MIDIAccess` exactly once, seven typed hooks for common consu… |
|
||||
| [`@suaveplan/react-ui`](./ui/react/react-ui/README.md) | React UI component library — opinionated providers, theme/pack plugin contracts, bundled translations, telemetry, shadcn-compatible registry generator. |
|
||||
<!-- end:auto-docs -->
|
||||
3
packages/_intro.md
Normal file
3
packages/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `packages`
|
||||
|
||||
Suaveplan package tree. Every `@suaveplan/*` library lives under a category (`packages/<category>/`) and a subcategory (`packages/<category>/<subcategory>/`).
|
||||
105
packages/api-stack/README.md
Normal file
105
packages/api-stack/README.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# `api-stack`
|
||||
|
||||
API infrastructure, plugin framework, and HTTP orchestration layer for Suaveplan.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Subcategories
|
||||
|
||||
1 subcategory, 89 packages.
|
||||
|
||||
### `api`
|
||||
|
||||
API orchestration, plugin lifecycle, routing, workers, and tenant resolution.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/api`](./api/README.md) | The thin Hono-based application orchestrator for the Suaveplan API stack. Composes the four sibling packages — `@suaveplan/api-plugin`, `@suaveplan/api-tenant`… |
|
||||
| [`@suaveplan/api-2pc`](./api-2pc/README.md) | Two-phase commit coordinator for the Suaveplan API plugin system. Drives Postgres PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED across registered p… |
|
||||
| [`@suaveplan/api-audit-file`](./api-audit-file/README.md) | File-rotating, HMAC-signed audit sink for `@suaveplan/api-host`. Implements `RequestAuditSink` from `@suaveplan/api-host/request-pipeline`: writes one JSON lin… |
|
||||
| [`@suaveplan/api-audit-stdout`](./api-audit-stdout/README.md) | Stdout audit sink for `@suaveplan/api-host`. Writes JSON-per-line audit records to a `LineWriter` (default `process.stdout`) with an HMAC-SHA-256 signature app… |
|
||||
| [`@suaveplan/api-auth`](./api-auth/README.md) | **$2** for the Suaveplan API plugin system. Defines the abstract session-resolver shape (`Request → Principal \| null`) used by the host on every request, plus… |
|
||||
| [`@suaveplan/api-auth-anonymous`](./api-auth-anonymous/README.md) | Stage-4 trivial session resolver for `@suaveplan/api`. Always returns `null` for every request. Use this as the tail of resolver chains for public endpoints, a… |
|
||||
| [`@suaveplan/api-auth-bearer-static`](./api-auth-bearer-static/README.md) | Stage-4 service-to-service `SessionResolver` adapter for the `@suaveplan/api` plugin system. Matches an inbound `Authorization: Bearer <token>` header against… |
|
||||
| [`@suaveplan/api-auth-better-auth`](./api-auth-better-auth/README.md) | Stage-6 Better-Auth adapter for `@suaveplan/api`. Ships two **independently-wireable** capability surfaces — `createBetterAuthSession` (implements `auth.sessio… |
|
||||
| [`@suaveplan/api-auth-jwt`](./api-auth-jwt/README.md) | Stage-4 concrete `SessionResolver` for the `@suaveplan/api` plugin system. Verifies a JWT presented on the `Authorization: Bearer <jwt>` header (or a configure… |
|
||||
| [`@suaveplan/api-auth-provider`](./api-auth-provider/README.md) | Abstract account-lifecycle contract for the Suaveplan API plugin system (contracts). This package is OPTIONAL: hosted-IdP deployments (Clerk, Auth0, Cognito) i… |
|
||||
| [`@suaveplan/api-auth-rolled`](./api-auth-rolled/README.md) | Stage-8.12 rolled session-token resolver for `@suaveplan/api`. Issues RS256-signed JWTs with rotating `kid` slots, verifies tokens through the rotator's active… |
|
||||
| [`@suaveplan/api-auth-static-principal`](./api-auth-static-principal/README.md) | Fixed-`Principal` session resolver for the `@suaveplan/api` plugin system. Returns the configured `Principal` verbatim on every request — no token decoding, no… |
|
||||
| [`@suaveplan/api-db-adapter`](./api-db-adapter/README.md) | Stage-4 bridge from the `@suaveplan/db` engine adapters to the plugin `AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces exposed by `@suaveplan/api-type… |
|
||||
| [`@suaveplan/api-db-sqlite-memory`](./api-db-sqlite-memory/README.md) | Stage-5 in-memory SQLite database adapter for the `@suaveplan/api` plugin host testing kit. Wraps `bun:sqlite` (in `:memory:` mode by default, or a file path f… |
|
||||
| [`@suaveplan/api-db-worker-proxy`](./api-db-worker-proxy/README.md) | Worker-side proxy for the api `db` capability. Marshals `AdminDatabaseLike` and `TenantScopedDatabaseLike` calls — including transactions and cursor streams —… |
|
||||
| [`@suaveplan/api-events-in-memory`](./api-events-in-memory/README.md) | In-memory events bus adapter for the `@suaveplan/api` testing kit. Wraps `@suaveplan/events`' production `EventBus` with a per-topic recording layer so tests c… |
|
||||
| [`@suaveplan/api-events-worker-proxy`](./api-events-worker-proxy/README.md) | Worker-tier proxy over a main-process `@suaveplan/events` `EventBus`. |
|
||||
| [`@suaveplan/api-graphql`](./api-graphql/README.md) | Stage-1 abstract GraphQL contract for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-graphql-apollo`](./api-graphql-apollo/README.md) | Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API plugin system. Implements the `GraphqlEngine` contract from `@suaveplan/api-graphql` and pas… |
|
||||
| [`@suaveplan/api-graphql-ui-altair`](./api-graphql-ui-altair/README.md) | Altair GraphQL UI plugin for the Suaveplan API plugin system. Structural sibling of `@suaveplan/api-graphql-ui-graphiql`. |
|
||||
| [`@suaveplan/api-graphql-ui-graphiql`](./api-graphql-ui-graphiql/README.md) | GraphiQL UI mount for the Suaveplan API plugin system. Stage 7 of the umbrella plan. |
|
||||
| [`@suaveplan/api-graphql-yoga`](./api-graphql-yoga/README.md) | Stage-7 GraphQL Yoga engine adapter. **Default GraphQL engine** for the Suaveplan API plugin system per design doc §10.5 — Bun-friendly, fastest for our usage… |
|
||||
| [`@suaveplan/api-host`](./api-host/README.md) | Host orchestration core for the Suaveplan API plugin system. Provides the stable error-code registry (HOST/PLUGIN/RELOAD/CAPABILITY/ADMIN/LEDGER), the author-facing definePlugin DX, the runtime CapabilityRegistry, the LifecycleDriver + ReloadDriver + PluginLedger + admin Hono sub-router, the ErrorBoundary, the §6 11-step RequestPipeline, the top-level createHost(config) entry point composing every internal driver, and the HostInstruments registry covering the §12.3 metric surface. The ./testing subpath is reserved for test utilities. |
|
||||
| [`@suaveplan/api-jobs-worker-proxy`](./api-jobs-worker-proxy/README.md) | Bidirectional `MessagePort` proxy that lets a worker-isolated API plugin participate in the host's main-process job scheduler. The worker calls `registerCron`… |
|
||||
| [`@suaveplan/api-lifecycle`](./api-lifecycle/README.md) | Stage-1 contract package for the Suaveplan API plugin system: the plugin-lifecycle finite state machine definition, the legal-transition table, the side-effect… |
|
||||
| [`@suaveplan/api-loader`](./api-loader/README.md) | Stage-1 contract package for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-logger-worker-proxy`](./api-logger-worker-proxy/README.md) | Stage-3 worker → main proxy for `@suaveplan/logger`. Lets a worker-isolated API plugin call a normal `LoggerLike` surface while the records cross a `MessagePor… |
|
||||
| [`@suaveplan/api-manifest`](./api-manifest/README.md) | Stage-1 contract package of the Suaveplan API plugin system: Zod schemas, parser, cross-field validators, dependency-graph resolver, capability resolver, and c… |
|
||||
| [`@suaveplan/api-migrations`](./api-migrations/README.md) | Database-agnostic migration runner for the Suaveplan API. Ships a narrow `MigrationAdapter` surface (six methods), three shipped adapters (`surrealql`, `postgr… |
|
||||
| [`@suaveplan/api-openapi`](./api-openapi/README.md) | OpenAPI 3.1 registry, typed `RouteBuilder`, schema registry, and document merger for the Suaveplan API. Single source of truth that wires into Hono via `OpenAP… |
|
||||
| [`@suaveplan/api-openapi-ui-redoc`](./api-openapi-ui-redoc/README.md) | Stage-8 ReDoc UI adapter for the Suaveplan api-host. Mounts the ReDoc HTML shell at `/docs/ui/redoc` (or any caller-chosen path) via `createRedocUiHandler` and… |
|
||||
| [`@suaveplan/api-openapi-ui-scalar`](./api-openapi-ui-scalar/README.md) | Scalar UI adapter for the Suaveplan OpenAPI plugin system. |
|
||||
| [`@suaveplan/api-openapi-ui-swagger`](./api-openapi-ui-swagger/README.md) | Swagger UI adapter for the `/docs/ui/swagger` endpoint of the Suaveplan API plugin system (Stage 8.8). Returns a pure HTTP handler whose response is a `text/ht… |
|
||||
| [`@suaveplan/api-plugin`](./api-plugin/README.md) | Plugin-system foundation for the Suaveplan API. Universal — runs unchanged in Bun, Node, and the browser. |
|
||||
| [`@suaveplan/api-plugin-audit`](./api-plugin-audit/README.md) | Hono plugin that wraps `@suaveplan/audit-logging` as a write-through audit trail for `@suaveplan/api`. Installs a global per-request audit middleware (method /… |
|
||||
| [`@suaveplan/api-plugin-auth`](./api-plugin-auth/README.md) | Better-Auth-first authentication plugin for `@suaveplan/api`. The canonical exemplar of the 3-way plug-and-play contract every `@suaveplan/api-plugin-*` packag… |
|
||||
| [`@suaveplan/api-plugin-campaigns`](./api-plugin-campaigns/README.md) | Campaigns + suppression + delivery-webhooks HTTP plugin for `@suaveplan/api`. Mounts the `/campaigns`, `/suppression`, and `/webhooks` route trees on a Hono `O… |
|
||||
| [`@suaveplan/api-plugin-cms`](./api-plugin-cms/README.md) | Headless content-management primitives for `@suaveplan/api`. Ships an in-memory `CmsEngine` that manages flexible content models, content entries with a `draft… |
|
||||
| [`@suaveplan/api-plugin-database`](./api-plugin-database/README.md) | SurrealDB-backed database plugin for `@suaveplan/api`. Pools `@suaveplan/surrealdb`'s `SurrealAdapter` (implementing `@suaveplan/db`'s `DatabaseAdapter`), runs… |
|
||||
| [`@suaveplan/api-plugin-event-bus`](./api-plugin-event-bus/README.md) | Typed event-bus plugin for `@suaveplan/api`. Wraps `@suaveplan/events` (pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener… |
|
||||
| [`@suaveplan/api-plugin-events`](./api-plugin-events/README.md) | Event-management plugin for `@suaveplan/api`. Ships a SurrealDB-backed `EventRepository` + `RsvpRepository` pair, ten Hono OpenAPI routes (list / get / create… |
|
||||
| [`@suaveplan/api-plugin-example`](./api-plugin-example/README.md) | Reference plugin for the `@suaveplan/api-host` plugin system. The package is a load-bearing integration target, not a tutorial: every contract surface the host… |
|
||||
| [`@suaveplan/api-plugin-export`](./api-plugin-export/README.md) | Data-export plugin for `@suaveplan/api`. Turns any registered resource into a CSV (RFC 4180), JSON, or XLSX (SpreadsheetML XML) blob through three self-contain… |
|
||||
| [`@suaveplan/api-plugin-file-upload`](./api-plugin-file-upload/README.md) | Multipart and chunked file-upload plugin for `@suaveplan/api`. Ships six OpenAPI-typed Hono routes, a pluggable storage + session-store contract, SHA-256 hash… |
|
||||
| [`@suaveplan/api-plugin-graphql`](./api-plugin-graphql/README.md) | GraphQL description + resolver-composition layer for `@suaveplan/api`. Server-tier — runs in Bun and Node. |
|
||||
| [`@suaveplan/api-plugin-import`](./api-plugin-import/README.md) | Tabular-data ingest plugin for `@suaveplan/api`. Parses CSV / JSON / TSV-XLSX payloads, applies declarative column mappings + per-field validation + computed f… |
|
||||
| [`@suaveplan/api-plugin-notifications`](./api-plugin-notifications/README.md) | SurrealDB-backed notifications + push plugin for `@suaveplan/api`. Ships a `NotificationRepository` + `DeviceTokenRepository` pair, an `ApnService` with a plug… |
|
||||
| [`@suaveplan/api-plugin-payment`](./api-plugin-payment/README.md) | Payment-lifecycle plugin for `@suaveplan/api`. Wraps `@suaveplan/payment` + a caller-injected `PaymentProvider` (Stripe / Adyen / PayPal / Square / Mollie / cu… |
|
||||
| [`@suaveplan/api-plugin-rate-limit`](./api-plugin-rate-limit/README.md) | Rate-limit plugin for `@suaveplan/api`. Wraps `@suaveplan/rate-limiter` as a `definePlugin` descriptor exposing per-route + per-tenant configuration, parameter… |
|
||||
| [`@suaveplan/api-plugin-search`](./api-plugin-search/README.md) | Full-text search plugin for `@suaveplan/api`. Brings TF-weighted relevance scoring, four built-in text analyzers (`standard`, `simple`, `whitespace`, `keyword`… |
|
||||
| [`@suaveplan/api-plugin-site`](./api-plugin-site/README.md) | Multi-site / multi-domain management plugin for `@suaveplan/api`. Stores `site` records (name, slug, theme, settings, push config) in SurrealDB, resolves incom… |
|
||||
| [`@suaveplan/api-plugin-sse`](./api-plugin-sse/README.md) | Server-Sent Events plugin for `@suaveplan/api`. Registers a typed `sseService` in the host `ServiceRegistry`, mounts a Hono streaming handler at `GET /sse/:cha… |
|
||||
| [`@suaveplan/api-plugin-tenant`](./api-plugin-tenant/README.md) | Multi-tenancy plugin for `@suaveplan/api`. Wraps `@suaveplan/api-tenant`'s resolver chain (header / subdomain / domain / path / JWT / query / cookie) as a firs… |
|
||||
| [`@suaveplan/api-plugin-user`](./api-plugin-user/README.md) | User-profile CRUD plugin for `@suaveplan/api`. Mounts the canonical `/users/me` GET/PATCH/DELETE surface on top of `@suaveplan/api-plugin-auth`, registers a ty… |
|
||||
| [`@suaveplan/api-plugin-vault`](./api-plugin-vault/README.md) | Per-user vault HTTP plugin for `@suaveplan/api`. Third instance of the 3-way plug-and-play contract every `@suaveplan/api-plugin-*` package follows: the plugin… |
|
||||
| [`@suaveplan/api-plugin-webhook`](./api-plugin-webhook/README.md) | Signed outbound webhook delivery plugin for `@suaveplan/api`. Wraps `@suaveplan/webhook-client` behind a `definePlugin` descriptor, registers a typed `webhookS… |
|
||||
| [`@suaveplan/api-rate-limit`](./api-rate-limit/README.md) | Per-route / per-principal / per-tenant / per-IP token-bucket rate-limit middleware for `@suaveplan/api`. Implements the `RequestRateLimiter` contract consumed… |
|
||||
| [`@suaveplan/api-rbac`](./api-rbac/README.md) | Scope + rule-based authorisation middleware for the Suaveplan API plugin system. Produces a framework-agnostic `Middleware` that consumes the resolved `Princip… |
|
||||
| [`@suaveplan/api-router`](./api-router/README.md) | Stage-1 contract package for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-router-hono`](./api-router-hono/README.md) | Stage-4 concrete Hono router adapter for `@suaveplan/api`. Implements the abstract `Router` contract from `@suaveplan/api-router` on top of a Hono `Hono` insta… |
|
||||
| [`@suaveplan/api-router-in-memory`](./api-router-in-memory/README.md) | Stage-5 in-memory router adapter for `@suaveplan/api`. Implements both the abstract `Router` contract from `@suaveplan/api-router` AND the `PipelineRouter` con… |
|
||||
| [`@suaveplan/api-router-worker-bridge`](./api-router-worker-bridge/README.md) | Stage-3 HTTP bridge that marshals Fetch `Request` and `Response` objects across the worker boundary. Pairs a main-side `dispatch` with a worker- side handler o… |
|
||||
| [`@suaveplan/api-saga`](./api-saga/README.md) | Saga orchestrator for the Suaveplan API plugin system. Drives manifest-declared multi-plugin workflows in declared order with reverse-order compensation on fai… |
|
||||
| [`@suaveplan/api-sandbox-permissions`](./api-sandbox-permissions/README.md) | Permission model for sandboxed-tier API plugins. Centralises the `permissions.fs[]` / `permissions.fetch[]` allowlists, the `permissions.budgets` enforcement,… |
|
||||
| [`@suaveplan/api-sandbox-vm`](./api-sandbox-vm/README.md) | `vm.Context`-based sandbox host for Suaveplan API plugins running in the sandboxed tier. The package owns the `vm.Context`+frozen-globals half of the design do… |
|
||||
| [`@suaveplan/api-sandbox-vm-host-bridge`](./api-sandbox-vm-host-bridge/README.md) | Host-integration bridge that lets a `VmSandboxHandle` from `@suaveplan/api-sandbox-vm` install into `@suaveplan/api-host` as a first-class plugin. |
|
||||
| [`@suaveplan/api-sandbox-wasm`](./api-sandbox-wasm/README.md) | Stage-8 WebAssembly sandbox host. Instantiates a sandboxed-tier plugin WASM module against a WASI snapshot preview1 implementation derived from the plugin's `P… |
|
||||
| [`@suaveplan/api-sandbox-wasm-host-bridge`](./api-sandbox-wasm-host-bridge/README.md) | Host-integration layer that wraps a `@suaveplan/api-sandbox-wasm` `WasmSandboxHandle` as a `@suaveplan/api-host` `PluginModule`. Sandboxed-tier plugins compile… |
|
||||
| [`@suaveplan/api-schemas`](./api-schemas/README.md) | Shared Zod schemas describing Suaveplan API request/response shapes. The single source of truth for server plugins, React data hooks, and generated OpenAPI doc… |
|
||||
| [`@suaveplan/api-secrets`](./api-secrets/README.md) | Abstract Secrets contract for the Suaveplan API plugin system — per-plugin secret access typed against `manifest.secrets[]`, canonical redaction marker, frozen… |
|
||||
| [`@suaveplan/api-secrets-bun`](./api-secrets-bun/README.md) | Stage-8 `SecretsLike` adapter backed by `Bun.secrets` (macOS Keychain, Linux libsecret, Windows Credential Manager) with per-plugin allow-list enforcement, OS-… |
|
||||
| [`@suaveplan/api-secrets-env`](./api-secrets-env/README.md) | Stage-4 `SecretsLike` adapter backed by `process.env` / `Bun.env` with per-plugin allowed-keys enforcement. |
|
||||
| [`@suaveplan/api-secrets-vault`](./api-secrets-vault/README.md) | Stage-8 `SecretsLike` adapter backed by `@suaveplan/vault`. The vault is passed in already unlocked by the host's startup sequence; the adapter never sees the… |
|
||||
| [`@suaveplan/api-secrets-worker-proxy`](./api-secrets-worker-proxy/README.md) | Stage-3 worker → main `SecretsLike` proxy for the API plugin system. |
|
||||
| [`@suaveplan/api-sse`](./api-sse/README.md) | Abstract Server-Sent Events contracts for `@suaveplan/api`. Stage-1 contract package: defines the engine-side `SseConnection`, `SseBroker`, `ReplayBuffer` (Las… |
|
||||
| [`@suaveplan/api-sse-bun`](./api-sse-bun/README.md) | `Bun.serve`-backed Server-Sent Events engine for the `@suaveplan/api` plugin system. Implements the abstract `@suaveplan/api-sse` contract and passes `runSseEn… |
|
||||
| [`@suaveplan/api-tenant`](./api-tenant/README.md) | Multi-tenant resolution + scoping for the Suaveplan API. Server-tier — runs on Bun and Node, depends on `hono` and `node:async_hooks`. |
|
||||
| [`@suaveplan/api-tenant-provider`](./api-tenant-provider/README.md) | Abstract `TenantProvider` contract for the Suaveplan API plugin system. Enumerates the universe of tenants known to a host so per-tenant cron / queued jobs (ma… |
|
||||
| [`@suaveplan/api-tenant-provider-memory`](./api-tenant-provider-memory/README.md) | In-memory reference implementation of the `@suaveplan/api-tenant-provider` `TenantProvider` contract. Backed by an insertion-ordered `Map<TenantId, TenantRecor… |
|
||||
| [`@suaveplan/api-tenant-resolver-header`](./api-tenant-resolver-header/README.md) | Stage-8 tenant resolver. Implements the `TenantResolver` contract from `@suaveplan/api-types/tenant-resolver` by reading the tenant identifier from a configura… |
|
||||
| [`@suaveplan/api-tenant-resolver-jwt`](./api-tenant-resolver-jwt/README.md) | Stage-4 tenant resolver for the Suaveplan API plugin system. Reads the already-resolved `Principal.tenantId` (attached by an earlier `api-auth-*` chain step) a… |
|
||||
| [`@suaveplan/api-tenant-resolver-subdomain`](./api-tenant-resolver-subdomain/README.md) | Stage-8 tenant resolver for the Suaveplan API plugin system. Reads the inbound `Host` header (and optionally a CIDR-trusted `X-Forwarded-Host`), strips a confi… |
|
||||
| [`@suaveplan/api-types`](./api-types/README.md) | Root contract-types package for the Suaveplan API plugin system. Defines every cross-cutting type the foundation packages (`api-manifest`, `api-router`, `api-h… |
|
||||
| [`@suaveplan/api-worker-host`](./api-worker-host/README.md) | Worker-side runtime that hosts exactly one Suaveplan API plugin inside an isolated `Worker` thread. The main-process counterpart is `@suaveplan/api-host`; this… |
|
||||
| [`@suaveplan/api-worker-rpc`](./api-worker-rpc/README.md) | MessagePort-based RPC protocol for worker-isolated API plugins. Provides the shared protocol — Zod-validated wire frames, monotonic correlation ids, AbortSigna… |
|
||||
| [`@suaveplan/api-ws`](./api-ws/README.md) | Abstract WebSocket engine contract for the Suaveplan API foundation — Connection, ConnectionRegistry, SubscriptionBus, WsBroker, reconnect-token envelope, backpressure + heartbeat policies, cross-tenant capability gate, and a runWsEngineConformance harness. Stage-1 contracts only; concrete engines (api-ws-bun) and brokers (api-ws-broker-redis / -in-memory) plug behind these interfaces. |
|
||||
| [`@suaveplan/api-ws-broker-in-memory`](./api-ws-broker-in-memory/README.md) | Single-host in-process implementation of the `@suaveplan/api-ws` `WsBroker` contract. Suitable for production single-host deployments AND as the default broker… |
|
||||
| [`@suaveplan/api-ws-broker-redis`](./api-ws-broker-redis/README.md) | Cross-host Redis pub/sub broker for `@suaveplan/api-ws` and `@suaveplan/api-sse`. |
|
||||
| [`@suaveplan/api-ws-bun`](./api-ws-bun/README.md) | Bun.serve-backed WebSocket engine for the `@suaveplan/api-ws` Stage-1 abstract contract. The engine composes the per-connection handshake pipeline, registry, s… |
|
||||
<!-- end:auto-docs -->
|
||||
3
packages/api-stack/_intro.md
Normal file
3
packages/api-stack/_intro.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `api-stack`
|
||||
|
||||
API infrastructure, plugin framework, and HTTP orchestration layer for Suaveplan.
|
||||
101
packages/api-stack/api/README.md
Normal file
101
packages/api-stack/api/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# `api`
|
||||
|
||||
API orchestration, plugin lifecycle, routing, workers, and tenant resolution.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `api`
|
||||
|
||||
89 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/api`](./api/README.md) | The thin Hono-based application orchestrator for the Suaveplan API stack. Composes the four sibling packages — `@suaveplan/api-plugin`, `@suaveplan/api-tenant`… |
|
||||
| [`@suaveplan/api-2pc`](./api-2pc/README.md) | Two-phase commit coordinator for the Suaveplan API plugin system. Drives Postgres PREPARE TRANSACTION / COMMIT PREPARED / ROLLBACK PREPARED across registered p… |
|
||||
| [`@suaveplan/api-audit-file`](./api-audit-file/README.md) | File-rotating, HMAC-signed audit sink for `@suaveplan/api-host`. Implements `RequestAuditSink` from `@suaveplan/api-host/request-pipeline`: writes one JSON lin… |
|
||||
| [`@suaveplan/api-audit-stdout`](./api-audit-stdout/README.md) | Stdout audit sink for `@suaveplan/api-host`. Writes JSON-per-line audit records to a `LineWriter` (default `process.stdout`) with an HMAC-SHA-256 signature app… |
|
||||
| [`@suaveplan/api-auth`](./api-auth/README.md) | **$2** for the Suaveplan API plugin system. Defines the abstract session-resolver shape (`Request → Principal \| null`) used by the host on every request, plus… |
|
||||
| [`@suaveplan/api-auth-anonymous`](./api-auth-anonymous/README.md) | Stage-4 trivial session resolver for `@suaveplan/api`. Always returns `null` for every request. Use this as the tail of resolver chains for public endpoints, a… |
|
||||
| [`@suaveplan/api-auth-bearer-static`](./api-auth-bearer-static/README.md) | Stage-4 service-to-service `SessionResolver` adapter for the `@suaveplan/api` plugin system. Matches an inbound `Authorization: Bearer <token>` header against… |
|
||||
| [`@suaveplan/api-auth-better-auth`](./api-auth-better-auth/README.md) | Stage-6 Better-Auth adapter for `@suaveplan/api`. Ships two **independently-wireable** capability surfaces — `createBetterAuthSession` (implements `auth.sessio… |
|
||||
| [`@suaveplan/api-auth-jwt`](./api-auth-jwt/README.md) | Stage-4 concrete `SessionResolver` for the `@suaveplan/api` plugin system. Verifies a JWT presented on the `Authorization: Bearer <jwt>` header (or a configure… |
|
||||
| [`@suaveplan/api-auth-provider`](./api-auth-provider/README.md) | Abstract account-lifecycle contract for the Suaveplan API plugin system (contracts). This package is OPTIONAL: hosted-IdP deployments (Clerk, Auth0, Cognito) i… |
|
||||
| [`@suaveplan/api-auth-rolled`](./api-auth-rolled/README.md) | Stage-8.12 rolled session-token resolver for `@suaveplan/api`. Issues RS256-signed JWTs with rotating `kid` slots, verifies tokens through the rotator's active… |
|
||||
| [`@suaveplan/api-auth-static-principal`](./api-auth-static-principal/README.md) | Fixed-`Principal` session resolver for the `@suaveplan/api` plugin system. Returns the configured `Principal` verbatim on every request — no token decoding, no… |
|
||||
| [`@suaveplan/api-db-adapter`](./api-db-adapter/README.md) | Stage-4 bridge from the `@suaveplan/db` engine adapters to the plugin `AdminDatabaseLike` / `TenantScopedDatabaseLike` surfaces exposed by `@suaveplan/api-type… |
|
||||
| [`@suaveplan/api-db-sqlite-memory`](./api-db-sqlite-memory/README.md) | Stage-5 in-memory SQLite database adapter for the `@suaveplan/api` plugin host testing kit. Wraps `bun:sqlite` (in `:memory:` mode by default, or a file path f… |
|
||||
| [`@suaveplan/api-db-worker-proxy`](./api-db-worker-proxy/README.md) | Worker-side proxy for the api `db` capability. Marshals `AdminDatabaseLike` and `TenantScopedDatabaseLike` calls — including transactions and cursor streams —… |
|
||||
| [`@suaveplan/api-events-in-memory`](./api-events-in-memory/README.md) | In-memory events bus adapter for the `@suaveplan/api` testing kit. Wraps `@suaveplan/events`' production `EventBus` with a per-topic recording layer so tests c… |
|
||||
| [`@suaveplan/api-events-worker-proxy`](./api-events-worker-proxy/README.md) | Worker-tier proxy over a main-process `@suaveplan/events` `EventBus`. |
|
||||
| [`@suaveplan/api-graphql`](./api-graphql/README.md) | Stage-1 abstract GraphQL contract for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-graphql-apollo`](./api-graphql-apollo/README.md) | Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API plugin system. Implements the `GraphqlEngine` contract from `@suaveplan/api-graphql` and pas… |
|
||||
| [`@suaveplan/api-graphql-ui-altair`](./api-graphql-ui-altair/README.md) | Altair GraphQL UI plugin for the Suaveplan API plugin system. Structural sibling of `@suaveplan/api-graphql-ui-graphiql`. |
|
||||
| [`@suaveplan/api-graphql-ui-graphiql`](./api-graphql-ui-graphiql/README.md) | GraphiQL UI mount for the Suaveplan API plugin system. Stage 7 of the umbrella plan. |
|
||||
| [`@suaveplan/api-graphql-yoga`](./api-graphql-yoga/README.md) | Stage-7 GraphQL Yoga engine adapter. **Default GraphQL engine** for the Suaveplan API plugin system per design doc §10.5 — Bun-friendly, fastest for our usage… |
|
||||
| [`@suaveplan/api-host`](./api-host/README.md) | Host orchestration core for the Suaveplan API plugin system. Provides the stable error-code registry (HOST/PLUGIN/RELOAD/CAPABILITY/ADMIN/LEDGER), the author-facing definePlugin DX, the runtime CapabilityRegistry, the LifecycleDriver + ReloadDriver + PluginLedger + admin Hono sub-router, the ErrorBoundary, the §6 11-step RequestPipeline, the top-level createHost(config) entry point composing every internal driver, and the HostInstruments registry covering the §12.3 metric surface. The ./testing subpath is reserved for test utilities. |
|
||||
| [`@suaveplan/api-jobs-worker-proxy`](./api-jobs-worker-proxy/README.md) | Bidirectional `MessagePort` proxy that lets a worker-isolated API plugin participate in the host's main-process job scheduler. The worker calls `registerCron`… |
|
||||
| [`@suaveplan/api-lifecycle`](./api-lifecycle/README.md) | Stage-1 contract package for the Suaveplan API plugin system: the plugin-lifecycle finite state machine definition, the legal-transition table, the side-effect… |
|
||||
| [`@suaveplan/api-loader`](./api-loader/README.md) | Stage-1 contract package for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-logger-worker-proxy`](./api-logger-worker-proxy/README.md) | Stage-3 worker → main proxy for `@suaveplan/logger`. Lets a worker-isolated API plugin call a normal `LoggerLike` surface while the records cross a `MessagePor… |
|
||||
| [`@suaveplan/api-manifest`](./api-manifest/README.md) | Stage-1 contract package of the Suaveplan API plugin system: Zod schemas, parser, cross-field validators, dependency-graph resolver, capability resolver, and c… |
|
||||
| [`@suaveplan/api-migrations`](./api-migrations/README.md) | Database-agnostic migration runner for the Suaveplan API. Ships a narrow `MigrationAdapter` surface (six methods), three shipped adapters (`surrealql`, `postgr… |
|
||||
| [`@suaveplan/api-openapi`](./api-openapi/README.md) | OpenAPI 3.1 registry, typed `RouteBuilder`, schema registry, and document merger for the Suaveplan API. Single source of truth that wires into Hono via `OpenAP… |
|
||||
| [`@suaveplan/api-openapi-ui-redoc`](./api-openapi-ui-redoc/README.md) | Stage-8 ReDoc UI adapter for the Suaveplan api-host. Mounts the ReDoc HTML shell at `/docs/ui/redoc` (or any caller-chosen path) via `createRedocUiHandler` and… |
|
||||
| [`@suaveplan/api-openapi-ui-scalar`](./api-openapi-ui-scalar/README.md) | Scalar UI adapter for the Suaveplan OpenAPI plugin system. |
|
||||
| [`@suaveplan/api-openapi-ui-swagger`](./api-openapi-ui-swagger/README.md) | Swagger UI adapter for the `/docs/ui/swagger` endpoint of the Suaveplan API plugin system (Stage 8.8). Returns a pure HTTP handler whose response is a `text/ht… |
|
||||
| [`@suaveplan/api-plugin`](./api-plugin/README.md) | Plugin-system foundation for the Suaveplan API. Universal — runs unchanged in Bun, Node, and the browser. |
|
||||
| [`@suaveplan/api-plugin-audit`](./api-plugin-audit/README.md) | Hono plugin that wraps `@suaveplan/audit-logging` as a write-through audit trail for `@suaveplan/api`. Installs a global per-request audit middleware (method /… |
|
||||
| [`@suaveplan/api-plugin-auth`](./api-plugin-auth/README.md) | Better-Auth-first authentication plugin for `@suaveplan/api`. The canonical exemplar of the 3-way plug-and-play contract every `@suaveplan/api-plugin-*` packag… |
|
||||
| [`@suaveplan/api-plugin-campaigns`](./api-plugin-campaigns/README.md) | Campaigns + suppression + delivery-webhooks HTTP plugin for `@suaveplan/api`. Mounts the `/campaigns`, `/suppression`, and `/webhooks` route trees on a Hono `O… |
|
||||
| [`@suaveplan/api-plugin-cms`](./api-plugin-cms/README.md) | Headless content-management primitives for `@suaveplan/api`. Ships an in-memory `CmsEngine` that manages flexible content models, content entries with a `draft… |
|
||||
| [`@suaveplan/api-plugin-database`](./api-plugin-database/README.md) | SurrealDB-backed database plugin for `@suaveplan/api`. Pools `@suaveplan/surrealdb`'s `SurrealAdapter` (implementing `@suaveplan/db`'s `DatabaseAdapter`), runs… |
|
||||
| [`@suaveplan/api-plugin-event-bus`](./api-plugin-event-bus/README.md) | Typed event-bus plugin for `@suaveplan/api`. Wraps `@suaveplan/events` (pub/sub, async dispatch, interceptors, bounded-queue backpressure, idempotent listener… |
|
||||
| [`@suaveplan/api-plugin-events`](./api-plugin-events/README.md) | Event-management plugin for `@suaveplan/api`. Ships a SurrealDB-backed `EventRepository` + `RsvpRepository` pair, ten Hono OpenAPI routes (list / get / create… |
|
||||
| [`@suaveplan/api-plugin-example`](./api-plugin-example/README.md) | Reference plugin for the `@suaveplan/api-host` plugin system. The package is a load-bearing integration target, not a tutorial: every contract surface the host… |
|
||||
| [`@suaveplan/api-plugin-export`](./api-plugin-export/README.md) | Data-export plugin for `@suaveplan/api`. Turns any registered resource into a CSV (RFC 4180), JSON, or XLSX (SpreadsheetML XML) blob through three self-contain… |
|
||||
| [`@suaveplan/api-plugin-file-upload`](./api-plugin-file-upload/README.md) | Multipart and chunked file-upload plugin for `@suaveplan/api`. Ships six OpenAPI-typed Hono routes, a pluggable storage + session-store contract, SHA-256 hash… |
|
||||
| [`@suaveplan/api-plugin-graphql`](./api-plugin-graphql/README.md) | GraphQL description + resolver-composition layer for `@suaveplan/api`. Server-tier — runs in Bun and Node. |
|
||||
| [`@suaveplan/api-plugin-import`](./api-plugin-import/README.md) | Tabular-data ingest plugin for `@suaveplan/api`. Parses CSV / JSON / TSV-XLSX payloads, applies declarative column mappings + per-field validation + computed f… |
|
||||
| [`@suaveplan/api-plugin-notifications`](./api-plugin-notifications/README.md) | SurrealDB-backed notifications + push plugin for `@suaveplan/api`. Ships a `NotificationRepository` + `DeviceTokenRepository` pair, an `ApnService` with a plug… |
|
||||
| [`@suaveplan/api-plugin-payment`](./api-plugin-payment/README.md) | Payment-lifecycle plugin for `@suaveplan/api`. Wraps `@suaveplan/payment` + a caller-injected `PaymentProvider` (Stripe / Adyen / PayPal / Square / Mollie / cu… |
|
||||
| [`@suaveplan/api-plugin-rate-limit`](./api-plugin-rate-limit/README.md) | Rate-limit plugin for `@suaveplan/api`. Wraps `@suaveplan/rate-limiter` as a `definePlugin` descriptor exposing per-route + per-tenant configuration, parameter… |
|
||||
| [`@suaveplan/api-plugin-search`](./api-plugin-search/README.md) | Full-text search plugin for `@suaveplan/api`. Brings TF-weighted relevance scoring, four built-in text analyzers (`standard`, `simple`, `whitespace`, `keyword`… |
|
||||
| [`@suaveplan/api-plugin-site`](./api-plugin-site/README.md) | Multi-site / multi-domain management plugin for `@suaveplan/api`. Stores `site` records (name, slug, theme, settings, push config) in SurrealDB, resolves incom… |
|
||||
| [`@suaveplan/api-plugin-sse`](./api-plugin-sse/README.md) | Server-Sent Events plugin for `@suaveplan/api`. Registers a typed `sseService` in the host `ServiceRegistry`, mounts a Hono streaming handler at `GET /sse/:cha… |
|
||||
| [`@suaveplan/api-plugin-tenant`](./api-plugin-tenant/README.md) | Multi-tenancy plugin for `@suaveplan/api`. Wraps `@suaveplan/api-tenant`'s resolver chain (header / subdomain / domain / path / JWT / query / cookie) as a firs… |
|
||||
| [`@suaveplan/api-plugin-user`](./api-plugin-user/README.md) | User-profile CRUD plugin for `@suaveplan/api`. Mounts the canonical `/users/me` GET/PATCH/DELETE surface on top of `@suaveplan/api-plugin-auth`, registers a ty… |
|
||||
| [`@suaveplan/api-plugin-vault`](./api-plugin-vault/README.md) | Per-user vault HTTP plugin for `@suaveplan/api`. Third instance of the 3-way plug-and-play contract every `@suaveplan/api-plugin-*` package follows: the plugin… |
|
||||
| [`@suaveplan/api-plugin-webhook`](./api-plugin-webhook/README.md) | Signed outbound webhook delivery plugin for `@suaveplan/api`. Wraps `@suaveplan/webhook-client` behind a `definePlugin` descriptor, registers a typed `webhookS… |
|
||||
| [`@suaveplan/api-rate-limit`](./api-rate-limit/README.md) | Per-route / per-principal / per-tenant / per-IP token-bucket rate-limit middleware for `@suaveplan/api`. Implements the `RequestRateLimiter` contract consumed… |
|
||||
| [`@suaveplan/api-rbac`](./api-rbac/README.md) | Scope + rule-based authorisation middleware for the Suaveplan API plugin system. Produces a framework-agnostic `Middleware` that consumes the resolved `Princip… |
|
||||
| [`@suaveplan/api-router`](./api-router/README.md) | Stage-1 contract package for the Suaveplan API plugin system. |
|
||||
| [`@suaveplan/api-router-hono`](./api-router-hono/README.md) | Stage-4 concrete Hono router adapter for `@suaveplan/api`. Implements the abstract `Router` contract from `@suaveplan/api-router` on top of a Hono `Hono` insta… |
|
||||
| [`@suaveplan/api-router-in-memory`](./api-router-in-memory/README.md) | Stage-5 in-memory router adapter for `@suaveplan/api`. Implements both the abstract `Router` contract from `@suaveplan/api-router` AND the `PipelineRouter` con… |
|
||||
| [`@suaveplan/api-router-worker-bridge`](./api-router-worker-bridge/README.md) | Stage-3 HTTP bridge that marshals Fetch `Request` and `Response` objects across the worker boundary. Pairs a main-side `dispatch` with a worker- side handler o… |
|
||||
| [`@suaveplan/api-saga`](./api-saga/README.md) | Saga orchestrator for the Suaveplan API plugin system. Drives manifest-declared multi-plugin workflows in declared order with reverse-order compensation on fai… |
|
||||
| [`@suaveplan/api-sandbox-permissions`](./api-sandbox-permissions/README.md) | Permission model for sandboxed-tier API plugins. Centralises the `permissions.fs[]` / `permissions.fetch[]` allowlists, the `permissions.budgets` enforcement,… |
|
||||
| [`@suaveplan/api-sandbox-vm`](./api-sandbox-vm/README.md) | `vm.Context`-based sandbox host for Suaveplan API plugins running in the sandboxed tier. The package owns the `vm.Context`+frozen-globals half of the design do… |
|
||||
| [`@suaveplan/api-sandbox-vm-host-bridge`](./api-sandbox-vm-host-bridge/README.md) | Host-integration bridge that lets a `VmSandboxHandle` from `@suaveplan/api-sandbox-vm` install into `@suaveplan/api-host` as a first-class plugin. |
|
||||
| [`@suaveplan/api-sandbox-wasm`](./api-sandbox-wasm/README.md) | Stage-8 WebAssembly sandbox host. Instantiates a sandboxed-tier plugin WASM module against a WASI snapshot preview1 implementation derived from the plugin's `P… |
|
||||
| [`@suaveplan/api-sandbox-wasm-host-bridge`](./api-sandbox-wasm-host-bridge/README.md) | Host-integration layer that wraps a `@suaveplan/api-sandbox-wasm` `WasmSandboxHandle` as a `@suaveplan/api-host` `PluginModule`. Sandboxed-tier plugins compile… |
|
||||
| [`@suaveplan/api-schemas`](./api-schemas/README.md) | Shared Zod schemas describing Suaveplan API request/response shapes. The single source of truth for server plugins, React data hooks, and generated OpenAPI doc… |
|
||||
| [`@suaveplan/api-secrets`](./api-secrets/README.md) | Abstract Secrets contract for the Suaveplan API plugin system — per-plugin secret access typed against `manifest.secrets[]`, canonical redaction marker, frozen… |
|
||||
| [`@suaveplan/api-secrets-bun`](./api-secrets-bun/README.md) | Stage-8 `SecretsLike` adapter backed by `Bun.secrets` (macOS Keychain, Linux libsecret, Windows Credential Manager) with per-plugin allow-list enforcement, OS-… |
|
||||
| [`@suaveplan/api-secrets-env`](./api-secrets-env/README.md) | Stage-4 `SecretsLike` adapter backed by `process.env` / `Bun.env` with per-plugin allowed-keys enforcement. |
|
||||
| [`@suaveplan/api-secrets-vault`](./api-secrets-vault/README.md) | Stage-8 `SecretsLike` adapter backed by `@suaveplan/vault`. The vault is passed in already unlocked by the host's startup sequence; the adapter never sees the… |
|
||||
| [`@suaveplan/api-secrets-worker-proxy`](./api-secrets-worker-proxy/README.md) | Stage-3 worker → main `SecretsLike` proxy for the API plugin system. |
|
||||
| [`@suaveplan/api-sse`](./api-sse/README.md) | Abstract Server-Sent Events contracts for `@suaveplan/api`. Stage-1 contract package: defines the engine-side `SseConnection`, `SseBroker`, `ReplayBuffer` (Las… |
|
||||
| [`@suaveplan/api-sse-bun`](./api-sse-bun/README.md) | `Bun.serve`-backed Server-Sent Events engine for the `@suaveplan/api` plugin system. Implements the abstract `@suaveplan/api-sse` contract and passes `runSseEn… |
|
||||
| [`@suaveplan/api-tenant`](./api-tenant/README.md) | Multi-tenant resolution + scoping for the Suaveplan API. Server-tier — runs on Bun and Node, depends on `hono` and `node:async_hooks`. |
|
||||
| [`@suaveplan/api-tenant-provider`](./api-tenant-provider/README.md) | Abstract `TenantProvider` contract for the Suaveplan API plugin system. Enumerates the universe of tenants known to a host so per-tenant cron / queued jobs (ma… |
|
||||
| [`@suaveplan/api-tenant-provider-memory`](./api-tenant-provider-memory/README.md) | In-memory reference implementation of the `@suaveplan/api-tenant-provider` `TenantProvider` contract. Backed by an insertion-ordered `Map<TenantId, TenantRecor… |
|
||||
| [`@suaveplan/api-tenant-resolver-header`](./api-tenant-resolver-header/README.md) | Stage-8 tenant resolver. Implements the `TenantResolver` contract from `@suaveplan/api-types/tenant-resolver` by reading the tenant identifier from a configura… |
|
||||
| [`@suaveplan/api-tenant-resolver-jwt`](./api-tenant-resolver-jwt/README.md) | Stage-4 tenant resolver for the Suaveplan API plugin system. Reads the already-resolved `Principal.tenantId` (attached by an earlier `api-auth-*` chain step) a… |
|
||||
| [`@suaveplan/api-tenant-resolver-subdomain`](./api-tenant-resolver-subdomain/README.md) | Stage-8 tenant resolver for the Suaveplan API plugin system. Reads the inbound `Host` header (and optionally a CIDR-trusted `X-Forwarded-Host`), strips a confi… |
|
||||
| [`@suaveplan/api-types`](./api-types/README.md) | Root contract-types package for the Suaveplan API plugin system. Defines every cross-cutting type the foundation packages (`api-manifest`, `api-router`, `api-h… |
|
||||
| [`@suaveplan/api-worker-host`](./api-worker-host/README.md) | Worker-side runtime that hosts exactly one Suaveplan API plugin inside an isolated `Worker` thread. The main-process counterpart is `@suaveplan/api-host`; this… |
|
||||
| [`@suaveplan/api-worker-rpc`](./api-worker-rpc/README.md) | MessagePort-based RPC protocol for worker-isolated API plugins. Provides the shared protocol — Zod-validated wire frames, monotonic correlation ids, AbortSigna… |
|
||||
| [`@suaveplan/api-ws`](./api-ws/README.md) | Abstract WebSocket engine contract for the Suaveplan API foundation — Connection, ConnectionRegistry, SubscriptionBus, WsBroker, reconnect-token envelope, backpressure + heartbeat policies, cross-tenant capability gate, and a runWsEngineConformance harness. Stage-1 contracts only; concrete engines (api-ws-bun) and brokers (api-ws-broker-redis / -in-memory) plug behind these interfaces. |
|
||||
| [`@suaveplan/api-ws-broker-in-memory`](./api-ws-broker-in-memory/README.md) | Single-host in-process implementation of the `@suaveplan/api-ws` `WsBroker` contract. Suitable for production single-host deployments AND as the default broker… |
|
||||
| [`@suaveplan/api-ws-broker-redis`](./api-ws-broker-redis/README.md) | Cross-host Redis pub/sub broker for `@suaveplan/api-ws` and `@suaveplan/api-sse`. |
|
||||
| [`@suaveplan/api-ws-bun`](./api-ws-bun/README.md) | Bun.serve-backed WebSocket engine for the `@suaveplan/api-ws` Stage-1 abstract contract. The engine composes the per-connection handshake pipeline, registry, s… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/api-stack/api/_intro.md
Normal file
1
packages/api-stack/api/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
API orchestration, plugin lifecycle, routing, workers, and tenant resolution.
|
||||
109
packages/api-stack/api/api-2pc/README.md
Normal file
109
packages/api-stack/api/api-2pc/README.md
Normal 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.
|
||||
|
||||
123
packages/api-stack/api/api-2pc/package.json
Normal file
123
packages/api-stack/api/api-2pc/package.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"name": "@suaveplan/api-2pc",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Two-phase commit coordinator for the Suaveplan API plugin foundation. 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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./coordinator": {
|
||||
"import": {
|
||||
"types": "./dist/coordinator/coordinator.d.ts",
|
||||
"default": "./dist/coordinator/coordinator.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/registry/participant-registry.d.ts",
|
||||
"default": "./dist/registry/participant-registry.js"
|
||||
}
|
||||
},
|
||||
"./phases": {
|
||||
"import": {
|
||||
"types": "./dist/phases/index.d.ts",
|
||||
"default": "./dist/phases/index.js"
|
||||
}
|
||||
},
|
||||
"./persistence": {
|
||||
"import": {
|
||||
"types": "./dist/persistence/persistence.d.ts",
|
||||
"default": "./dist/persistence/persistence.js"
|
||||
}
|
||||
},
|
||||
"./recovery": {
|
||||
"import": {
|
||||
"types": "./dist/recovery/recovery.d.ts",
|
||||
"default": "./dist/recovery/recovery.js"
|
||||
}
|
||||
},
|
||||
"./limits": {
|
||||
"import": {
|
||||
"types": "./dist/limits/index.d.ts",
|
||||
"default": "./dist/limits/index.js"
|
||||
}
|
||||
},
|
||||
"./instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./options": {
|
||||
"import": {
|
||||
"types": "./dist/options/options.d.ts",
|
||||
"default": "./dist/options/options.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/conformance.d.ts",
|
||||
"default": "./dist/testing/conformance.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "bun test --timeout=30000 src/",
|
||||
"test:watch": "bun test --watch src/",
|
||||
"test:coverage": "bun test --coverage --coverage-reporter=lcov --timeout=30000 src/",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts --passWithNoTests",
|
||||
"lint": "bunx biome check src/ tests/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-postgres": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"postgres": "catalog:database",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
150
packages/api-stack/api/api-2pc/src/coordinator/coordinator.md
Normal file
150
packages/api-stack/api/api-2pc/src/coordinator/coordinator.md
Normal 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.
|
||||
@@ -0,0 +1,414 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type { LedgerDb } from "../persistence/persistence.js";
|
||||
import { TwoPcParticipantRegistry } from "../registry/participant-registry.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import { createTwoPcCoordinator } from "./coordinator.js";
|
||||
|
||||
let nextId = 0;
|
||||
const FROZEN_NOW = 1_700_000_000_000;
|
||||
const baseDeps = () => {
|
||||
const db = new Database(":memory:") as unknown as LedgerDb;
|
||||
db.run(`CREATE TABLE IF NOT EXISTS suaveplan_2pc_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
coordinator TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
participants_json TEXT NOT NULL,
|
||||
prepared_at TEXT,
|
||||
committed_at TEXT,
|
||||
aborted_at TEXT
|
||||
)`);
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
return {
|
||||
ledgerDb: db,
|
||||
registry,
|
||||
clock: () => FROZEN_NOW,
|
||||
idGenerator: () => `tx-${++nextId}`,
|
||||
options: { hostInstanceId: "h" },
|
||||
};
|
||||
};
|
||||
|
||||
interface Spy {
|
||||
readonly id: string;
|
||||
readonly calls: string[];
|
||||
participant: RegisteredTwoPcParticipant;
|
||||
}
|
||||
|
||||
function buildSpy(
|
||||
id: string,
|
||||
opts: {
|
||||
vote?: "commit" | "abort";
|
||||
writeIntent?: boolean;
|
||||
voterMode?: "writer" | "read-only";
|
||||
sqlFail?: { stage: "prepare" | "commit" | "rollback"; err: Error };
|
||||
prepareThrows?: Error;
|
||||
delayMs?: number;
|
||||
} = {}
|
||||
): Spy {
|
||||
const calls: string[] = [];
|
||||
const participant: RegisteredTwoPcParticipant = {
|
||||
id,
|
||||
voterMode: opts.voterMode ?? "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async (sql: string) => {
|
||||
calls.push(sql);
|
||||
if (opts.sqlFail) {
|
||||
const matchesStage =
|
||||
(opts.sqlFail.stage === "prepare" &&
|
||||
sql.startsWith("PREPARE")) ||
|
||||
(opts.sqlFail.stage === "commit" &&
|
||||
sql.startsWith("COMMIT")) ||
|
||||
(opts.sqlFail.stage === "rollback" &&
|
||||
sql.startsWith("ROLLBACK"));
|
||||
if (matchesStage) throw opts.sqlFail.err;
|
||||
}
|
||||
if (opts.delayMs) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, opts.delayMs)
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
prepare: async () => {
|
||||
if (opts.prepareThrows) throw opts.prepareThrows;
|
||||
return {
|
||||
vote: opts.vote ?? "commit",
|
||||
writeIntent: opts.writeIntent ?? true,
|
||||
};
|
||||
},
|
||||
};
|
||||
return { id, calls, participant };
|
||||
}
|
||||
|
||||
describe("createTwoPcCoordinator", () => {
|
||||
it("unanimous commit issues COMMIT PREPARED on every participant", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a");
|
||||
const b = buildSpy("b");
|
||||
deps.registry.register(a.participant);
|
||||
deps.registry.register(b.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
const outcome = await c.run2pc({
|
||||
participants: [
|
||||
{ id: "a", input: {} },
|
||||
{ id: "b", input: {} },
|
||||
],
|
||||
});
|
||||
expect(outcome.state).toBe("committed");
|
||||
expect(outcome.votes).toEqual([
|
||||
{ participantId: "a", vote: "commit" },
|
||||
{ participantId: "b", vote: "commit" },
|
||||
]);
|
||||
expect(
|
||||
a.calls.filter((s) => s.startsWith("COMMIT PREPARED")).length
|
||||
).toBe(1);
|
||||
expect(
|
||||
b.calls.filter((s) => s.startsWith("COMMIT PREPARED")).length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("any vote-abort rolls back everywhere with TWOPC_VOTE_ABORT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a");
|
||||
const b = buildSpy("b", { vote: "abort" });
|
||||
deps.registry.register(a.participant);
|
||||
deps.registry.register(b.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({
|
||||
participants: [
|
||||
{ id: "a", input: {} },
|
||||
{ id: "b", input: {} },
|
||||
],
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(TWOPC_CODES.VOTE_ABORT);
|
||||
}
|
||||
expect(
|
||||
a.calls.filter((s) => s.startsWith("ROLLBACK PREPARED")).length
|
||||
).toBe(1);
|
||||
expect(
|
||||
b.calls.filter((s) => s.startsWith("ROLLBACK PREPARED")).length
|
||||
).toBe(0); // b voted abort, never prepared.
|
||||
});
|
||||
|
||||
it("deadline expiry during prepare triggers rollback with TWOPC_TIMEOUT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const slow = buildSpy("slow", {
|
||||
sqlFail: {
|
||||
stage: "prepare",
|
||||
err: new Error("connection aborted"),
|
||||
},
|
||||
});
|
||||
// Patch the run() implementation to abort the signal mid-prepare
|
||||
// — emulates the deadline elapsing while the SQL is in flight.
|
||||
const original = slow.participant.connection.run;
|
||||
let runs = 0;
|
||||
slow.participant = {
|
||||
...slow.participant,
|
||||
connection: {
|
||||
...slow.participant.connection,
|
||||
run: async (sql: string) => {
|
||||
runs += 1;
|
||||
if (runs === 1) {
|
||||
// Sleep long enough for the deadline (1ms) to fire.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
return original(sql);
|
||||
},
|
||||
},
|
||||
};
|
||||
deps.registry.register(slow.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({
|
||||
participants: [{ id: "slow", input: {} }],
|
||||
timeoutMs: 1,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(TWOPC_CODES.TIMEOUT);
|
||||
}
|
||||
});
|
||||
|
||||
it("commit failure escalates to TWOPC_IN_DOUBT with the row marked in_doubt", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a", {
|
||||
sqlFail: { stage: "commit", err: new Error("commit failed") },
|
||||
});
|
||||
deps.registry.register(a.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({ participants: [{ id: "a", input: {} }] });
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(TWOPC_CODES.IN_DOUBT);
|
||||
}
|
||||
});
|
||||
|
||||
it("rollback failure escalates to TWOPC_IN_DOUBT", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a");
|
||||
const b = buildSpy("b", { vote: "abort" });
|
||||
// a's rollback SQL fails (non-idempotency error)
|
||||
const originalA = a.participant.connection.run;
|
||||
a.participant = {
|
||||
...a.participant,
|
||||
connection: {
|
||||
...a.participant.connection,
|
||||
run: async (sql: string) => {
|
||||
if (sql.startsWith("ROLLBACK PREPARED"))
|
||||
throw new Error("connection dropped");
|
||||
return originalA(sql);
|
||||
},
|
||||
},
|
||||
};
|
||||
deps.registry.register(a.participant);
|
||||
deps.registry.register(b.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({
|
||||
participants: [
|
||||
{ id: "a", input: {} },
|
||||
{ id: "b", input: {} },
|
||||
],
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(TWOPC_CODES.IN_DOUBT);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses unknown participant id with TWOPC_PREPARE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({ participants: [{ id: "ghost", input: {} }] });
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.PREPARE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects timeoutMs above the hard ceiling without contacting participants", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a");
|
||||
deps.registry.register(a.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({
|
||||
participants: [{ id: "a", input: {} }],
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED
|
||||
);
|
||||
}
|
||||
expect(a.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("outstanding-prepared cap rejects 257th concurrent transaction", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
deps.options = { hostInstanceId: "h", outstandingPreparedCap: 2 };
|
||||
let resolveA: () => void = () => undefined;
|
||||
let resolveB: () => void = () => undefined;
|
||||
const aGate = new Promise<void>((r) => {
|
||||
resolveA = r;
|
||||
});
|
||||
const bGate = new Promise<void>((r) => {
|
||||
resolveB = r;
|
||||
});
|
||||
const a = buildSpy("a");
|
||||
const b = buildSpy("b");
|
||||
a.participant = {
|
||||
...a.participant,
|
||||
prepare: async () => {
|
||||
await aGate;
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
};
|
||||
b.participant = {
|
||||
...b.participant,
|
||||
prepare: async () => {
|
||||
await bGate;
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
};
|
||||
deps.registry.register(a.participant);
|
||||
deps.registry.register(b.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
const p1 = c.run2pc({ participants: [{ id: "a", input: {} }] });
|
||||
const p2 = c.run2pc({ participants: [{ id: "b", input: {} }] });
|
||||
// Third call should hit the cap and reject synchronously.
|
||||
await expect(
|
||||
c.run2pc({ participants: [{ id: "a", input: {} }] })
|
||||
).rejects.toMatchObject({
|
||||
twoPcCode: TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED,
|
||||
});
|
||||
resolveA();
|
||||
resolveB();
|
||||
await Promise.all([p1, p2]);
|
||||
// After draining, a fresh call admits.
|
||||
const fresh = buildSpy("fresh");
|
||||
deps.registry.register(fresh.participant);
|
||||
const r = await c.run2pc({
|
||||
participants: [{ id: "fresh", input: {} }],
|
||||
});
|
||||
expect(r.state).toBe("committed");
|
||||
});
|
||||
|
||||
it("exposes outstandingCount and resolved options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = baseDeps();
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
expect(c.outstandingCount()).toBe(0);
|
||||
expect(c.options.hostInstanceId).toBe("h");
|
||||
expect(c.options.defaultTimeoutMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves non-TwoPcError throws from a participant prepare callback as is", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a", { prepareThrows: new Error("custom") });
|
||||
deps.registry.register(a.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
// The prepare phase wraps the cause into TWOPC_PREPARE_FAILED.
|
||||
try {
|
||||
await c.run2pc({ participants: [{ id: "a", input: {} }] });
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.PREPARE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rethrows non-TwoPcError exceptions raised by commitPhaseTwo", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const a = buildSpy("a");
|
||||
// Force commit to throw a plain Error (not a TwoPcError) by
|
||||
// patching the connection's run to raise a non-TwoPcError on
|
||||
// the COMMIT PREPARED call but wrap that exception in a way
|
||||
// commitPhaseTwo doesn't recognise — we patch commitPhaseTwo's
|
||||
// invocation by replacing the participant's run AFTER prepare.
|
||||
deps.registry.register(a.participant);
|
||||
const original = a.participant.connection.run;
|
||||
let calls = 0;
|
||||
a.participant.connection.run = (async (sql: string) => {
|
||||
calls += 1;
|
||||
if (sql.startsWith("COMMIT PREPARED")) {
|
||||
// Throw something that bypasses TwoPcError detection
|
||||
// inside commitPhaseTwo: the phase wraps any thrown
|
||||
// value into TWOPC_COMMIT_FAILED, so the coordinator
|
||||
// never sees a non-TwoPcError. Confirm the wrapping
|
||||
// happens (so the else-branch in the coordinator's
|
||||
// catch is unreachable by design and the test asserts
|
||||
// that the wrapping path is the one taken).
|
||||
throw new RangeError("raw");
|
||||
}
|
||||
return original.call(a.participant.connection, sql);
|
||||
}) as typeof original;
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
try {
|
||||
await c.run2pc({ participants: [{ id: "a", input: {} }] });
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
// commitPhaseTwo wraps any thrown value into TWOPC_COMMIT_FAILED,
|
||||
// which the coordinator collects into commitErrors and escalates
|
||||
// to TWOPC_IN_DOUBT.
|
||||
expect((err as TwoPcError).twoPcCode).toBe(TWOPC_CODES.IN_DOUBT);
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a read-only voter in the vote tally without sending SQL", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const deps = baseDeps();
|
||||
const writer = buildSpy("writer");
|
||||
const reader = buildSpy("reader", {
|
||||
voterMode: "read-only",
|
||||
writeIntent: false,
|
||||
});
|
||||
deps.registry.register(writer.participant);
|
||||
deps.registry.register(reader.participant);
|
||||
const c = createTwoPcCoordinator(deps);
|
||||
const outcome = await c.run2pc({
|
||||
participants: [
|
||||
{ id: "writer", input: {} },
|
||||
{ id: "reader", input: {} },
|
||||
],
|
||||
});
|
||||
expect(outcome.state).toBe("committed");
|
||||
expect(reader.calls).toEqual([]); // no SQL ever to the read-only voter
|
||||
});
|
||||
});
|
||||
281
packages/api-stack/api/api-2pc/src/coordinator/coordinator.ts
Normal file
281
packages/api-stack/api/api-2pc/src/coordinator/coordinator.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { TwoPcVote } from "@suaveplan/api-types/twopc";
|
||||
import type { Clock, IdGenerator } from "@suaveplan/types";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
createTwoPcInstruments,
|
||||
type TwoPcInstruments,
|
||||
} from "../instruments/instruments.js";
|
||||
import { OutstandingPreparedCap } from "../limits/outstanding-cap.js";
|
||||
import { assertTimeoutBelowCeiling } from "../limits/timeout-ceiling.js";
|
||||
import {
|
||||
type ResolvedTwoPcCoordinatorOptions,
|
||||
resolveTwoPcCoordinatorOptions,
|
||||
type TwoPcCoordinatorOptions,
|
||||
} from "../options/options.js";
|
||||
import type { LedgerDb } from "../persistence/persistence.js";
|
||||
import {
|
||||
type LedgerParticipantEntry,
|
||||
TwoPcLedgerStore,
|
||||
} from "../persistence/persistence.js";
|
||||
import {
|
||||
commitPhaseTwo,
|
||||
type ParticipantPrepareOutcome,
|
||||
preparePhaseOne,
|
||||
rollbackPhaseTwo,
|
||||
} from "../phases/index.js";
|
||||
import type { TwoPcParticipantRegistry } from "../registry/participant-registry.js";
|
||||
import type { Run2PcInput, Run2PcOutcome } from "../types/types.types.js";
|
||||
|
||||
/**
|
||||
* Adapter surface the coordinator depends on. Mirrors the foundation
|
||||
* pattern: dependencies passed in, not imported.
|
||||
*/
|
||||
export interface TwoPcCoordinatorDeps {
|
||||
readonly options: TwoPcCoordinatorOptions;
|
||||
readonly registry: TwoPcParticipantRegistry;
|
||||
readonly ledgerDb: LedgerDb;
|
||||
readonly clock: Clock;
|
||||
readonly idGenerator: IdGenerator;
|
||||
/**
|
||||
* Optional override; defaults to a fresh instance backed by the OTel
|
||||
* globals. Tests inject their own meter/tracer providers.
|
||||
*/
|
||||
readonly instruments?: TwoPcInstruments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public coordinator surface. `run2pc` is the only entry point;
|
||||
* `outstandingCount` and `getOptions` are exposed for diagnostics and
|
||||
* the OTel observable gauge.
|
||||
*/
|
||||
export interface TwoPcCoordinator {
|
||||
run2pc(input: Run2PcInput): Promise<Run2PcOutcome>;
|
||||
outstandingCount(): number;
|
||||
readonly options: ResolvedTwoPcCoordinatorOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory entry point. Builds a fully-wired coordinator from the
|
||||
* supplied dependencies. The returned object is the only surface
|
||||
* application code touches.
|
||||
*/
|
||||
export function createTwoPcCoordinator(
|
||||
deps: TwoPcCoordinatorDeps
|
||||
): TwoPcCoordinator {
|
||||
const options = resolveTwoPcCoordinatorOptions(deps.options);
|
||||
const ledger = new TwoPcLedgerStore(deps.ledgerDb);
|
||||
const cap = new OutstandingPreparedCap(options.outstandingPreparedCap);
|
||||
const instruments =
|
||||
deps.instruments ??
|
||||
createTwoPcInstruments({
|
||||
outstandingPreparedSnapshot: () => cap.snapshot(),
|
||||
});
|
||||
const { registry, clock, idGenerator } = deps;
|
||||
|
||||
async function run2pc(input: Run2PcInput): Promise<Run2PcOutcome> {
|
||||
const requestedTimeout = input.timeoutMs ?? options.defaultTimeoutMs;
|
||||
assertTimeoutBelowCeiling(requestedTimeout);
|
||||
cap.admit();
|
||||
const txId = idGenerator();
|
||||
const lookups = input.participants.map((p) => {
|
||||
const registered = registry.get(p.id);
|
||||
if (registered === undefined) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.PREPARE_FAILED,
|
||||
`Unknown participant ${p.id}`,
|
||||
{ context: { participantId: p.id, txId } }
|
||||
);
|
||||
}
|
||||
return { spec: p, registered };
|
||||
});
|
||||
const participantEntries: LedgerParticipantEntry[] = lookups.map(
|
||||
({ registered }) => ({
|
||||
participantId: registered.id,
|
||||
voterMode: registered.voterMode,
|
||||
vote: null,
|
||||
})
|
||||
);
|
||||
|
||||
ledger.insert({
|
||||
id: txId,
|
||||
coordinator: options.hostInstanceId,
|
||||
participants: participantEntries,
|
||||
});
|
||||
|
||||
const txSpan = instruments.tracer.startSpan("2pc.transaction", {
|
||||
attributes: {
|
||||
"tx.id": txId,
|
||||
"participant.count": lookups.length,
|
||||
"coordinator.host": options.hostInstanceId,
|
||||
},
|
||||
});
|
||||
const ac = new AbortController();
|
||||
const deadlineHandle = setTimeout(() => ac.abort(), requestedTimeout);
|
||||
|
||||
const prepareT0 = clock();
|
||||
const outcomes: ParticipantPrepareOutcome[] = [];
|
||||
let abortReason: TwoPcError | null = null;
|
||||
try {
|
||||
try {
|
||||
for (const { spec, registered } of lookups) {
|
||||
const o = await preparePhaseOne({
|
||||
hostInstanceId: options.hostInstanceId,
|
||||
txId,
|
||||
participant: registered,
|
||||
payload: spec.input,
|
||||
instruments,
|
||||
signal: ac.signal,
|
||||
});
|
||||
outcomes.push(o);
|
||||
if (o.vote === "abort") {
|
||||
abortReason = new TwoPcError(
|
||||
TWOPC_CODES.VOTE_ABORT,
|
||||
`Participant ${o.participantId} voted abort`,
|
||||
{
|
||||
context: {
|
||||
participantId: o.participantId,
|
||||
txId,
|
||||
},
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof TwoPcError) {
|
||||
abortReason = err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const prepareDuration = clock() - prepareT0;
|
||||
const commitT0 = clock();
|
||||
let outcomeState: Run2PcOutcome["state"];
|
||||
let commitDuration = 0;
|
||||
|
||||
if (
|
||||
abortReason === null &&
|
||||
outcomes.every((o) => o.vote === "commit")
|
||||
) {
|
||||
ledger.markAllVotedCommit({
|
||||
id: txId,
|
||||
participants: participantEntries.map((e) => {
|
||||
const matched = outcomes.find(
|
||||
(o) => o.participantId === e.participantId
|
||||
);
|
||||
return {
|
||||
...e,
|
||||
vote: matched?.vote ?? null,
|
||||
};
|
||||
}),
|
||||
preparedAt: new Date(clock()).toISOString(),
|
||||
});
|
||||
const commitErrors: TwoPcError[] = [];
|
||||
for (const o of outcomes) {
|
||||
try {
|
||||
await commitPhaseTwo({
|
||||
txId,
|
||||
participant: registry.get(o.participantId)!,
|
||||
outcome: o,
|
||||
instruments,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TwoPcError) commitErrors.push(err);
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
commitDuration = clock() - commitT0;
|
||||
if (commitErrors.length > 0) {
|
||||
ledger.markInDoubt(txId);
|
||||
outcomeState = "in_doubt";
|
||||
instruments.transactionsTotal.add(1, {
|
||||
outcome: "in_doubt",
|
||||
});
|
||||
instruments.commitDurationMs.record(commitDuration, {
|
||||
outcome: "in_doubt",
|
||||
});
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.IN_DOUBT,
|
||||
`Transaction ${txId} is in doubt (${commitErrors.length} commit failure(s))`,
|
||||
{
|
||||
cause: commitErrors[0],
|
||||
context: { txId, failed: commitErrors.length },
|
||||
}
|
||||
);
|
||||
}
|
||||
ledger.markCommitted(txId, new Date(clock()).toISOString());
|
||||
outcomeState = "committed";
|
||||
instruments.transactionsTotal.add(1, { outcome: "committed" });
|
||||
instruments.commitDurationMs.record(commitDuration, {
|
||||
outcome: "committed",
|
||||
});
|
||||
} else {
|
||||
// Rollback path. Abort the deadline so any pending prepares
|
||||
// hit the TIMEOUT path on their next SQL call.
|
||||
ac.abort();
|
||||
ledger.markAborting({
|
||||
id: txId,
|
||||
participants: participantEntries.map((e) => {
|
||||
const matched = outcomes.find(
|
||||
(o) => o.participantId === e.participantId
|
||||
);
|
||||
return { ...e, vote: matched?.vote ?? null };
|
||||
}),
|
||||
});
|
||||
const rollbackErrors: TwoPcError[] = [];
|
||||
for (const o of outcomes) {
|
||||
try {
|
||||
await rollbackPhaseTwo({
|
||||
txId,
|
||||
participant: registry.get(o.participantId)!,
|
||||
outcome: o,
|
||||
instruments,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TwoPcError) rollbackErrors.push(err);
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
ledger.markAborted(txId, new Date(clock()).toISOString());
|
||||
outcomeState = "aborted";
|
||||
instruments.transactionsTotal.add(1, { outcome: "aborted" });
|
||||
if (rollbackErrors.length > 0) {
|
||||
ledger.markInDoubt(txId);
|
||||
instruments.transactionsTotal.add(1, {
|
||||
outcome: "in_doubt",
|
||||
});
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.IN_DOUBT,
|
||||
`Rollback partially failed for ${txId}`,
|
||||
{
|
||||
cause: rollbackErrors[0],
|
||||
context: { txId },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (abortReason !== null) throw abortReason;
|
||||
}
|
||||
|
||||
return {
|
||||
txId,
|
||||
state: outcomeState,
|
||||
votes: outcomes.map((o) => ({
|
||||
participantId: o.participantId,
|
||||
vote: o.vote as TwoPcVote,
|
||||
})),
|
||||
prepareDurationMs: prepareDuration,
|
||||
commitDurationMs: commitDuration,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(deadlineHandle);
|
||||
txSpan.end();
|
||||
cap.release();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
run2pc,
|
||||
outstandingCount: () => cap.snapshot(),
|
||||
options,
|
||||
};
|
||||
}
|
||||
5
packages/api-stack/api/api-2pc/src/coordinator/index.ts
Normal file
5
packages/api-stack/api/api-2pc/src/coordinator/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTwoPcCoordinator,
|
||||
type TwoPcCoordinator,
|
||||
type TwoPcCoordinatorDeps,
|
||||
} from "./coordinator.js";
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError, type TwoPcErrorCode } from "./error-codes.js";
|
||||
|
||||
describe("TWOPC_CODES", () => {
|
||||
it("exposes ten prefixed string codes", { timeout: 5000 }, () => {
|
||||
const values = Object.values(TWOPC_CODES);
|
||||
expect(values).toHaveLength(10);
|
||||
for (const v of values) {
|
||||
expect(v.startsWith("TWOPC.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes every required code identifier", { timeout: 5000 }, () => {
|
||||
expect(TWOPC_CODES.TIMEOUT).toBe("TWOPC.TIMEOUT");
|
||||
expect(TWOPC_CODES.VOTE_ABORT).toBe("TWOPC.VOTE_ABORT");
|
||||
expect(TWOPC_CODES.PREPARE_FAILED).toBe("TWOPC.PREPARE_FAILED");
|
||||
expect(TWOPC_CODES.COMMIT_FAILED).toBe("TWOPC.COMMIT_FAILED");
|
||||
expect(TWOPC_CODES.ROLLBACK_FAILED).toBe("TWOPC.ROLLBACK_FAILED");
|
||||
expect(TWOPC_CODES.IN_DOUBT).toBe("TWOPC.IN_DOUBT");
|
||||
expect(TWOPC_CODES.PARTICIPANT_NOT_PREPARABLE).toBe(
|
||||
"TWOPC.PARTICIPANT_NOT_PREPARABLE"
|
||||
);
|
||||
expect(TWOPC_CODES.READ_ONLY_PARTICIPANT_CANNOT_WRITE).toBe(
|
||||
"TWOPC.READ_ONLY_PARTICIPANT_CANNOT_WRITE"
|
||||
);
|
||||
expect(TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED).toBe(
|
||||
"TWOPC.OUTSTANDING_LIMIT_EXCEEDED"
|
||||
);
|
||||
expect(TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED).toBe(
|
||||
"TWOPC.TIMEOUT_CEILING_EXCEEDED"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TwoPcError", () => {
|
||||
it("carries the supplied code on both code and twoPcCode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new TwoPcError(TWOPC_CODES.TIMEOUT, "deadline");
|
||||
expect(err.code).toBe(TWOPC_CODES.TIMEOUT);
|
||||
expect(err.twoPcCode).toBe(TWOPC_CODES.TIMEOUT);
|
||||
expect(err.message).toBe("deadline");
|
||||
});
|
||||
|
||||
it("preserves the cause and context options", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new TwoPcError(TWOPC_CODES.IN_DOUBT, "partial commit", {
|
||||
cause,
|
||||
context: { txId: "tx-1" },
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ txId: "tx-1" });
|
||||
});
|
||||
|
||||
it("narrows the code type to TwoPcErrorCode", { timeout: 5000 }, () => {
|
||||
const err = new TwoPcError(TWOPC_CODES.VOTE_ABORT, "abort");
|
||||
expectTypeOf(err.twoPcCode).toEqualTypeOf<TwoPcErrorCode>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-2pc`. The ten codes
|
||||
* cover every production failure mode the coordinator surfaces:
|
||||
*
|
||||
* - `TWOPC_TIMEOUT` — the prepare-phase deadline elapsed before all
|
||||
* participants returned a vote. The coordinator rolls back every
|
||||
* participant that already voted commit.
|
||||
* - `TWOPC_VOTE_ABORT` — at least one participant voted abort; the
|
||||
* coordinator rolled back every commit-voter.
|
||||
* - `TWOPC_PREPARE_FAILED` — a participant raised during phase 1
|
||||
* (network drop, SQL error, capability check failure). Treated as an
|
||||
* implicit abort vote: rollback runs everywhere a prepare succeeded.
|
||||
* - `TWOPC_COMMIT_FAILED` — at least one `COMMIT PREPARED` raised. The
|
||||
* transaction is `in_doubt`; the recovery runner is the only path
|
||||
* that can resolve it.
|
||||
* - `TWOPC_ROLLBACK_FAILED` — at least one `ROLLBACK PREPARED` raised.
|
||||
* Recovery resolves it on next boot.
|
||||
* - `TWOPC_IN_DOUBT` — terminal state surfacing a partially-committed
|
||||
* transaction whose follow-up requires the recovery runner.
|
||||
* - `TWOPC_PARTICIPANT_NOT_PREPARABLE` — install-time refusal of a
|
||||
* write participant whose engine does not advertise `db.preparable`.
|
||||
* SQLite / SurrealDB SHALL hit this when registered as writers.
|
||||
* - `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE` — a read-only voter's
|
||||
* prepare callback returned a write-intent payload; the coordinator
|
||||
* refused the transaction before phase 2.
|
||||
* - `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` — admission control refused a
|
||||
* new `run2pc` call because the host has 256 prepared transactions
|
||||
* already in flight. The cap is operator-tunable but the default
|
||||
* ships at 256 per design doc §14.
|
||||
* - `TWOPC_TIMEOUT_CEILING_EXCEEDED` — caller-supplied `timeoutMs`
|
||||
* exceeded the 30 000 ms hard ceiling. The call is refused at the
|
||||
* boundary (no participant is contacted).
|
||||
*
|
||||
* Consumers branch on the string value:
|
||||
*
|
||||
* ```ts
|
||||
* if (error.code === TWOPC_CODES.TIMEOUT) { ... }
|
||||
* ```
|
||||
*/
|
||||
export const TWOPC_CODES = defineCodes("TWOPC", [
|
||||
"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",
|
||||
] as const);
|
||||
|
||||
export type TwoPcErrorCode = (typeof TWOPC_CODES)[keyof typeof TWOPC_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the `api-2pc` package. Extends
|
||||
* {@link SuaveplanError} so `instanceof TwoPcError` narrows
|
||||
* `code` to {@link TwoPcErrorCode} at the type level.
|
||||
*/
|
||||
export class TwoPcError extends SuaveplanError<TwoPcErrorCode> {
|
||||
public readonly twoPcCode: TwoPcErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: TwoPcErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.twoPcCode = code;
|
||||
}
|
||||
}
|
||||
5
packages/api-stack/api/api-2pc/src/error-codes/index.ts
Normal file
5
packages/api-stack/api/api-2pc/src/error-codes/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
TWOPC_CODES,
|
||||
TwoPcError,
|
||||
type TwoPcErrorCode,
|
||||
} from "./error-codes.js";
|
||||
68
packages/api-stack/api/api-2pc/src/index.ts
Normal file
68
packages/api-stack/api/api-2pc/src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export {
|
||||
createTwoPcCoordinator,
|
||||
type TwoPcCoordinator,
|
||||
type TwoPcCoordinatorDeps,
|
||||
} from "./coordinator/coordinator.js";
|
||||
export {
|
||||
TWOPC_CODES,
|
||||
TwoPcError,
|
||||
type TwoPcErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
createTwoPcInstruments,
|
||||
type TwoPcInstruments,
|
||||
type TwoPcInstrumentsConfig,
|
||||
type TwoPcOutcome,
|
||||
} from "./instruments/instruments.js";
|
||||
export {
|
||||
assertTimeoutBelowCeiling,
|
||||
OutstandingPreparedCap,
|
||||
} from "./limits/index.js";
|
||||
export {
|
||||
DEFAULT_OUTSTANDING_PREPARED_CAP,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
type ResolvedTwoPcCoordinatorOptions,
|
||||
resolveTwoPcCoordinatorOptions,
|
||||
TIMEOUT_CEILING_MS,
|
||||
type TwoPcCoordinatorOptions,
|
||||
twoPcCoordinatorOptionsSchema,
|
||||
} from "./options/options.js";
|
||||
export {
|
||||
type LedgerDb,
|
||||
type LedgerParticipantEntry,
|
||||
type LedgerStatement,
|
||||
type TwoPcLedgerRow,
|
||||
TwoPcLedgerStore,
|
||||
} from "./persistence/persistence.js";
|
||||
export {
|
||||
buildGid,
|
||||
type CommitPhaseInput,
|
||||
commitPhaseTwo,
|
||||
gidPrefixFor,
|
||||
isUnknownPreparedTransaction,
|
||||
type ParticipantPrepareOutcome,
|
||||
type PreparePhaseInput,
|
||||
parseGid,
|
||||
preparePhaseOne,
|
||||
type RollbackPhaseInput,
|
||||
rollbackPhaseTwo,
|
||||
} from "./phases/index.js";
|
||||
export {
|
||||
buildProbeFromRegistry,
|
||||
type RecoveryInput,
|
||||
type RecoveryProbe,
|
||||
type RecoveryReport,
|
||||
resolveRecovery,
|
||||
} from "./recovery/recovery.js";
|
||||
export {
|
||||
type PreparableCapabilityCheck,
|
||||
TwoPcParticipantRegistry,
|
||||
} from "./registry/participant-registry.js";
|
||||
export type {
|
||||
PreparableConnection,
|
||||
RegisteredTwoPcParticipant,
|
||||
Run2PcInput,
|
||||
Run2PcOutcome,
|
||||
TwoPcCoordinatorState,
|
||||
TwoPcPrepareResult,
|
||||
} from "./types/types.types.js";
|
||||
6
packages/api-stack/api/api-2pc/src/instruments/index.ts
Normal file
6
packages/api-stack/api/api-2pc/src/instruments/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createTwoPcInstruments,
|
||||
type TwoPcInstruments,
|
||||
type TwoPcInstrumentsConfig,
|
||||
type TwoPcOutcome,
|
||||
} from "./instruments.js";
|
||||
105
packages/api-stack/api/api-2pc/src/instruments/instruments.md
Normal file
105
packages/api-stack/api/api-2pc/src/instruments/instruments.md
Normal 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.
|
||||
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
BatchObservableCallback,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
ObservableCallback,
|
||||
ObservableGauge,
|
||||
Tracer,
|
||||
TracerProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createTwoPcInstruments } from "./instruments.js";
|
||||
|
||||
interface FakeInstrument {
|
||||
readonly kind: "counter" | "histogram" | "gauge";
|
||||
readonly name: string;
|
||||
readonly options: Readonly<Record<string, unknown>>;
|
||||
addCalls: Array<{
|
||||
value: number;
|
||||
attrs?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
recordCalls: Array<{
|
||||
value: number;
|
||||
attrs?: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
gaugeCallbacks: ObservableCallback[];
|
||||
}
|
||||
|
||||
interface CapturedMeter {
|
||||
readonly name: string;
|
||||
readonly instruments: FakeInstrument[];
|
||||
}
|
||||
|
||||
function buildFakeMeterProvider(): {
|
||||
provider: MeterProvider;
|
||||
meters: CapturedMeter[];
|
||||
} {
|
||||
const meters: CapturedMeter[] = [];
|
||||
const provider: MeterProvider = {
|
||||
getMeter(name: string): Meter {
|
||||
const captured: CapturedMeter = { name, instruments: [] };
|
||||
meters.push(captured);
|
||||
const meter: Meter = {
|
||||
createCounter(n, opts) {
|
||||
const inst: FakeInstrument = {
|
||||
kind: "counter",
|
||||
name: n,
|
||||
options: opts ?? {},
|
||||
addCalls: [],
|
||||
recordCalls: [],
|
||||
gaugeCallbacks: [],
|
||||
};
|
||||
captured.instruments.push(inst);
|
||||
return {
|
||||
add(v: number, a?: Readonly<Record<string, unknown>>) {
|
||||
inst.addCalls.push({ value: v, attrs: a });
|
||||
},
|
||||
} as ReturnType<Meter["createCounter"]>;
|
||||
},
|
||||
createHistogram(n, opts) {
|
||||
const inst: FakeInstrument = {
|
||||
kind: "histogram",
|
||||
name: n,
|
||||
options: opts ?? {},
|
||||
addCalls: [],
|
||||
recordCalls: [],
|
||||
gaugeCallbacks: [],
|
||||
};
|
||||
captured.instruments.push(inst);
|
||||
return {
|
||||
record(
|
||||
v: number,
|
||||
a?: Readonly<Record<string, unknown>>
|
||||
) {
|
||||
inst.recordCalls.push({ value: v, attrs: a });
|
||||
},
|
||||
} as ReturnType<Meter["createHistogram"]>;
|
||||
},
|
||||
createObservableGauge(n, opts) {
|
||||
const inst: FakeInstrument = {
|
||||
kind: "gauge",
|
||||
name: n,
|
||||
options: opts ?? {},
|
||||
addCalls: [],
|
||||
recordCalls: [],
|
||||
gaugeCallbacks: [],
|
||||
};
|
||||
captured.instruments.push(inst);
|
||||
const gauge: ObservableGauge = {
|
||||
addCallback(cb: ObservableCallback) {
|
||||
inst.gaugeCallbacks.push(cb);
|
||||
},
|
||||
removeCallback(_cb: ObservableCallback) {
|
||||
// no-op for tests
|
||||
},
|
||||
} as unknown as ObservableGauge;
|
||||
return gauge;
|
||||
},
|
||||
addBatchObservableCallback(_cb: BatchObservableCallback) {
|
||||
// no-op
|
||||
},
|
||||
removeBatchObservableCallback(_cb: BatchObservableCallback) {
|
||||
// no-op
|
||||
},
|
||||
} as unknown as Meter;
|
||||
return meter;
|
||||
},
|
||||
} as MeterProvider;
|
||||
return { provider, meters };
|
||||
}
|
||||
|
||||
function buildFakeTracerProvider(): TracerProvider {
|
||||
return {
|
||||
getTracer(_name: string): Tracer {
|
||||
return {
|
||||
startSpan() {
|
||||
return {
|
||||
setAttribute: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
recordException: () => undefined,
|
||||
addEvent: () => undefined,
|
||||
end: () => undefined,
|
||||
spanContext: () => ({
|
||||
traceId: "0",
|
||||
spanId: "0",
|
||||
traceFlags: 0,
|
||||
}),
|
||||
isRecording: () => false,
|
||||
updateName: () => undefined,
|
||||
};
|
||||
},
|
||||
startActiveSpan: undefined as never,
|
||||
} as unknown as Tracer;
|
||||
},
|
||||
} as TracerProvider;
|
||||
}
|
||||
|
||||
describe("createTwoPcInstruments", () => {
|
||||
it("creates every documented instrument on the package meter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { provider, meters } = buildFakeMeterProvider();
|
||||
const instruments = createTwoPcInstruments({
|
||||
meterProvider: provider,
|
||||
tracerProvider: buildFakeTracerProvider(),
|
||||
outstandingPreparedSnapshot: () => 7,
|
||||
});
|
||||
expect(meters[0]?.name).toBe("@suaveplan/api-2pc");
|
||||
const names = meters[0]?.instruments.map((i) => i.name);
|
||||
expect(names).toEqual([
|
||||
"2pc.transactions_total",
|
||||
"2pc.prepare_duration_ms",
|
||||
"2pc.commit_duration_ms",
|
||||
"2pc.outstanding_prepared",
|
||||
]);
|
||||
expect(instruments.transactionsTotal).toBeDefined();
|
||||
expect(instruments.prepareDurationMs).toBeDefined();
|
||||
expect(instruments.commitDurationMs).toBeDefined();
|
||||
expect(instruments.outstandingPreparedGauge).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses ms units on the duration histograms", { timeout: 5000 }, () => {
|
||||
const { provider, meters } = buildFakeMeterProvider();
|
||||
createTwoPcInstruments({
|
||||
meterProvider: provider,
|
||||
tracerProvider: buildFakeTracerProvider(),
|
||||
outstandingPreparedSnapshot: () => 0,
|
||||
});
|
||||
const histograms = meters[0]?.instruments.filter(
|
||||
(i) => i.kind === "histogram"
|
||||
);
|
||||
const byName = new Map(histograms?.map((h) => [h.name, h]));
|
||||
expect(byName.get("2pc.prepare_duration_ms")?.options).toEqual({
|
||||
unit: "ms",
|
||||
});
|
||||
expect(byName.get("2pc.commit_duration_ms")?.options).toEqual({
|
||||
unit: "ms",
|
||||
});
|
||||
});
|
||||
|
||||
it("the observable gauge callback reports the snapshot value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { provider, meters } = buildFakeMeterProvider();
|
||||
let snapshot = 3;
|
||||
createTwoPcInstruments({
|
||||
meterProvider: provider,
|
||||
tracerProvider: buildFakeTracerProvider(),
|
||||
outstandingPreparedSnapshot: () => snapshot,
|
||||
});
|
||||
const gauge = meters[0]?.instruments.find((i) => i.kind === "gauge");
|
||||
const observations: number[] = [];
|
||||
const observer = {
|
||||
observe(value: number) {
|
||||
observations.push(value);
|
||||
},
|
||||
};
|
||||
for (const cb of gauge?.gaugeCallbacks ?? []) {
|
||||
cb(observer as Parameters<ObservableCallback>[0]);
|
||||
}
|
||||
snapshot = 9;
|
||||
for (const cb of gauge?.gaugeCallbacks ?? []) {
|
||||
cb(observer as Parameters<ObservableCallback>[0]);
|
||||
}
|
||||
expect(observations).toEqual([3, 9]);
|
||||
});
|
||||
|
||||
it("falls back to OTel globals when no provider supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// The OTel API returns a no-op provider when no SDK is installed;
|
||||
// this exercises the default-branch wiring.
|
||||
const instruments = createTwoPcInstruments({
|
||||
outstandingPreparedSnapshot: () => 0,
|
||||
});
|
||||
expect(instruments.transactionsTotal).toBeDefined();
|
||||
expect(instruments.tracer).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
ObservableGauge,
|
||||
Tracer,
|
||||
TracerProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
|
||||
/** Outcome attribute attached to every counter/histogram observation. */
|
||||
export type TwoPcOutcome = "committed" | "aborted" | "in_doubt";
|
||||
|
||||
/**
|
||||
* Config consumed by {@link createTwoPcInstruments}. The two
|
||||
* providers default to the OTel globals (which fall back to no-op
|
||||
* implementations when no SDK is installed) so the coordinator works
|
||||
* in test envs without ceremony.
|
||||
*/
|
||||
export interface TwoPcInstrumentsConfig {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
readonly tracerProvider?: TracerProvider;
|
||||
/** Callback returning the current outstanding-prepared count for the gauge. */
|
||||
readonly outstandingPreparedSnapshot: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen bundle of every OTel instrument the coordinator emits. The
|
||||
* names follow design doc §9.2:
|
||||
*
|
||||
* - `2pc.transaction` — span per transaction lifetime.
|
||||
* - `2pc.prepare` / `2pc.commit` / `2pc.rollback` — spans per
|
||||
* participant phase.
|
||||
* - `2pc.transactions_total` — counter labelled `{ outcome }`.
|
||||
* - `2pc.prepare_duration_ms` / `2pc.commit_duration_ms` — histograms
|
||||
* labelled `{ outcome }`.
|
||||
* - `2pc.outstanding_prepared` — observable gauge.
|
||||
*/
|
||||
export interface TwoPcInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly tracer: Tracer;
|
||||
readonly transactionsTotal: Counter;
|
||||
readonly prepareDurationMs: Histogram;
|
||||
readonly commitDurationMs: Histogram;
|
||||
readonly outstandingPreparedGauge: ObservableGauge;
|
||||
}
|
||||
|
||||
const METER_NAME = "@suaveplan/api-2pc";
|
||||
|
||||
/**
|
||||
* Build the coordinator's instrument registry. Returns a frozen bundle
|
||||
* — every counter / histogram / gauge is constructed once so the
|
||||
* first measurement has zero allocation cost.
|
||||
*
|
||||
* The observable gauge is wired to `config.outstandingPreparedSnapshot`
|
||||
* at construction time. Operators who want a live admission-cap
|
||||
* dashboard register their SDK once at process boot and the
|
||||
* coordinator picks it up via the global provider.
|
||||
*/
|
||||
export function createTwoPcInstruments(
|
||||
config: TwoPcInstrumentsConfig
|
||||
): TwoPcInstruments {
|
||||
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
|
||||
const tracerProvider = config.tracerProvider ?? trace.getTracerProvider();
|
||||
const meter = meterProvider.getMeter(METER_NAME);
|
||||
const tracer = tracerProvider.getTracer(METER_NAME);
|
||||
const transactionsTotal = meter.createCounter("2pc.transactions_total");
|
||||
const prepareDurationMs = meter.createHistogram("2pc.prepare_duration_ms", {
|
||||
unit: "ms",
|
||||
});
|
||||
const commitDurationMs = meter.createHistogram("2pc.commit_duration_ms", {
|
||||
unit: "ms",
|
||||
});
|
||||
const outstandingPreparedGauge = meter.createObservableGauge(
|
||||
"2pc.outstanding_prepared"
|
||||
);
|
||||
outstandingPreparedGauge.addCallback((observer) => {
|
||||
observer.observe(config.outstandingPreparedSnapshot());
|
||||
});
|
||||
return Object.freeze({
|
||||
meter,
|
||||
tracer,
|
||||
transactionsTotal,
|
||||
prepareDurationMs,
|
||||
commitDurationMs,
|
||||
outstandingPreparedGauge,
|
||||
});
|
||||
}
|
||||
2
packages/api-stack/api/api-2pc/src/limits/index.ts
Normal file
2
packages/api-stack/api/api-2pc/src/limits/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { OutstandingPreparedCap } from "./outstanding-cap.js";
|
||||
export { assertTimeoutBelowCeiling } from "./timeout-ceiling.js";
|
||||
104
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.md
Normal file
104
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.md
Normal 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { OutstandingPreparedCap } from "./outstanding-cap.js";
|
||||
|
||||
describe("OutstandingPreparedCap", () => {
|
||||
it("admits up to the configured cap", { timeout: 5000 }, () => {
|
||||
const cap = new OutstandingPreparedCap(3);
|
||||
cap.admit();
|
||||
cap.admit();
|
||||
cap.admit();
|
||||
expect(cap.snapshot()).toBe(3);
|
||||
expect(cap.capacity()).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects the (cap+1)th admission with TWOPC.OUTSTANDING_LIMIT_EXCEEDED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cap = new OutstandingPreparedCap(2);
|
||||
cap.admit();
|
||||
cap.admit();
|
||||
try {
|
||||
cap.admit();
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers as transactions release slots", { timeout: 5000 }, () => {
|
||||
const cap = new OutstandingPreparedCap(1);
|
||||
cap.admit();
|
||||
expect(() => cap.admit()).toThrow();
|
||||
cap.release();
|
||||
cap.admit();
|
||||
expect(cap.snapshot()).toBe(1);
|
||||
});
|
||||
|
||||
it("release below zero clamps at zero (defends against double-release)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cap = new OutstandingPreparedCap(2);
|
||||
cap.release();
|
||||
cap.release();
|
||||
expect(cap.snapshot()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
])("constructor rejects non-positive-integer cap %p", (bad) => {
|
||||
expect(() => new OutstandingPreparedCap(bad as number)).toThrow();
|
||||
});
|
||||
});
|
||||
64
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.ts
Normal file
64
packages/api-stack/api/api-2pc/src/limits/outstanding-cap.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Admission counter for outstanding prepared transactions. Tracks how
|
||||
* many `run2pc` calls are between `admit()` and `release()`; new calls
|
||||
* past the cap raise `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` until an
|
||||
* in-flight transaction releases a slot.
|
||||
*
|
||||
* The implementation is single-threaded — `admit` and `release` are
|
||||
* synchronous balanced calls. Bun's event loop guarantees the call
|
||||
* pair is atomic relative to other tasks, so no mutex is required.
|
||||
*/
|
||||
export class OutstandingPreparedCap {
|
||||
private outstanding = 0;
|
||||
|
||||
public constructor(private readonly cap: number) {
|
||||
if (!Number.isInteger(cap) || cap <= 0) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED,
|
||||
`outstandingPreparedCap must be a positive integer; got ${cap}`,
|
||||
{ context: { cap } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve a slot. Throws
|
||||
* `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` when the cap is already at
|
||||
* capacity. On success the caller MUST eventually invoke
|
||||
* `release()` in a `finally` block.
|
||||
*/
|
||||
public admit(): void {
|
||||
if (this.outstanding >= this.cap) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.OUTSTANDING_LIMIT_EXCEEDED,
|
||||
`Outstanding prepared transactions at cap ${this.cap}`,
|
||||
{ context: { cap: this.cap, outstanding: this.outstanding } }
|
||||
);
|
||||
}
|
||||
this.outstanding += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the slot reserved by a matching {@link admit}. Idempotent
|
||||
* against arithmetic underflow — repeated calls clamp at zero so
|
||||
* a buggy double-release in caller code does not allow the next
|
||||
* `admit` to silently exceed the real cap.
|
||||
*/
|
||||
public release(): void {
|
||||
if (this.outstanding > 0) {
|
||||
this.outstanding -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current outstanding count. Snapshot read for the OTel gauge. */
|
||||
public snapshot(): number {
|
||||
return this.outstanding;
|
||||
}
|
||||
|
||||
/** Configured cap. Diagnostics only. */
|
||||
public capacity(): number {
|
||||
return this.cap;
|
||||
}
|
||||
}
|
||||
77
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.md
Normal file
77
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.md
Normal 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.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { DEFAULT_TIMEOUT_MS, TIMEOUT_CEILING_MS } from "../options/options.js";
|
||||
import { assertTimeoutBelowCeiling } from "./timeout-ceiling.js";
|
||||
|
||||
function expectCeilingError(fn: () => void): TwoPcError {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
const e = err as TwoPcError;
|
||||
expect(e.twoPcCode).toBe(TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED);
|
||||
return e;
|
||||
}
|
||||
throw new Error("expected TWOPC_TIMEOUT_CEILING_EXCEEDED");
|
||||
}
|
||||
|
||||
describe("assertTimeoutBelowCeiling", () => {
|
||||
it("accepts the default timeoutMs (5000)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
assertTimeoutBelowCeiling(DEFAULT_TIMEOUT_MS)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a value equal to the ceiling", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
assertTimeoutBelowCeiling(TIMEOUT_CEILING_MS)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects timeoutMs above the ceiling", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() =>
|
||||
assertTimeoutBelowCeiling(TIMEOUT_CEILING_MS + 1)
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects zero", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() => assertTimeoutBelowCeiling(0));
|
||||
});
|
||||
|
||||
it("rejects negative values", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() => assertTimeoutBelowCeiling(-1));
|
||||
});
|
||||
|
||||
it("rejects NaN", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() => assertTimeoutBelowCeiling(Number.NaN));
|
||||
});
|
||||
|
||||
it("rejects Infinity", { timeout: 5000 }, () => {
|
||||
expectCeilingError(() =>
|
||||
assertTimeoutBelowCeiling(Number.POSITIVE_INFINITY)
|
||||
);
|
||||
});
|
||||
});
|
||||
29
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.ts
Normal file
29
packages/api-stack/api/api-2pc/src/limits/timeout-ceiling.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { TIMEOUT_CEILING_MS } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Validate a caller-supplied `timeoutMs`. Per spec the coordinator
|
||||
* MUST refuse values above the hard ceiling (default 30 000 ms) at
|
||||
* call time — silently clamping would hide the misuse and let
|
||||
* long-held prepares regress production throughput.
|
||||
*
|
||||
* Negative or non-finite values are also refused — they would
|
||||
* collapse the deadline to "immediate" which is operationally
|
||||
* indistinguishable from a flaky participant.
|
||||
*/
|
||||
export function assertTimeoutBelowCeiling(timeoutMs: number): void {
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED,
|
||||
`timeoutMs must be a positive finite number; got ${timeoutMs}`,
|
||||
{ context: { timeoutMs } }
|
||||
);
|
||||
}
|
||||
if (timeoutMs > TIMEOUT_CEILING_MS) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT_CEILING_EXCEEDED,
|
||||
`timeoutMs ${timeoutMs} exceeds hard ceiling ${TIMEOUT_CEILING_MS}`,
|
||||
{ context: { timeoutMs, ceiling: TIMEOUT_CEILING_MS } }
|
||||
);
|
||||
}
|
||||
}
|
||||
9
packages/api-stack/api/api-2pc/src/options/index.ts
Normal file
9
packages/api-stack/api/api-2pc/src/options/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
DEFAULT_OUTSTANDING_PREPARED_CAP,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
type ResolvedTwoPcCoordinatorOptions,
|
||||
resolveTwoPcCoordinatorOptions,
|
||||
TIMEOUT_CEILING_MS,
|
||||
type TwoPcCoordinatorOptions,
|
||||
twoPcCoordinatorOptionsSchema,
|
||||
} from "./options.js";
|
||||
96
packages/api-stack/api/api-2pc/src/options/options.md
Normal file
96
packages/api-stack/api/api-2pc/src/options/options.md
Normal 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.
|
||||
70
packages/api-stack/api/api-2pc/src/options/options.test.ts
Normal file
70
packages/api-stack/api/api-2pc/src/options/options.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
DEFAULT_OUTSTANDING_PREPARED_CAP,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
resolveTwoPcCoordinatorOptions,
|
||||
TIMEOUT_CEILING_MS,
|
||||
twoPcCoordinatorOptionsSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("resolveTwoPcCoordinatorOptions", () => {
|
||||
it("applies all defaults when only required fields supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const resolved = resolveTwoPcCoordinatorOptions({
|
||||
hostInstanceId: "host-1",
|
||||
});
|
||||
expect(resolved.hostInstanceId).toBe("host-1");
|
||||
expect(resolved.defaultTimeoutMs).toBe(DEFAULT_TIMEOUT_MS);
|
||||
expect(resolved.outstandingPreparedCap).toBe(
|
||||
DEFAULT_OUTSTANDING_PREPARED_CAP
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit overrides", { timeout: 5000 }, () => {
|
||||
const resolved = resolveTwoPcCoordinatorOptions({
|
||||
hostInstanceId: "h",
|
||||
defaultTimeoutMs: 12345,
|
||||
outstandingPreparedCap: 4,
|
||||
});
|
||||
expect(resolved.defaultTimeoutMs).toBe(12345);
|
||||
expect(resolved.outstandingPreparedCap).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects defaultTimeoutMs above the ceiling at the schema boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
resolveTwoPcCoordinatorOptions({
|
||||
hostInstanceId: "h",
|
||||
defaultTimeoutMs: TIMEOUT_CEILING_MS + 1,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty hostInstanceId", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
resolveTwoPcCoordinatorOptions({ hostInstanceId: "" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict schema)", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
twoPcCoordinatorOptionsSchema.parse({
|
||||
hostInstanceId: "h",
|
||||
bogus: 1,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-integer outstandingPreparedCap", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
resolveTwoPcCoordinatorOptions({
|
||||
hostInstanceId: "h",
|
||||
outstandingPreparedCap: 1.5,
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
84
packages/api-stack/api/api-2pc/src/options/options.ts
Normal file
84
packages/api-stack/api/api-2pc/src/options/options.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Default phase-1 deadline. Design doc §9.2 anchors at 5 000 ms — long
|
||||
* enough for a real cross-database prepare, short enough that
|
||||
* row-level locks held by `PREPARE TRANSACTION` do not pile up.
|
||||
*/
|
||||
export const DEFAULT_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Hard ceiling on `timeoutMs`. Calls above this are refused at the
|
||||
* boundary rather than silently clamped — operators want to see the
|
||||
* misuse loudly because long prepares hold row-level locks.
|
||||
*/
|
||||
export const TIMEOUT_CEILING_MS = 30000;
|
||||
|
||||
/**
|
||||
* Default cap on outstanding prepared transactions per coordinator.
|
||||
* Design doc §14 left this open; this is the spec'd default and is
|
||||
* operator-tunable. New `run2pc` calls past the cap fail with
|
||||
* `TWOPC_OUTSTANDING_LIMIT_EXCEEDED` until in-flight transactions
|
||||
* complete.
|
||||
*/
|
||||
export const DEFAULT_OUTSTANDING_PREPARED_CAP = 256;
|
||||
|
||||
/**
|
||||
* Caller-tunable options handed to `createTwoPcCoordinator`. Every
|
||||
* field is optional; defaults are applied by `resolveOptions`.
|
||||
*/
|
||||
export interface TwoPcCoordinatorOptions {
|
||||
/** Stable host id pre-tagged on every prepared transaction's gid. */
|
||||
readonly hostInstanceId: string;
|
||||
/**
|
||||
* Override of `DEFAULT_OUTSTANDING_PREPARED_CAP`. Must be `>= 1`.
|
||||
*/
|
||||
readonly outstandingPreparedCap?: number;
|
||||
/**
|
||||
* Override of `DEFAULT_TIMEOUT_MS`. Must satisfy
|
||||
* `1 <= value <= TIMEOUT_CEILING_MS`.
|
||||
*/
|
||||
readonly defaultTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Strict-shape Zod schema for {@link TwoPcCoordinatorOptions}. */
|
||||
export const twoPcCoordinatorOptionsSchema = z
|
||||
.object({
|
||||
hostInstanceId: z.string().min(1),
|
||||
outstandingPreparedCap: z.number().int().positive().optional(),
|
||||
defaultTimeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(TIMEOUT_CEILING_MS)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Resolved option shape — every field present, defaults applied. The
|
||||
* coordinator only ever reads this shape; the optional fields exist on
|
||||
* the input shape only.
|
||||
*/
|
||||
export interface ResolvedTwoPcCoordinatorOptions {
|
||||
readonly hostInstanceId: string;
|
||||
readonly outstandingPreparedCap: number;
|
||||
readonly defaultTimeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply defaults and Zod validation. Throws the raw `z.ZodError` so
|
||||
* callers can repackage it into a domain error if they prefer; the
|
||||
* coordinator boundary wraps this in a `TwoPcError` itself.
|
||||
*/
|
||||
export function resolveTwoPcCoordinatorOptions(
|
||||
raw: TwoPcCoordinatorOptions
|
||||
): ResolvedTwoPcCoordinatorOptions {
|
||||
const parsed = twoPcCoordinatorOptionsSchema.parse(raw);
|
||||
return {
|
||||
hostInstanceId: parsed.hostInstanceId,
|
||||
outstandingPreparedCap:
|
||||
parsed.outstandingPreparedCap ?? DEFAULT_OUTSTANDING_PREPARED_CAP,
|
||||
defaultTimeoutMs: parsed.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
7
packages/api-stack/api/api-2pc/src/persistence/index.ts
Normal file
7
packages/api-stack/api/api-2pc/src/persistence/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type LedgerDb,
|
||||
type LedgerParticipantEntry,
|
||||
type LedgerStatement,
|
||||
type TwoPcLedgerRow,
|
||||
TwoPcLedgerStore,
|
||||
} from "./persistence.js";
|
||||
114
packages/api-stack/api/api-2pc/src/persistence/persistence.md
Normal file
114
packages/api-stack/api/api-2pc/src/persistence/persistence.md
Normal 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.
|
||||
@@ -0,0 +1,174 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type LedgerDb,
|
||||
type LedgerParticipantEntry,
|
||||
TwoPcLedgerStore,
|
||||
} from "./persistence.js";
|
||||
|
||||
function makeDb(): LedgerDb {
|
||||
const db = new Database(":memory:") as unknown as LedgerDb;
|
||||
db.run(`CREATE TABLE IF NOT EXISTS suaveplan_2pc_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
coordinator TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
participants_json TEXT NOT NULL,
|
||||
prepared_at TEXT,
|
||||
committed_at TEXT,
|
||||
aborted_at TEXT
|
||||
)`);
|
||||
return db;
|
||||
}
|
||||
|
||||
const SAMPLE_PARTICIPANTS: ReadonlyArray<LedgerParticipantEntry> = [
|
||||
{ participantId: "billing", voterMode: "writer", vote: null },
|
||||
{ participantId: "inventory", voterMode: "writer", vote: null },
|
||||
];
|
||||
|
||||
describe("TwoPcLedgerStore", () => {
|
||||
it("inserts a new row in voting state", { timeout: 5000 }, () => {
|
||||
const store = new TwoPcLedgerStore(makeDb());
|
||||
store.insert({
|
||||
id: "tx-1",
|
||||
coordinator: "host-1",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
const row = store.findById("tx-1");
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.state).toBe("voting");
|
||||
expect(row?.coordinator).toBe("host-1");
|
||||
expect(row?.preparedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the row committing once all participants voted commit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = new TwoPcLedgerStore(makeDb());
|
||||
store.insert({
|
||||
id: "tx-2",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.markAllVotedCommit({
|
||||
id: "tx-2",
|
||||
participants: SAMPLE_PARTICIPANTS.map((p) => ({
|
||||
...p,
|
||||
vote: "commit",
|
||||
})),
|
||||
preparedAt: "2026-05-26T00:00:00.000Z",
|
||||
});
|
||||
const row = store.findById("tx-2");
|
||||
expect(row?.state).toBe("committing");
|
||||
expect(row?.preparedAt).toBe("2026-05-26T00:00:00.000Z");
|
||||
const decoded = TwoPcLedgerStore.decodeParticipants(
|
||||
row as NonNullable<typeof row>
|
||||
);
|
||||
expect(decoded[0]?.vote).toBe("commit");
|
||||
});
|
||||
|
||||
it("transitions through aborting then committed and aborted final states", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = new TwoPcLedgerStore(makeDb());
|
||||
store.insert({
|
||||
id: "tx-3",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.markAborting({ id: "tx-3", participants: SAMPLE_PARTICIPANTS });
|
||||
expect(store.findById("tx-3")?.state).toBe("aborting");
|
||||
|
||||
store.markCommitted("tx-3", "2026-05-26T00:01:00.000Z");
|
||||
expect(store.findById("tx-3")?.committedAt).toBe(
|
||||
"2026-05-26T00:01:00.000Z"
|
||||
);
|
||||
|
||||
store.markAborted("tx-3", "2026-05-26T00:02:00.000Z");
|
||||
expect(store.findById("tx-3")?.abortedAt).toBe(
|
||||
"2026-05-26T00:02:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("marks rows in-doubt", { timeout: 5000 }, () => {
|
||||
const store = new TwoPcLedgerStore(makeDb());
|
||||
store.insert({
|
||||
id: "tx-4",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.markInDoubt("tx-4");
|
||||
expect(store.findById("tx-4")?.state).toBe("in_doubt");
|
||||
});
|
||||
|
||||
it("findById returns null for missing rows", { timeout: 5000 }, () => {
|
||||
const store = new TwoPcLedgerStore(makeDb());
|
||||
expect(store.findById("never")).toBeNull();
|
||||
});
|
||||
|
||||
it("findRecoverable returns only non-terminal rows for matching coordinator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const store = new TwoPcLedgerStore(makeDb());
|
||||
store.insert({
|
||||
id: "a",
|
||||
coordinator: "h1",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.insert({
|
||||
id: "b",
|
||||
coordinator: "h1",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.markCommitted("b", "2026-05-26T00:00:00.000Z");
|
||||
store.insert({
|
||||
id: "c",
|
||||
coordinator: "h2",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.insert({
|
||||
id: "d",
|
||||
coordinator: "h1",
|
||||
participants: SAMPLE_PARTICIPANTS,
|
||||
});
|
||||
store.markInDoubt("d");
|
||||
|
||||
const recoverable = store.findRecoverable("h1");
|
||||
const ids = recoverable.map((r) => r.id).sort();
|
||||
expect(ids).toEqual(["a", "d"]);
|
||||
});
|
||||
|
||||
it("decodeParticipants returns an empty array on non-array JSON", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const decoded = TwoPcLedgerStore.decodeParticipants({
|
||||
id: "x",
|
||||
coordinator: "h",
|
||||
state: "voting",
|
||||
participantsJson: '{"not":"array"}',
|
||||
preparedAt: null,
|
||||
committedAt: null,
|
||||
abortedAt: null,
|
||||
});
|
||||
expect(decoded).toEqual([]);
|
||||
});
|
||||
|
||||
it("decodeParticipants round-trips real entries", { timeout: 5000 }, () => {
|
||||
const decoded = TwoPcLedgerStore.decodeParticipants({
|
||||
id: "x",
|
||||
coordinator: "h",
|
||||
state: "committing",
|
||||
participantsJson: JSON.stringify([
|
||||
{
|
||||
participantId: "p",
|
||||
voterMode: "writer",
|
||||
vote: "commit",
|
||||
},
|
||||
]),
|
||||
preparedAt: null,
|
||||
committedAt: null,
|
||||
abortedAt: null,
|
||||
});
|
||||
expect(decoded).toHaveLength(1);
|
||||
expect(decoded[0]?.vote).toBe("commit");
|
||||
});
|
||||
});
|
||||
228
packages/api-stack/api/api-2pc/src/persistence/persistence.ts
Normal file
228
packages/api-stack/api/api-2pc/src/persistence/persistence.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import type { TwoPcVote } from "@suaveplan/api-types/twopc";
|
||||
import type { TwoPcCoordinatorState } from "../types/types.types.js";
|
||||
|
||||
/**
|
||||
* Structural view of a SQLite-shaped statement returned by
|
||||
* `LedgerDb.prepare(sql)`. Matches `bun:sqlite`'s `Statement` surface
|
||||
* narrowly: only the methods this module needs are exposed.
|
||||
*/
|
||||
export interface LedgerStatement {
|
||||
run(...params: readonly unknown[]): { changes: number } | unknown;
|
||||
all<T = Record<string, unknown>>(
|
||||
...params: readonly unknown[]
|
||||
): readonly T[];
|
||||
get<T = Record<string, unknown>>(
|
||||
...params: readonly unknown[]
|
||||
): T | undefined | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural SQLite-like database handle. Compatible with `bun:sqlite`,
|
||||
* `@suaveplan/db-sqlite`'s thin wrapper, and `better-sqlite3`. The
|
||||
* coordinator only ever sees this interface — concrete drivers stay
|
||||
* outside the package boundary.
|
||||
*/
|
||||
export interface LedgerDb {
|
||||
prepare(sql: string): LedgerStatement;
|
||||
/** Execute a single DDL or DML statement with no parameters. */
|
||||
run(sql: string): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of `suaveplan_2pc_transactions`. The schema is owned by
|
||||
* `@suaveplan/api-host` (see `plugin-ledger/migrations.ts` migration
|
||||
* id 2, `saga-2pc-reserved`). This row shape mirrors the DDL fields
|
||||
* verbatim — keep them in lockstep when the host schema evolves.
|
||||
*/
|
||||
export interface TwoPcLedgerRow {
|
||||
readonly id: string;
|
||||
readonly coordinator: string;
|
||||
readonly state: TwoPcCoordinatorState;
|
||||
readonly participantsJson: string;
|
||||
readonly preparedAt: string | null;
|
||||
readonly committedAt: string | null;
|
||||
readonly abortedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-participant entry inside the `participants_json` blob. Captures
|
||||
* the vote and the voter mode so recovery can re-derive whether to
|
||||
* send commit/rollback SQL on boot.
|
||||
*/
|
||||
export interface LedgerParticipantEntry {
|
||||
readonly participantId: string;
|
||||
readonly voterMode: "writer" | "read-only";
|
||||
readonly vote: TwoPcVote | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence facade over `suaveplan_2pc_transactions`. The schema is
|
||||
* created by `api-host`'s migration runner; this module only reads and
|
||||
* writes rows.
|
||||
*/
|
||||
export class TwoPcLedgerStore {
|
||||
public constructor(private readonly db: LedgerDb) {}
|
||||
|
||||
/** Insert a new row in the `voting` state. */
|
||||
public insert(input: {
|
||||
id: string;
|
||||
coordinator: string;
|
||||
participants: ReadonlyArray<LedgerParticipantEntry>;
|
||||
}): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO suaveplan_2pc_transactions
|
||||
(id, coordinator, state, participants_json, prepared_at, committed_at, aborted_at)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, NULL)`
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
input.coordinator,
|
||||
"voting" satisfies TwoPcCoordinatorState,
|
||||
JSON.stringify(input.participants)
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark the row as `committing` and stamp `prepared_at`. */
|
||||
public markAllVotedCommit(input: {
|
||||
id: string;
|
||||
participants: ReadonlyArray<LedgerParticipantEntry>;
|
||||
preparedAt: string;
|
||||
}): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE suaveplan_2pc_transactions
|
||||
SET state = ?, participants_json = ?, prepared_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
"committing" satisfies TwoPcCoordinatorState,
|
||||
JSON.stringify(input.participants),
|
||||
input.preparedAt,
|
||||
input.id
|
||||
);
|
||||
}
|
||||
|
||||
/** Mark the row as `aborting` after a vote_abort or timeout. */
|
||||
public markAborting(input: {
|
||||
id: string;
|
||||
participants: ReadonlyArray<LedgerParticipantEntry>;
|
||||
}): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE suaveplan_2pc_transactions
|
||||
SET state = ?, participants_json = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
"aborting" satisfies TwoPcCoordinatorState,
|
||||
JSON.stringify(input.participants),
|
||||
input.id
|
||||
);
|
||||
}
|
||||
|
||||
/** Final-state stamp once every participant acknowledged commit. */
|
||||
public markCommitted(id: string, committedAt: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE suaveplan_2pc_transactions
|
||||
SET state = ?, committed_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run("committed" satisfies TwoPcCoordinatorState, committedAt, id);
|
||||
}
|
||||
|
||||
/** Final-state stamp once every participant acknowledged rollback. */
|
||||
public markAborted(id: string, abortedAt: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE suaveplan_2pc_transactions
|
||||
SET state = ?, aborted_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run("aborted" satisfies TwoPcCoordinatorState, abortedAt, id);
|
||||
}
|
||||
|
||||
/** Mark a transaction as in-doubt (commit-prepared / rollback-prepared SQL failed). */
|
||||
public markInDoubt(id: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE suaveplan_2pc_transactions
|
||||
SET state = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run("in_doubt" satisfies TwoPcCoordinatorState, id);
|
||||
}
|
||||
|
||||
/** Lookup a single row by id. Returns `null` when none exists. */
|
||||
public findById(id: string): TwoPcLedgerRow | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, coordinator, state, participants_json,
|
||||
prepared_at, committed_at, aborted_at
|
||||
FROM suaveplan_2pc_transactions WHERE id = ?`
|
||||
)
|
||||
.get<{
|
||||
id: string;
|
||||
coordinator: string;
|
||||
state: TwoPcCoordinatorState;
|
||||
participants_json: string;
|
||||
prepared_at: string | null;
|
||||
committed_at: string | null;
|
||||
aborted_at: string | null;
|
||||
}>(id);
|
||||
if (row === null || row === undefined) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
coordinator: row.coordinator,
|
||||
state: row.state,
|
||||
participantsJson: row.participants_json,
|
||||
preparedAt: row.prepared_at,
|
||||
committedAt: row.committed_at,
|
||||
abortedAt: row.aborted_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery scan — every row whose state is non-terminal AND whose
|
||||
* coordinator matches the provided host instance id. These rows
|
||||
* are the candidates the recovery runner must resolve on boot.
|
||||
*/
|
||||
public findRecoverable(coordinator: string): readonly TwoPcLedgerRow[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, coordinator, state, participants_json,
|
||||
prepared_at, committed_at, aborted_at
|
||||
FROM suaveplan_2pc_transactions
|
||||
WHERE coordinator = ?
|
||||
AND state IN ('voting','committing','aborting','in_doubt')`
|
||||
)
|
||||
.all<{
|
||||
id: string;
|
||||
coordinator: string;
|
||||
state: TwoPcCoordinatorState;
|
||||
participants_json: string;
|
||||
prepared_at: string | null;
|
||||
committed_at: string | null;
|
||||
aborted_at: string | null;
|
||||
}>(coordinator);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
coordinator: r.coordinator,
|
||||
state: r.state,
|
||||
participantsJson: r.participants_json,
|
||||
preparedAt: r.prepared_at,
|
||||
committedAt: r.committed_at,
|
||||
abortedAt: r.aborted_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Decode the `participants_json` blob back into an array. */
|
||||
public static decodeParticipants(
|
||||
row: TwoPcLedgerRow
|
||||
): ReadonlyArray<LedgerParticipantEntry> {
|
||||
const parsed = JSON.parse(row.participantsJson) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as ReadonlyArray<LedgerParticipantEntry>;
|
||||
}
|
||||
}
|
||||
94
packages/api-stack/api/api-2pc/src/phases/commit.md
Normal file
94
packages/api-stack/api/api-2pc/src/phases/commit.md
Normal 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.
|
||||
98
packages/api-stack/api/api-2pc/src/phases/commit.test.ts
Normal file
98
packages/api-stack/api/api-2pc/src/phases/commit.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { createTwoPcInstruments } from "../instruments/instruments.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import { commitPhaseTwo } from "./commit.js";
|
||||
import type { ParticipantPrepareOutcome } from "./prepare.js";
|
||||
|
||||
const INSTRUMENTS = createTwoPcInstruments({
|
||||
outstandingPreparedSnapshot: () => 0,
|
||||
});
|
||||
|
||||
function makeParticipant(opts: { fail?: Error } = {}) {
|
||||
const calls: string[] = [];
|
||||
const p: RegisteredTwoPcParticipant = {
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async (sql: string) => {
|
||||
calls.push(sql);
|
||||
if (opts.fail) throw opts.fail;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
};
|
||||
return { participant: p, calls };
|
||||
}
|
||||
|
||||
const PREPARED_OUTCOME: ParticipantPrepareOutcome = {
|
||||
participantId: "billing",
|
||||
vote: "commit",
|
||||
prepared: true,
|
||||
gid: "2pc-h-t-billing",
|
||||
};
|
||||
|
||||
describe("commitPhaseTwo", () => {
|
||||
it("issues COMMIT PREPARED against a prepared writer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { participant, calls } = makeParticipant();
|
||||
await commitPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: PREPARED_OUTCOME,
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
expect(calls[0]).toBe("COMMIT PREPARED '2pc-h-t-billing'");
|
||||
});
|
||||
|
||||
it("is a no-op when outcome.prepared === false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { participant, calls } = makeParticipant();
|
||||
await commitPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: { ...PREPARED_OUTCOME, prepared: false, gid: "" },
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("wraps a SQL throw into TWOPC.COMMIT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("commit failed");
|
||||
const { participant } = makeParticipant({ fail: cause });
|
||||
try {
|
||||
await commitPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: PREPARED_OUTCOME,
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.COMMIT_FAILED
|
||||
);
|
||||
expect((err as TwoPcError).cause).toBe(cause);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes single quotes inside the gid before interpolation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { participant, calls } = makeParticipant();
|
||||
await commitPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: { ...PREPARED_OUTCOME, gid: "a'b" },
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
expect(calls[0]).toBe("COMMIT PREPARED 'a''b'");
|
||||
});
|
||||
});
|
||||
66
packages/api-stack/api/api-2pc/src/phases/commit.ts
Normal file
66
packages/api-stack/api/api-2pc/src/phases/commit.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type { TwoPcInstruments } from "../instruments/instruments.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import type { ParticipantPrepareOutcome } from "./prepare.js";
|
||||
|
||||
/**
|
||||
* Input to {@link commitPhaseTwo}. The participant entry is consulted
|
||||
* for the connection; the outcome carries the gid.
|
||||
*/
|
||||
export interface CommitPhaseInput {
|
||||
readonly txId: string;
|
||||
readonly participant: RegisteredTwoPcParticipant;
|
||||
readonly outcome: ParticipantPrepareOutcome;
|
||||
readonly instruments: TwoPcInstruments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 commit for a single participant. Issues
|
||||
* `COMMIT PREPARED '<gid>'` and records the commit duration.
|
||||
*
|
||||
* Read-only voters and writers that did not actually prepare (vote
|
||||
* was `abort`) are skipped — the caller only routes prepared writers
|
||||
* through this function.
|
||||
*
|
||||
* Failures throw `TWOPC_COMMIT_FAILED` with the cause preserved. The
|
||||
* coordinator escalates to `TWOPC_IN_DOUBT` once it has tried every
|
||||
* participant.
|
||||
*/
|
||||
export async function commitPhaseTwo(input: CommitPhaseInput): Promise<void> {
|
||||
const { txId, participant, outcome, instruments } = input;
|
||||
if (!outcome.prepared) return;
|
||||
const span = instruments.tracer.startSpan("2pc.commit", {
|
||||
attributes: {
|
||||
"tx.id": txId,
|
||||
"participant.id": participant.id,
|
||||
gid: outcome.gid,
|
||||
},
|
||||
});
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
try {
|
||||
await participant.connection.run(
|
||||
`COMMIT PREPARED '${outcome.gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} catch (cause) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.COMMIT_FAILED,
|
||||
`COMMIT PREPARED failed for ${participant.id}`,
|
||||
{
|
||||
cause,
|
||||
context: {
|
||||
participantId: participant.id,
|
||||
txId,
|
||||
gid: outcome.gid,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
instruments.commitDurationMs.record(Date.now() - t0, {
|
||||
outcome: "committed",
|
||||
"participant.id": participant.id,
|
||||
});
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
85
packages/api-stack/api/api-2pc/src/phases/gid.md
Normal file
85
packages/api-stack/api/api-2pc/src/phases/gid.md
Normal 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.
|
||||
58
packages/api-stack/api/api-2pc/src/phases/gid.test.ts
Normal file
58
packages/api-stack/api/api-2pc/src/phases/gid.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildGid, gidPrefixFor, parseGid } from "./gid.js";
|
||||
|
||||
describe("buildGid", () => {
|
||||
it("produces the documented prefix-host-tx-participant format", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildGid("h1", "tx-abc", "billing")).toBe(
|
||||
"2pc-h1-tx-abc-billing"
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves hyphens in the host instance id", { timeout: 5000 }, () => {
|
||||
expect(buildGid("host-01", "tx", "billing")).toBe(
|
||||
"2pc-host-01-tx-billing"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGid", () => {
|
||||
it("round-trips a freshly-built gid", { timeout: 5000 }, () => {
|
||||
const gid = buildGid("host-01", "tx-uuid-with-hyphens", "billing");
|
||||
const parts = parseGid(gid, "host-01");
|
||||
expect(parts).toEqual({
|
||||
txId: "tx-uuid-with-hyphens",
|
||||
participantId: "billing",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for gids without the 2pc- prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseGid("foreign-gid", "h")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for gids written by another host", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gid = buildGid("host-other", "tx", "billing");
|
||||
expect(parseGid(gid, "host-mine")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there is no participant suffix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseGid("2pc-h-onlyhost", "h")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on an empty txId", { timeout: 5000 }, () => {
|
||||
expect(parseGid("2pc-h--billing", "h")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gidPrefixFor", () => {
|
||||
it("returns the recovery-filter prefix", { timeout: 5000 }, () => {
|
||||
expect(gidPrefixFor("host-01")).toBe("2pc-host-01-");
|
||||
});
|
||||
});
|
||||
64
packages/api-stack/api/api-2pc/src/phases/gid.ts
Normal file
64
packages/api-stack/api/api-2pc/src/phases/gid.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Global transaction id helpers. Postgres' `PREPARE TRANSACTION '<gid>'`
|
||||
* accepts a string up to ~200 bytes. The coordinator's gid format is:
|
||||
*
|
||||
* `2pc-<hostInstanceId>-<txId>-<participantId>`
|
||||
*
|
||||
* — the host instance id is the recovery anchor (every prepared row
|
||||
* tagged with the host that wrote it; recovery filters on this prefix),
|
||||
* the txId is the coordinator-owned transaction id, the participantId
|
||||
* disambiguates per-participant rows for hosts that participate as more
|
||||
* than one logical actor.
|
||||
*
|
||||
* Embedded single quotes in any field are escaped by SQL-escaping the
|
||||
* gid at the prepared-statement layer — the coordinator never builds
|
||||
* the SQL with string concatenation against this value.
|
||||
*/
|
||||
|
||||
const PREFIX = "2pc";
|
||||
|
||||
/** Build the gid string for a given transaction + participant. */
|
||||
export function buildGid(
|
||||
hostInstanceId: string,
|
||||
txId: string,
|
||||
participantId: string
|
||||
): string {
|
||||
return `${PREFIX}-${hostInstanceId}-${txId}-${participantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a gid back into its `txId` and `participantId` parts, given
|
||||
* the host instance id of the current coordinator. Returns `null`
|
||||
* when the gid does not match the expected prefix (foreign prepared
|
||||
* transactions sharing the host) — recovery skips those silently.
|
||||
*
|
||||
* `participantId` is constrained by the registry to never contain
|
||||
* hyphens, so the last hyphen unambiguously delimits it from
|
||||
* `txId`.
|
||||
*/
|
||||
export function parseGid(
|
||||
gid: string,
|
||||
hostInstanceId: string
|
||||
): {
|
||||
readonly txId: string;
|
||||
readonly participantId: string;
|
||||
} | null {
|
||||
const prefix = gidPrefixFor(hostInstanceId);
|
||||
if (!gid.startsWith(prefix)) return null;
|
||||
const remainder = gid.slice(prefix.length);
|
||||
const lastHyphen = remainder.lastIndexOf("-");
|
||||
if (lastHyphen === -1) return null;
|
||||
const participantId = remainder.slice(lastHyphen + 1);
|
||||
const txId = remainder.slice(0, lastHyphen);
|
||||
if (txId.length === 0 || participantId.length === 0) return null;
|
||||
return { txId, participantId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate matching the prefix the recovery scan uses against
|
||||
* `pg_prepared_xacts`. Centralised so the SQL filter and parsing
|
||||
* logic never drift.
|
||||
*/
|
||||
export function gidPrefixFor(hostInstanceId: string): string {
|
||||
return `${PREFIX}-${hostInstanceId}-`;
|
||||
}
|
||||
15
packages/api-stack/api/api-2pc/src/phases/index.ts
Normal file
15
packages/api-stack/api/api-2pc/src/phases/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
type CommitPhaseInput,
|
||||
commitPhaseTwo,
|
||||
} from "./commit.js";
|
||||
export { buildGid, gidPrefixFor, parseGid } from "./gid.js";
|
||||
export {
|
||||
type ParticipantPrepareOutcome,
|
||||
type PreparePhaseInput,
|
||||
preparePhaseOne,
|
||||
} from "./prepare.js";
|
||||
export {
|
||||
isUnknownPreparedTransaction,
|
||||
type RollbackPhaseInput,
|
||||
rollbackPhaseTwo,
|
||||
} from "./rollback.js";
|
||||
105
packages/api-stack/api/api-2pc/src/phases/prepare.md
Normal file
105
packages/api-stack/api/api-2pc/src/phases/prepare.md
Normal 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.
|
||||
252
packages/api-stack/api/api-2pc/src/phases/prepare.test.ts
Normal file
252
packages/api-stack/api/api-2pc/src/phases/prepare.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { createTwoPcInstruments } from "../instruments/instruments.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import { preparePhaseOne } from "./prepare.js";
|
||||
|
||||
const INSTRUMENTS = createTwoPcInstruments({
|
||||
outstandingPreparedSnapshot: () => 0,
|
||||
});
|
||||
|
||||
function makeConn(opts: { fail?: Error } = {}) {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
connection: {
|
||||
engine: "postgres" as const,
|
||||
run: async (sql: string) => {
|
||||
calls.push(sql);
|
||||
if (opts.fail) throw opts.fail;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeWriter(
|
||||
id: string,
|
||||
prepareImpl: RegisteredTwoPcParticipant["prepare"],
|
||||
connOpts: { fail?: Error } = {}
|
||||
): RegisteredTwoPcParticipant & { calls: string[] } {
|
||||
const c = makeConn(connOpts);
|
||||
return {
|
||||
id,
|
||||
voterMode: "writer",
|
||||
connection: c.connection,
|
||||
prepare: prepareImpl,
|
||||
calls: c.calls,
|
||||
};
|
||||
}
|
||||
|
||||
describe("preparePhaseOne", () => {
|
||||
it("emits PREPARE TRANSACTION on commit-voted writer and returns prepared=true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeWriter("billing", async () => ({
|
||||
vote: "commit",
|
||||
writeIntent: true,
|
||||
}));
|
||||
const ac = new AbortController();
|
||||
const outcome = await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: ac.signal,
|
||||
});
|
||||
expect(outcome.vote).toBe("commit");
|
||||
expect(outcome.prepared).toBe(true);
|
||||
expect(outcome.gid).toBe("2pc-h-t-billing");
|
||||
expect(p.calls[0]).toBe("PREPARE TRANSACTION '2pc-h-t-billing'");
|
||||
});
|
||||
|
||||
it("returns vote=abort without emitting SQL when participant votes abort", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeWriter("billing", async () => ({
|
||||
vote: "abort",
|
||||
writeIntent: false,
|
||||
}));
|
||||
const outcome = await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(outcome.vote).toBe("abort");
|
||||
expect(outcome.prepared).toBe(false);
|
||||
expect(p.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns prepared=false for a read-only voter that voted commit", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p: RegisteredTwoPcParticipant & { calls: string[] } = {
|
||||
...makeWriter("audit", async () => ({
|
||||
vote: "commit",
|
||||
writeIntent: false,
|
||||
})),
|
||||
voterMode: "read-only",
|
||||
};
|
||||
const outcome = await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(outcome.vote).toBe("commit");
|
||||
expect(outcome.prepared).toBe(false);
|
||||
expect(p.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a read-only voter that returns writeIntent=true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p: RegisteredTwoPcParticipant & { calls: string[] } = {
|
||||
...makeWriter("rogue", async () => ({
|
||||
vote: "commit",
|
||||
writeIntent: true,
|
||||
})),
|
||||
voterMode: "read-only",
|
||||
};
|
||||
await expect(
|
||||
preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
twoPcCode: TWOPC_CODES.READ_ONLY_PARTICIPANT_CANNOT_WRITE,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps a prepare-callback throw into TWOPC.PREPARE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("boom");
|
||||
const p = makeWriter("billing", async () => {
|
||||
throw cause;
|
||||
});
|
||||
try {
|
||||
await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.PREPARE_FAILED
|
||||
);
|
||||
expect((err as TwoPcError).cause).toBe(cause);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps a SQL throw into TWOPC.PREPARE_FAILED when not aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sqlCause = new Error("sql failed");
|
||||
const p = makeWriter(
|
||||
"billing",
|
||||
async () => ({ vote: "commit", writeIntent: true }),
|
||||
{ fail: sqlCause }
|
||||
);
|
||||
try {
|
||||
await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.PREPARE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a SQL throw to TWOPC.TIMEOUT when the signal is aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
const p = makeWriter(
|
||||
"billing",
|
||||
async () => ({ vote: "commit", writeIntent: true }),
|
||||
{ fail: new Error("connection terminated") }
|
||||
);
|
||||
// Abort BEFORE the PREPARE TRANSACTION runs.
|
||||
const original = p.connection.run;
|
||||
p.connection.run = async (sql: string) => {
|
||||
ac.abort();
|
||||
return original(sql);
|
||||
};
|
||||
try {
|
||||
await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: ac.signal,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect((err as TwoPcError).twoPcCode).toBe(TWOPC_CODES.TIMEOUT);
|
||||
}
|
||||
});
|
||||
|
||||
it("raises TWOPC.TIMEOUT immediately when the signal was already aborted", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const p = makeWriter("billing", async () => ({
|
||||
vote: "commit",
|
||||
writeIntent: true,
|
||||
}));
|
||||
await expect(
|
||||
preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: ac.signal,
|
||||
})
|
||||
).rejects.toMatchObject({ twoPcCode: TWOPC_CODES.TIMEOUT });
|
||||
});
|
||||
|
||||
it("escapes single quotes in the gid before interpolation", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const p = makeWriter("bill'ing", async () => ({
|
||||
vote: "commit",
|
||||
writeIntent: true,
|
||||
}));
|
||||
const outcome = await preparePhaseOne({
|
||||
hostInstanceId: "h",
|
||||
txId: "t",
|
||||
participant: p,
|
||||
payload: {},
|
||||
instruments: INSTRUMENTS,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(outcome.gid).toBe("2pc-h-t-bill'ing");
|
||||
expect(p.calls[0]).toBe("PREPARE TRANSACTION '2pc-h-t-bill''ing'");
|
||||
});
|
||||
});
|
||||
160
packages/api-stack/api/api-2pc/src/phases/prepare.ts
Normal file
160
packages/api-stack/api/api-2pc/src/phases/prepare.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { TwoPcVote } from "@suaveplan/api-types/twopc";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type { TwoPcInstruments } from "../instruments/instruments.js";
|
||||
import type {
|
||||
RegisteredTwoPcParticipant,
|
||||
TwoPcPrepareResult,
|
||||
} from "../types/types.types.js";
|
||||
import { buildGid } from "./gid.js";
|
||||
|
||||
/**
|
||||
* Outcome of phase-1 prepare for a single participant. `prepared`
|
||||
* tracks whether `PREPARE TRANSACTION '<gid>'` actually ran — the
|
||||
* rollback phase consults this to decide which participants need
|
||||
* `ROLLBACK PREPARED`.
|
||||
*/
|
||||
export interface ParticipantPrepareOutcome {
|
||||
readonly participantId: string;
|
||||
readonly vote: TwoPcVote;
|
||||
readonly prepared: boolean;
|
||||
/** SQL gid passed to PREPARE TRANSACTION — empty when no SQL ran. */
|
||||
readonly gid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input passed by the coordinator to a phase-1 run.
|
||||
*/
|
||||
export interface PreparePhaseInput {
|
||||
readonly hostInstanceId: string;
|
||||
readonly txId: string;
|
||||
readonly participant: RegisteredTwoPcParticipant;
|
||||
readonly payload: Readonly<Record<string, unknown>>;
|
||||
readonly instruments: TwoPcInstruments;
|
||||
/** Caller-driven AbortSignal — wired to the phase-1 deadline. */
|
||||
readonly signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run phase-1 for one participant. Three things happen, in order:
|
||||
*
|
||||
* 1. The participant's `prepare` callback runs its business work.
|
||||
* 2. If the participant voted `commit` AND is a writer, the coordinator
|
||||
* issues `PREPARE TRANSACTION '<gid>'` against the participant's
|
||||
* connection. Read-only voters skip this step.
|
||||
* 3. The vote and prepared-flag are returned to the coordinator.
|
||||
*
|
||||
* Failures map to {@link TWOPC_CODES} as follows:
|
||||
*
|
||||
* - read-only voter returned `writeIntent: true` →
|
||||
* `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`.
|
||||
* - prepare callback threw OR `PREPARE TRANSACTION` SQL threw →
|
||||
* `TWOPC_PREPARE_FAILED` (the cause is preserved).
|
||||
* - signal aborted during the SQL call → `TWOPC_TIMEOUT`.
|
||||
*/
|
||||
export async function preparePhaseOne(
|
||||
input: PreparePhaseInput
|
||||
): Promise<ParticipantPrepareOutcome> {
|
||||
const { hostInstanceId, txId, participant, payload, instruments, signal } =
|
||||
input;
|
||||
const span = instruments.tracer.startSpan("2pc.prepare", {
|
||||
attributes: {
|
||||
"tx.id": txId,
|
||||
"participant.id": participant.id,
|
||||
"voter.mode": participant.voterMode,
|
||||
},
|
||||
});
|
||||
const t0 = Date.now();
|
||||
let outcome: TwoPcPrepareResult;
|
||||
try {
|
||||
try {
|
||||
outcome = await participant.prepare(txId, payload);
|
||||
} catch (cause) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.PREPARE_FAILED,
|
||||
`Participant ${participant.id} prepare callback raised`,
|
||||
{
|
||||
cause,
|
||||
context: { participantId: participant.id, txId },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (outcome.vote === "abort") {
|
||||
instruments.prepareDurationMs.record(Date.now() - t0, {
|
||||
outcome: "aborted",
|
||||
"participant.id": participant.id,
|
||||
});
|
||||
return {
|
||||
participantId: participant.id,
|
||||
vote: "abort",
|
||||
prepared: false,
|
||||
gid: "",
|
||||
};
|
||||
}
|
||||
if (
|
||||
participant.voterMode === "read-only" &&
|
||||
outcome.writeIntent === true
|
||||
) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.READ_ONLY_PARTICIPANT_CANNOT_WRITE,
|
||||
`Read-only voter ${participant.id} returned writeIntent=true`,
|
||||
{ context: { participantId: participant.id, txId } }
|
||||
);
|
||||
}
|
||||
if (participant.voterMode === "read-only") {
|
||||
instruments.prepareDurationMs.record(Date.now() - t0, {
|
||||
outcome: "committed",
|
||||
"participant.id": participant.id,
|
||||
});
|
||||
return {
|
||||
participantId: participant.id,
|
||||
vote: "commit",
|
||||
prepared: false,
|
||||
gid: "",
|
||||
};
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT,
|
||||
`Phase-1 aborted before PREPARE TRANSACTION for ${participant.id}`,
|
||||
{ context: { participantId: participant.id, txId } }
|
||||
);
|
||||
}
|
||||
const gid = buildGid(hostInstanceId, txId, participant.id);
|
||||
try {
|
||||
await participant.connection.run(
|
||||
`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} catch (cause) {
|
||||
if (signal.aborted) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.TIMEOUT,
|
||||
`Phase-1 deadline expired while preparing ${participant.id}`,
|
||||
{
|
||||
cause,
|
||||
context: { participantId: participant.id, txId },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.PREPARE_FAILED,
|
||||
`PREPARE TRANSACTION failed for ${participant.id}`,
|
||||
{
|
||||
cause,
|
||||
context: { participantId: participant.id, txId, gid },
|
||||
}
|
||||
);
|
||||
}
|
||||
instruments.prepareDurationMs.record(Date.now() - t0, {
|
||||
outcome: "committed",
|
||||
"participant.id": participant.id,
|
||||
});
|
||||
return {
|
||||
participantId: participant.id,
|
||||
vote: "commit",
|
||||
prepared: true,
|
||||
gid,
|
||||
};
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
92
packages/api-stack/api/api-2pc/src/phases/rollback.md
Normal file
92
packages/api-stack/api/api-2pc/src/phases/rollback.md
Normal 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.
|
||||
145
packages/api-stack/api/api-2pc/src/phases/rollback.test.ts
Normal file
145
packages/api-stack/api/api-2pc/src/phases/rollback.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import { createTwoPcInstruments } from "../instruments/instruments.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import type { ParticipantPrepareOutcome } from "./prepare.js";
|
||||
import { isUnknownPreparedTransaction, rollbackPhaseTwo } from "./rollback.js";
|
||||
|
||||
const INSTRUMENTS = createTwoPcInstruments({
|
||||
outstandingPreparedSnapshot: () => 0,
|
||||
});
|
||||
|
||||
const OUTCOME: ParticipantPrepareOutcome = {
|
||||
participantId: "billing",
|
||||
vote: "commit",
|
||||
prepared: true,
|
||||
gid: "2pc-h-t-billing",
|
||||
};
|
||||
|
||||
function makeParticipant(opts: { fail?: unknown } = {}) {
|
||||
const calls: string[] = [];
|
||||
const p: RegisteredTwoPcParticipant = {
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async (sql: string) => {
|
||||
calls.push(sql);
|
||||
if (opts.fail !== undefined) throw opts.fail;
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
};
|
||||
return { participant: p, calls };
|
||||
}
|
||||
|
||||
describe("rollbackPhaseTwo", () => {
|
||||
it("issues ROLLBACK PREPARED against a prepared writer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { participant, calls } = makeParticipant();
|
||||
await rollbackPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: OUTCOME,
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
expect(calls[0]).toBe("ROLLBACK PREPARED '2pc-h-t-billing'");
|
||||
});
|
||||
|
||||
it("is a no-op when outcome.prepared === false", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { participant, calls } = makeParticipant();
|
||||
await rollbackPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: { ...OUTCOME, prepared: false, gid: "" },
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats 42704 (undefined_object) as a no-op (idempotency)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = Object.assign(new Error("does not exist"), {
|
||||
code: "42704",
|
||||
});
|
||||
const { participant } = makeParticipant({ fail: cause });
|
||||
await expect(
|
||||
rollbackPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: OUTCOME,
|
||||
instruments: INSTRUMENTS,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats 'does not exist' message-only errors as a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error(
|
||||
"prepared transaction with identifier 'x' does not exist"
|
||||
);
|
||||
const { participant } = makeParticipant({ fail: cause });
|
||||
await expect(
|
||||
rollbackPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: OUTCOME,
|
||||
instruments: INSTRUMENTS,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("wraps a non-idempotency SQL throw into TWOPC.ROLLBACK_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cause = new Error("connection broken");
|
||||
const { participant } = makeParticipant({ fail: cause });
|
||||
try {
|
||||
await rollbackPhaseTwo({
|
||||
txId: "t",
|
||||
participant,
|
||||
outcome: OUTCOME,
|
||||
instruments: INSTRUMENTS,
|
||||
});
|
||||
throw new Error("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.ROLLBACK_FAILED
|
||||
);
|
||||
expect((err as TwoPcError).cause).toBe(cause);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUnknownPreparedTransaction", () => {
|
||||
it("returns true for SQLSTATE 42704", { timeout: 5000 }, () => {
|
||||
expect(isUnknownPreparedTransaction({ code: "42704" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for messages containing 'does not exist'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
isUnknownPreparedTransaction({ message: "tx X does not exist" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for null / undefined / primitives", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(isUnknownPreparedTransaction(null)).toBe(false);
|
||||
expect(isUnknownPreparedTransaction(undefined)).toBe(false);
|
||||
expect(isUnknownPreparedTransaction(42)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unrelated errors", { timeout: 5000 }, () => {
|
||||
expect(isUnknownPreparedTransaction(new Error("other"))).toBe(false);
|
||||
});
|
||||
});
|
||||
85
packages/api-stack/api/api-2pc/src/phases/rollback.ts
Normal file
85
packages/api-stack/api/api-2pc/src/phases/rollback.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type { TwoPcInstruments } from "../instruments/instruments.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import type { ParticipantPrepareOutcome } from "./prepare.js";
|
||||
|
||||
/**
|
||||
* Input to {@link rollbackPhaseTwo}. Mirrors the commit-phase input
|
||||
* shape so the coordinator can swap between them without remapping.
|
||||
*/
|
||||
export interface RollbackPhaseInput {
|
||||
readonly txId: string;
|
||||
readonly participant: RegisteredTwoPcParticipant;
|
||||
readonly outcome: ParticipantPrepareOutcome;
|
||||
readonly instruments: TwoPcInstruments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-2 rollback for a single participant. Issues
|
||||
* `ROLLBACK PREPARED '<gid>'`. Idempotent at the protocol level —
|
||||
* Postgres returns an error for a gid that does not exist in
|
||||
* `pg_prepared_xacts`, which the coordinator catches and treats as a
|
||||
* no-op (the row was already rolled back, e.g. by recovery).
|
||||
*
|
||||
* Failures other than "no such prepared transaction" throw
|
||||
* `TWOPC_ROLLBACK_FAILED` with the cause preserved.
|
||||
*/
|
||||
export async function rollbackPhaseTwo(
|
||||
input: RollbackPhaseInput
|
||||
): Promise<void> {
|
||||
const { txId, participant, outcome, instruments } = input;
|
||||
if (!outcome.prepared) return;
|
||||
const span = instruments.tracer.startSpan("2pc.rollback", {
|
||||
attributes: {
|
||||
"tx.id": txId,
|
||||
"participant.id": participant.id,
|
||||
gid: outcome.gid,
|
||||
},
|
||||
});
|
||||
try {
|
||||
try {
|
||||
await participant.connection.run(
|
||||
`ROLLBACK PREPARED '${outcome.gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} catch (cause) {
|
||||
if (isUnknownPreparedTransaction(cause)) {
|
||||
return; // idempotent — already rolled back
|
||||
}
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.ROLLBACK_FAILED,
|
||||
`ROLLBACK PREPARED failed for ${participant.id}`,
|
||||
{
|
||||
cause,
|
||||
context: {
|
||||
participantId: participant.id,
|
||||
txId,
|
||||
gid: outcome.gid,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Postgres' "prepared transaction with identifier ... does not
|
||||
* exist" error. The driver-level cause includes the SQLSTATE `42704`
|
||||
* (undefined_object) — we sniff the message and the code defensively
|
||||
* so the check works against both `postgres` (porsager/postgres) and
|
||||
* `pg.Client`. The fallback to message-substring lookup catches the
|
||||
* uncommon case where a driver does not surface the SQLSTATE.
|
||||
*/
|
||||
export function isUnknownPreparedTransaction(cause: unknown): boolean {
|
||||
if (cause === null || typeof cause !== "object") return false;
|
||||
const errObj = cause as { code?: unknown; message?: unknown };
|
||||
if (errObj.code === "42704") return true;
|
||||
if (
|
||||
typeof errObj.message === "string" &&
|
||||
errObj.message.includes("does not exist")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
7
packages/api-stack/api/api-2pc/src/recovery/index.ts
Normal file
7
packages/api-stack/api/api-2pc/src/recovery/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
buildProbeFromRegistry,
|
||||
type RecoveryInput,
|
||||
type RecoveryProbe,
|
||||
type RecoveryReport,
|
||||
resolveRecovery,
|
||||
} from "./recovery.js";
|
||||
133
packages/api-stack/api/api-2pc/src/recovery/recovery.md
Normal file
133
packages/api-stack/api/api-2pc/src/recovery/recovery.md
Normal 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.
|
||||
308
packages/api-stack/api/api-2pc/src/recovery/recovery.test.ts
Normal file
308
packages/api-stack/api/api-2pc/src/recovery/recovery.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
type LedgerDb,
|
||||
type LedgerParticipantEntry,
|
||||
TwoPcLedgerStore,
|
||||
} from "../persistence/persistence.js";
|
||||
import { TwoPcParticipantRegistry } from "../registry/participant-registry.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import {
|
||||
buildProbeFromRegistry,
|
||||
type RecoveryProbe,
|
||||
resolveRecovery,
|
||||
} from "./recovery.js";
|
||||
|
||||
function makeLedger(): TwoPcLedgerStore {
|
||||
const db = new Database(":memory:") as unknown as LedgerDb;
|
||||
db.run(`CREATE TABLE IF NOT EXISTS suaveplan_2pc_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
coordinator TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
participants_json TEXT NOT NULL,
|
||||
prepared_at TEXT,
|
||||
committed_at TEXT,
|
||||
aborted_at TEXT
|
||||
)`);
|
||||
return new TwoPcLedgerStore(db);
|
||||
}
|
||||
|
||||
const SAMPLE: ReadonlyArray<LedgerParticipantEntry> = [
|
||||
{ participantId: "billing", voterMode: "writer", vote: "commit" },
|
||||
];
|
||||
|
||||
function makeProbe(opts: {
|
||||
orphans?: readonly string[];
|
||||
failCommit?: Error;
|
||||
failRollback?: Error;
|
||||
}): RecoveryProbe & { committed: string[]; rolledBack: string[] } {
|
||||
const committed: string[] = [];
|
||||
const rolledBack: string[] = [];
|
||||
return {
|
||||
participantId: "billing",
|
||||
committed,
|
||||
rolledBack,
|
||||
async queryOrphans(_prefix) {
|
||||
return opts.orphans ?? [];
|
||||
},
|
||||
async commit(gid) {
|
||||
if (opts.failCommit) throw opts.failCommit;
|
||||
committed.push(gid);
|
||||
},
|
||||
async rollback(gid) {
|
||||
if (opts.failRollback) throw opts.failRollback;
|
||||
rolledBack.push(gid);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveRecovery", () => {
|
||||
it("commits orphans whose ledger state is committing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
ledger.insert({
|
||||
id: "tx-1",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE,
|
||||
});
|
||||
ledger.markAllVotedCommit({
|
||||
id: "tx-1",
|
||||
participants: SAMPLE,
|
||||
preparedAt: "2026-05-26T00:00:00.000Z",
|
||||
});
|
||||
const probe = makeProbe({ orphans: ["2pc-h-tx-1-billing"] });
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(report.committed).toEqual(["2pc-h-tx-1-billing"]);
|
||||
expect(report.rolledBack).toEqual([]);
|
||||
expect(probe.committed).toEqual(["2pc-h-tx-1-billing"]);
|
||||
expect(ledger.findById("tx-1")?.state).toBe("committed");
|
||||
});
|
||||
|
||||
it("rolls back orphans whose ledger row is missing (safe default)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
const probe = makeProbe({ orphans: ["2pc-h-tx-2-billing"] });
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(report.rolledBack).toEqual(["2pc-h-tx-2-billing"]);
|
||||
expect(report.committed).toEqual([]);
|
||||
expect(probe.rolledBack).toEqual(["2pc-h-tx-2-billing"]);
|
||||
});
|
||||
|
||||
it("rolls back orphans whose ledger state is voting (not yet all-voted-commit)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
ledger.insert({
|
||||
id: "tx-3",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE,
|
||||
});
|
||||
const probe = makeProbe({ orphans: ["2pc-h-tx-3-billing"] });
|
||||
await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(ledger.findById("tx-3")?.state).toBe("aborted");
|
||||
});
|
||||
|
||||
it("treats Postgres 42704 during rollback as success", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
const cause = Object.assign(new Error("does not exist"), {
|
||||
code: "42704",
|
||||
});
|
||||
const probe = makeProbe({
|
||||
orphans: ["2pc-h-tx-4-billing"],
|
||||
failRollback: cause,
|
||||
});
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(report.errors).toEqual([]);
|
||||
expect(report.rolledBack).toEqual(["2pc-h-tx-4-billing"]);
|
||||
});
|
||||
|
||||
it("skips foreign gids that don't match the host prefix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
const probe = makeProbe({
|
||||
orphans: ["2pc-other-host-tx-5-billing", "foreign-gid"],
|
||||
});
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(report.skipped).toContain("2pc-other-host-tx-5-billing");
|
||||
expect(report.skipped).toContain("foreign-gid");
|
||||
expect(report.committed).toEqual([]);
|
||||
expect(report.rolledBack).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks irrecoverable orphans in_doubt and surfaces them on the report", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
ledger.insert({
|
||||
id: "tx-6",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE,
|
||||
});
|
||||
ledger.markAllVotedCommit({
|
||||
id: "tx-6",
|
||||
participants: SAMPLE,
|
||||
preparedAt: "2026-05-26T00:00:00.000Z",
|
||||
});
|
||||
const cause = new Error("connection broken");
|
||||
const probe = makeProbe({
|
||||
orphans: ["2pc-h-tx-6-billing"],
|
||||
failCommit: cause,
|
||||
});
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(report.errors).toHaveLength(1);
|
||||
expect(report.errors[0]?.error.twoPcCode).toBe(TWOPC_CODES.IN_DOUBT);
|
||||
expect(ledger.findById("tx-6")?.state).toBe("in_doubt");
|
||||
});
|
||||
|
||||
it("invokes onResolved for every outcome", { timeout: 5000 }, async () => {
|
||||
const ledger = makeLedger();
|
||||
ledger.insert({
|
||||
id: "tx-7",
|
||||
coordinator: "h",
|
||||
participants: SAMPLE,
|
||||
});
|
||||
const events: Array<{ gid: string; outcome: string }> = [];
|
||||
await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [makeProbe({ orphans: ["2pc-h-tx-7-billing", "foreign"] })],
|
||||
onResolved: (e) => events.push({ ...e }),
|
||||
});
|
||||
expect(events.length).toBe(2);
|
||||
expect(events.some((e) => e.outcome === "rolledBack")).toBe(true);
|
||||
expect(events.some((e) => e.outcome === "skipped")).toBe(true);
|
||||
});
|
||||
|
||||
it("re-running recovery on a clean state is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ledger = makeLedger();
|
||||
const probe = makeProbe({ orphans: [] });
|
||||
const r1 = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
const r2 = await resolveRecovery({
|
||||
hostInstanceId: "h",
|
||||
ledger,
|
||||
probes: [probe],
|
||||
});
|
||||
expect(r1.committed.length).toBe(0);
|
||||
expect(r1.rolledBack.length).toBe(0);
|
||||
expect(r2.committed.length).toBe(0);
|
||||
expect(r2.rolledBack.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProbeFromRegistry", () => {
|
||||
it("emits one probe per writer participant; skips read-only voters", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
const writer: RegisteredTwoPcParticipant = {
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async () => undefined,
|
||||
},
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
};
|
||||
const reader: RegisteredTwoPcParticipant = {
|
||||
id: "audit",
|
||||
voterMode: "read-only",
|
||||
connection: {
|
||||
engine: "surrealdb",
|
||||
run: async () => undefined,
|
||||
},
|
||||
prepare: async () => ({ vote: "commit", writeIntent: false }),
|
||||
};
|
||||
registry.register(writer);
|
||||
registry.register(reader);
|
||||
const probes = buildProbeFromRegistry(registry);
|
||||
expect(probes).toHaveLength(1);
|
||||
expect(probes[0]?.participantId).toBe("billing");
|
||||
});
|
||||
|
||||
it("emitted probe runs the documented pg_prepared_xacts query", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const calls: string[] = [];
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
registry.register({
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async (sql: string) => {
|
||||
calls.push(sql);
|
||||
if (sql.includes("SELECT gid"))
|
||||
return [{ gid: "2pc-h-tx-1-billing" }];
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
});
|
||||
const probes = buildProbeFromRegistry(registry);
|
||||
const orphans = await probes[0]?.queryOrphans("2pc-h-");
|
||||
expect(orphans).toEqual(["2pc-h-tx-1-billing"]);
|
||||
expect(calls[0]).toContain("SELECT gid FROM pg_prepared_xacts");
|
||||
expect(calls[0]).toContain("LIKE '2pc-h-%'");
|
||||
await probes[0]?.commit("2pc-h-tx-1-billing");
|
||||
await probes[0]?.rollback("2pc-h-tx-1-billing");
|
||||
expect(calls).toEqual([
|
||||
"SELECT gid FROM pg_prepared_xacts WHERE gid LIKE '2pc-h-%'",
|
||||
"COMMIT PREPARED '2pc-h-tx-1-billing'",
|
||||
"ROLLBACK PREPARED '2pc-h-tx-1-billing'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("queryOrphans handles a probe that returns undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
registry.register({
|
||||
id: "billing",
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async () => undefined,
|
||||
},
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
});
|
||||
const probes = buildProbeFromRegistry(registry);
|
||||
const orphans = await probes[0]?.queryOrphans("2pc-h-");
|
||||
expect(orphans).toEqual([]);
|
||||
});
|
||||
});
|
||||
196
packages/api-stack/api/api-2pc/src/recovery/recovery.ts
Normal file
196
packages/api-stack/api/api-2pc/src/recovery/recovery.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type {
|
||||
TwoPcLedgerRow,
|
||||
TwoPcLedgerStore,
|
||||
} from "../persistence/persistence.js";
|
||||
import { gidPrefixFor, parseGid } from "../phases/gid.js";
|
||||
import { isUnknownPreparedTransaction } from "../phases/rollback.js";
|
||||
import type { TwoPcParticipantRegistry } from "../registry/participant-registry.js";
|
||||
|
||||
/**
|
||||
* Per-participant probe surface — pings a single Postgres connection
|
||||
* for prepared transactions matching the host's gid prefix. The
|
||||
* coordinator iterates registered writers and asks each one for its
|
||||
* orphans on boot.
|
||||
*/
|
||||
export interface RecoveryProbe {
|
||||
readonly participantId: string;
|
||||
queryOrphans(prefix: string): Promise<readonly string[]>;
|
||||
commit(gid: string): Promise<void>;
|
||||
rollback(gid: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of running recovery. `committed` and `rolledBack` carry the
|
||||
* exact gids resolved on this boot; `skipped` carries gids the
|
||||
* coordinator could not parse (foreign prepared transactions sharing
|
||||
* the host).
|
||||
*/
|
||||
export interface RecoveryReport {
|
||||
readonly committed: readonly string[];
|
||||
readonly rolledBack: readonly string[];
|
||||
readonly skipped: readonly string[];
|
||||
readonly errors: ReadonlyArray<{
|
||||
readonly gid: string;
|
||||
readonly error: TwoPcError;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input to {@link resolveRecovery}.
|
||||
*/
|
||||
export interface RecoveryInput {
|
||||
readonly hostInstanceId: string;
|
||||
readonly ledger: TwoPcLedgerStore;
|
||||
/**
|
||||
* Per-participant Postgres probes. The recovery runner does not
|
||||
* care which participants are registered — it sweeps every probe.
|
||||
*/
|
||||
readonly probes: ReadonlyArray<RecoveryProbe>;
|
||||
/**
|
||||
* Optional callback fired for every resolved gid; useful for
|
||||
* progress logging in tests and ops dashboards.
|
||||
*/
|
||||
readonly onResolved?: (event: {
|
||||
readonly gid: string;
|
||||
readonly outcome: "committed" | "rolledBack" | "skipped";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time orphan resolution. Per design doc §9.2 paragraph 3:
|
||||
*
|
||||
* 1. Query every participant's `pg_prepared_xacts` for gids matching
|
||||
* this host's prefix.
|
||||
* 2. For each orphan: if the ledger row records `all-voted-commit`
|
||||
* (state `committing` or `committed`) issue `COMMIT PREPARED`;
|
||||
* otherwise issue `ROLLBACK PREPARED`. Missing ledger row → safe
|
||||
* default `ROLLBACK PREPARED` (no commit without explicit log).
|
||||
* 3. Update the ledger row to a terminal state. Idempotent — running
|
||||
* recovery twice in a row is a no-op once orphans are resolved.
|
||||
*
|
||||
* Errors are collected per-gid rather than thrown — the runner makes
|
||||
* progress on every recoverable orphan and surfaces unresolved ones
|
||||
* to the caller for follow-up.
|
||||
*/
|
||||
export async function resolveRecovery(
|
||||
input: RecoveryInput
|
||||
): Promise<RecoveryReport> {
|
||||
const { hostInstanceId, ledger, probes, onResolved } = input;
|
||||
const prefix = gidPrefixFor(hostInstanceId);
|
||||
const committed: string[] = [];
|
||||
const rolledBack: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: Array<{ gid: string; error: TwoPcError }> = [];
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
for (const probe of probes) {
|
||||
const orphans = await probe.queryOrphans(prefix);
|
||||
for (const gid of orphans) {
|
||||
const parts = parseGid(gid, hostInstanceId);
|
||||
if (parts === null) {
|
||||
skipped.push(gid);
|
||||
onResolved?.({ gid, outcome: "skipped" });
|
||||
continue;
|
||||
}
|
||||
const row = ledger.findById(parts.txId);
|
||||
const shouldCommit =
|
||||
row !== null &&
|
||||
(row.state === "committing" || row.state === "committed");
|
||||
try {
|
||||
if (shouldCommit) {
|
||||
await probe.commit(gid);
|
||||
finaliseLedgerForCommit(ledger, row, nowIso);
|
||||
committed.push(gid);
|
||||
onResolved?.({ gid, outcome: "committed" });
|
||||
} else {
|
||||
try {
|
||||
await probe.rollback(gid);
|
||||
} catch (cause) {
|
||||
if (!isUnknownPreparedTransaction(cause)) throw cause;
|
||||
}
|
||||
finaliseLedgerForAbort(ledger, row, nowIso);
|
||||
rolledBack.push(gid);
|
||||
onResolved?.({ gid, outcome: "rolledBack" });
|
||||
}
|
||||
} catch (cause) {
|
||||
if (row !== null) ledger.markInDoubt(row.id);
|
||||
errors.push({
|
||||
gid,
|
||||
error: new TwoPcError(
|
||||
TWOPC_CODES.IN_DOUBT,
|
||||
`Recovery could not resolve orphan ${gid}`,
|
||||
{
|
||||
cause,
|
||||
context: {
|
||||
gid,
|
||||
participantId: probe.participantId,
|
||||
},
|
||||
}
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { committed, rolledBack, skipped, errors };
|
||||
}
|
||||
|
||||
function finaliseLedgerForCommit(
|
||||
ledger: TwoPcLedgerStore,
|
||||
row: TwoPcLedgerRow | null,
|
||||
nowIso: () => string
|
||||
): void {
|
||||
if (row === null) return;
|
||||
if (row.state === "committed") return;
|
||||
ledger.markCommitted(row.id, nowIso());
|
||||
}
|
||||
|
||||
function finaliseLedgerForAbort(
|
||||
ledger: TwoPcLedgerStore,
|
||||
row: TwoPcLedgerRow | null,
|
||||
nowIso: () => string
|
||||
): void {
|
||||
if (row === null) return;
|
||||
if (row.state === "aborted") return;
|
||||
ledger.markAborted(row.id, nowIso());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience adapter: builds a {@link RecoveryProbe} backed by a
|
||||
* registered participant. The participant's connection is used for
|
||||
* the `pg_prepared_xacts` scan AND the COMMIT/ROLLBACK PREPARED SQL;
|
||||
* the coordinator never asks a participant to manipulate another
|
||||
* participant's prepared transactions.
|
||||
*/
|
||||
export function buildProbeFromRegistry(
|
||||
registry: TwoPcParticipantRegistry
|
||||
): readonly RecoveryProbe[] {
|
||||
const probes: RecoveryProbe[] = [];
|
||||
for (const p of registry.list()) {
|
||||
if (p.voterMode === "read-only") continue;
|
||||
const conn = p.connection;
|
||||
probes.push({
|
||||
participantId: p.id,
|
||||
async queryOrphans(prefix) {
|
||||
const rows = (await conn.run<ReadonlyArray<{ gid: string }>>(
|
||||
`SELECT gid FROM pg_prepared_xacts WHERE gid LIKE ${escapeLiteral(`${prefix}%`)}`
|
||||
)) as ReadonlyArray<{ gid: string }> | undefined;
|
||||
return (rows ?? []).map((r) => r.gid);
|
||||
},
|
||||
async commit(gid) {
|
||||
await conn.run(`COMMIT PREPARED '${gid.replace(/'/g, "''")}'`);
|
||||
},
|
||||
async rollback(gid) {
|
||||
await conn.run(
|
||||
`ROLLBACK PREPARED '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
return probes;
|
||||
}
|
||||
|
||||
function escapeLiteral(s: string): string {
|
||||
return `'${s.replace(/'/g, "''")}'`;
|
||||
}
|
||||
4
packages/api-stack/api/api-2pc/src/registry/index.ts
Normal file
4
packages/api-stack/api/api-2pc/src/registry/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type PreparableCapabilityCheck,
|
||||
TwoPcParticipantRegistry,
|
||||
} from "./participant-registry.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import { TwoPcParticipantRegistry } from "./participant-registry.js";
|
||||
|
||||
const NOOP_PREPARE = async () => ({
|
||||
vote: "commit" as const,
|
||||
writeIntent: false,
|
||||
});
|
||||
|
||||
function makeParticipant(
|
||||
id: string,
|
||||
overrides: Partial<RegisteredTwoPcParticipant> = {}
|
||||
): RegisteredTwoPcParticipant {
|
||||
return {
|
||||
id,
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async () => undefined,
|
||||
},
|
||||
prepare: NOOP_PREPARE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TwoPcParticipantRegistry", () => {
|
||||
it("registers preparable writers", { timeout: 5000 }, () => {
|
||||
const r = new TwoPcParticipantRegistry(() => true);
|
||||
r.register(makeParticipant("billing"));
|
||||
expect(r.size()).toBe(1);
|
||||
expect(r.get("billing")?.id).toBe("billing");
|
||||
});
|
||||
|
||||
it("rejects writer whose engine is not preparable with TWOPC.PARTICIPANT_NOT_PREPARABLE", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new TwoPcParticipantRegistry(() => false);
|
||||
try {
|
||||
r.register(
|
||||
makeParticipant("sqlite-plugin", {
|
||||
connection: {
|
||||
engine: "sqlite",
|
||||
run: async () => undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
throw new Error("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TwoPcError);
|
||||
expect((err as TwoPcError).twoPcCode).toBe(
|
||||
TWOPC_CODES.PARTICIPANT_NOT_PREPARABLE
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts read-only voter regardless of engine", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new TwoPcParticipantRegistry(() => false);
|
||||
r.register(
|
||||
makeParticipant("audit", {
|
||||
voterMode: "read-only",
|
||||
connection: {
|
||||
engine: "surrealdb",
|
||||
run: async () => undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(r.size()).toBe(1);
|
||||
expect(r.get("audit")?.voterMode).toBe("read-only");
|
||||
});
|
||||
|
||||
it("lists every registered participant", { timeout: 5000 }, () => {
|
||||
const r = new TwoPcParticipantRegistry(() => true);
|
||||
r.register(makeParticipant("a"));
|
||||
r.register(makeParticipant("b"));
|
||||
expect(
|
||||
r
|
||||
.list()
|
||||
.map((p) => p.id)
|
||||
.sort()
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("unregister removes the entry and reports outcome", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new TwoPcParticipantRegistry(() => true);
|
||||
r.register(makeParticipant("c"));
|
||||
expect(r.unregister("c")).toBe(true);
|
||||
expect(r.unregister("c")).toBe(false);
|
||||
expect(r.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { TWOPC_CODES, TwoPcError } from "../error-codes/error-codes.js";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
|
||||
/**
|
||||
* Capability assertion handed to the registry. The host's capability
|
||||
* registry implements this — the registry only needs to know whether
|
||||
* a specific participant's engine is `db.preparable`. Modelling this
|
||||
* as a structural callback keeps the registry free of any direct
|
||||
* dependency on `@suaveplan/api-host`.
|
||||
*/
|
||||
export type PreparableCapabilityCheck = (participantId: string) => boolean;
|
||||
|
||||
/**
|
||||
* Registry of participants the coordinator can hand a transaction to.
|
||||
* Refuses writers whose bound engine is not `db.preparable` (SQLite,
|
||||
* SurrealDB). Read-only voters are allowed regardless of engine but
|
||||
* are refused later if their prepare callback returns
|
||||
* `writeIntent: true` — that refusal lives in `phases/prepare.ts`.
|
||||
*/
|
||||
export class TwoPcParticipantRegistry {
|
||||
private readonly entries = new Map<string, RegisteredTwoPcParticipant>();
|
||||
|
||||
public constructor(
|
||||
private readonly isPreparable: PreparableCapabilityCheck
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Add a participant. Throws
|
||||
* `TWOPC_PARTICIPANT_NOT_PREPARABLE` when a writer's engine does
|
||||
* not advertise the `db.preparable` capability.
|
||||
*/
|
||||
public register(participant: RegisteredTwoPcParticipant): void {
|
||||
if (
|
||||
participant.voterMode === "writer" &&
|
||||
!this.isPreparable(participant.id)
|
||||
) {
|
||||
throw new TwoPcError(
|
||||
TWOPC_CODES.PARTICIPANT_NOT_PREPARABLE,
|
||||
`Participant ${participant.id} is not preparable (engine: ${participant.connection.engine})`,
|
||||
{
|
||||
context: {
|
||||
participantId: participant.id,
|
||||
engine: participant.connection.engine,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
this.entries.set(participant.id, participant);
|
||||
}
|
||||
|
||||
/** Lookup by id; returns `undefined` when none registered. */
|
||||
public get(id: string): RegisteredTwoPcParticipant | undefined {
|
||||
return this.entries.get(id);
|
||||
}
|
||||
|
||||
/** Snapshot of every registered participant. */
|
||||
public list(): readonly RegisteredTwoPcParticipant[] {
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
/** Total registered participants — useful for diagnostic assertions. */
|
||||
public size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
/** Remove a participant; returns `true` when one was removed. */
|
||||
public unregister(id: string): boolean {
|
||||
return this.entries.delete(id);
|
||||
}
|
||||
}
|
||||
100
packages/api-stack/api/api-2pc/src/testing/conformance.md
Normal file
100
packages/api-stack/api/api-2pc/src/testing/conformance.md
Normal 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.
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
import { run2pcParticipantConformance } from "./conformance.js";
|
||||
|
||||
/**
|
||||
* Build a fake Postgres-shaped participant that maintains an internal
|
||||
* set of "prepared" gids. Used purely to exercise the conformance
|
||||
* harness against deterministic semantics — the actual integration
|
||||
* test runs the same harness against a real Postgres container.
|
||||
*/
|
||||
function fakeParticipantFactory(): {
|
||||
create(): Promise<{
|
||||
participant: RegisteredTwoPcParticipant;
|
||||
teardown(): Promise<void>;
|
||||
}>;
|
||||
} {
|
||||
return {
|
||||
async create() {
|
||||
const prepared = new Set<string>();
|
||||
const participant: RegisteredTwoPcParticipant = {
|
||||
id: "fake",
|
||||
voterMode: "writer",
|
||||
connection: {
|
||||
engine: "postgres",
|
||||
run: async (sql: string) => {
|
||||
const trimmed = sql.trim();
|
||||
const match = trimmed.match(
|
||||
/^(PREPARE TRANSACTION|COMMIT PREPARED|ROLLBACK PREPARED) '(.+)'$/
|
||||
);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const verb = match[1] as string;
|
||||
const gid = (match[2] as string).replace(/''/g, "'");
|
||||
if (verb === "PREPARE TRANSACTION") {
|
||||
prepared.add(gid);
|
||||
return undefined;
|
||||
}
|
||||
if (verb === "COMMIT PREPARED") {
|
||||
if (!prepared.has(gid)) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`prepared transaction with identifier '${gid}' does not exist`
|
||||
),
|
||||
{ code: "42704" }
|
||||
);
|
||||
}
|
||||
prepared.delete(gid);
|
||||
return undefined;
|
||||
}
|
||||
if (verb === "ROLLBACK PREPARED") {
|
||||
if (!prepared.has(gid)) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`prepared transaction with identifier '${gid}' does not exist`
|
||||
),
|
||||
{ code: "42704" }
|
||||
);
|
||||
}
|
||||
prepared.delete(gid);
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
prepare: async () => ({
|
||||
vote: "commit",
|
||||
writeIntent: true,
|
||||
}),
|
||||
};
|
||||
return {
|
||||
participant,
|
||||
async teardown() {
|
||||
prepared.clear();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
run2pcParticipantConformance(fakeParticipantFactory());
|
||||
|
||||
describe("run2pcParticipantConformance smoke", () => {
|
||||
it("is callable without throwing at module load", { timeout: 5000 }, () => {
|
||||
expect(typeof run2pcParticipantConformance).toBe("function");
|
||||
});
|
||||
});
|
||||
155
packages/api-stack/api/api-2pc/src/testing/conformance.ts
Normal file
155
packages/api-stack/api/api-2pc/src/testing/conformance.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { RegisteredTwoPcParticipant } from "../types/types.types.js";
|
||||
|
||||
/**
|
||||
* Factory the conformance suite calls before every scenario. Returns
|
||||
* a fresh participant + a teardown handle. The harness drives
|
||||
* prepare/commit/rollback semantics against the participant; the
|
||||
* factory's only job is to wire a real (or sufficiently realistic)
|
||||
* Postgres connection in the returned `participant.connection`.
|
||||
*/
|
||||
export interface TwoPcParticipantFactory {
|
||||
create(): Promise<{
|
||||
readonly participant: RegisteredTwoPcParticipant;
|
||||
teardown(): Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conformance suite. Re-exported on the `./testing` subpath so every
|
||||
* participant implementation can drop the following one-liner into
|
||||
* its `tests/conformance/run-2pc-participant.test.ts`:
|
||||
*
|
||||
* `run2pcParticipantConformance({ create: createMyParticipant })`
|
||||
*
|
||||
* The suite asserts six invariants:
|
||||
*
|
||||
* 1. A writer's prepare callback returning `commit` must be valid
|
||||
* for the coordinator to subsequently call `connection.run` with
|
||||
* `PREPARE TRANSACTION '<gid>'` and have the statement succeed
|
||||
* against the underlying engine.
|
||||
* 2. `COMMIT PREPARED '<gid>'` against a prepared gid must succeed.
|
||||
* 3. `ROLLBACK PREPARED '<gid>'` against a prepared gid must
|
||||
* succeed.
|
||||
* 4. A second `ROLLBACK PREPARED` against the same already-rolled-back
|
||||
* gid must raise SQLSTATE `42704` (or a "does not exist" message),
|
||||
* proving the idempotency anchor the coordinator depends on.
|
||||
* 5. The participant's `connection.engine` must be `"postgres"`.
|
||||
* 6. The participant's `voterMode` must be `"writer"` (read-only
|
||||
* voters are not subject to this suite — they do not run prepare
|
||||
* SQL).
|
||||
*/
|
||||
export function run2pcParticipantConformance(
|
||||
factory: TwoPcParticipantFactory
|
||||
): void {
|
||||
describe("2PC participant conformance", () => {
|
||||
it("declares engine 'postgres' and voterMode 'writer'", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { participant, teardown } = await factory.create();
|
||||
try {
|
||||
expect(participant.connection.engine).toBe("postgres");
|
||||
expect(participant.voterMode).toBe("writer");
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("PREPARE TRANSACTION succeeds after a commit-voted prepare callback", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { participant, teardown } = await factory.create();
|
||||
const gid = `2pc-conformance-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
try {
|
||||
const vote = await participant.prepare(gid, {});
|
||||
expect(vote.vote).toBe("commit");
|
||||
await participant.connection.run(
|
||||
`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
// Cleanup so the test doesn't leave row locks behind.
|
||||
await participant.connection.run(
|
||||
`ROLLBACK PREPARED '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("COMMIT PREPARED resolves a prepared gid", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { participant, teardown } = await factory.create();
|
||||
const gid = `2pc-conformance-commit-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
try {
|
||||
await participant.prepare(gid, {});
|
||||
await participant.connection.run(
|
||||
`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
await participant.connection.run(
|
||||
`COMMIT PREPARED '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("ROLLBACK PREPARED resolves a prepared gid", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { participant, teardown } = await factory.create();
|
||||
const gid = `2pc-conformance-rollback-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
try {
|
||||
await participant.prepare(gid, {});
|
||||
await participant.connection.run(
|
||||
`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
await participant.connection.run(
|
||||
`ROLLBACK PREPARED '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
|
||||
it("a second ROLLBACK PREPARED raises the documented 'does not exist' error", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const { participant, teardown } = await factory.create();
|
||||
const gid = `2pc-conformance-idem-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`;
|
||||
try {
|
||||
await participant.prepare(gid, {});
|
||||
await participant.connection.run(
|
||||
`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
await participant.connection.run(
|
||||
`ROLLBACK PREPARED '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await participant.connection.run(
|
||||
`ROLLBACK PREPARED '${gid.replace(/'/g, "''")}'`
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeDefined();
|
||||
const e = caught as { code?: string; message?: string };
|
||||
const matches =
|
||||
e.code === "42704" ||
|
||||
(typeof e.message === "string" &&
|
||||
e.message.includes("does not exist"));
|
||||
expect(matches).toBe(true);
|
||||
} finally {
|
||||
await teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
4
packages/api-stack/api/api-2pc/src/testing/index.ts
Normal file
4
packages/api-stack/api/api-2pc/src/testing/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
run2pcParticipantConformance,
|
||||
type TwoPcParticipantFactory,
|
||||
} from "./conformance.js";
|
||||
8
packages/api-stack/api/api-2pc/src/types/index.ts
Normal file
8
packages/api-stack/api/api-2pc/src/types/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
PreparableConnection,
|
||||
RegisteredTwoPcParticipant,
|
||||
Run2PcInput,
|
||||
Run2PcOutcome,
|
||||
TwoPcCoordinatorState,
|
||||
TwoPcPrepareResult,
|
||||
} from "./types.types.js";
|
||||
108
packages/api-stack/api/api-2pc/src/types/types.types.md
Normal file
108
packages/api-stack/api/api-2pc/src/types/types.types.md
Normal 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.
|
||||
117
packages/api-stack/api/api-2pc/src/types/types.types.ts
Normal file
117
packages/api-stack/api/api-2pc/src/types/types.types.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
DatabaseEngine,
|
||||
PreparedStatement,
|
||||
} from "@suaveplan/api-types/db";
|
||||
import type { TwoPcVote } from "@suaveplan/api-types/twopc";
|
||||
|
||||
/**
|
||||
* Coordinator-side lifecycle state machine for a single 2PC run. Mirrors
|
||||
* the design doc §9.2 vocabulary (`voting → committing | aborting →
|
||||
* committed | aborted | in_doubt`) — `voting` is the prepare phase,
|
||||
* the terminal states are persisted to `suaveplan_2pc_transactions` for
|
||||
* recovery.
|
||||
*/
|
||||
export type TwoPcCoordinatorState =
|
||||
| "voting"
|
||||
| "committing"
|
||||
| "aborting"
|
||||
| "committed"
|
||||
| "aborted"
|
||||
| "in_doubt";
|
||||
|
||||
/**
|
||||
* Participant write surface used by the coordinator during phase 1 and
|
||||
* phase 2. Modelled as a single structural method `run(sql, params)`
|
||||
* to match `postgres` (porsager/postgres)'s `sql.unsafe(...)` and
|
||||
* `pg.Client.query(text, values)` interchangeably. Returning a row
|
||||
* shape is irrelevant for 2PC SQL — the coordinator only cares that
|
||||
* the statement either succeeds or throws.
|
||||
*/
|
||||
export interface PreparableConnection {
|
||||
/** Engine identifier — coordinator gates on `db.preparable` capability. */
|
||||
readonly engine: DatabaseEngine;
|
||||
/**
|
||||
* Execute an arbitrary SQL statement. Used for
|
||||
* `PREPARE TRANSACTION '<gid>'`, `COMMIT PREPARED '<gid>'`,
|
||||
* `ROLLBACK PREPARED '<gid>'`, and the recovery's
|
||||
* `SELECT gid FROM pg_prepared_xacts ...` probe.
|
||||
*/
|
||||
run<R = unknown>(sql: string, params?: ReadonlyArray<unknown>): Promise<R>;
|
||||
/**
|
||||
* Optional prepared-statement bridge. The 2PC coordinator does not
|
||||
* use prepared statements itself (every call is one-shot SQL), but
|
||||
* the participant SHOULD expose its production `prepare` hook so
|
||||
* the in-package conformance suite can verify the engine actually
|
||||
* supports prepared transactions.
|
||||
*/
|
||||
prepare?<R = unknown>(sql: string): Promise<PreparedStatement<R>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinator-side view of a participant. The `id` is unique per
|
||||
* coordinator and is folded into the global transaction id passed to
|
||||
* `PREPARE TRANSACTION '<gid>'`. `voterMode` distinguishes write
|
||||
* participants (full prepare/commit/rollback SQL) from read-only
|
||||
* voters (vote-only — no SQL crosses the wire in phase 2).
|
||||
*/
|
||||
export interface RegisteredTwoPcParticipant {
|
||||
readonly id: string;
|
||||
readonly voterMode: "writer" | "read-only";
|
||||
readonly connection: PreparableConnection;
|
||||
/**
|
||||
* Prepare callback — runs the participant's business work inside
|
||||
* a transaction, returns `commit` if the work passed local
|
||||
* invariants. The coordinator turns a `commit` vote into a
|
||||
* `PREPARE TRANSACTION` SQL call. Write-intent payloads from a
|
||||
* read-only voter are rejected with
|
||||
* `TWOPC_READ_ONLY_PARTICIPANT_CANNOT_WRITE`.
|
||||
*/
|
||||
prepare(
|
||||
txId: string,
|
||||
input: Readonly<Record<string, unknown>>
|
||||
): Promise<TwoPcPrepareResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return value from a participant's prepare callback. `writeIntent`
|
||||
* carries whether the work would mutate state — read-only voters MUST
|
||||
* return `writeIntent: false` (the coordinator refuses otherwise).
|
||||
*/
|
||||
export interface TwoPcPrepareResult {
|
||||
readonly vote: TwoPcVote;
|
||||
readonly writeIntent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller-supplied input to `run2pc`. `participants` is an ordered list
|
||||
* — phase 1 collects votes left-to-right, phase 2 commits/rolls back
|
||||
* left-to-right. `timeoutMs` is the phase-1 deadline; phase 2 is
|
||||
* unbounded by design (the protocol cannot tolerate giving up on
|
||||
* commit-prepared mid-flight).
|
||||
*/
|
||||
export interface Run2PcInput {
|
||||
readonly participants: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly input: Readonly<Record<string, unknown>>;
|
||||
}>;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only outcome handed back from `run2pc`. Includes the final
|
||||
* coordinator state and the participant votes — useful for callers
|
||||
* that want to surface per-participant outcomes in audit logs.
|
||||
*/
|
||||
export interface Run2PcOutcome {
|
||||
readonly txId: string;
|
||||
readonly state: Extract<
|
||||
TwoPcCoordinatorState,
|
||||
"committed" | "aborted" | "in_doubt"
|
||||
>;
|
||||
readonly votes: ReadonlyArray<{
|
||||
readonly participantId: string;
|
||||
readonly vote: TwoPcVote;
|
||||
}>;
|
||||
readonly prepareDurationMs: number;
|
||||
readonly commitDurationMs: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Vitest globalSetup — registers the Postgres fixture in the
|
||||
* globalSetup process's module scope.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration";
|
||||
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
|
||||
|
||||
createIntegrationSetup({
|
||||
postgres: new PostgresFixture(),
|
||||
});
|
||||
|
||||
export {
|
||||
setup,
|
||||
teardown,
|
||||
} from "@suaveplan/testing-integration/setup/global-setup";
|
||||
@@ -0,0 +1,33 @@
|
||||
import { inject as vitestInject } from "@suaveplan/testing/runner";
|
||||
import type { PostgresConfig } from "@suaveplan/testing-integration-postgres";
|
||||
|
||||
interface IntegrationConfigs {
|
||||
postgres: PostgresConfig;
|
||||
}
|
||||
|
||||
export function inject<K extends keyof IntegrationConfigs>(
|
||||
key: K
|
||||
): IntegrationConfigs[K] {
|
||||
const all = (vitestInject as (k: string) => IntegrationConfigs)(
|
||||
"__integration_configs__"
|
||||
);
|
||||
return all[key];
|
||||
}
|
||||
|
||||
export const postgresUnavailable: boolean = (() => {
|
||||
try {
|
||||
const failed = (vitestInject as (k: string) => string[] | undefined)(
|
||||
"__integration_failed__"
|
||||
);
|
||||
if (failed === undefined) return true;
|
||||
if (failed.includes("postgres")) return true;
|
||||
const configs = (
|
||||
vitestInject as (
|
||||
k: string
|
||||
) => Record<string, Record<string, unknown>> | undefined
|
||||
)("__integration_configs__");
|
||||
return configs === undefined || configs.postgres === undefined;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Real-Postgres integration test for the 2PC coordinator. Drives a
|
||||
* single coordinator against TWO Postgres connections (acting as two
|
||||
* participants on the same physical instance) and asserts the full
|
||||
* commit + abort + recovery scenarios end-to-end.
|
||||
*
|
||||
* NOTE: 2PC requires `max_prepared_transactions > 0` in the Postgres
|
||||
* config. The default-image fixture starts with `max_prepared_transactions=0`,
|
||||
* so this test bootstraps a fresh config and reloads it via SQL before
|
||||
* running. We can't ALTER SYSTEM SET inside an in-memory fixture without
|
||||
* restarting, so we run the prepare against an in-test-cluster ALTER
|
||||
* SYSTEM + SELECT pg_reload_conf() flow that postgres supports without
|
||||
* a full restart.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import postgres from "postgres";
|
||||
import {
|
||||
createTwoPcCoordinator,
|
||||
type LedgerDb,
|
||||
type RegisteredTwoPcParticipant,
|
||||
TwoPcParticipantRegistry,
|
||||
} from "../../src/index.js";
|
||||
import { TwoPcLedgerStore } from "../../src/persistence/persistence.js";
|
||||
import {
|
||||
buildProbeFromRegistry,
|
||||
resolveRecovery,
|
||||
} from "../../src/recovery/index.js";
|
||||
import { inject, postgresUnavailable } from "./integration-setup.js";
|
||||
|
||||
interface PgClient {
|
||||
readonly engine: "postgres";
|
||||
run<R = unknown>(sql: string, params?: ReadonlyArray<unknown>): Promise<R>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function makeClient(url: string): PgClient {
|
||||
const sql = postgres(url, { max: 4, prepare: false });
|
||||
return {
|
||||
engine: "postgres",
|
||||
async run<R>(text: string, params?: ReadonlyArray<unknown>) {
|
||||
const out = await sql.unsafe(text, params as unknown[]);
|
||||
return out as unknown as R;
|
||||
},
|
||||
async close() {
|
||||
await sql.end({ timeout: 1 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMaxPrepared(adminUrl: string): Promise<void> {
|
||||
const sql = postgres(adminUrl, { max: 1, prepare: false });
|
||||
try {
|
||||
const cur = (await sql.unsafe(
|
||||
"SHOW max_prepared_transactions"
|
||||
)) as unknown as { max_prepared_transactions: string }[];
|
||||
const current = Number.parseInt(
|
||||
cur[0]?.max_prepared_transactions ?? "0",
|
||||
10
|
||||
);
|
||||
if (Number.isFinite(current) && current > 0) return;
|
||||
// ALTER SYSTEM requires a restart for max_prepared_transactions.
|
||||
// For a fresh fixture this is the first run; we cannot reload
|
||||
// dynamically, so we surface that the test cannot proceed.
|
||||
throw new Error(
|
||||
"max_prepared_transactions=0 on the Postgres fixture; cannot run 2PC integration tests"
|
||||
);
|
||||
} finally {
|
||||
await sql.end({ timeout: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
function makeLedger(): { ledgerDb: LedgerDb; ledger: TwoPcLedgerStore } {
|
||||
const db = new Database(":memory:") as unknown as LedgerDb;
|
||||
db.run(`CREATE TABLE IF NOT EXISTS suaveplan_2pc_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
coordinator TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
participants_json TEXT NOT NULL,
|
||||
prepared_at TEXT,
|
||||
committed_at TEXT,
|
||||
aborted_at TEXT
|
||||
)`);
|
||||
return { ledgerDb: db, ledger: new TwoPcLedgerStore(db) };
|
||||
}
|
||||
|
||||
describe.skipIf(postgresUnavailable)("real Postgres 2PC coordinator", () => {
|
||||
it("commits successfully across two participants", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { url } = inject("postgres");
|
||||
await ensureMaxPrepared(url);
|
||||
const c1 = makeClient(url);
|
||||
const c2 = makeClient(url);
|
||||
await c1.run("CREATE TABLE IF NOT EXISTS twopc_smoke_a (n int)");
|
||||
await c2.run("CREATE TABLE IF NOT EXISTS twopc_smoke_b (n int)");
|
||||
await c1.run("DELETE FROM twopc_smoke_a");
|
||||
await c2.run("DELETE FROM twopc_smoke_b");
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
const a: RegisteredTwoPcParticipant = {
|
||||
id: "a",
|
||||
voterMode: "writer",
|
||||
connection: c1,
|
||||
prepare: async (txId) => {
|
||||
await c1.run("BEGIN");
|
||||
await c1.run("INSERT INTO twopc_smoke_a (n) VALUES (1)");
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
};
|
||||
const b: RegisteredTwoPcParticipant = {
|
||||
id: "b",
|
||||
voterMode: "writer",
|
||||
connection: c2,
|
||||
prepare: async (txId) => {
|
||||
await c2.run("BEGIN");
|
||||
await c2.run("INSERT INTO twopc_smoke_b (n) VALUES (2)");
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
};
|
||||
registry.register(a);
|
||||
registry.register(b);
|
||||
|
||||
const { ledgerDb } = makeLedger();
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: { hostInstanceId: "host-it" },
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () =>
|
||||
`tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
|
||||
const outcome = await coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "a", input: {} },
|
||||
{ id: "b", input: {} },
|
||||
],
|
||||
});
|
||||
expect(outcome.state).toBe("committed");
|
||||
const rowsA = (await c1.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_a"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
const rowsB = (await c2.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_b"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
expect(rowsA[0]?.c).toBe(1);
|
||||
expect(rowsB[0]?.c).toBe(1);
|
||||
|
||||
await c1.close();
|
||||
await c2.close();
|
||||
});
|
||||
|
||||
it("rolls back everywhere when one participant votes abort", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { url } = inject("postgres");
|
||||
const c1 = makeClient(url);
|
||||
const c2 = makeClient(url);
|
||||
await c1.run("CREATE TABLE IF NOT EXISTS twopc_smoke_c (n int)");
|
||||
await c2.run("CREATE TABLE IF NOT EXISTS twopc_smoke_d (n int)");
|
||||
await c1.run("DELETE FROM twopc_smoke_c");
|
||||
await c2.run("DELETE FROM twopc_smoke_d");
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
registry.register({
|
||||
id: "c",
|
||||
voterMode: "writer",
|
||||
connection: c1,
|
||||
prepare: async () => {
|
||||
await c1.run("BEGIN");
|
||||
await c1.run("INSERT INTO twopc_smoke_c (n) VALUES (3)");
|
||||
return { vote: "commit", writeIntent: true };
|
||||
},
|
||||
});
|
||||
registry.register({
|
||||
id: "d",
|
||||
voterMode: "writer",
|
||||
connection: c2,
|
||||
prepare: async () => {
|
||||
return { vote: "abort", writeIntent: false };
|
||||
},
|
||||
});
|
||||
|
||||
const { ledgerDb } = makeLedger();
|
||||
const coordinator = createTwoPcCoordinator({
|
||||
options: { hostInstanceId: "host-it" },
|
||||
registry,
|
||||
ledgerDb,
|
||||
clock: () => Date.now(),
|
||||
idGenerator: () =>
|
||||
`tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.run2pc({
|
||||
participants: [
|
||||
{ id: "c", input: {} },
|
||||
{ id: "d", input: {} },
|
||||
],
|
||||
})
|
||||
).rejects.toMatchObject({ twoPcCode: "TWOPC.VOTE_ABORT" });
|
||||
|
||||
const rowsC = (await c1.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_c"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
expect(rowsC[0]?.c).toBe(0);
|
||||
|
||||
await c1.close();
|
||||
await c2.close();
|
||||
});
|
||||
|
||||
it("recovery commits an orphan whose ledger row says committing", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { url } = inject("postgres");
|
||||
const c1 = makeClient(url);
|
||||
await c1.run("CREATE TABLE IF NOT EXISTS twopc_smoke_e (n int)");
|
||||
await c1.run("DELETE FROM twopc_smoke_e");
|
||||
|
||||
const { ledger } = makeLedger();
|
||||
// Simulate a crash: PREPARE the transaction, write the ledger row
|
||||
// as "committing", but never run COMMIT PREPARED.
|
||||
const txId = `recover-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
const gid = `2pc-host-it-${txId}-e`;
|
||||
await c1.run("BEGIN");
|
||||
await c1.run("INSERT INTO twopc_smoke_e (n) VALUES (42)");
|
||||
await c1.run(`PREPARE TRANSACTION '${gid.replace(/'/g, "''")}'`);
|
||||
ledger.insert({
|
||||
id: txId,
|
||||
coordinator: "host-it",
|
||||
participants: [
|
||||
{ participantId: "e", voterMode: "writer", vote: "commit" },
|
||||
],
|
||||
});
|
||||
ledger.markAllVotedCommit({
|
||||
id: txId,
|
||||
participants: [
|
||||
{ participantId: "e", voterMode: "writer", vote: "commit" },
|
||||
],
|
||||
preparedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const registry = new TwoPcParticipantRegistry(() => true);
|
||||
registry.register({
|
||||
id: "e",
|
||||
voterMode: "writer",
|
||||
connection: c1,
|
||||
prepare: async () => ({ vote: "commit", writeIntent: true }),
|
||||
});
|
||||
const report = await resolveRecovery({
|
||||
hostInstanceId: "host-it",
|
||||
ledger,
|
||||
probes: buildProbeFromRegistry(registry),
|
||||
});
|
||||
expect(report.committed).toContain(gid);
|
||||
const rows = (await c1.run(
|
||||
"SELECT count(*)::int as c FROM twopc_smoke_e"
|
||||
)) as ReadonlyArray<{ c: number }>;
|
||||
expect(rows[0]?.c).toBe(1);
|
||||
|
||||
await c1.close();
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-2pc/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-2pc/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/api-stack/api/api-2pc/vite.config.ts
Normal file
37
packages/api-stack/api/api-2pc/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-2pc",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^node:/,
|
||||
/^@opentelemetry\//,
|
||||
"tslib",
|
||||
"zod",
|
||||
"postgres",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-2pc/vitest.config.ts
Normal file
31
packages/api-stack/api/api-2pc/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**", "tests/integration/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
19
packages/api-stack/api/api-2pc/vitest.integration.config.ts
Normal file
19
packages/api-stack/api/api-2pc/vitest.integration.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const globalSetup = fileURLToPath(
|
||||
new URL("./tests/integration/global-setup.ts", import.meta.url)
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
globalSetup: [globalSetup],
|
||||
include: ["tests/integration/**/*.integration.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**", "src/**"],
|
||||
testTimeout: 90000,
|
||||
hookTimeout: 180000,
|
||||
},
|
||||
});
|
||||
85
packages/api-stack/api/api-audit-file/README.md
Normal file
85
packages/api-stack/api/api-audit-file/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @suaveplan/api-audit-file — audit sink writing JSON-per-line, HMAC-SHA-256 signed records to a rotated file. Size + time rotation, maxFiles retention, bounded-queue backpressure with observable drops, append + strict-sync write modes. Implements `RequestAuditSink` from `@suaveplan/api-host/request-pipeline`.
|
||||
|
||||
File-rotating, HMAC-signed audit sink for `@suaveplan/api-host`.
|
||||
Implements `RequestAuditSink` from
|
||||
`@suaveplan/api-host/request-pipeline`: writes one JSON line per
|
||||
record with an HMAC-SHA-256 signature, rolls the file on a size OR age
|
||||
trigger, and prunes the oldest rotated siblings to a configurable
|
||||
`maxFiles` retention cap.
|
||||
|
||||
Suitable for single-host deployments, on-prem environments without a
|
||||
log shipper, and any host that prefers durable local storage of the
|
||||
audit trail. The sibling `@suaveplan/api-audit-stdout` covers the
|
||||
log-shipped use case; both sinks share the wire shape so dashboards
|
||||
union on metric name and partition by the `sink` attribute.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of the six stable `AUDIT_FILE.*` error codes raised by the file-rotating audit sink, plus the concrete…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of the six stable `AUDIT_FILE.*` error codes raised by the file-rotating audit sink, plus the concrete…
|
||||
- [fs-helpers](./src/fs-helpers/fs-helpers.md) — Isolates the `node:fs` surface the sink touches behind an injectable `FsAdapter` interface and concrete `nodeFsAdapter`…
|
||||
- [fs-helpers](./src/fs-helpers/fs-helpers.md) — Isolates the `node:fs` surface the sink touches behind an injectable `FsAdapter` interface and concrete `nodeFsAdapter`…
|
||||
- [hmac](./src/hmac/hmac.md) — Wraps `@suaveplan/crypto-hash` HMAC-SHA-256 into the two operations the file sink needs: signing a canonical record on…
|
||||
- [hmac](./src/hmac/hmac.md) — Wraps `@suaveplan/crypto-hash` HMAC-SHA-256 into the two operations the file sink needs: signing a canonical record on…
|
||||
- [instruments](./src/instruments/instruments.md) — Constructs the OTel counter bundle the file sink emits and exposes the canonical `sink="file"` attribute set. Every obs…
|
||||
- [instruments](./src/instruments/instruments.md) — Constructs the OTel counter bundle the file sink emits and exposes the canonical `sink="file"` attribute set. Every obs…
|
||||
- [retention](./src/retention/retention.md) — Enforces the `maxFiles` retention dimension of the rotation policy by deleting the oldest rotated files until at most `…
|
||||
- [retention](./src/retention/retention.md) — Enforces the `maxFiles` retention dimension of the rotation policy by deleting the oldest rotated files until at most `…
|
||||
- [rotation](./src/rotation/rotation.md) — Resolves the rotation policy carried by every `createFileAuditSink` instance and implements the rotation primitive itse…
|
||||
- [rotation](./src/rotation/rotation.md) — Resolves the rotation policy carried by every `createFileAuditSink` instance and implements the rotation primitive itse…
|
||||
- [schema](./src/schema/schema.md) — Defines the Zod schema and canonical-bytes function that bracket every audit record before it is signed and written. Th…
|
||||
- [schema](./src/schema/schema.md) — Defines the Zod schema and canonical-bytes function that bracket every audit record before it is signed and written. Th…
|
||||
- [sink](./src/sink/sink.md) — The public entry point of `@suaveplan/api-audit-file`. The `createFileAuditSink` factory composes the schema, hmac, rot…
|
||||
- [sink](./src/sink/sink.md) — The public entry point of `@suaveplan/api-audit-file`. The `createFileAuditSink` factory composes the schema, hmac, rot…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-audit-file
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```ts
|
||||
import { createFileAuditSink } from "@suaveplan/api-audit-file/sink";
|
||||
|
||||
const sink = await createFileAuditSink({
|
||||
path: "/var/log/suaveplan/audit.log",
|
||||
hmacKey: new TextEncoder().encode(process.env.AUDIT_HMAC_KEY ?? ""),
|
||||
rotation: { maxSizeBytes: 100 * 1024 * 1024, maxAgeMs: 86_400_000, maxFiles: 30 },
|
||||
appendMode: "strict-sync",
|
||||
});
|
||||
|
||||
await sink.emit(envelopeFromHostPipeline);
|
||||
await sink.dispose();
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `./sink` — `createFileAuditSink`, `FileAuditSink`, `CreateFileAuditSinkOptions`, `AppendMode`.
|
||||
- `./rotation` — `RotationPolicy`, `RotationState`, `resolveRotationPolicy`, `shouldRotate`, `rotate`.
|
||||
- `./retention` — `enforceRetention`.
|
||||
- `./schema` — `AuditRecord`, `AuditRecordSchema`, `canonicalise`, `buildSignedLine`.
|
||||
- `./hmac` — `signRecord`, `verifyRecord`.
|
||||
- `./instruments` — `AuditFileInstruments`, `createAuditFileInstruments`, `SINK_ATTRIBUTE`.
|
||||
- `./fs-helpers` — `FsAdapter`, `nodeFsAdapter`, `listRotatedFiles`, `rotatedFilePathAt`.
|
||||
- `./error-codes` — `AUDIT_FILE_CODES`, `AuditFileError`, `AuditFileCode`.
|
||||
|
||||
## Error codes
|
||||
|
||||
`AUDIT_FILE.INVALID_RECORD`, `AUDIT_FILE.WRITER_FAILED`,
|
||||
`AUDIT_FILE.QUEUE_FULL`, `AUDIT_FILE.ROTATION_FAILED`,
|
||||
`AUDIT_FILE.RETENTION_FAILED`, `AUDIT_FILE.PATH_NOT_WRITEABLE`.
|
||||
|
||||
## OTel instruments
|
||||
|
||||
- `api.audit.records_total{sink="file"}`
|
||||
- `api.audit.dropped_total{sink="file"}`
|
||||
- `api.audit.rotation_evicted_total{sink="file"}`
|
||||
|
||||
## Tier
|
||||
|
||||
Server-tier — depends on `node:fs/promises`.
|
||||
|
||||
108
packages/api-stack/api/api-audit-file/package.json
Normal file
108
packages/api-stack/api/api-audit-file/package.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "@suaveplan/api-audit-file",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-8 audit sink writing JSON-per-line, HMAC-SHA-256 signed records to a rotated file. Size + time rotation, maxFiles retention, bounded-queue backpressure with observable drops, append + strict-sync write modes. Implements `RequestAuditSink` from `@suaveplan/api-host/request-pipeline`.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./sink": {
|
||||
"import": {
|
||||
"types": "./dist/sink/sink.d.ts",
|
||||
"default": "./dist/sink/sink.js"
|
||||
}
|
||||
},
|
||||
"./rotation": {
|
||||
"import": {
|
||||
"types": "./dist/rotation/rotation.d.ts",
|
||||
"default": "./dist/rotation/rotation.js"
|
||||
}
|
||||
},
|
||||
"./retention": {
|
||||
"import": {
|
||||
"types": "./dist/retention/retention.d.ts",
|
||||
"default": "./dist/retention/retention.js"
|
||||
}
|
||||
},
|
||||
"./schema": {
|
||||
"import": {
|
||||
"types": "./dist/schema/schema.d.ts",
|
||||
"default": "./dist/schema/schema.js"
|
||||
}
|
||||
},
|
||||
"./hmac": {
|
||||
"import": {
|
||||
"types": "./dist/hmac/hmac.d.ts",
|
||||
"default": "./dist/hmac/hmac.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./instruments": {
|
||||
"import": {
|
||||
"types": "./dist/instruments/instruments.d.ts",
|
||||
"default": "./dist/instruments/instruments.js"
|
||||
}
|
||||
},
|
||||
"./fs-helpers": {
|
||||
"import": {
|
||||
"types": "./dist/fs-helpers/fs-helpers.d.ts",
|
||||
"default": "./dist/fs-helpers/fs-helpers.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/crypto-hash": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of the six stable `AUDIT_FILE.*` error codes raised by
|
||||
the file-rotating audit sink, plus the concrete
|
||||
`AuditFileError extends SuaveplanError` subclass that carries them.
|
||||
Operators rely on the codes for dashboard slicing; package consumers
|
||||
rely on the typed subclass to differentiate sink failures from generic
|
||||
host errors without parsing message strings.
|
||||
|
||||
The registry is built with `defineCodes("AUDIT_FILE", [...])` from
|
||||
`@suaveplan/error`, so every code is recorded in the global registry
|
||||
exactly once at module load. The OpenSpec conformance gate verifies
|
||||
every entry here is also raised by a production code path elsewhere in
|
||||
`src/` — no dead codes are permitted.
|
||||
|
||||
## Features
|
||||
|
||||
- Six stable codes: `INVALID_RECORD`, `WRITER_FAILED`, `QUEUE_FULL`,
|
||||
`ROTATION_FAILED`, `RETENTION_FAILED`, `PATH_NOT_WRITEABLE`.
|
||||
- Typed union `AuditFileCode` for narrowing on `error.code`.
|
||||
- `AuditFileError` subclass with stable `error.name === "AuditFileError"`.
|
||||
- Forwards `cause`, `context`, `redacted`, and other
|
||||
`SuaveplanErrorOptions` to the base constructor unchanged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "@suaveplan/api-audit-file/error-codes";
|
||||
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE,
|
||||
"error.audit.file.path_not_writeable",
|
||||
{ context: { path: "/var/log/suaveplan/audit.log" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Narrow on the union after catching to drive code-specific recovery:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
type AuditFileCode,
|
||||
AuditFileError,
|
||||
} from "@suaveplan/api-audit-file/error-codes";
|
||||
|
||||
function recover(err: unknown): "retry" | "drop" {
|
||||
if (!(err instanceof AuditFileError)) throw err;
|
||||
const code: AuditFileCode = err.code;
|
||||
if (code === AUDIT_FILE_CODES.QUEUE_FULL) return "drop";
|
||||
if (code === AUDIT_FILE_CODES.WRITER_FAILED) return "retry";
|
||||
if (code === AUDIT_FILE_CODES.ROTATION_FAILED) return "retry";
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUDIT_FILE_CODES: Readonly<Record<Name, "AUDIT_FILE.<Name>">>`
|
||||
- `type AuditFileCode = (typeof AUDIT_FILE_CODES)[keyof typeof AUDIT_FILE_CODES]`
|
||||
- `AUDIT_FILE_ERROR_NAME = "AuditFileError"`
|
||||
- `class AuditFileError extends SuaveplanError<AuditFileCode>`
|
||||
- `new AuditFileError(code, messageKey, options?)`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are declared `as const` so TypeScript freezes both the keys
|
||||
and the fully-qualified string values. The `name` literal is exported
|
||||
separately because some structured loggers group errors on `name`
|
||||
before they inspect `code` — pinning a constant keeps that grouping
|
||||
stable across releases. The module is side-effect-imported from
|
||||
`src/index.ts` so a consumer who only loads a subpath
|
||||
(`@suaveplan/api-audit-file/sink`) still triggers code registration.
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen registry of stable error codes raised by the
|
||||
* `@suaveplan/api-audit-file` sink. Codes are documented in the design
|
||||
* doc (§12.5) and surface verbatim on `RequestAuditSink` callers and
|
||||
* operator dashboards so post-hoc triage of audit-write failures is
|
||||
* trivial.
|
||||
*
|
||||
* - `INVALID_RECORD` — the record handed to `emit()` failed Zod
|
||||
* validation against the canonical `RequestAuditEnvelope` shape.
|
||||
* - `WRITER_FAILED` — a low-level `node:fs` write surfaced an `EIO`,
|
||||
* `ENOSPC`, or other error after the file was opened successfully.
|
||||
* - `QUEUE_FULL` — the bounded in-memory queue rejected the record
|
||||
* because the writer is stalled and `maxQueueSize` is exceeded.
|
||||
* Always paired with a `dropped_total` increment.
|
||||
* - `ROTATION_FAILED` — size or time rotation failed mid-rename. The
|
||||
* sink stays operational against the current file; the next write
|
||||
* re-attempts rotation.
|
||||
* - `RETENTION_FAILED` — `maxFiles` eviction failed to delete the
|
||||
* oldest rotated file. The sink keeps writing the current file; the
|
||||
* surplus file is reported via the OTel attribute on the next eviction.
|
||||
* - `PATH_NOT_WRITEABLE` — sink construction discovered the path's
|
||||
* parent directory is missing, not a directory, or not writeable.
|
||||
* Raised synchronously from `createFileAuditSink` so misconfiguration
|
||||
* fails fast at host boot.
|
||||
*/
|
||||
export const AUDIT_FILE_CODES = defineCodes("AUDIT_FILE", [
|
||||
"INVALID_RECORD",
|
||||
"WRITER_FAILED",
|
||||
"QUEUE_FULL",
|
||||
"ROTATION_FAILED",
|
||||
"RETENTION_FAILED",
|
||||
"PATH_NOT_WRITEABLE",
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* Union of fully-qualified `AUDIT_FILE.*` codes. Use as the `code`
|
||||
* field type on any `AuditFileError` throwable.
|
||||
*/
|
||||
export type AuditFileCode =
|
||||
(typeof AUDIT_FILE_CODES)[keyof typeof AUDIT_FILE_CODES];
|
||||
|
||||
/** Stable `error.name` value for every throwable this package emits. */
|
||||
export const AUDIT_FILE_ERROR_NAME = "AuditFileError" as const;
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for file-audit failures. Mirrors
|
||||
* the shape of every other api-stack error class so callers may
|
||||
* `instanceof`-check or code-match without parsing message strings.
|
||||
*/
|
||||
export class AuditFileError extends SuaveplanError<AuditFileCode> {
|
||||
public constructor(
|
||||
code: AuditFileCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = AUDIT_FILE_ERROR_NAME;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
AUDIT_FILE_CODES,
|
||||
AUDIT_FILE_ERROR_NAME,
|
||||
type AuditFileCode,
|
||||
AuditFileError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
# `fs-helpers`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Isolates the `node:fs` surface the sink touches behind an injectable
|
||||
`FsAdapter` interface and concrete `nodeFsAdapter` implementation. The
|
||||
indirection lets tests run the sink against a real filesystem under a
|
||||
temp directory (the default for this package's test suite) while
|
||||
keeping a clean seam for in-memory adapters used by external consumers.
|
||||
|
||||
The module also exports the rotated-file enumeration helper used by
|
||||
the retention module — keeping the file-name conventions next to the
|
||||
filesystem primitives means rotation, retention, and sink all share
|
||||
the same view of "what counts as a rotated sibling of this base path".
|
||||
|
||||
## Features
|
||||
|
||||
- `FsAdapter` — six-method surface: `appendLine`, `fileSize`, `rename`,
|
||||
`unlink`, `readdir`, `ensureWriteableDir`.
|
||||
- `nodeFsAdapter` — production adapter against `node:fs/promises`.
|
||||
- `listRotatedFiles(fs, basePath)` — returns full paths of every
|
||||
rotated sibling, sorted oldest-first by embedded timestamp.
|
||||
- `rotatedFilePathAt(basePath, iso)` — builds the rotated path for the
|
||||
supplied ISO timestamp; `:` is replaced with `-` so the name is
|
||||
Windows-safe and grep-friendly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { nodeFsAdapter } from "@suaveplan/api-audit-file/fs-helpers";
|
||||
|
||||
await nodeFsAdapter.ensureWriteableDir("/var/log/suaveplan");
|
||||
await nodeFsAdapter.appendLine("/var/log/suaveplan/audit.log", "...\n", false);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Provide an injectable adapter to drive the sink against an in-memory
|
||||
filesystem:
|
||||
|
||||
```ts
|
||||
import type { FsAdapter } from "@suaveplan/api-audit-file/fs-helpers";
|
||||
|
||||
const inMemory = new Map<string, string>();
|
||||
const adapter: FsAdapter = {
|
||||
async appendLine(p, line) {
|
||||
inMemory.set(p, (inMemory.get(p) ?? "") + line);
|
||||
},
|
||||
async fileSize(p) {
|
||||
return Buffer.byteLength(inMemory.get(p) ?? "", "utf8");
|
||||
},
|
||||
async rename(from, to) {
|
||||
inMemory.set(to, inMemory.get(from) ?? "");
|
||||
inMemory.delete(from);
|
||||
},
|
||||
async unlink(p) {
|
||||
inMemory.delete(p);
|
||||
},
|
||||
async readdir() {
|
||||
return [...inMemory.keys()];
|
||||
},
|
||||
async ensureWriteableDir() {
|
||||
/* always writeable */
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface FsAdapter` — see Features above.
|
||||
- `const nodeFsAdapter: FsAdapter`
|
||||
- `listRotatedFiles(fs: FsAdapter, basePath: string): Promise<string[]>`
|
||||
- `rotatedFilePathAt(basePath: string, isoTimestamp: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ensureWriteableDir` calls `mkdir(..., { recursive: true })` then a
|
||||
`W_OK` `access` probe so a fresh boot creates the directory and a
|
||||
borked permission setup throws `PATH_NOT_WRITEABLE` synchronously
|
||||
during sink construction. `fileSize` swallows `ENOENT` and returns `0`
|
||||
so the rotation logic naturally treats a not-yet-created file as
|
||||
empty — that simplifies the open-on-first-write code path.
|
||||
@@ -0,0 +1,133 @@
|
||||
import { chmod, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
listRotatedFiles,
|
||||
nodeFsAdapter,
|
||||
rotatedFilePathAt,
|
||||
} from "./fs-helpers.js";
|
||||
|
||||
async function freshDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), "audit-fs-"));
|
||||
}
|
||||
|
||||
describe("api-audit-file fs-helpers", () => {
|
||||
it("ensureWriteableDir creates the dir then probes W_OK", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const dir = await freshDir();
|
||||
const sub = join(dir, "deep", "nested");
|
||||
await nodeFsAdapter.ensureWriteableDir(sub);
|
||||
await writeFile(join(sub, "ok.txt"), "yes", "utf8");
|
||||
});
|
||||
|
||||
it("ensureWriteableDir raises PATH_NOT_WRITEABLE on permission denied", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const dir = await freshDir();
|
||||
await chmod(dir, 0o400);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await nodeFsAdapter.ensureWriteableDir(join(dir, "child"));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
} finally {
|
||||
await chmod(dir, 0o700);
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditFileError);
|
||||
expect((caught as AuditFileError).code).toBe(
|
||||
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("fileSize returns the byte length of an existing file", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const dir = await freshDir();
|
||||
const path = join(dir, "x.log");
|
||||
await writeFile(path, "0123456789", "utf8");
|
||||
const size = await nodeFsAdapter.fileSize(path);
|
||||
expect(size).toBe(10);
|
||||
});
|
||||
|
||||
it("fileSize returns 0 for a non-existent path", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const dir = await freshDir();
|
||||
const size = await nodeFsAdapter.fileSize(join(dir, "absent.log"));
|
||||
expect(size).toBe(0);
|
||||
});
|
||||
|
||||
it("rename moves a file between paths", { timeout: 30_000 }, async () => {
|
||||
const dir = await freshDir();
|
||||
const a = join(dir, "a.log");
|
||||
const b = join(dir, "b.log");
|
||||
await writeFile(a, "hello", "utf8");
|
||||
await nodeFsAdapter.rename(a, b);
|
||||
expect(await nodeFsAdapter.fileSize(b)).toBe(5);
|
||||
expect(await nodeFsAdapter.fileSize(a)).toBe(0);
|
||||
});
|
||||
|
||||
it("unlink removes a file", { timeout: 30_000 }, async () => {
|
||||
const dir = await freshDir();
|
||||
const path = join(dir, "x.log");
|
||||
await writeFile(path, "x", "utf8");
|
||||
await nodeFsAdapter.unlink(path);
|
||||
expect(await nodeFsAdapter.fileSize(path)).toBe(0);
|
||||
});
|
||||
|
||||
it("readdir enumerates directory entries", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const dir = await freshDir();
|
||||
await writeFile(join(dir, "a"), "a", "utf8");
|
||||
await writeFile(join(dir, "b"), "b", "utf8");
|
||||
const entries = await nodeFsAdapter.readdir(dir);
|
||||
expect(entries.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("listRotatedFiles returns [] when readdir throws", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const failingAdapter = {
|
||||
...nodeFsAdapter,
|
||||
async readdir() {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const out = await listRotatedFiles(failingAdapter, "/tmp/x/audit.log");
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("listRotatedFiles ignores the active file and non-rotated siblings", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const dir = await freshDir();
|
||||
const basePath = join(dir, "audit.log");
|
||||
await writeFile(basePath, "active", "utf8");
|
||||
await writeFile(
|
||||
`${basePath}.2026-05-26T10-00-00.000Z.log`,
|
||||
"x",
|
||||
"utf8"
|
||||
);
|
||||
await writeFile(join(dir, "README.md"), "doc", "utf8");
|
||||
const out = await listRotatedFiles(nodeFsAdapter, basePath);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toContain("audit.log.2026-05-26T10-00-00.000Z.log");
|
||||
});
|
||||
|
||||
it("rotatedFilePathAt replaces colons with hyphens", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const path = rotatedFilePathAt(
|
||||
"/var/log/audit.log",
|
||||
"2026-05-26T12:00:00.000Z"
|
||||
);
|
||||
expect(path).toBe("/var/log/audit.log.2026-05-26T12-00-00.000Z.log");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { constants } from "node:fs";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
open,
|
||||
readdir,
|
||||
rename,
|
||||
stat,
|
||||
unlink,
|
||||
} from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Injectable adapter so the sink can run against an in-memory filesystem
|
||||
* in tests when desired. All production paths default to `node:fs`
|
||||
* primitives wired in {@link nodeFsAdapter}.
|
||||
*/
|
||||
export interface FsAdapter {
|
||||
appendLine(path: string, line: string, syncOnWrite: boolean): Promise<void>;
|
||||
fileSize(path: string): Promise<number>;
|
||||
rename(from: string, to: string): Promise<void>;
|
||||
unlink(path: string): Promise<void>;
|
||||
readdir(dir: string): Promise<string[]>;
|
||||
ensureWriteableDir(dir: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete `node:fs.promises` adapter. Production sinks construct this
|
||||
* once during boot; tests may swap in an in-memory shim that implements
|
||||
* the same surface.
|
||||
*/
|
||||
export const nodeFsAdapter: FsAdapter = {
|
||||
async appendLine(path, line, syncOnWrite) {
|
||||
const handle = await open(path, "a");
|
||||
try {
|
||||
await handle.appendFile(line, "utf8");
|
||||
if (syncOnWrite) await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
async fileSize(path) {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
async rename(from, to) {
|
||||
await rename(from, to);
|
||||
},
|
||||
async unlink(path) {
|
||||
await unlink(path);
|
||||
},
|
||||
async readdir(dir) {
|
||||
return readdir(dir);
|
||||
},
|
||||
async ensureWriteableDir(dir) {
|
||||
try {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await access(dir, constants.W_OK);
|
||||
} catch (cause) {
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.PATH_NOT_WRITEABLE,
|
||||
"error.audit.file.path_not_writeable",
|
||||
{ context: { dir }, cause }
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* List the rotated-file siblings of `basePath`, returning their full
|
||||
* paths sorted oldest-first (ascending by the embedded ISO timestamp).
|
||||
* Files NOT matching the rotation suffix `<base>.<utc-iso>.log` are
|
||||
* ignored so an operator's `.gitkeep` or unrelated content in the
|
||||
* audit directory does not throw the retention logic off.
|
||||
*/
|
||||
export async function listRotatedFiles(
|
||||
fs: FsAdapter,
|
||||
basePath: string
|
||||
): Promise<string[]> {
|
||||
const dir = dirname(basePath);
|
||||
const base = basename(basePath);
|
||||
const prefix = `${base}.`;
|
||||
const suffix = ".log";
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const matches = entries.filter(
|
||||
(e) => e.startsWith(prefix) && e.endsWith(suffix) && e !== base
|
||||
);
|
||||
matches.sort();
|
||||
return matches.map((m) => join(dir, m));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rotated-file name for `basePath` at the supplied
|
||||
* `Date`-rendered timestamp. The timestamp is the file's ROTATION
|
||||
* moment (i.e. the close time of the segment), not the open time.
|
||||
*/
|
||||
export function rotatedFilePathAt(
|
||||
basePath: string,
|
||||
isoTimestamp: string
|
||||
): string {
|
||||
return `${basePath}.${isoTimestamp.replace(/:/g, "-")}.log`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type FsAdapter,
|
||||
listRotatedFiles,
|
||||
nodeFsAdapter,
|
||||
rotatedFilePathAt,
|
||||
} from "./fs-helpers.js";
|
||||
76
packages/api-stack/api/api-audit-file/src/hmac/hmac.md
Normal file
76
packages/api-stack/api/api-audit-file/src/hmac/hmac.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# `hmac`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wraps `@suaveplan/crypto-hash` HMAC-SHA-256 into the two operations the
|
||||
file sink needs: signing a canonical record on write and verifying a
|
||||
record + signature on read. The wrapper hex-encodes the digest so the
|
||||
on-disk line stays human-greppable and ASCII-clean, and exposes a
|
||||
constant-time verifier so tampering checks do not leak position
|
||||
information through timing.
|
||||
|
||||
## Features
|
||||
|
||||
- `signRecord(key, bytes): string` — 64-char lowercase hex of
|
||||
HMAC-SHA-256 over `bytes` using `key`.
|
||||
- `verifyRecord(key, bytes, hmacHex): boolean` — constant-time
|
||||
comparison of recomputed HMAC against the provided hex.
|
||||
- Empty-key guard raises a typed `Error` so misconfiguration cannot
|
||||
silently produce a deterministic null-MAC.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { signRecord, verifyRecord } from "@suaveplan/api-audit-file/hmac";
|
||||
|
||||
const key = new TextEncoder().encode("super-secret-rotating-key");
|
||||
const bytes = new TextEncoder().encode('{"requestId":"r-1"}');
|
||||
const hmac = signRecord(key, bytes);
|
||||
verifyRecord(key, bytes, hmac); // → true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use with the canonical-bytes helper to verify an on-disk line:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AuditRecordSchema,
|
||||
canonicalise,
|
||||
} from "@suaveplan/api-audit-file/schema";
|
||||
import { verifyRecord } from "@suaveplan/api-audit-file/hmac";
|
||||
|
||||
for await (const line of lines) {
|
||||
const { record, hmac } = JSON.parse(line) as {
|
||||
record: unknown;
|
||||
hmac: string;
|
||||
};
|
||||
const parsed = AuditRecordSchema.parse(record);
|
||||
const ok = verifyRecord(key, canonicalise(parsed), hmac);
|
||||
if (!ok) throw new Error("hmac mismatch");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `signRecord(key: Uint8Array, bytes: Uint8Array): string`
|
||||
- `verifyRecord(key: Uint8Array, bytes: Uint8Array, hmacHex: string): boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hex encoder is hand-rolled rather than borrowed from
|
||||
`@suaveplan/crypto-utils` so this module stays a leaf of the dependency
|
||||
graph — the sink runs on every audit-emitting request, and keeping the
|
||||
hot path's transitive deps minimal helps cold-start latency on
|
||||
serverless deploys. Constant-time comparison XORs each character code
|
||||
and accumulates into a bitmask; the boolean conversion at the end keeps
|
||||
the early-return branch (length mismatch) bounded to attacker-visible
|
||||
metadata only. The 64-character length check up front is intentionally
|
||||
outside the constant-time region because the length of the supplied
|
||||
HMAC is not secret — an attacker who supplies a malformed hex string
|
||||
sees an immediate rejection but learns nothing about the recomputed
|
||||
digest.
|
||||
69
packages/api-stack/api/api-audit-file/src/hmac/hmac.test.ts
Normal file
69
packages/api-stack/api/api-audit-file/src/hmac/hmac.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { signRecord, verifyRecord } from "./hmac.js";
|
||||
|
||||
const KEY = new TextEncoder().encode("super-secret-rotating-key");
|
||||
const ALT_KEY = new TextEncoder().encode("another-key");
|
||||
|
||||
describe("api-audit-file hmac", () => {
|
||||
it("produces a 64-char lowercase hex digest", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode('{"requestId":"r-1"}');
|
||||
const out = signRecord(KEY, bytes);
|
||||
expect(out).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("is deterministic for the same key + bytes", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("payload");
|
||||
expect(signRecord(KEY, bytes)).toBe(signRecord(KEY, bytes));
|
||||
});
|
||||
|
||||
it("differs when the key changes", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("payload");
|
||||
expect(signRecord(KEY, bytes)).not.toBe(signRecord(ALT_KEY, bytes));
|
||||
});
|
||||
|
||||
it("rejects an empty key with a descriptive throw", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => signRecord(new Uint8Array(0), new Uint8Array(4))).toThrow(
|
||||
/key/
|
||||
);
|
||||
});
|
||||
|
||||
it("verifies a freshly-signed record", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("payload");
|
||||
const hex = signRecord(KEY, bytes);
|
||||
expect(verifyRecord(KEY, bytes, hex)).toBe(true);
|
||||
});
|
||||
|
||||
it("tampering invalidates HMAC", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode('{"requestId":"r-1"}');
|
||||
const hex = signRecord(KEY, bytes);
|
||||
const tampered = new Uint8Array(bytes);
|
||||
tampered[1] = (tampered[1] as number) ^ 0x01;
|
||||
expect(verifyRecord(KEY, tampered, hex)).toBe(false);
|
||||
});
|
||||
|
||||
it("fails verification on a wrong-length hex", { timeout: 5000 }, () => {
|
||||
const bytes = new TextEncoder().encode("payload");
|
||||
expect(verifyRecord(KEY, bytes, "deadbeef")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails verification on a same-length but mismatched hex", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = new TextEncoder().encode("payload");
|
||||
const wrong = "0".repeat(64);
|
||||
expect(verifyRecord(KEY, bytes, wrong)).toBe(false);
|
||||
});
|
||||
|
||||
it("zero-pads single-hex-digit bytes", { timeout: 5000 }, () => {
|
||||
// Trigger the `b < 16` branch in bytesToHex deterministically by
|
||||
// signing inputs that lead to at least one < 0x10 digest byte;
|
||||
// statistically inevitable but we verify the regex on output.
|
||||
for (let i = 0; i < 32; i += 1) {
|
||||
const bytes = new Uint8Array([i]);
|
||||
const hex = signRecord(KEY, bytes);
|
||||
expect(hex).toMatch(/^[0-9a-f]{64}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
45
packages/api-stack/api/api-audit-file/src/hmac/hmac.ts
Normal file
45
packages/api-stack/api/api-audit-file/src/hmac/hmac.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { hmac as cryptoHmac } from "@suaveplan/crypto-hash";
|
||||
|
||||
/**
|
||||
* HMAC-SHA-256 over the canonical record bytes. Returns lowercase hex
|
||||
* so the disk format is grep-able and platform-portable.
|
||||
*
|
||||
* Keys are typed `Uint8Array` so the caller has explicit control over
|
||||
* binary key material; pass `new TextEncoder().encode(secret)` if a
|
||||
* UTF-8 string is the source. The returned hex is 64 chars
|
||||
* (32 bytes × 2 hex chars).
|
||||
*/
|
||||
export function signRecord(key: Uint8Array, bytes: Uint8Array): string {
|
||||
if (key.byteLength === 0) {
|
||||
throw new Error("hmac key must not be empty");
|
||||
}
|
||||
const digest = cryptoHmac("SHA-256", key, bytes);
|
||||
return bytesToHex(digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time verification of an HMAC-hex against the canonical
|
||||
* bytes. Returns `true` on match, `false` on mismatch.
|
||||
*/
|
||||
export function verifyRecord(
|
||||
key: Uint8Array,
|
||||
bytes: Uint8Array,
|
||||
hmacHex: string
|
||||
): boolean {
|
||||
if (hmacHex.length !== 64) return false;
|
||||
const expected = signRecord(key, bytes);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i += 1) {
|
||||
diff |= expected.charCodeAt(i) ^ hmacHex.charCodeAt(i);
|
||||
}
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.byteLength; i += 1) {
|
||||
const b = bytes[i] as number;
|
||||
out += (b < 16 ? "0" : "") + b.toString(16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
1
packages/api-stack/api/api-audit-file/src/hmac/index.ts
Normal file
1
packages/api-stack/api/api-audit-file/src/hmac/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { signRecord, verifyRecord } from "./hmac.js";
|
||||
51
packages/api-stack/api/api-audit-file/src/index.ts
Normal file
51
packages/api-stack/api/api-audit-file/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `@suaveplan/api-audit-file` — file-rotating, HMAC-signed audit sink.
|
||||
*
|
||||
* Implements the `RequestAuditSink` contract from
|
||||
* `@suaveplan/api-host/request-pipeline`. Writes JSON-per-line with an
|
||||
* HMAC-SHA-256 signature over the canonical record bytes; rolls the
|
||||
* file on size or age; prunes the oldest rotated siblings to keep at
|
||||
* most `maxFiles` retained.
|
||||
*/
|
||||
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
AUDIT_FILE_CODES,
|
||||
AUDIT_FILE_ERROR_NAME,
|
||||
type AuditFileCode,
|
||||
AuditFileError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
type FsAdapter,
|
||||
listRotatedFiles,
|
||||
nodeFsAdapter,
|
||||
rotatedFilePathAt,
|
||||
} from "./fs-helpers/index.js";
|
||||
export { signRecord, verifyRecord } from "./hmac/index.js";
|
||||
export {
|
||||
type AuditFileInstruments,
|
||||
type AuditFileInstrumentsConfig,
|
||||
createAuditFileInstruments,
|
||||
SINK_ATTRIBUTE,
|
||||
} from "./instruments/index.js";
|
||||
export { enforceRetention } from "./retention/index.js";
|
||||
export {
|
||||
type RotationPolicy,
|
||||
type RotationState,
|
||||
resolveRotationPolicy,
|
||||
rotate,
|
||||
shouldRotate,
|
||||
} from "./rotation/index.js";
|
||||
export {
|
||||
type AuditRecord,
|
||||
AuditRecordSchema,
|
||||
buildSignedLine,
|
||||
canonicalise,
|
||||
} from "./schema/index.js";
|
||||
export {
|
||||
type AppendMode,
|
||||
type CreateFileAuditSinkOptions,
|
||||
createFileAuditSink,
|
||||
type FileAuditSink,
|
||||
} from "./sink/index.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type AuditFileInstruments,
|
||||
type AuditFileInstrumentsConfig,
|
||||
createAuditFileInstruments,
|
||||
SINK_ATTRIBUTE,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,78 @@
|
||||
# `instruments`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Constructs the OTel counter bundle the file sink emits and exposes the
|
||||
canonical `sink="file"` attribute set. Every observation carries the
|
||||
attribute so a dashboard tracking `api.audit.records_total` across the
|
||||
sink family — `stdout`, `file`, and future siblings — can union the
|
||||
metric name and partition by `sink`. The three counters answer the
|
||||
operator questions "are records flowing?", "are records dropping?",
|
||||
and "is retention pruning old files?".
|
||||
|
||||
## Features
|
||||
|
||||
- `createAuditFileInstruments({ meterProvider? })` — returns a frozen
|
||||
bundle of three counters bound to the supplied or global
|
||||
`MeterProvider`.
|
||||
- `SINK_ATTRIBUTE` — frozen `{ sink: "file" }` literal exported for
|
||||
reuse by the sink, retention, and test harnesses.
|
||||
- Counters: `api.audit.records_total`, `api.audit.dropped_total`,
|
||||
`api.audit.rotation_evicted_total`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments";
|
||||
|
||||
const instruments = createAuditFileInstruments();
|
||||
instruments.recordsTotal.add(1, { sink: "file" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Install a real `MeterProvider` and assert deltas in tests:
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeterProvider,
|
||||
InMemoryMetricExporter,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { createAuditFileInstruments } from "@suaveplan/api-audit-file/instruments";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
|
||||
const instruments = createAuditFileInstruments({ meterProvider: provider });
|
||||
instruments.recordsTotal.add(1, { sink: "file" });
|
||||
|
||||
await reader.collect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface AuditFileInstruments`
|
||||
- `meter: Meter`
|
||||
- `recordsTotal: Counter`
|
||||
- `droppedTotal: Counter`
|
||||
- `rotationEvictedTotal: Counter`
|
||||
- `interface AuditFileInstrumentsConfig`
|
||||
- `meterProvider?: MeterProvider`
|
||||
- `createAuditFileInstruments(config?): AuditFileInstruments`
|
||||
- `SINK_ATTRIBUTE: Readonly<{ sink: "file" }>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The bundle is intentionally NOT module-level cached — every sink
|
||||
instance gets its own bundle so an operator who reconstructs the host
|
||||
under a fresh `MeterProvider` (a test-host pattern in
|
||||
`@suaveplan/api-host/testing`) sees deltas against the right provider.
|
||||
Counters from the sibling `api-audit-stdout` sink share the metric
|
||||
NAMES with this package so a single dashboard panel covers the family;
|
||||
the `sink="file"` attribute distinguishes the source.
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Meter,
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/api-audit-file";
|
||||
|
||||
/**
|
||||
* Bundle of OTel counters the sink emits. Each carries a stable
|
||||
* `sink="file"` attribute so dashboards can union the family of audit
|
||||
* sinks (`stdout`, `file`, future siblings) on the metric name and
|
||||
* partition by the attribute.
|
||||
*
|
||||
* - `recordsTotal` — every successful write increments by 1.
|
||||
* - `droppedTotal` — every queue-overflow or boot-time rejection
|
||||
* increments by 1. Operators MUST alert on a non-zero rate.
|
||||
* - `rotationEvictedTotal` — every retention-eviction increments by 1.
|
||||
*/
|
||||
export interface AuditFileInstruments {
|
||||
readonly meter: Meter;
|
||||
readonly recordsTotal: Counter;
|
||||
readonly droppedTotal: Counter;
|
||||
readonly rotationEvictedTotal: Counter;
|
||||
}
|
||||
|
||||
/** Construction options for {@link createAuditFileInstruments}. */
|
||||
export interface AuditFileInstrumentsConfig {
|
||||
readonly meterProvider?: MeterProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the counter bundle against the supplied (or global) meter
|
||||
* provider. The default reads from `@opentelemetry/api`'s global
|
||||
* provider, which is the no-op tracer until the operator installs an
|
||||
* SDK. Tests supply a real `MeterProvider` so the per-instrument
|
||||
* deltas are observable.
|
||||
*/
|
||||
export function createAuditFileInstruments(
|
||||
config: AuditFileInstrumentsConfig = {}
|
||||
): AuditFileInstruments {
|
||||
const provider = config.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter = provider.getMeter(METER_NAME);
|
||||
const recordsTotal = meter.createCounter("api.audit.records_total", {
|
||||
description:
|
||||
"Total audit records successfully written to the file sink",
|
||||
});
|
||||
const droppedTotal = meter.createCounter("api.audit.dropped_total", {
|
||||
description:
|
||||
"Total audit records dropped before reaching disk (queue overflow or validation rejection)",
|
||||
});
|
||||
const rotationEvictedTotal = meter.createCounter(
|
||||
"api.audit.rotation_evicted_total",
|
||||
{
|
||||
description:
|
||||
"Total rotated audit files deleted by retention policy (maxFiles enforcement)",
|
||||
}
|
||||
);
|
||||
return Object.freeze({
|
||||
meter,
|
||||
recordsTotal,
|
||||
droppedTotal,
|
||||
rotationEvictedTotal,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable `{ sink: "file" }` attribute set every instrument observation
|
||||
* carries. Exported so verification harnesses and tests can compare
|
||||
* the canonical literal instead of duplicating the string.
|
||||
*/
|
||||
export const SINK_ATTRIBUTE: Readonly<{ sink: "file" }> = Object.freeze({
|
||||
sink: "file" as const,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { enforceRetention } from "./retention.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# `retention`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Enforces the `maxFiles` retention dimension of the rotation policy by
|
||||
deleting the oldest rotated files until at most `maxFiles` remain.
|
||||
Retention runs immediately after each rotation succeeds so the disk
|
||||
footprint is bounded across long-running deployments — an audit
|
||||
directory left to grow unchecked is the most common operator pain
|
||||
point with file-based sinks, and this module's existence is exactly
|
||||
the answer to it.
|
||||
|
||||
The increment on `api.audit.rotation_evicted_total{sink="file"}` makes
|
||||
eviction observable. Operators alert on an unexpectedly high rate so a
|
||||
runaway rotation cycle (e.g. clock skew, misconfigured `maxAgeMs`) is
|
||||
visible before the retention sweep drops historical data.
|
||||
|
||||
## Features
|
||||
|
||||
- `enforceRetention(fs, basePath, maxFiles, instruments)` — deletes
|
||||
the oldest rotated files until at most `maxFiles` remain. Idempotent:
|
||||
re-running with no surplus is a no-op.
|
||||
- Counter increment per deletion with the canonical `sink="file"`
|
||||
attribute set.
|
||||
- Surfaces `AUDIT_FILE.RETENTION_FAILED` on a delete failure with the
|
||||
offending path in `context.path` and the underlying `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { enforceRetention } from "@suaveplan/api-audit-file/retention";
|
||||
|
||||
await enforceRetention(fs, "/var/log/audit.log", 30, instruments);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Call from a custom rotation cadence — e.g. an external cron that wants
|
||||
to prune without rotating:
|
||||
|
||||
```ts
|
||||
import { enforceRetention } from "@suaveplan/api-audit-file/retention";
|
||||
|
||||
// Cron job: every hour, prune to a smaller retention than the sink's
|
||||
// default to claw back disk during high-traffic windows.
|
||||
setInterval(async () => {
|
||||
await enforceRetention(fs, basePath, 10, instruments);
|
||||
}, 60 * 60 * 1000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `enforceRetention(fs: FsAdapter, basePath: string, maxFiles: number, instruments: AuditFileInstruments): Promise<void>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`listRotatedFiles` returns paths sorted oldest-first by the embedded
|
||||
ISO timestamp; we slice off the first `(count - maxFiles)` entries and
|
||||
unlink them. Sorting is lexicographic on the ISO string, which is also
|
||||
chronological for the `YYYY-MM-DDTHH-MM-SS.sssZ` shape — no extra
|
||||
parsing required. If `unlink` fails midway, the previously-evicted
|
||||
deletions are not undone; the throw surfaces the failure and the next
|
||||
rotation re-attempts retention against whatever survived.
|
||||
@@ -0,0 +1,143 @@
|
||||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { nodeFsAdapter } from "../fs-helpers/fs-helpers.js";
|
||||
import {
|
||||
createAuditFileInstruments,
|
||||
SINK_ATTRIBUTE,
|
||||
} from "../instruments/instruments.js";
|
||||
import { enforceRetention } from "./retention.js";
|
||||
|
||||
interface Harness {
|
||||
dir: string;
|
||||
basePath: string;
|
||||
reader: PeriodicExportingMetricReader;
|
||||
instruments: ReturnType<typeof createAuditFileInstruments>;
|
||||
}
|
||||
|
||||
async function setup(): Promise<Harness> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "audit-retention-"));
|
||||
const basePath = join(dir, "audit.log");
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
const instruments = createAuditFileInstruments({ meterProvider: provider });
|
||||
return { dir, basePath, reader, instruments };
|
||||
}
|
||||
|
||||
async function makeRotated(basePath: string, isoTimestamps: string[]) {
|
||||
for (const ts of isoTimestamps) {
|
||||
await writeFile(`${basePath}.${ts}.log`, "stub", "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
async function collectMetrics(reader: PeriodicExportingMetricReader) {
|
||||
const { resourceMetrics } = await reader.collect();
|
||||
return resourceMetrics.scopeMetrics.flatMap((sm) => sm.metrics);
|
||||
}
|
||||
|
||||
describe("api-audit-file retention", () => {
|
||||
let h: Harness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await setup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await h.reader.shutdown();
|
||||
});
|
||||
|
||||
it("evicts oldest beyond maxFiles and increments counter", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const timestamps = [
|
||||
"2026-05-26T10-00-00.000Z",
|
||||
"2026-05-26T11-00-00.000Z",
|
||||
"2026-05-26T12-00-00.000Z",
|
||||
"2026-05-26T13-00-00.000Z",
|
||||
];
|
||||
await makeRotated(h.basePath, timestamps);
|
||||
await enforceRetention(nodeFsAdapter, h.basePath, 3, h.instruments);
|
||||
const survivors = (await readdir(h.dir)).filter((f) =>
|
||||
f.endsWith(".log")
|
||||
);
|
||||
expect(survivors).toHaveLength(3);
|
||||
expect(survivors).not.toContain(`audit.log.${timestamps[0]}.log`);
|
||||
const metrics = await collectMetrics(h.reader);
|
||||
const counter = metrics.find(
|
||||
(m) => m.descriptor.name === "api.audit.rotation_evicted_total"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
expect(counter?.dataPoints[0]?.value).toBe(1);
|
||||
expect(counter?.dataPoints[0]?.attributes).toEqual(SINK_ATTRIBUTE);
|
||||
});
|
||||
|
||||
it("is a no-op when below maxFiles", { timeout: 30_000 }, async () => {
|
||||
const timestamps = [
|
||||
"2026-05-26T10-00-00.000Z",
|
||||
"2026-05-26T11-00-00.000Z",
|
||||
];
|
||||
await makeRotated(h.basePath, timestamps);
|
||||
await enforceRetention(nodeFsAdapter, h.basePath, 5, h.instruments);
|
||||
const survivors = (await readdir(h.dir)).filter((f) =>
|
||||
f.endsWith(".log")
|
||||
);
|
||||
expect(survivors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("surfaces RETENTION_FAILED on unlink failure", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const adapter = {
|
||||
...nodeFsAdapter,
|
||||
async unlink(_p: string) {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
await makeRotated(h.basePath, [
|
||||
"2026-05-26T10-00-00.000Z",
|
||||
"2026-05-26T11-00-00.000Z",
|
||||
]);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await enforceRetention(adapter, h.basePath, 1, h.instruments);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditFileError);
|
||||
expect((caught as AuditFileError).code).toBe(
|
||||
AUDIT_FILE_CODES.RETENTION_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-rotated entries in the directory", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
await writeFile(h.basePath, "active", "utf8");
|
||||
await writeFile(join(h.dir, "README.md"), "doc", "utf8");
|
||||
await makeRotated(h.basePath, [
|
||||
"2026-05-26T10-00-00.000Z",
|
||||
"2026-05-26T11-00-00.000Z",
|
||||
]);
|
||||
await enforceRetention(nodeFsAdapter, h.basePath, 1, h.instruments);
|
||||
const entries = await readdir(h.dir);
|
||||
expect(entries).toContain("audit.log");
|
||||
expect(entries).toContain("README.md");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { type FsAdapter, listRotatedFiles } from "../fs-helpers/fs-helpers.js";
|
||||
import type { AuditFileInstruments } from "../instruments/instruments.js";
|
||||
import { SINK_ATTRIBUTE } from "../instruments/instruments.js";
|
||||
|
||||
/**
|
||||
* Inspect the rotated-file siblings of `basePath` and delete the
|
||||
* oldest ones until at most `maxFiles` remain. Increments
|
||||
* `api.audit.rotation_evicted_total{sink="file"}` once per deletion.
|
||||
*
|
||||
* Failures during a delete surface as `AUDIT_FILE.RETENTION_FAILED`
|
||||
* with the offending path in the cause context; the caller's logger
|
||||
* records the failure and the next rotation re-attempts retention so
|
||||
* one transient EBUSY does not permanently disable enforcement.
|
||||
*/
|
||||
export async function enforceRetention(
|
||||
fs: FsAdapter,
|
||||
basePath: string,
|
||||
maxFiles: number,
|
||||
instruments: AuditFileInstruments
|
||||
): Promise<void> {
|
||||
const rotated = await listRotatedFiles(fs, basePath);
|
||||
if (rotated.length <= maxFiles) return;
|
||||
const evictCount = rotated.length - maxFiles;
|
||||
const victims = rotated.slice(0, evictCount);
|
||||
for (const path of victims) {
|
||||
try {
|
||||
await fs.unlink(path);
|
||||
instruments.rotationEvictedTotal.add(1, SINK_ATTRIBUTE);
|
||||
} catch (cause) {
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.RETENTION_FAILED,
|
||||
"error.audit.file.retention_failed",
|
||||
{ context: { path }, cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type RotationPolicy,
|
||||
type RotationState,
|
||||
resolveRotationPolicy,
|
||||
rotate,
|
||||
shouldRotate,
|
||||
} from "./rotation.js";
|
||||
@@ -0,0 +1,95 @@
|
||||
# `rotation`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Resolves the rotation policy carried by every `createFileAuditSink`
|
||||
instance and implements the rotation primitive itself — the
|
||||
`rename(currentFile → currentFile.<utc-iso>.log)` step plus the
|
||||
post-rotation state reset. The module owns both dimensions documented
|
||||
in the spec: size-based (`maxSizeBytes`, default 100 MiB) and
|
||||
time-based (`maxAgeMs`, default 24h). Either condition reaching its
|
||||
limit triggers a rotation on the next write.
|
||||
|
||||
Rotation lives in a separate module so the sink stays focused on
|
||||
queueing + writing. The retention module reads the policy through the
|
||||
same `RotationState` so the operator-facing knobs are documented in
|
||||
exactly one place.
|
||||
|
||||
## Features
|
||||
|
||||
- `resolveRotationPolicy(input?)` — Zod-equivalent validator returning
|
||||
a frozen `RotationPolicy`. Defaults filled from the design doc.
|
||||
- `shouldRotate(state, nowMs)` — pure predicate; size OR age satisfies.
|
||||
- `rotate(fs, basePath, state, clock)` — performs the rename, updates
|
||||
state, returns the rotated path for retention to inspect.
|
||||
- Raises `AUDIT_FILE.ROTATION_FAILED` on rename failure with full
|
||||
`cause` for forensic logging.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
resolveRotationPolicy,
|
||||
rotate,
|
||||
shouldRotate,
|
||||
} from "@suaveplan/api-audit-file/rotation";
|
||||
|
||||
const policy = resolveRotationPolicy({ maxSizeBytes: 1024 * 1024 });
|
||||
const state = { openedAtMs: Date.now(), bytesWritten: 0, policy };
|
||||
if (shouldRotate(state, Date.now())) {
|
||||
await rotate(fs, "/var/log/audit.log", state, () => Date.now());
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Disable a dimension by setting it to `Number.POSITIVE_INFINITY` —
|
||||
useful when an operator wants pure time rotation regardless of size:
|
||||
|
||||
```ts
|
||||
const policy = resolveRotationPolicy({
|
||||
maxSizeBytes: Number.POSITIVE_INFINITY,
|
||||
maxAgeMs: 60 * 60 * 1000, // hourly
|
||||
});
|
||||
```
|
||||
|
||||
In tests, drive rotation deterministically with a `FakeClock` from
|
||||
`@suaveplan/testing`:
|
||||
|
||||
```ts
|
||||
import { FakeClock } from "@suaveplan/testing";
|
||||
|
||||
const clock = new FakeClock(0);
|
||||
const state = { openedAtMs: 0, bytesWritten: 0, policy };
|
||||
clock.advance(61_000);
|
||||
await rotate(fs, basePath, state, clock.read);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RotationPolicy`
|
||||
- `maxSizeBytes: number` — bytes before size rotation; default 100 MiB.
|
||||
- `maxAgeMs: number` — ms before time rotation; default 24h.
|
||||
- `maxFiles: number` — kept rotated files; default 30.
|
||||
- `interface RotationState`
|
||||
- `openedAtMs: number` — wall-clock open time of current segment.
|
||||
- `bytesWritten: number` — running byte count.
|
||||
- `policy: RotationPolicy` — frozen at construction.
|
||||
- `resolveRotationPolicy(input?): RotationPolicy`
|
||||
- `shouldRotate(state, nowMs): boolean`
|
||||
- `rotate(fs, basePath, state, clock): Promise<string>` — returns the
|
||||
rotated file path.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rotated suffix replaces `:` with `-` so the resulting filename is
|
||||
Windows-portable and grep-friendly across shells. State mutation
|
||||
happens AFTER the rename succeeds — a failed rename leaves the original
|
||||
state intact, so the next call retries against the same segment rather
|
||||
than corrupting accounting. `bytesWritten` resets to 0 instead of
|
||||
re-stat'ing the new empty file; the next successful append updates it
|
||||
in lock-step with the on-disk length.
|
||||
@@ -0,0 +1,170 @@
|
||||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { nodeFsAdapter } from "../fs-helpers/fs-helpers.js";
|
||||
import {
|
||||
type RotationState,
|
||||
resolveRotationPolicy,
|
||||
rotate,
|
||||
shouldRotate,
|
||||
} from "./rotation.js";
|
||||
|
||||
let dir: string;
|
||||
let basePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "audit-rot-"));
|
||||
basePath = join(dir, "audit.log");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// tmp dirs cleaned by os.tmpdir lifecycle
|
||||
});
|
||||
|
||||
describe("api-audit-file rotation — policy resolver", () => {
|
||||
it("applies the documented defaults", { timeout: 5000 }, () => {
|
||||
const p = resolveRotationPolicy();
|
||||
expect(p.maxSizeBytes).toBe(100 * 1024 * 1024);
|
||||
expect(p.maxAgeMs).toBe(24 * 60 * 60 * 1000);
|
||||
expect(p.maxFiles).toBe(30);
|
||||
});
|
||||
|
||||
it("merges partial input over defaults", { timeout: 5000 }, () => {
|
||||
const p = resolveRotationPolicy({ maxSizeBytes: 1024 });
|
||||
expect(p.maxSizeBytes).toBe(1024);
|
||||
expect(p.maxAgeMs).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("rejects maxSizeBytes < 1", { timeout: 5000 }, () => {
|
||||
expect(() => resolveRotationPolicy({ maxSizeBytes: 0 })).toThrow(
|
||||
AuditFileError
|
||||
);
|
||||
try {
|
||||
resolveRotationPolicy({ maxSizeBytes: 0 });
|
||||
} catch (err) {
|
||||
expect((err as AuditFileError).code).toBe(
|
||||
AUDIT_FILE_CODES.ROTATION_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects maxAgeMs < 1", { timeout: 5000 }, () => {
|
||||
expect(() => resolveRotationPolicy({ maxAgeMs: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects maxFiles < 1", { timeout: 5000 }, () => {
|
||||
expect(() => resolveRotationPolicy({ maxFiles: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer maxFiles", { timeout: 5000 }, () => {
|
||||
expect(() => resolveRotationPolicy({ maxFiles: 2.5 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("api-audit-file rotation — shouldRotate", () => {
|
||||
const policy = resolveRotationPolicy({
|
||||
maxSizeBytes: 1024,
|
||||
maxAgeMs: 60_000,
|
||||
});
|
||||
|
||||
it("triggers on size overflow", { timeout: 5000 }, () => {
|
||||
const state: RotationState = {
|
||||
openedAtMs: 0,
|
||||
bytesWritten: 1024,
|
||||
policy,
|
||||
};
|
||||
expect(shouldRotate(state, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("triggers on age overflow", { timeout: 5000 }, () => {
|
||||
const state: RotationState = {
|
||||
openedAtMs: 0,
|
||||
bytesWritten: 0,
|
||||
policy,
|
||||
};
|
||||
expect(shouldRotate(state, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not trigger when under both limits", { timeout: 5000 }, () => {
|
||||
const state: RotationState = {
|
||||
openedAtMs: 0,
|
||||
bytesWritten: 100,
|
||||
policy,
|
||||
};
|
||||
expect(shouldRotate(state, 10_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("api-audit-file rotation — rotate()", () => {
|
||||
it("size rotation produces timestamp-suffixed file", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
await writeFile(basePath, "x".repeat(2048), "utf8");
|
||||
const policy = resolveRotationPolicy({ maxSizeBytes: 1024 });
|
||||
const state: RotationState = {
|
||||
openedAtMs: 0,
|
||||
bytesWritten: 2048,
|
||||
policy,
|
||||
};
|
||||
const rotatedPath = await rotate(nodeFsAdapter, basePath, state, () =>
|
||||
Date.parse("2026-05-26T12:00:00.000Z")
|
||||
);
|
||||
expect(rotatedPath).toContain("audit.log.2026-05-26T12-00-00.000Z.log");
|
||||
const entries = await readdir(dir);
|
||||
expect(entries).toContain("audit.log.2026-05-26T12-00-00.000Z.log");
|
||||
expect(state.bytesWritten).toBe(0);
|
||||
});
|
||||
|
||||
it("time rotation triggers on clock advance", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
await writeFile(basePath, "data", "utf8");
|
||||
const policy = resolveRotationPolicy({ maxAgeMs: 60_000 });
|
||||
const state: RotationState = {
|
||||
openedAtMs: 0,
|
||||
bytesWritten: 4,
|
||||
policy,
|
||||
};
|
||||
expect(shouldRotate(state, 61_000)).toBe(true);
|
||||
await rotate(nodeFsAdapter, basePath, state, () => 61_000);
|
||||
expect(state.openedAtMs).toBe(61_000);
|
||||
});
|
||||
|
||||
it("surfaces ROTATION_FAILED on rename failure", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const policy = resolveRotationPolicy();
|
||||
const state: RotationState = {
|
||||
openedAtMs: 0,
|
||||
bytesWritten: 0,
|
||||
policy,
|
||||
};
|
||||
const failingAdapter = {
|
||||
...nodeFsAdapter,
|
||||
async rename() {
|
||||
throw new Error("EBUSY");
|
||||
},
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
await rotate(failingAdapter, basePath, state, () => 0);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(AuditFileError);
|
||||
expect((caught as AuditFileError).code).toBe(
|
||||
AUDIT_FILE_CODES.ROTATION_FAILED
|
||||
);
|
||||
});
|
||||
});
|
||||
106
packages/api-stack/api/api-audit-file/src/rotation/rotation.ts
Normal file
106
packages/api-stack/api/api-audit-file/src/rotation/rotation.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Clock } from "@suaveplan/types/clock";
|
||||
import {
|
||||
AUDIT_FILE_CODES,
|
||||
AuditFileError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { type FsAdapter, rotatedFilePathAt } from "../fs-helpers/fs-helpers.js";
|
||||
|
||||
/**
|
||||
* Rotation policy resolved + validated at sink construction. Carries
|
||||
* the defaults documented in the design doc: 100 MiB size cap, 24-hour
|
||||
* age cap. The retention `maxFiles` lives here for ergonomics — the
|
||||
* retention module reads it through the same {@link RotationState}.
|
||||
*/
|
||||
export interface RotationPolicy {
|
||||
readonly maxSizeBytes: number;
|
||||
readonly maxAgeMs: number;
|
||||
readonly maxFiles: number;
|
||||
}
|
||||
|
||||
/** Mutable rotation state carried per sink. */
|
||||
export interface RotationState {
|
||||
/** Wall-clock ms when the current segment was opened. */
|
||||
openedAtMs: number;
|
||||
/** Last observed byte size of the current segment. */
|
||||
bytesWritten: number;
|
||||
/** Resolved policy, frozen. */
|
||||
readonly policy: RotationPolicy;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_SIZE_BYTES = 100 * 1024 * 1024;
|
||||
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_MAX_FILES = 30;
|
||||
|
||||
/**
|
||||
* Resolve a partial policy against defaults, raising on invalid input.
|
||||
* `maxSizeBytes >= 1`, `maxAgeMs >= 1`, `maxFiles >= 1`. Callers may
|
||||
* disable a dimension by setting it to `Number.POSITIVE_INFINITY`.
|
||||
*/
|
||||
export function resolveRotationPolicy(
|
||||
input?: Partial<RotationPolicy>
|
||||
): RotationPolicy {
|
||||
const maxSizeBytes = input?.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
||||
const maxAgeMs = input?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
|
||||
const maxFiles = input?.maxFiles ?? DEFAULT_MAX_FILES;
|
||||
if (!(maxSizeBytes >= 1)) {
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.ROTATION_FAILED,
|
||||
"error.audit.file.rotation.invalid_policy",
|
||||
{ context: { field: "maxSizeBytes", value: maxSizeBytes } }
|
||||
);
|
||||
}
|
||||
if (!(maxAgeMs >= 1)) {
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.ROTATION_FAILED,
|
||||
"error.audit.file.rotation.invalid_policy",
|
||||
{ context: { field: "maxAgeMs", value: maxAgeMs } }
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(maxFiles) || maxFiles < 1) {
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.ROTATION_FAILED,
|
||||
"error.audit.file.rotation.invalid_policy",
|
||||
{ context: { field: "maxFiles", value: maxFiles } }
|
||||
);
|
||||
}
|
||||
return Object.freeze({ maxSizeBytes, maxAgeMs, maxFiles });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the current segment needs to rotate BEFORE the next
|
||||
* line is appended. Returns `true` when size OR age has been reached.
|
||||
*/
|
||||
export function shouldRotate(state: RotationState, nowMs: number): boolean {
|
||||
if (state.bytesWritten >= state.policy.maxSizeBytes) return true;
|
||||
if (nowMs - state.openedAtMs >= state.policy.maxAgeMs) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the current segment by renaming it to a timestamp-suffixed
|
||||
* sibling. Returns the rotated path so the caller can hand it to the
|
||||
* retention module. On rename failure, raises
|
||||
* `AUDIT_FILE.ROTATION_FAILED`; the caller's outer loop catches and
|
||||
* surfaces via the configured logger so the next write retries cleanly.
|
||||
*/
|
||||
export async function rotate(
|
||||
fs: FsAdapter,
|
||||
basePath: string,
|
||||
state: RotationState,
|
||||
clock: Clock
|
||||
): Promise<string> {
|
||||
const rotatedAt = new Date(clock()).toISOString();
|
||||
const rotatedPath = rotatedFilePathAt(basePath, rotatedAt);
|
||||
try {
|
||||
await fs.rename(basePath, rotatedPath);
|
||||
} catch (cause) {
|
||||
throw new AuditFileError(
|
||||
AUDIT_FILE_CODES.ROTATION_FAILED,
|
||||
"error.audit.file.rotation_failed",
|
||||
{ context: { basePath, rotatedPath }, cause }
|
||||
);
|
||||
}
|
||||
state.openedAtMs = clock();
|
||||
state.bytesWritten = 0;
|
||||
return rotatedPath;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type AuditRecord,
|
||||
AuditRecordSchema,
|
||||
buildSignedLine,
|
||||
canonicalise,
|
||||
} from "./schema.js";
|
||||
79
packages/api-stack/api/api-audit-file/src/schema/schema.md
Normal file
79
packages/api-stack/api/api-audit-file/src/schema/schema.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# `schema`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod schema and canonical-bytes function that bracket every
|
||||
audit record before it is signed and written. The schema mirrors the
|
||||
`RequestAuditEnvelope` interface exported by
|
||||
`@suaveplan/api-host/request-pipeline`; the canonical-bytes function
|
||||
gives every other module a deterministic input for HMAC signing,
|
||||
file-line construction, and signature verification.
|
||||
|
||||
The package re-validates at the sink boundary even though the host
|
||||
constructs envelopes from typed data — operators frequently hand-craft
|
||||
audit records in tests, replay tooling, or migration scripts. The
|
||||
boundary check makes corrupt records fail loudly with a typed
|
||||
`AUDIT_FILE.INVALID_RECORD` instead of silently bypassing the HMAC.
|
||||
|
||||
## Features
|
||||
|
||||
- `AuditRecordSchema` — strict Zod object accepting only the
|
||||
envelope's documented fields. Unknown keys reject.
|
||||
- `AuditRecord` — `z.infer<typeof AuditRecordSchema>`; structurally
|
||||
assignable to `RequestAuditEnvelope`.
|
||||
- `canonicalise(record)` — UTF-8 bytes of the record JSON-stringified
|
||||
with sorted top-level keys.
|
||||
- `buildSignedLine(bytes, hmacHex)` — emits the on-disk JSON line
|
||||
carrying the record + HMAC, with a trailing `\n`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AuditRecordSchema,
|
||||
buildSignedLine,
|
||||
canonicalise,
|
||||
} from "@suaveplan/api-audit-file/schema";
|
||||
|
||||
const record = AuditRecordSchema.parse(rawEnvelope);
|
||||
const bytes = canonicalise(record);
|
||||
const line = buildSignedLine(bytes, "deadbeef...");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Verify an on-disk line:
|
||||
|
||||
```ts
|
||||
import { hmac } from "@suaveplan/crypto-hash";
|
||||
import { AuditRecordSchema, canonicalise } from "@suaveplan/api-audit-file/schema";
|
||||
|
||||
const { record, hmac: hmacHex } = JSON.parse(lineFromDisk);
|
||||
const parsed = AuditRecordSchema.parse(record);
|
||||
const bytes = canonicalise(parsed);
|
||||
const expected = Buffer.from(hmac("SHA-256", key, bytes)).toString("hex");
|
||||
if (expected !== hmacHex) {
|
||||
throw new Error("hmac mismatch — record was tampered");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AuditRecordSchema: z.ZodObject` (strict)
|
||||
- `type AuditRecord = z.infer<typeof AuditRecordSchema>`
|
||||
- `canonicalise(record: AuditRecord): Uint8Array`
|
||||
- `buildSignedLine(canonicalBytes: Uint8Array, hmacHex: string): string`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The canonical bytes drop `undefined` values for the optional `cause`
|
||||
slot so the absence of an error never affects the signature. Top-level
|
||||
keys are sorted alphabetically to give every implementation (TypeScript,
|
||||
Go log-reader, Python forensic tooling) the same input bytes for
|
||||
verification regardless of construction order. `principal.scopes` is
|
||||
preserved in array order — the order is semantically meaningful in the
|
||||
pipeline so we do not collapse it.
|
||||
70
packages/api-stack/api/api-audit-file/src/schema/schema.ts
Normal file
70
packages/api-stack/api/api-audit-file/src/schema/schema.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for the `RequestAuditEnvelope` shape produced by
|
||||
* `@suaveplan/api-host/request-pipeline`. We re-validate at the sink
|
||||
* boundary because callers (host code, plugin code, tests) may hand us
|
||||
* an object that was reshaped after the pipeline emitted it. Mirrors
|
||||
* the structural type exported from `api-host`; kept in lock-step at
|
||||
* compile-time via the `AuditRecord` re-export below.
|
||||
*/
|
||||
export const AuditRecordSchema = z
|
||||
.object({
|
||||
at: z.string().min(1),
|
||||
requestId: z.string().min(1),
|
||||
traceId: z.string().nullable(),
|
||||
route: z.string().min(1),
|
||||
plugin: z.string().min(1),
|
||||
method: z.string().min(1),
|
||||
principal: z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
scopes: z.array(z.string()).readonly(),
|
||||
})
|
||||
.nullable(),
|
||||
tenantId: z.string().nullable(),
|
||||
status: z.number().int().min(0),
|
||||
outcome: z.union([z.literal("success"), z.literal("failure")]),
|
||||
cause: z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Structural type re-exported for downstream typing. */
|
||||
export type AuditRecord = z.infer<typeof AuditRecordSchema>;
|
||||
|
||||
/**
|
||||
* Canonicalise a record into the byte stream that gets HMAC-signed and
|
||||
* persisted. The canonical form is JSON with sorted top-level keys and
|
||||
* no trailing whitespace; sorting the keys means the same logical
|
||||
* record always serialises to the same bytes regardless of how the
|
||||
* upstream pipeline constructed it (object spread, property order, ...).
|
||||
*
|
||||
* Returns the UTF-8 bytes ready for HMAC input. The caller appends an
|
||||
* HMAC suffix when writing to disk; see {@link buildSignedLine}.
|
||||
*/
|
||||
export function canonicalise(record: AuditRecord): Uint8Array {
|
||||
const ordered: Record<string, unknown> = {};
|
||||
for (const key of (Object.keys(record) as (keyof AuditRecord)[]).sort()) {
|
||||
ordered[key] = record[key];
|
||||
}
|
||||
return new TextEncoder().encode(JSON.stringify(ordered));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the on-disk line for a record + HMAC. The signed shape is a
|
||||
* JSON object with two top-level keys, `record` (the canonical payload
|
||||
* unwrapped) and `hmac` (lowercase hex of HMAC-SHA-256). One trailing
|
||||
* `\n` so consumers can read the file as JSON-per-line.
|
||||
*/
|
||||
export function buildSignedLine(
|
||||
canonicalBytes: Uint8Array,
|
||||
hmacHex: string
|
||||
): string {
|
||||
const recordObject = JSON.parse(new TextDecoder().decode(canonicalBytes));
|
||||
return `${JSON.stringify({ record: recordObject, hmac: hmacHex })}\n`;
|
||||
}
|
||||
6
packages/api-stack/api/api-audit-file/src/sink/index.ts
Normal file
6
packages/api-stack/api/api-audit-file/src/sink/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { createFileAuditSink } from "./sink.js";
|
||||
export type {
|
||||
AppendMode,
|
||||
CreateFileAuditSinkOptions,
|
||||
FileAuditSink,
|
||||
} from "./sink.types.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user