# Durable checkout

In [chapter 3](https://rickylabs.github.io/netscript/tutorials/storefront/03-cart-contracts/) you designed the cart contract. Checkout is what turns a cart into an order — and it is the one place in a shop where a crash mid-flight costs real money. A naive `async` function that charges a card, reserves inventory, then books shipment is a liability: if the process dies after the charge but before the reservation, you have taken money and shipped nothing. This chapter rebuilds checkout as a **durable saga** — a state machine that checkpoints its progress, reacts to payment and inventory messages, and runs a **compensation** path when a step fails.

1. [1 · Scaffold](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/01-scaffold/)
2. [2 · Catalog service](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/02-catalog-service/)
3. [3 · Cart contracts](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/03-cart-contracts/)
4. [4 · Checkout saga](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/04-checkout-saga/)
5. [5 · Shipping webhook](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/05-shipping-webhook/)
6. [6 · Storefront UI](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/06-storefront-ui/)
7. [7 · Deploy](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/07-deploy/)

## What you will build

You will add the runtime plugins checkout depends on, then author a `CheckoutSaga` with `defineSaga(...)`: typed per-instance state, a correlation key, and message handlers that walk a checkout from `OrderCreated` through payment toward fulfillment. You will also author the `process-payment` worker job that a checkout trigger enqueues, and you will wire the **failure path** so a declined payment cancels the order instead of stranding it. By the end you can drive a checkout to a **paid** order and watch a failed payment **compensate to cancelled** — both observable on the Sagas API.

## Before you begin

You should have finished [chapter 3](https://rickylabs.github.io/netscript/tutorials/storefront/03-cart-contracts/), so:

- `my-shop/` has the `products` service and the `cart` contract.
- `aspire start` is up (the dashboard answers at [https://localhost:18888](https://localhost:18888)). The saga registry and durable instance store both depend on Aspire-managed resources — Deno KV for the registry, and either KV or Postgres for instance state.

> Aspire must already be running
>
> The Sagas API service and its KV-backed registry come up as part of the orchestrated app. If you closed your
>
> aspire start
>
> terminal, restart it from the
>
> aspire/
>
> folder (
>
> cd aspire && aspire start
>
> )
>
> before
>
> running any
>
> netscript
>
> command in this chapter — the durable store and registry only exist while Aspire is up.

## Step 1 — Add the checkout runtime plugins

Checkout spans four official runtime plugins, and you install each one explicitly: **`sagas`** (the durable workflow), **`workers`** (background jobs), **`triggers`** (the supported worker enqueue boundary), and **`streams`** (the durable transport). Add them from the project root, with samples so you have working modules to adapt:

```sh
netscript plugin install worker --name workers --samples
netscript plugin install saga --name sagas --samples
netscript plugin install trigger --name triggers --samples
netscript plugin install stream --name streams --samples
```

Each plugin lands at its canonical location (**`plugins/sagas/`**, `plugins/workers/`, `plugins/streams/`), and `netscript.config.ts` is updated to reference each `mod.ts`. A slimmer top-level staging copy (e.g. `sagas/`) is also created for the background processor — you author against `plugins/<name>/`.

Confirm they registered:

```sh
netscript plugin list
```

You should see `workers`, `sagas`, `triggers`, and `streams` in the list.

## Step 2 — Read the saga builder

NetScript sagas are authored with a **fluent builder** imported from `@netscript/plugin-sagas-core`. Each call narrows the saga's type and configuration; `.build()` produces the definition the runtime consumes. The methods you will use:

**SagaBuilder methods (from @netscript/plugin-sagas-core)**

| Name | Type | Description |
| --- | --- | --- |
| `defineSaga(id)` | `start the chain` | Begins a saga definition with a stable id used in the registry and instance keys. |
| `.durability(tier)` | `persistence tier` | Selects the durability tier (defaults to T1). The persisted tier checkpoints instance state so an in-flight workflow survives a restart. |
| `.state(initial)` | `per-instance state` | Declares the state shape and its initial value. Every correlated instance gets its own copy. Must come before any handler. |
| `.correlate(fn)` | `instance routing` | Extracts the correlation key from an incoming message so it reaches the right instance — e.g. correlate by orderId. |
| `.on(type, handler)` | `message handler` | Subscribes to a message type. The handler reads state + message and returns an array of effects. |
| `.compensate(type, handler)` | `compensation handler` | Registers a handler for a FAILED event type — the undo path. Same shape as .on(), reserved for compensation. |
| `.build()` | `finalize` | Produces the frozen SagaDefinition the runner executes. Requires at least one handler. |

The saga DSL also exports **`send(target, payload)`** for cascaded messages handled by registered saga definitions. It does not call a service, enqueue a worker job, or run a task. This payment leg does not need an internal cascade: its trigger uses `enqueueJob(...)` for durable worker dispatch, and the worker publishes a typed result back to the saga.

## Step 3 — Scaffold the checkout saga

Start with the saga definition and config scaffold:

The `add-saga` verb uses the spaced `add saga` shell syntax:

> ns-sagas is a shorthand you install once
>
> ns-sagas
>
> is a name
>
> you
>
> give the sagas plugin's CLI — the scaffold does not create it. Install it once, globally, and every
>
> ns-sagas
>
> command on this page works as written:
>
> ```bash
> deno install -gArf -n ns-sagas jsr:@netscript/plugin-sagas@0.0.6/cli
> ```
>
> Rather not install it? Each
>
> ns-sagas <verb …>
>
> is exactly
>
> deno x -A jsr:@netscript/plugin-sagas@0.0.6/cli <verb …>
>
> — run that full form instead.

```sh
ns-sagas add saga checkout --message-type=OrderCreated --durability=t1 --topic=checkout
```

The command writes `sagas/checkout-saga.ts` plus `sagas/checkout.config.ts`, including a normal handler and a compensation-handler skeleton, and refreshes the saga registry. Extend that generated definition with the checkout state and lifecycle below. It correlates by `orderId`, starts `pending`, and walks the lifecycle. When payment fails, the forward handler returns a `sagaCompensate(...)` effect and the matching `.compensate(...)` branch records cancellation.

```ts
// sagas/checkout-saga.ts
import { defineSaga, sagaCompensate } from '@netscript/plugin-sagas-core';
import type { SagaCorrelationKey, SagaState } from '@netscript/plugin-sagas-core/domain';

type OrderStatus =
  | 'pending'
  | 'payment_pending'
  | 'paid'
  | 'cancelled';

// Per-instance checkout state. Runtime metadata is handled for you.
interface CheckoutState extends SagaState {
  orderId: string;
  customerId: string;
  status: OrderStatus;
  items: Array<{ productId: string; quantity: number }>;
  total: number;
  transactionId?: string;
  cancelReason?: string;
}

const initialState: CheckoutState = {
  orderId: '',
  customerId: '',
  status: 'pending',
  items: [],
  total: 0,
};

export const checkoutSaga = defineSaga('CheckoutSaga')
  .state(initialState)
  // OrderCreated records the workflow. The trigger below owns worker dispatch.
  .on('OrderCreated', (saga, event) => {
    const msg = event.payload as { orderId: string; customerId: string; items: CheckoutState['items']; total: number };
    saga.state = {
      ...saga.state,
      orderId: msg.orderId,
      customerId: msg.customerId,
      items: msg.items,
      total: msg.total,
      status: 'payment_pending',
    };
    return [];
  })

  // The payment worker publishes this result back to the saga.
  .on('PaymentCompleted', (saga, event) => {
    if (saga.state.status !== 'payment_pending') return [];
    const msg = event.payload as { transactionId: string };
    saga.state = { ...saga.state, status: 'paid', transactionId: msg.transactionId };
    return [];
  })

  // Request compensation using the message type registered below.
  .on('PaymentFailed', (saga, event) => {
    if (saga.state.status !== 'payment_pending') return [];
    const msg = event.payload as { orderId: string; reason: string };
    return [sagaCompensate({ type: 'PaymentFailed', payload: msg }, msg.reason)];
  })

  // The default durable runtime routes the returned effect here.
  .compensate('PaymentFailed', (saga, event) => {
    const msg = event.payload as { orderId: string; reason: string };
    saga.state = {
      ...saga.state,
      status: 'cancelled',
      cancelReason: `Payment failed: ${msg.reason}`,
    };
    return [];
  })

  // Route every handled message to the instance whose orderId matches.
  .correlate((message) =>
    String((message.payload as { orderId?: string }).orderId ?? '') as SagaCorrelationKey
  )

  .build();

export default checkoutSaga;
```

Read the shape, not the line count:

- **State is a typed state machine.** `status` is a union; every handler guards on it (`if (saga.state.status !== 'paid') return []`) so a redelivered or out-of-order message is a no-op, not a corruption. [Durable workflows are state machines](https://rickylabs.github.io/netscript/explanation/durability-model/) is a NetScript axiom, not a slogan.
- **This saga does not dispatch worker work.** `send(...)` is available for messages handled by a registered saga definition, but no such cascade is needed in this payment leg. The trigger below owns the explicit cross-plugin enqueue boundary.
- **Compensation is an explicit effect and registered branch.** The forward `PaymentFailed` handler returns `sagaCompensate(...)`; the matching `.compensate('PaymentFailed', ...)` handler transitions the same state machine to `cancelled` without inventing another unhandled message.

> The durable runtime wires compensation
>
> An inbound
>
> PaymentFailed
>
> is first handled by
>
> .on(...)
>
> . Returning
>
> sagaCompensate(...)
>
> from that handler is what routes execution into the matching
>
> .compensate(...)
>
> branch.
>
> createDurableSagaRuntime(...)
>
> installs the compensator by default; custom composition with lower-level
>
> createSagaRuntime(...)
>
> must supply
>
> native.compensator
>
> explicitly.

## Step 4 — Enqueue payment through the triggers API

Use the supported triggers action to turn checkout ingress into durable background work. The generated `checkout-payment` webhook can be reduced to this definition (use a signed verifier for a real public endpoint; `memory` keeps this local tutorial runnable):

```ts
// triggers/checkout-payment-trigger.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core/builders';
import type { JobDefinition } from '@netscript/plugin-workers-core';

const processPaymentJob = {
  id: 'process-payment' as JobDefinition<'process-payment'>['id'],
  name: 'Process payment',
  topic: 'default',
} satisfies JobDefinition<'process-payment'>;

export default defineWebhook(
  (event) => Promise.resolve([enqueueJob(processPaymentJob, { payload: event.payload.body })]),
  { id: 'checkout-payment', path: 'checkout/payment', verifier: 'memory' },
);
```

Post the same order payload to this webhook when you publish `OrderCreated`. The saga records and emits its internal `CheckoutPaymentRequested` message; the trigger independently and durably enqueues `process-payment`. This explicit choreography avoids pretending that a saga cascade is a workers transport.

## Step 5 — Author the payment worker job

The trigger enqueues `process-payment`; the **worker job** does the work and reports back. It publishes `PaymentCompleted` (or `PaymentFailed`) to the saga with `createSagaPublisher`, closing the explicit cross-plugin choreography.

```ts
// workers/jobs/process-payment.ts
import {
  createFailureResult,
  createSuccessResult,
  defineJobHandler,
} from '@netscript/plugin-workers-core';
import { createSagaPublisher } from '@netscript/plugin-sagas/runtime';
import { z } from 'zod';
import type { OrderSagaMessage } from '../saga-message-types.ts';

// Publishes results back to the saga bus.
const sagaPublisher = createSagaPublisher<OrderSagaMessage>();

const PayloadSchema = z.object({
  orderId: z.string().min(1),
  amount: z.number().positive(),
});

const handler = defineJobHandler(async (ctx) => {
  const { orderId, amount } = PayloadSchema.parse(ctx.payload ?? {});

  try {
    // ... charge the card via your provider (mock here) ...
    const transactionId = `txn_${Date.now()}`;

    // Tell the saga payment succeeded — it advances to paid.
    await sagaPublisher.publish({ type: 'PaymentCompleted', payload: { orderId, transactionId } });

    return createSuccessResult({ orderId, transactionId, amount });
  } catch (error) {
    const reason = error instanceof Error ? error.message : String(error);

    // Tell the saga payment failed — its compensation branch cancels the order.
    await sagaPublisher.publish({ type: 'PaymentFailed', payload: { orderId, reason } });

    return createFailureResult(`${reason} (orderId: ${orderId})`);
  }
});

export default Object.assign(handler, { id: 'process-payment' });
```

The contract between the two halves is the **message type string**. The job publishes `PaymentCompleted` / `PaymentFailed`; the saga `.on('PaymentCompleted', …)` and `.on('PaymentFailed', …)` listen for exactly those. There is no shared function call — they are isolated background processors joined only by the message traveling through the streams transport. Keep the strings identical on both sides.

**Worker job primitives (from @netscript/plugin-workers-core)**

| Name | Type | Description |
| --- | --- | --- |
| `defineJobHandler(fn)` | `define a job` | Wraps an async handler that receives a typed ctx (payload, logging, tracing) and returns a result. |
| `createSuccessResult(data)` | `success` | The handler's return for a completed job; carries result data. |
| `createFailureResult(reason)` | `failure` | The handler's return for a failed job; the message string is recorded on the execution. |
| `createSagaPublisher()` | `from @netscript/plugin-sagas/runtime` | Publishes typed messages onto the saga bus so a running saga can react — how the job reports back. |

## Step 6 — Type-check the workflow

The Sagas API service lists sagas from a KV-backed registry, and the scaffold's saga runtime registers your built definition on startup. Because `aspire start` already brings the sagas processor and API up together, you do not start anything by hand — your saga is picked up when the orchestrated app (re)starts. First, prove it compiles against the builder's generic signatures:

```sh
deno task check
```

A clean check means `defineSaga`, `.state()`, `.correlate()`, `.on()`, and `.build()` all line up with the message and state types you declared, and that the worker job's publish calls match the saga message types.

## Verify your progress

With Aspire up, confirm the saga registered through the **Sagas API** (the CLI resolves its allocated endpoint for you):

```sh
ns-sagas list --registered --json
```

You should see `CheckoutSaga` in the list, with `OrderCreated`, `PaymentCompleted`, and `PaymentFailed` among its handled message types. Now drive an instance directly by publishing messages to the saga bus — `ns-sagas publish` sends `{ type, payload }`, and the saga correlates on `payload.orderId`. Start an order, then complete its payment:

```sh
# 1. Open the checkout — the saga records payment_pending and emits CheckoutPaymentRequested.
ns-sagas publish OrderCreated \
  --payload='{ "orderId": "ord_1001", "customerId": "cust_1001", "items": [{ "productId": "1", "quantity": 2 }], "total": 4999 }' \
  --correlation-key=ord_1001

# 2. Enqueue the process-payment worker through the supported triggers ingress.
#    Take <triggers-endpoint> from the Aspire resource list.
curl -X POST <triggers-endpoint>/api/v1/webhooks/checkout/payment \
  -H 'content-type: application/json' \
  -d '{ "orderId": "ord_1001", "amount": 4999 }'
```

The registered worker resolves and runs `process-payment`, which publishes `PaymentCompleted` back to the correlated saga instance. No direct `PaymentCompleted` command is needed on the happy path.

Inspect that instance and confirm it reached `paid`:

```sh
ns-sagas list --instances --saga=CheckoutSaga --json
```

Now prove **compensation**. Open a second order and fail its payment — the `PaymentFailed` branch walks the state machine to `cancelled`:

```sh
ns-sagas publish OrderCreated \
  --payload='{ "orderId": "ord_2002", "customerId": "cust_2002", "items": [{ "productId": "1", "quantity": 1 }], "total": 1999 }' \
  --correlation-key=ord_2002

ns-sagas publish PaymentFailed \
  --payload='{ "orderId": "ord_2002", "reason": "card_declined" }' \
  --correlation-key=ord_2002

ns-sagas list --instances --saga=CheckoutSaga --json
```

The first instance shows `status: 'paid'` carrying its `transactionId`; the second shows `status: 'cancelled'` carrying the `cancelReason` your compensation branch stamped. (The forward path can continue once you separately author inventory and shipment triggers/jobs. Saga `send(...)` cascades remain internal saga-bus messages; this chapter stops at the implemented payment leg, so `paid` is checkout's observable checkpoint.)

- [ ] The workers, sagas, triggers, and streams plugins are installed and registered.
- [ ] `checkout-saga.ts` defines state, a correlation key, the forward handlers, and a `PaymentFailed` compensation branch.
- [ ] `workers/jobs/process-payment.ts` publishes `PaymentCompleted` / `PaymentFailed` back to the saga.
- [ ] `triggers/checkout-payment-trigger.ts` enqueues `process-payment` with `enqueueJob(...)`.
- [ ] `ns-sagas list --registered --json` lists `CheckoutSaga`.
- [ ] Publishing `OrderCreated` and invoking the checkout trigger yields an instance at `status: 'paid'`; publishing `PaymentFailed` directly exercises the isolated compensation branch and yields one at `status: 'cancelled'`.
- [ ] `deno task check` passes.

> Durability is not free correctness
>
> The durability tier persists instance state so a workflow survives a restart, but it does
>
> not
>
> dedupe inbound messages for you. That is why every handler above guards on
>
> saga.state.status
>
> before acting — a redelivered
>
> PaymentCompleted
>
> must not charge twice or double-advance. Make handlers idempotent; durability remembers state, it does not deduplicate delivery.

## What you built

- The workers, sagas, triggers, and streams runtime plugins — the workflow, its explicit worker enqueue boundary, its jobs, and the transport between them.
- A `CheckoutSaga` built with `defineSaga().state().correlate().on().build()` — a durable state machine that walks order → payment, with a `PaymentFailed` **compensation branch** that cancels the order. Inventory and shipping remain explicit future trigger/job legs.
- A `process-payment` worker job (`defineJobHandler`, `createSuccessResult` / `createFailureResult`) that publishes results back to the saga with `createSagaPublisher`, closing the choreography.
- A workflow observable as instances on the Sagas API.

Checkout now survives restarts and records a durable cancellation when payment fails. The next chapter adds a verified webhook for outside providers.

[3 · Cart contracts](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/03-cart-contracts/) [5 · Shipping webhook](https://rickylabs.github.io/netscript/netscript/tutorials/storefront/05-shipping-webhook/)
