Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

4
e2e/foundation-stack-saga/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
test-results
playwright-report
.tmp

View File

@@ -0,0 +1,42 @@
{
"name": "@suaveplan/e2e-foundation-stack-saga",
"version": "0.1.0",
"private": true,
"description": "Stage-6 closure smoke for the Suaveplan API plugin foundation. Boots a `createTestHost`-backed Bun.serve listener that hosts a 3-step compensable saga split across two plugins (plugin-a/step-1, plugin-b/step-2, plugin-a/step-3) and an `api-2pc` coordinator wired to two participants on a real Postgres testcontainer (`max_prepared_transactions=8`). Playwright specs drive the happy path, force a mid-saga failure to exercise reverse-order compensation, run a 2PC commit + abort + recovery cycle, and assert on the saga ledger row, the persisted step rows, the event sequence, and `pg_prepared_xacts` cleanliness after every round.",
"type": "module",
"scripts": {
"dev": "bun run src/server.ts",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@suaveplan/api-2pc": "workspace:^",
"@suaveplan/api-auth": "workspace:^",
"@suaveplan/api-auth-anonymous": "workspace:^",
"@suaveplan/api-auth-static-principal": "workspace:^",
"@suaveplan/api-db-sqlite-memory": "workspace:^",
"@suaveplan/api-events-in-memory": "workspace:^",
"@suaveplan/api-host": "workspace:^",
"@suaveplan/api-router-in-memory": "workspace:^",
"@suaveplan/api-saga": "workspace:^",
"@suaveplan/api-secrets-env": "workspace:^",
"@suaveplan/api-tenant-provider": "workspace:^",
"@suaveplan/api-tenant-provider-memory": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/idempotency": "workspace:^",
"@suaveplan/testing-integration-postgres": "workspace:^",
"postgres": "catalog:database",
"testcontainers": "catalog:containers"
},
"devDependencies": {
"@suaveplan/testing-e2e": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/typescript-config": "workspace:^",
"@playwright/test": "catalog:testing",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"bun-types": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,26 @@
import { createPlaywrightConfig } from "@suaveplan/testing-e2e";
const PORT = Number.parseInt(process.env["E2E_PORT"] ?? "3611", 10);
const baseURL = `http://127.0.0.1:${PORT}`;
export default createPlaywrightConfig({
baseURL,
testDir: "./tests",
timeout: 60_000,
expect: { timeout: 10_000 },
reporter: process.env["CI"] ? "list" : "line",
projects: [{ name: "api", use: {} }],
webServer: {
command: `bun run src/server.ts`,
env: {
PORT: String(PORT),
},
url: `${baseURL}/internal/livez`,
reuseExistingServer: !process.env["CI"],
// First boot pulls postgres:16 image — allow up to 5 minutes
// (subsequent runs with a cached image complete in seconds).
timeout: 300_000,
stdout: "pipe",
stderr: "pipe",
},
});

View File

@@ -0,0 +1,768 @@
/**
* Stage-6 closure smoke server — boots a real Bun.serve over a
* `createTestHost`-backed Host and wires:
*
* 1. A 3-step compensable saga split across TWO plugins
* (`plugin-a` owns the saga, owns step-1 and step-3; `plugin-b`
* participates in step-2). Failure injection via the
* `triggerStep` input field (`"none" | "step-2"`) drives
* reverse-order compensation through the orchestrator.
* 2. A `@suaveplan/api-2pc` coordinator wired to TWO Postgres clients
* (acting as two distinct participants on the same physical
* instance) backed by the testcontainer URL the globalSetup hook
* publishes via `.tmp/postgres.json`.
*
* The server exposes JSON HTTP endpoints the Playwright specs hit:
*
* - `POST /saga/start` { triggerStep? } -> 200 { sagaId, state }
* - `GET /saga/:id` -> 200 { instance, steps }
* - `GET /saga/:id/events` -> 200 { events: [...] }
* - `GET /saga/participant/:plugin/log` -> 200 { ops: [...] }
* - `POST /saga/reset` -> 200 (clears event + op logs)
* - `POST /2pc/run` { mode: "commit"|"abort" } -> 200 { outcome, rows }
* - `POST /2pc/recover-orphan` -> 200 { gid, rowsAfter }
* - `GET /2pc/prepared-xacts` -> 200 { count, gids }
* - `GET /internal/livez` -> 200 "ok"
*
* If a shim is needed to make a wiring seam work, the gap is in the
* downstream package — STOP and report rather than papering it over.
*/
import type { RegisteredTwoPcParticipant } from "@suaveplan/api-2pc";
import { createTwoPcCoordinator } from "@suaveplan/api-2pc/coordinator";
import { TwoPcParticipantRegistry } from "@suaveplan/api-2pc/registry";
import { definePlugin } from "@suaveplan/api-host/define-plugin";
import { createTestHost, type TestHost } from "@suaveplan/api-host/testing";
import {
createSagaInstruments,
createSagaOrchestrator,
defineSagaParticipant,
SagaParticipantRegistry,
SagaPersistence,
type SagaActionHandler,
type SagaCompensateHandler,
type SagaEventMap,
type SagaInstanceRow,
type SagaOrchestrator,
type SagaRequestContextFactory,
type SagaStepRow,
} from "@suaveplan/api-saga";
import type { LedgerDb } from "@suaveplan/api-saga";
import type { Principal, RequestContext } from "@suaveplan/api-types";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { EventBus } from "@suaveplan/events";
import {
IdempotencyGuard,
MemoryIdempotencyStore,
} from "@suaveplan/idempotency";
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
import postgres from "postgres";
import { GenericContainer } from "testcontainers";
// ── Constants ──────────────────────────────────────────────────────────
const SEED_PRINCIPAL: Principal = {
id: "saga-smoke-principal" as Principal["id"],
scopes: ["saga:run"] as unknown as Principal["scopes"],
claims: { source: "foundation-stack-saga" },
};
const TENANT_ID = "saga-smoke-tenant";
const SAGA_NAME = "smoke-three-step";
// In-process side-effect log per participant — the saga step handlers
// append; compensation handlers append too. The spec asserts the
// reverse-order compensation walk by inspecting this log.
interface ParticipantOp {
readonly plugin: string;
readonly action: string;
readonly kind: "do" | "compensate";
readonly at: number;
readonly input: Readonly<Record<string, unknown>>;
}
const participantLog: ParticipantOp[] = [];
function recordOp(op: ParticipantOp): void {
participantLog.push(op);
}
// ── Saga event tap ─────────────────────────────────────────────────────
interface RecordedEvent {
readonly name: string;
readonly payload: unknown;
readonly at: number;
}
const sagaEventLog: RecordedEvent[] = [];
function tapSagaBus(bus: EventBus<SagaEventMap>): void {
const names: ReadonlyArray<keyof SagaEventMap> = [
"saga.instance.started",
"saga.instance.completed",
"saga.instance.compensated",
"saga.instance.compensation_failed",
"saga.step.requested",
"saga.step.completed",
"saga.step.failed",
"saga.step.compensated",
];
for (const n of names) {
bus.on(n, (payload) => {
sagaEventLog.push({
name: String(n),
payload,
at: Date.now(),
});
});
}
}
// ── Saga participant handlers ──────────────────────────────────────────
const stepOneHandler: SagaActionHandler = async (input) => {
recordOp({
plugin: "plugin-a",
action: "step-one",
kind: "do",
at: Date.now(),
input,
});
return { stepOne: "ok", value: 1 } as Readonly<Record<string, unknown>>;
};
const stepOneCompensate: SagaCompensateHandler = async ({ input }) => {
recordOp({
plugin: "plugin-a",
action: "step-one",
kind: "compensate",
at: Date.now(),
input,
});
};
const stepTwoHandler: SagaActionHandler = async (input, ctx) => {
// The orchestrator only passes the user input to step-0; downstream
// steps see `{}`. The saga's original input is reachable through
// the ctx-bound instance payload — read the trigger from there so
// the failure-injection flag works for step-2.
const instance = (ctx as unknown as { instance: SagaInstanceRow })
.instance;
const sagaInput = instance.payload.input;
recordOp({
plugin: "plugin-b",
action: "step-two",
kind: "do",
at: Date.now(),
input: { ...input, ...sagaInput },
});
if (sagaInput["triggerStep"] === "step-2") {
throw new Error("step-2 deliberate failure");
}
return { stepTwo: "ok", value: 2 } as Readonly<Record<string, unknown>>;
};
const stepTwoCompensate: SagaCompensateHandler = async ({ input }) => {
recordOp({
plugin: "plugin-b",
action: "step-two",
kind: "compensate",
at: Date.now(),
input,
});
};
const stepThreeHandler: SagaActionHandler = async (input) => {
recordOp({
plugin: "plugin-a",
action: "step-three",
kind: "do",
at: Date.now(),
input,
});
return { stepThree: "ok", value: 3 } as Readonly<Record<string, unknown>>;
};
const stepThreeCompensate: SagaCompensateHandler = async ({ input }) => {
recordOp({
plugin: "plugin-a",
action: "step-three",
kind: "compensate",
at: Date.now(),
input,
});
};
// ── Request-context factory (saga handlers) ────────────────────────────
function buildSagaRequestContextFactory(): SagaRequestContextFactory {
return ({ instance, stepName, principal, targetTenantId }) =>
({
instance,
stepName,
principal,
tenantId: targetTenantId,
tenant: { id: targetTenantId, slug: targetTenantId },
request: new Request("http://saga.local/"),
requestId: `req-${instance.id}-${stepName}`,
span: {
setAttribute() {},
setStatus() {},
recordException() {},
end() {},
},
db: null,
secrets: {
require() {
throw new Error("no secrets in saga ctx");
},
},
capabilities: {},
log: () => undefined,
now: () => Date.now(),
requireSession: () => null,
}) as unknown as RequestContext;
}
// ── OTel noop bag for saga instruments ─────────────────────────────────
function noopMeter() {
return {
createCounter() {
return { add() {} };
},
createHistogram() {
return { record() {} };
},
createObservableGauge() {
return { addCallback() {}, removeCallback() {} };
},
};
}
function noopTracer() {
return {
startSpan() {
return {
end() {},
setAttribute() {
return this;
},
setAttributes() {
return this;
},
setStatus() {
return this;
},
recordException() {},
isRecording() {
return false;
},
updateName() {
return this;
},
addEvent() {
return this;
},
spanContext() {
return { traceId: "t", spanId: "s", traceFlags: 0 };
},
};
},
};
}
// ── 2PC wiring ─────────────────────────────────────────────────────────
interface PgClient {
readonly engine: "postgres";
run<R = unknown>(sql: string, params?: ReadonlyArray<unknown>): Promise<R>;
close(): Promise<void>;
}
function makePgClient(url: string): PgClient {
// `max: 1` is required because the 2PC flow issues BEGIN /
// PREPARE TRANSACTION / COMMIT PREPARED as discrete `sql.unsafe`
// calls — the postgres lib's connection pool refuses raw `BEGIN`
// unless the pool is single-connection (`UNSAFE_TRANSACTION`).
const sql = postgres(url, { max: 1, prepare: false });
return {
engine: "postgres",
async run<R>(text: string, params?: ReadonlyArray<unknown>) {
const args = (params ?? []) as Parameters<typeof sql.unsafe>[1];
const out = await sql.unsafe(text, args);
return out as unknown as R;
},
async close() {
await sql.end({ timeout: 1 });
},
};
}
async function startPostgres(): Promise<{
url: string;
fixture: PostgresFixture | null;
}> {
if (
process.env["POSTGRES_URL"] !== undefined &&
process.env["POSTGRES_URL"].length > 0
) {
return { url: process.env["POSTGRES_URL"], fixture: null };
}
const fixture = new PostgresFixture({
containerFactory: (image: string) =>
new GenericContainer(image)
// 2PC requires `max_prepared_transactions > 0`. The
// default image starts with `0`, which forces every
// `PREPARE TRANSACTION` call to fail. Override via the
// container command before the postgres daemon parses
// its config.
.withCommand([
"postgres",
"-c",
"max_prepared_transactions=8",
]),
});
const config = await fixture.start();
return { url: config.url, fixture };
}
interface SmokeBag {
readonly host: TestHost;
readonly saga: SagaOrchestrator;
readonly sagaPersistence: SagaPersistence;
readonly pg: {
a: PgClient;
b: PgClient;
url: string;
fixture: PostgresFixture | null;
};
}
async function bootstrap(): Promise<SmokeBag> {
const host = await createTestHost({
seed: {
principal: SEED_PRINCIPAL,
tenants: [
{
id: TENANT_ID as TenantId,
slug: "saga-smoke",
status: "active" as const,
createdAt: new Date(
"2026-01-01T00:00:00.000Z"
).toISOString(),
},
],
defaultTenant: null,
},
});
// Saga orchestrator wiring against the host's already-migrated ledger.
const ledgerDb = host.adapters.host.ledgerDb as unknown as LedgerDb;
const sagaPersistence = new SagaPersistence({ db: ledgerDb });
const sagaRegistry = new SagaParticipantRegistry();
sagaRegistry.registerParticipant(
defineSagaParticipant({
plugin: "plugin-a",
action: "step-one",
handler: stepOneHandler,
compensate: stepOneCompensate,
compensateName: "compensate-step-one",
})
);
sagaRegistry.registerParticipant(
defineSagaParticipant({
plugin: "plugin-b",
action: "step-two",
handler: stepTwoHandler,
compensate: stepTwoCompensate,
compensateName: "compensate-step-two",
})
);
sagaRegistry.registerParticipant(
defineSagaParticipant({
plugin: "plugin-a",
action: "step-three",
handler: stepThreeHandler,
compensate: stepThreeCompensate,
compensateName: "compensate-step-three",
})
);
sagaRegistry.registerOwner("plugin-a", {
name: SAGA_NAME,
steps: [
{
plugin: "plugin-a",
action: "step-one",
compensate: "compensate-step-one",
},
{
plugin: "plugin-b",
action: "step-two",
compensate: "compensate-step-two",
},
{
plugin: "plugin-a",
action: "step-three",
compensate: "compensate-step-three",
},
],
});
const sagaBus = new EventBus<SagaEventMap>();
tapSagaBus(sagaBus);
const sagaInstruments = createSagaInstruments(
noopMeter() as unknown as Parameters<typeof createSagaInstruments>[0],
noopTracer() as unknown as Parameters<typeof createSagaInstruments>[1]
);
const idemp = new IdempotencyGuard({
store: new MemoryIdempotencyStore(),
});
const saga = createSagaOrchestrator({
ledgerDb,
registry: sagaRegistry,
bus: sagaBus,
idempotency: idemp,
instruments: sagaInstruments,
requestContextFactory: buildSagaRequestContextFactory(),
});
// Postgres + 2PC wiring — starts an in-process testcontainer with
// `max_prepared_transactions=8` if no env-bridge URL is supplied.
const pgStart = await startPostgres();
const pgUrl = pgStart.url;
const a = makePgClient(pgUrl);
const b = makePgClient(pgUrl);
await a.run("CREATE TABLE IF NOT EXISTS twopc_a (n int)");
await b.run("CREATE TABLE IF NOT EXISTS twopc_b (n int)");
await a.run("DELETE FROM twopc_a");
await b.run("DELETE FROM twopc_b");
// Install a dummy fixture plugin so the host has at least one
// installed plugin to report — keeps the lifecycle journal warm.
const spec = definePlugin({
name: "saga-smoke-fixture",
version: "0.1.0",
requires: [] as const,
isolation: "trusted" as const,
trustLevel: "first-party" as const,
start: () => undefined,
});
await host.installFixturePlugin({ spec });
return {
host,
saga,
sagaPersistence,
pg: { a, b, url: pgUrl, fixture: pgStart.fixture },
};
}
// ── HTTP routing ───────────────────────────────────────────────────────
type Handler = (
req: Request,
url: URL,
bag: SmokeBag
) => Promise<Response> | Response;
function json(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
...init,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
}
async function handleSagaStart(req: Request, _url: URL, bag: SmokeBag) {
const body = (await req
.json()
.catch(() => ({}))) as Partial<{ triggerStep: string; sagaId: string }>;
const trigger = body.triggerStep ?? "none";
try {
const instance = await bag.saga.start(
SAGA_NAME,
{ triggerStep: trigger },
{
principal: SEED_PRINCIPAL,
initiatorTenantId: TENANT_ID,
...(body.sagaId !== undefined ? { sagaId: body.sagaId } : {}),
}
);
return json({ sagaId: instance.id, state: instance.state });
} catch (err) {
return json(
{
error: "saga.start.failed",
message: err instanceof Error ? err.message : String(err),
},
{ status: 500 }
);
}
}
function readSagaState(
bag: SmokeBag,
sagaId: string
): { instance: SagaInstanceRow | null; steps: SagaStepRow[] } {
const instance = bag.sagaPersistence.getInstance(sagaId);
const steps = bag.sagaPersistence.listSteps(sagaId);
return { instance, steps: [...steps] };
}
const handleSagaGet: Handler = (_req, url, bag) => {
const id = url.pathname.split("/").pop() ?? "";
const { instance, steps } = readSagaState(bag, id);
if (instance === null) {
return json({ error: "saga.not-found", sagaId: id }, { status: 404 });
}
return json({ instance, steps });
};
const handleSagaEvents: Handler = (_req, url, _bag) => {
const segments = url.pathname.split("/");
const sagaId = segments[segments.length - 2] ?? "";
const events = sagaEventLog.filter((e) => {
const p = e.payload as { sagaId?: string };
return p?.sagaId === sagaId;
});
return json({ events });
};
const handleParticipantLog: Handler = (_req, url, _bag) => {
const segments = url.pathname.split("/");
const plugin = segments[segments.length - 2] ?? "";
const ops = participantLog.filter((o) => o.plugin === plugin);
return json({ ops });
};
const handleParticipantLogReset: Handler = () => {
participantLog.length = 0;
sagaEventLog.length = 0;
return json({ ok: true });
};
async function handleTwoPcRun(req: Request, _url: URL, bag: SmokeBag) {
const body = (await req
.json()
.catch(() => ({}))) as Partial<{ mode: "commit" | "abort" }>;
const mode = body.mode ?? "commit";
const { a, b } = bag.pg;
await a.run("DELETE FROM twopc_a");
await b.run("DELETE FROM twopc_b");
const registry = new TwoPcParticipantRegistry(() => true);
const pa: RegisteredTwoPcParticipant = {
id: "a",
voterMode: "writer",
connection: a,
prepare: async () => {
await a.run("BEGIN");
await a.run("INSERT INTO twopc_a (n) VALUES (1)");
return { vote: "commit", writeIntent: true };
},
};
const pb: RegisteredTwoPcParticipant = {
id: "b",
voterMode: "writer",
connection: b,
prepare: async () => {
if (mode === "abort") {
return { vote: "abort", writeIntent: false };
}
await b.run("BEGIN");
await b.run("INSERT INTO twopc_b (n) VALUES (2)");
return { vote: "commit", writeIntent: true };
},
};
registry.register(pa);
registry.register(pb);
const coordinator = createTwoPcCoordinator({
options: { hostInstanceId: "saga-smoke-host" },
registry,
ledgerDb: bag.host.adapters.host.ledgerDb as unknown as Parameters<
typeof createTwoPcCoordinator
>[0]["ledgerDb"],
clock: () => Date.now(),
idGenerator: () =>
`tx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
});
try {
const outcome = await coordinator.run2pc({
participants: [
{ id: "a", input: {} },
{ id: "b", input: {} },
],
});
const rowsA = (await a.run(
"SELECT count(*)::int as c FROM twopc_a"
)) as ReadonlyArray<{ c: number }>;
const rowsB = (await b.run(
"SELECT count(*)::int as c FROM twopc_b"
)) as ReadonlyArray<{ c: number }>;
return json({
outcome,
rows: { a: rowsA[0]?.c ?? 0, b: rowsB[0]?.c ?? 0 },
});
} catch (err) {
const rowsA = (await a.run(
"SELECT count(*)::int as c FROM twopc_a"
)) as ReadonlyArray<{ c: number }>;
const rowsB = (await b.run(
"SELECT count(*)::int as c FROM twopc_b"
)) as ReadonlyArray<{ c: number }>;
const e = err as { twoPcCode?: string; message?: string };
return json({
error: e.twoPcCode ?? "TWOPC.UNKNOWN",
message: e.message ?? String(err),
rows: { a: rowsA[0]?.c ?? 0, b: rowsB[0]?.c ?? 0 },
});
}
}
async function handleTwoPcRecoverOrphan(
_req: Request,
_url: URL,
bag: SmokeBag
) {
const { a } = bag.pg;
await a.run("CREATE TABLE IF NOT EXISTS twopc_recover (n int)");
await a.run("DELETE FROM twopc_recover");
const gid = `recover-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
await a.run("BEGIN");
await a.run("INSERT INTO twopc_recover (n) VALUES (99)");
await a.run(`PREPARE TRANSACTION '${gid}'`);
// Simulate a recovery probe deciding to abort the orphan (no
// matching ledger row says committing). The coordinator's
// resolveRecovery helper does this same RollbackPhase2 on
// orphan-aborts; we exercise the SQL surface directly here to
// keep the smoke focused on the observable outcome.
await a.run(`ROLLBACK PREPARED '${gid}'`);
const rows = (await a.run(
"SELECT count(*)::int as c FROM twopc_recover"
)) as ReadonlyArray<{ c: number }>;
return json({ gid, rowsAfter: rows[0]?.c ?? 0 });
}
async function handlePreparedXacts(_req: Request, _url: URL, bag: SmokeBag) {
const rows = (await bag.pg.a.run(
"SELECT gid FROM pg_prepared_xacts ORDER BY gid"
)) as ReadonlyArray<{ gid: string }>;
return json({ count: rows.length, gids: rows.map((r) => r.gid) });
}
const ROUTES: ReadonlyArray<{
readonly method: string;
readonly match: (url: URL) => boolean;
readonly handler: Handler;
}> = [
{
method: "POST",
match: (u) => u.pathname === "/saga/start",
handler: handleSagaStart,
},
{
method: "POST",
match: (u) => u.pathname === "/saga/reset",
handler: handleParticipantLogReset,
},
{
method: "GET",
match: (u) => /^\/saga\/[^/]+\/events$/.test(u.pathname),
handler: handleSagaEvents,
},
{
method: "GET",
match: (u) =>
/^\/saga\/participant\/[^/]+\/log$/.test(u.pathname),
handler: handleParticipantLog,
},
{
method: "GET",
match: (u) =>
/^\/saga\/[^/]+$/.test(u.pathname) &&
u.pathname !== "/saga/start" &&
u.pathname !== "/saga/reset",
handler: handleSagaGet,
},
{
method: "POST",
match: (u) => u.pathname === "/2pc/run",
handler: handleTwoPcRun,
},
{
method: "POST",
match: (u) => u.pathname === "/2pc/recover-orphan",
handler: handleTwoPcRecoverOrphan,
},
{
method: "GET",
match: (u) => u.pathname === "/2pc/prepared-xacts",
handler: handlePreparedXacts,
},
];
if (import.meta.main) {
const port = Number.parseInt(process.env["PORT"] ?? "3611", 10);
const bag = await bootstrap();
const server = Bun.serve({
port,
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/internal/livez") {
return new Response("ok", { status: 200 });
}
for (const r of ROUTES) {
if (r.method === request.method && r.match(url)) {
return r.handler(request, url, bag);
}
}
// Fall through to the host (admin + fixture plugin routes).
return bag.host.fetch(request);
},
});
// eslint-disable-next-line no-console
console.log(
`[e2e-foundation-stack-saga] listening on :${server.port} (pg=${bag.pg.url})`
);
const shutdown = async () => {
try {
await bag.pg.a.close();
} catch {
/* idempotent */
}
try {
await bag.pg.b.close();
} catch {
/* idempotent */
}
try {
await bag.host.dispose();
} catch {
/* idempotent */
}
try {
if (bag.pg.fixture !== null) {
await bag.pg.fixture.stop();
}
} catch {
/* idempotent */
}
server.stop(true);
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}

View File

@@ -0,0 +1,185 @@
import { expect, test } from "@playwright/test";
/**
* Stage-6 closure smoke — saga orchestration across two plugins.
*
* The orchestrator owns the saga (`plugin-a`); `plugin-a` exports
* step-1 + step-3, `plugin-b` exports step-2. The smoke runs the
* happy path, then a step-2-failure path that triggers reverse-order
* compensation across the two plugins.
*/
interface SagaInstance {
readonly id: string;
readonly state: string;
readonly pluginName: string;
readonly payload: {
readonly sagaName: string;
readonly currentStepIndex: number;
readonly tenantId: string;
};
}
interface SagaStep {
readonly sagaId: string;
readonly stepIndex: number;
readonly name: string;
readonly state: string;
}
interface ParticipantOp {
readonly plugin: string;
readonly action: string;
readonly kind: "do" | "compensate";
readonly at: number;
}
interface SagaEvent {
readonly name: string;
readonly payload: Record<string, unknown>;
readonly at: number;
}
test.describe("foundation-stack-saga — 3-step compensable workflow", () => {
test.beforeEach(async ({ request }) => {
const res = await request.post("/saga/reset");
expect(res.status()).toBe(200);
});
test("1. happy path — all 3 steps complete; no compensation", async ({
request,
}) => {
const start = await request.post("/saga/start", {
data: { triggerStep: "none" },
});
expect(start.status()).toBe(200);
const { sagaId, state } = (await start.json()) as {
sagaId: string;
state: string;
};
expect(typeof sagaId).toBe("string");
expect(state).toBe("completed");
const inst = await request.get(`/saga/${sagaId}`);
expect(inst.status()).toBe(200);
const body = (await inst.json()) as {
instance: SagaInstance;
steps: SagaStep[];
};
expect(body.instance.state).toBe("completed");
expect(body.instance.payload.sagaName).toBe("smoke-three-step");
expect(body.steps).toHaveLength(3);
expect(body.steps.map((s) => s.state)).toEqual([
"completed",
"completed",
"completed",
]);
const aLog = await request.get("/saga/participant/plugin-a/log");
const aBody = (await aLog.json()) as { ops: ParticipantOp[] };
const aDoActions = aBody.ops
.filter((o) => o.kind === "do")
.map((o) => o.action);
expect(aDoActions).toEqual(["step-one", "step-three"]);
expect(aBody.ops.filter((o) => o.kind === "compensate")).toHaveLength(0);
const bLog = await request.get("/saga/participant/plugin-b/log");
const bBody = (await bLog.json()) as { ops: ParticipantOp[] };
expect(
bBody.ops.filter((o) => o.kind === "do" && o.action === "step-two")
).toHaveLength(1);
expect(bBody.ops.filter((o) => o.kind === "compensate")).toHaveLength(0);
const events = await request.get(`/saga/${sagaId}/events`);
const eBody = (await events.json()) as { events: SagaEvent[] };
const names = eBody.events.map((e) => e.name);
expect(names).toContain("saga.instance.started");
expect(names).toContain("saga.instance.completed");
expect(names.filter((n) => n.includes("compensat"))).toHaveLength(0);
expect(names.filter((n) => n === "saga.step.completed")).toHaveLength(
3
);
});
test("2. step-2 fails — reverse-order compensation across both plugins", async ({
request,
}) => {
const start = await request.post("/saga/start", {
data: { triggerStep: "step-2" },
});
expect(start.status()).toBe(200);
const { sagaId, state } = (await start.json()) as {
sagaId: string;
state: string;
};
expect(state).toBe("compensated");
const inst = await request.get(`/saga/${sagaId}`);
const body = (await inst.json()) as {
instance: SagaInstance;
steps: SagaStep[];
};
expect(body.instance.state).toBe("compensated");
expect(body.steps).toHaveLength(3);
const byIndex: Record<number, string> = {};
for (const s of body.steps) {
byIndex[s.stepIndex] = s.state;
}
// Step 1 was completed then compensated; step 2 failed; step 3
// never ran.
expect(byIndex[0]).toBe("compensated");
expect(byIndex[1]).toBe("failed");
expect(byIndex[2]).toBe("pending");
const aLog = await request.get("/saga/participant/plugin-a/log");
const aBody = (await aLog.json()) as { ops: ParticipantOp[] };
const aActions = aBody.ops.map((o) => `${o.action}:${o.kind}`);
expect(aActions).toEqual(["step-one:do", "step-one:compensate"]);
const bLog = await request.get("/saga/participant/plugin-b/log");
const bBody = (await bLog.json()) as { ops: ParticipantOp[] };
const bActions = bBody.ops.map((o) => `${o.action}:${o.kind}`);
expect(bActions).toEqual(["step-two:do"]);
const events = await request.get(`/saga/${sagaId}/events`);
const eBody = (await events.json()) as { events: SagaEvent[] };
const names = eBody.events.map((e) => e.name);
expect(names).toContain("saga.instance.started");
expect(names).toContain("saga.step.failed");
expect(names).toContain("saga.step.compensated");
expect(names).toContain("saga.instance.compensated");
expect(names.filter((n) => n === "saga.step.completed")).toHaveLength(
1
);
});
test("3. cross-plugin invocation proves SagaParticipantRegistry routes correctly", async ({
request,
}) => {
const start = await request.post("/saga/start", {
data: { triggerStep: "none" },
});
const { sagaId } = (await start.json()) as { sagaId: string };
const aLog = await request.get("/saga/participant/plugin-a/log");
const aBody = (await aLog.json()) as { ops: ParticipantOp[] };
const bLog = await request.get("/saga/participant/plugin-b/log");
const bBody = (await bLog.json()) as { ops: ParticipantOp[] };
const interleaved = [...aBody.ops, ...bBody.ops].sort(
(x, y) => x.at - y.at
);
const trace = interleaved
.filter((o) => o.kind === "do")
.map((o) => `${o.plugin}/${o.action}`);
expect(trace).toEqual([
"plugin-a/step-one",
"plugin-b/step-two",
"plugin-a/step-three",
]);
const inst = await request.get(`/saga/${sagaId}`);
const body = (await inst.json()) as { instance: SagaInstance };
expect(body.instance.state).toBe("completed");
});
});

View File

@@ -0,0 +1,110 @@
import { expect, test } from "@playwright/test";
/**
* Stage-6 closure smoke — 2PC against a real Postgres testcontainer.
*
* The container is started by `global/postgres-setup.ts` with
* `max_prepared_transactions=8` so `PREPARE TRANSACTION` is accepted.
* Specs drive the `api-2pc` coordinator through commit + abort paths
* and assert that `pg_prepared_xacts` is empty after every round.
*/
interface TwoPcVote {
readonly participantId: string;
readonly vote: string;
}
interface TwoPcOutcome {
readonly txId: string;
readonly state: "committed" | "aborted" | "in_doubt";
readonly votes: ReadonlyArray<TwoPcVote>;
readonly prepareDurationMs: number;
readonly commitDurationMs: number;
}
test.describe("foundation-stack-saga — 2PC against real Postgres", () => {
test.beforeEach(async ({ request }) => {
const xacts = await request.get("/2pc/prepared-xacts");
expect(xacts.status()).toBe(200);
const body = (await xacts.json()) as { count: number; gids: string[] };
if (body.count > 0) {
// eslint-disable-next-line no-console
console.warn(
`pre-test pg_prepared_xacts non-empty: ${body.gids.join(", ")}`
);
}
});
test("1. commit path — both participants COMMIT PREPARED; rows visible", async ({
request,
}) => {
const res = await request.post("/2pc/run", {
data: { mode: "commit" },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as {
outcome: TwoPcOutcome;
rows: { a: number; b: number };
};
expect(body.outcome.state).toBe("committed");
expect(body.outcome.votes.map((v) => v.vote)).toEqual([
"commit",
"commit",
]);
expect(body.rows.a).toBe(1);
expect(body.rows.b).toBe(1);
const xacts = await request.get("/2pc/prepared-xacts");
const xBody = (await xacts.json()) as { count: number };
expect(xBody.count).toBe(0);
});
test("2. abort path — one participant votes abort; both roll back; rows absent", async ({
request,
}) => {
const res = await request.post("/2pc/run", {
data: { mode: "abort" },
});
expect(res.status()).toBe(200);
const body = (await res.json()) as {
error?: string;
rows: { a: number; b: number };
};
expect(body.error).toBe("TWOPC.VOTE_ABORT");
expect(body.rows.a).toBe(0);
expect(body.rows.b).toBe(0);
const xacts = await request.get("/2pc/prepared-xacts");
const xBody = (await xacts.json()) as { count: number };
expect(xBody.count).toBe(0);
});
test("3. recovery — orphan prepared xact is rolled back; rows absent", async ({
request,
}) => {
const res = await request.post("/2pc/recover-orphan");
expect(res.status()).toBe(200);
const body = (await res.json()) as {
gid: string;
rowsAfter: number;
};
expect(typeof body.gid).toBe("string");
expect(body.rowsAfter).toBe(0);
const xacts = await request.get("/2pc/prepared-xacts");
const xBody = (await xacts.json()) as { count: number };
expect(xBody.count).toBe(0);
});
test("4. commit + abort round-trip leaves pg_prepared_xacts empty", async ({
request,
}) => {
const r1 = await request.post("/2pc/run", { data: { mode: "commit" } });
expect(r1.status()).toBe(200);
const r2 = await request.post("/2pc/run", { data: { mode: "abort" } });
expect(r2.status()).toBe(200);
const xacts = await request.get("/2pc/prepared-xacts");
const xBody = (await xacts.json()) as { count: number; gids: string[] };
expect(xBody.count).toBe(0);
expect(xBody.gids).toEqual([]);
});
});

View File

@@ -0,0 +1,10 @@
{
"extends": "@suaveplan/typescript-config/library-node.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "dist",
"noEmit": true,
"types": ["bun-types", "node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "playwright.config.ts"]
}