diff --git a/README.md b/README.md index 3056e08..2da917c 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ The navigation validation tests run with `pnpm test`. They verify tool roots, de ## Interactive Throughline examples -Selected Throughline guides render the published `@0xbuff/throughline@0.0.2` React player. Sources are kept in `src/lib/throughline-demos.ts`; MDX pages use ``. The component is registered in `src/components/mdx.tsx`. Its compiler, player and namespaced CSS load in a separate module only when a demo is rendered. All other 0xBUFF tool documentation remains independent of Throughline. +Selected Throughline guides render the published `@0xbuff/throughline@0.0.2` React player. The overview leads with a multi-system checkout recovery trace; introductory pages retain smaller teaching examples. Sources are kept in `src/lib/throughline-demos.ts`; MDX pages use ``. The component is registered in `src/components/mdx.tsx`. Its compiler, player and namespaced CSS load in a separate module only when a demo is rendered. All other 0xBUFF tool documentation remains independent of Throughline. -The showcase is deliberately limited to a few pages and does not automatically transform every `thl` code fence. This preserves portable source examples and avoids unnecessary client JavaScript. `scripts/throughline-interactive.test.mjs` compiles every showcased trace with the installed package; CI also typechecks and builds both environments. +The showcase is deliberately limited to a few pages and does not automatically transform every `thl` code fence. The official `remarkThroughline({ mode: "mdx" })` integration and `ThroughlineEmbed` require a subsequent package release; v0.0.2 must not be assumed to contain them. This preserves portable source examples and avoids unnecessary client JavaScript. `scripts/throughline-interactive.test.mjs` compiles every showcased trace with the installed package; CI also typechecks and builds both environments. ## Content ownership and publication diff --git a/content/docs/throughline/index.mdx b/content/docs/throughline/index.mdx index cd0eb7e..5e941b4 100644 --- a/content/docs/throughline/index.mdx +++ b/content/docs/throughline/index.mdx @@ -10,9 +10,9 @@ Use it to explain algorithms, data structures, recursion, and system workflows. ## See it in action - + -Open **View trace source** to see exactly what the player is visualizing. The compiler creates the frames before playback; **Play** and the timeline only select existing frames. +This checkout scenario follows a lost provider reply, an unknown payment outcome, a retry with the same idempotency key and eventual recovery. Directed transfers show what was **sent**; order, provider and customer values change only at explicitly authored writes. Open **View trace source** to inspect both kinds of events. The compiler creates the frames before playback; **Play** and the timeline only select existing frames. ## Get started diff --git a/scripts/throughline-interactive.test.mjs b/scripts/throughline-interactive.test.mjs index bca818f..942b89a 100644 --- a/scripts/throughline-interactive.test.mjs +++ b/scripts/throughline-interactive.test.mjs @@ -4,7 +4,7 @@ import { compile } from "@0xbuff/throughline"; import { THROUGHLINE_DEMOS } from "../src/lib/throughline-demos.ts"; test("interactive documentation demos compile with the published Throughline package", () => { - assert.deepEqual(Object.keys(THROUGHLINE_DEMOS).sort(), ["binary", "counter", "references"]); + assert.deepEqual(Object.keys(THROUGHLINE_DEMOS).sort(), ["binary", "checkout", "counter", "references"]); for (const [id, demo] of Object.entries(THROUGHLINE_DEMOS)) { const trace = compile(demo.source); assert.equal(trace.version, 1, id); @@ -14,6 +14,47 @@ test("interactive documentation demos compile with the published Throughline pac } }); +test("the checkout demo preserves an unknown outcome until confirmation", () => { + const trace = compile(THROUGHLINE_DEMOS.checkout.source); + const get = (index, id) => trace.frames[index]?.state.entities.find((item) => item.id === id)?.value; + assert.deepEqual(get(6, "order"), { status: "awaiting_payment" }); + assert.equal(get(6, "worker"), "outcome unknown"); + assert.equal(get(6, "status"), "processing"); + assert.equal(get(8, "provider"), "one charge: pay-order-1; retry deduplicated"); + assert.deepEqual(get(11, "order"), { status: "paid" }); + assert.equal(get(11, "status"), "confirmed"); +}); + +test("checkout transfers preserve real link endpoints and never imply persistence", () => { + const trace = compile(THROUGHLINE_DEMOS.checkout.source); + const links = new Map(trace.frames[0].state.relations.map((link) => [link.id, link])); + const expected = [ + ["persist"], ["enqueue"], ["report"], ["deliver"], ["charge"], [], + ["retry"], ["charge"], ["receipt"], ["confirm", "persist"], ["report"], + ]; + for (const [index, relationIds] of expected.entries()) { + const frame = trace.frames[index + 1]; + assert.deepEqual(frame.state.transfers.map(({ relation }) => relation), relationIds, `Step ${index + 1}`); + for (const transfer of frame.state.transfers) { + const link = links.get(transfer.relation); + assert.ok(link, `Unknown link ${transfer.relation}`); + assert.ok(frame.state.entities.some((entity) => entity.id === link.from)); + assert.ok(frame.state.entities.some((entity) => entity.id === link.to)); + } + } + + const state = (index, id) => + trace.frames[index].state.entities.find((entity) => entity.id === id)?.value; + assert.equal(state(4, "provider"), "no charge", "delivery is not a persisted provider charge"); + assert.deepEqual(state(9, "order"), { status: "awaiting_payment" }, "confirmation is not an order write"); + assert.equal(state(10, "status"), "processing", "recording paid does not automatically notify the customer"); + assert.equal(state(11, "status"), "confirmed"); + assert.deepEqual( + trace.frames[10].state.transfers.find(({ relation }) => relation === "persist")?.value, + { status: "paid" }, + ); +}); + test("the counter and binary demos show their expected final values", () => { const counter = compile(THROUGHLINE_DEMOS.counter.source); const lastCounter = counter.frames.at(-1)?.state.entities.find((item) => item.id === "counter"); diff --git a/src/lib/throughline-demos.ts b/src/lib/throughline-demos.ts index d2c751e..911acca 100644 --- a/src/lib/throughline-demos.ts +++ b/src/lib/throughline-demos.ts @@ -14,6 +14,78 @@ export const THROUGHLINE_DEMOS = { ].join("\n"), expectedFrames: 3, }, + checkout: { + title: "Recover a distributed checkout after a timeout", + description: "Trace a payment whose reply is lost, then follow an idempotent retry through order recovery.", + source: [ + "thl flow", + 'group "Checkout" as column', + 'group "Payment" as column', + 'node api "Checkout API" in "Checkout" = "received"', + 'node order "Order record" in "Checkout" as record = {"status":"absent"}', + 'node status "Customer status" in "Checkout" = "not started"', + 'node queue "Payment queue" in "Payment" as array = []', + 'node worker "Worker" in "Payment" = "idle"', + 'node provider "Provider" in "Payment" = "no charge"', + 'link persist api -> order "persist"', + 'link enqueue api -> queue "enqueue"', + 'link deliver queue -> worker "deliver"', + 'link charge worker -> provider "charge"', + 'link report api -> status "status"', + 'link retry worker -> queue "retry"', + 'link receipt provider -> worker "confirmation"', + 'link confirm worker -> api "confirm payment"', + 'step "Persist pending order"', + ' send persist = {"status":"awaiting_payment"}', + ' set order = {"status":"awaiting_payment"}', + ' note "The order is pending. No payment is confirmed yet."', + 'step "Enqueue payment work"', + ' send enqueue = "pay-order-1"', + ' set queue = ["pay-order-1"]', + ' note "A job is queued with a stable payment key."', + 'step "Report processing"', + ' send report = "processing"', + ' set status = "processing"', + ' note "The customer is told the operation is in progress, not that it succeeded."', + 'step "Deliver the job"', + ' send deliver = "pay-order-1"', + ' set queue = []', + ' set worker = "charging with pay-order-1"', + ' note "The worker begins the first payment attempt."', + 'step "Provider records one charge"', + ' send charge = "pay-order-1"', + ' set provider = "one charge: pay-order-1; reply lost"', + ' note "The provider recorded one charge, but its response was lost."', + 'step "Worker times out"', + ' set worker = "outcome unknown"', + ' note "A timeout does not establish payment failure. The order remains pending."', + 'step "Retry with the same key"', + ' send retry = "pay-order-1"', + ' set queue = ["retry pay-order-1"]', + ' set worker = "retry queued"', + ' note "The retry uses the original idempotency key."', + 'step "Provider deduplicates"', + ' send charge = "pay-order-1"', + ' set queue = []', + ' set provider = "one charge: pay-order-1; retry deduplicated"', + ' set worker = "retrying pay-order-1"', + ' note "For this provider, the same key identifies the existing charge."', + 'step "Worker receives confirmation"', + ' send receipt = "pay-order-1 paid"', + ' set worker = "payment confirmed"', + ' note "The response arrives before local payment success is recorded."', + 'step "Persist paid order"', + ' send confirm = "pay-order-1 paid"', + ' send persist = {"status":"paid"}', + ' set order = {"status":"paid"}', + ' note "The confirmed outcome is now persisted."', + 'step "Notify customer"', + ' send report = "confirmed"', + ' set status = "confirmed"', + ' note "The customer sees the recovered order; no second charge was created."', + ].join("\n"), + expectedFrames: 12, + }, references: { title: "Watch two references share an array", description: "Step through copying a reference, mutating the array, and moving one pointer.",