diff --git a/src/content/blog/event-sourcing-on-cloudflare-durable-objects.md b/src/content/blog/event-sourcing-on-cloudflare-durable-objects.md new file mode 100644 index 0000000..32454d9 --- /dev/null +++ b/src/content/blog/event-sourcing-on-cloudflare-durable-objects.md @@ -0,0 +1,472 @@ +--- +title: Build an event-sourced backend on Cloudflare Durable Objects +description: From an empty folder to a deployed event-sourced API on Cloudflare in one sitting, with Bounda. A Durable Object per tenant, a policy in its alarm, tests in workerd, and what each command costs. +date: 2026-11-07 +kind: tutorial +tryIt: + label: Let a hold run out in the live demo + href: https://demo.bounda.dev/?scene=expire +draft: true +--- + +This tutorial builds a small order API with event sourcing and deploys it to Cloudflare. It +uses [Bounda](https://docs.bounda.dev/getting-started/), an event sourcing and CQRS framework for +TypeScript, and its Cloudflare adapter, which runs each tenant of your app in its own +[Durable Object](https://developers.cloudflare.com/durable-objects/): the event store, the read +models and the background work, all in the object's SQLite. There is no database to provision and +no queue or cron to run. + +By the end you will have placed orders, paid them, let an unpaid one expire on a timer, tested all +of it inside workerd and deployed it with one command. It takes about half an hour. You need +Node.js 22.18 or later, and a Cloudflare account for the last step; the free plan is enough. + +## 1. Create the project + +```bash +npm create bounda@latest my-app -- --runtime cloudflare +cd my-app +``` + +That writes the project, initialises a git repository and installs the dependencies. If you +would rather start from GitHub, the +[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/bounda-dev/bounda-cloudflare-template) +button forks [bounda-cloudflare-template](https://github.com/bounda-dev/bounda-cloudflare-template), +the same project, into your account and deploys it; clone your fork and carry on from step 2. + +## 2. What you got + +The app is an `order` aggregate with one command and one event, and an `orders` read model with +one query. In Bounda a file's folder and name are its declaration, so the tree is the app: + +``` +app/domain/order/order-placed.ts the event OrderPlaced +app/domain/order/commands/place-order.ts the command placeOrder +app/read/orders/view.ts the read model's table +app/read/orders/projections/order/order-placed.ts +app/read/orders/queries/list-orders.ts the query listOrders +bounda.config.ts storage: cloudflare() +src/worker.ts the Durable Object class and the HTTP API +wrangler.jsonc the binding and the SQLite migration +tests/orders.test.ts the domain, on an in-memory store +tests/api.test.ts the API, against the Durable Object +``` + +The command decides; it returns events or a typed rejection, and never touches the database: + +```ts +// app/domain/order/commands/place-order.ts +import type { Command } from "./+types/place-order"; + +export const payload = ({ z }: Command.PayloadArgs) => + z.object({ orderId: z.uuid(), customerId: z.string().min(1), total: z.number().positive() }); + +export const rejections = ({ command }: Command.RejectionsArgs) => ({ + AlreadyPlaced: `Order ${command.aggregateId} was already placed`, +}); + +export const handler = ({ command, state, events, reject }: Command.HandlerArgs) => { + if (state.status !== undefined) return reject("AlreadyPlaced"); + return [ + events.orderPlaced({ customerId: command.payload.customerId, total: command.payload.total }), + ]; +}; +``` + +The event says what its payload is and how it changes the order's state: + +```ts +// app/domain/order/order-placed.ts +import type { Event } from "./+types/order-placed"; + +export const payload = ({ z }: Event.PayloadArgs) => + z.object({ customerId: z.string(), total: z.number().positive() }); + +export const begin = ({ event }: Event.BeginArgs) => ({ + status: "placed" as const, + customerId: event.payload.customerId, + total: event.payload.total, +}); +``` + +The `+types` imports are generated by `bounda generate`, which every script runs first, so the +state, the events and the commands are typed from these files without a line of wiring. + +The Cloudflare part is two exports in `src/worker.ts`: + +```ts +// src/worker.ts +import { createBoundaObject, createWorker } from "@bounda-dev/cloudflare"; +import { registry } from "../.bounda/registry.ts"; +import config from "../bounda.config.ts"; + +export const Store = createBoundaObject({ registry, config }); + +export default createWorker({ + config, + tenantOf: (request) => request.headers.get("x-bounda-tenant") ?? "default", +}); +``` + +`Store` is the Durable Object class, bound as `STORE` in `wrangler.jsonc` and created as a SQLite +class by its migration. `createWorker` is a JSON API in front: `POST /commands/` and +`POST /queries/`, routed to the tenant's object by the `x-bounda-tenant` header. + +## 3. Run it + +```bash +npm test +``` + +Both test files run inside workerd, the runtime Cloudflare deploys to, through +[Cloudflare's Vitest integration](https://developers.cloudflare.com/workers/testing/vitest-integration/). +`tests/orders.test.ts` runs the domain on an in-memory store, and `tests/api.test.ts` sends +requests to the Worker and reaches a real Durable Object and its SQLite. + +Then start it locally: + +```bash +npm run dev +``` + +`http://localhost:8787` serves a small page that places and lists orders. Or from a terminal: + +```bash +curl -X POST localhost:8787/commands/placeOrder \ + -H 'content-type: application/json' -H 'x-bounda-tenant: acme' \ + -d '{"orderId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","customerId":"ada","total":42}' +``` + +The answer names the event the command stored and its position in the tenant's global stream: + +```json +{"scheduled":false,"aggregateType":"order","aggregateId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","version":1,"eventIds":["01a1b656-1123-748e-932f-2899972e40c3"],"eventTypes":["OrderPlaced"],"position":1} +``` + +## 4. Add a real step: pay, or expire + +An order that nobody pays should not hold stock forever. The rule: an order can be paid while it +is placed, and an order still unpaid 30 minutes after it was placed expires. That is two events, +two commands and one policy. + +The events only change the state. Each file next to `order-placed.ts` is an event of the order: + +```ts +// app/domain/order/order-paid.ts +import type { Event } from "./+types/order-paid"; + +export const evolve = ({ event }: Event.EvolveArgs) => ({ + status: "paid" as const, + paidAt: event.timestamp, +}); +``` + +```ts +// app/domain/order/order-expired.ts +import type { Event } from "./+types/order-expired"; + +export const evolve = (_: Event.EvolveArgs) => ({ status: "expired" as const }); +``` + +Paying is refused unless the order is placed. The rejection is typed, and it is an answer, not a +fact: nothing is stored when it happens. + +```ts +// app/domain/order/commands/pay-order.ts +import type { Command } from "./+types/pay-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const rejections = ({ command, state }: Command.RejectionsArgs) => ({ + NotPayable: `Order ${command.aggregateId} is ${state.status ?? "unknown"}, not placed`, +}); + +export const handler = ({ state, events, reject }: Command.HandlerArgs) => { + if (state.status !== "placed") return reject("NotPayable"); + return [events.orderPaid()]; +}; +``` + +Expiring decides against the state at the moment it runs. If the order was paid in the meantime, +it returns no events and nothing happens: + +```ts +// app/domain/order/commands/expire-order.ts +import type { Command } from "./+types/expire-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const handler = ({ state, events }: Command.HandlerArgs) => + state.status === "placed" ? [events.orderExpired()] : []; +``` + +And the policy ties them together. Its file name says what it reacts to, `OrderPlaced`, and it +schedules `expireOrder` for half an hour later: + +```ts +// app/domain/order/policies/expire-on-order-placed.ts +import type { Policy } from "./+types/expire-on-order-placed"; + +export const handler = async ({ event, commands }: Policy.HandlerArgs) => { + await commands.expireOrder({ orderId: event.aggregateId }, { delay: "30m" }); +}; +``` + +`commands` is typed from your command files: a misspelt command or a wrong payload does not +compile. + +To see the status in `listOrders`, give the read model a column and project the two new events. +Add `status` to the view: + +```ts +// app/read/orders/view.ts +import type { View } from "./+types/view"; + +export const fields = ({ f }: View.FieldsArgs) => ({ + orderId: f.string().primaryKey(), + customerId: f.string().index(), + total: f.number(), + status: f.string(), + placedAt: f.date(), +}); +``` + +Set it in the existing projection, by adding `status: "placed",` to the row that +`projections/order/order-placed.ts` upserts, and add one projection per new event: + +```ts +// app/read/orders/projections/order/order-paid.ts +import type { Projection } from "./+types/order-paid"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "paid" }); +}; +``` + +```ts +// app/read/orders/projections/order/order-expired.ts +import type { Projection } from "./+types/order-expired"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "expired" }); +}; +``` + +Run `npm run typecheck`. It regenerates the types and checks the whole app, including the +`status` the new projections write. + +If you placed orders in step 3, or deployed with the button, those rows come back without a +status. Bounda adds the new column to the table when the object starts, but a row only gets a +value when a projection touches it again. A read model is derived data, so the fix is to project +the whole stream again with the new code: `rebuildReadModel("orders")` on the client that +`connect` gives you does it without taking the read model offline +([rebuilding a read model](https://docs.bounda.dev/guides/deployment/#rebuilding-a-read-model)). +For the local store of `npm run dev`, deleting `.wrangler/state` is enough. + +## 5. Test it, without waiting half an hour + +`createTestApp` gives you the app on an in-memory store with a clock that only moves when you move +it. A scheduled command becomes due because the clock moved, never because the test waited: + +```ts +// tests/expiry.test.ts +import { createTestApp } from "@bounda-dev/core/testing"; +import { describe, expect, it } from "vitest"; +import { registry } from "../.bounda/registry.ts"; + +const orderId = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01"; +const MINUTE = 60_000; + +describe("an order nobody pays", () => { + it("expires 30 minutes after it was placed", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.runUntilIdle(); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "expired" }], + }); + await expect(app.commands.payOrder({ orderId })).rejects.toMatchObject({ + rejected: "NotPayable", + }); + await app.stop(); + }); + + it("stays paid when it was paid in time", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.commands.payOrder({ orderId }); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "paid" }], + }); + await app.stop(); + }); +}); +``` + +And one more case in `tests/api.test.ts`, inside its `describe`, through HTTP and the real +Durable Object. It uses the file's own `post` and `listOrders` helpers: + +```ts +it("pays an order, and the next query sees it paid", async () => { + const orderId = crypto.randomUUID(); + await post("/commands/placeOrder", { orderId, customerId: "ada", total: 42 }, "pays"); + expect((await post("/commands/payOrder", { orderId }, "pays")).status).toBe(200); + expect(await listOrders("pays", "ada")).toMatchObject({ orders: [{ status: "paid" }] }); +}); +``` + +`npm test` now runs ten tests. One of the template's fails first, and rightly: the +`toEqual` in `tests/orders.test.ts` lists every field of the row, so add `status: "placed"` to +it. That is the read model telling you its shape changed. + +Paying twice over HTTP shows the rejection as the API returns it, a 409 with the reason: + +```json +{"error":{"code":"DOMAIN_ERROR","message":"Order 018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01 is paid, not placed","rejected":"NotPayable"}} +``` + +## 6. Deploy + +```bash +npx wrangler login +npm run deploy +``` + +`deploy` generates the types and runs `wrangler deploy`, which uploads the Worker, creates the +Durable Object class from the migration in `wrangler.jsonc` and prints your +`workers.dev` address. Use it in place of `localhost:8787` in the `curl` above. To see what would +be uploaded without deploying, `npm run check` type-checks the app and runs +`wrangler deploy --dry-run`. + +Before real users reach it: the API of `createWorker` has **no authentication**, on purpose. It +is a starting point, and anyone who knows the address can pick any tenant with the header. An app +with users writes its own `fetch`, decides the tenant from the session and calls the object with +`connect(env.STORE.get(env.STORE.idFromName(tenant)))`, which gives the same typed `commands` +and `queries` as the tests. The [Cloudflare guide](https://docs.bounda.dev/adapters/cloudflare/) +shows it. + +## How it runs + +Just enough to reason about what you deployed: + +- **One Durable Object per tenant.** Each tenant gets its own object, with its own SQLite + holding its events, its read models, its scheduled commands and the inbox of its policies. + Nothing is shared between tenants. The SQL is the same code Bounda runs on SQLite anywhere else. +- **A command updates the read models before it answers.** The object stores the events and + projects them in the same request, so the query that follows sees the change: that is why the + API test can pay and then list without waiting. +- **Policies and timers run in the object's alarm.** Right after the command answers, the object + arms its alarm, and the alarm runs the policy: `expire-on-order-placed` runs there, and stores + `expireOrder` as a scheduled command. The object then arms its alarm for when that command is + due, 30 minutes later. A slow policy never slows the command down, and an object with nothing + pending arms nothing. +- **A policy runs at least once, with an inbox.** Its attempt commits everything it decided in + one transaction or nothing, and an event it has handled is not handled again. The details are + in [delivery guarantees](https://docs.bounda.dev/concepts/delivery-guarantees/). + +## What it costs + +Durable Objects with SQLite bill by the row, so the honest measure of a command is a count of +rows. Each SQLite cursor in a Durable Object reports the rows it read and wrote, index updates +included, which is what Cloudflare bills. Counting them for the template's `placeOrder`, after +the first command has created the schema, gives **8 rows written**: + +| Rows | What | +|---|---| +| 4 | The event, with the event store's indexes | +| 3 | The read-model row, with its primary key and its `customerId` index | +| 1 | The read model's checkpoint | + +It reads 13. A command sent with an idempotency key writes 3 more, to remember its answer. + +With Cloudflare's prices, as published on the +[Durable Objects pricing page](https://developers.cloudflare.com/durable-objects/platform/pricing/) +(last updated 18 October 2026) and the +[Workers pricing page](https://developers.cloudflare.com/workers/platform/pricing/) +(2 October 2026), both checked on 7 November 2026: + +- **On the Free plan**, rows written run out first: 100,000 a day is about **12,500 commands a + day** like this one. Past it, writes fail until the day resets at 00:00 UTC. +- **On the Paid plan** ($5 a month), 50 million rows written a month are included, which is + **6.25 million commands**. Past that, a million more cost **about $8.50**: $8.00 in rows + written, $0.15 in Durable Object requests, $0.30 in Worker requests, and cents in rows read and + duration. + +Storage is billed apart, at $0.20 per GB-month past 5 GB-month on Paid, and an event store only +grows. + +The app you just built costs more per order, because its policy has work to do in the alarm: +the inbox entry that makes it run once, the scheduled `expireOrder`, a checkpoint, and one row +each time the object arms its alarm. Measured with the same instrumentation, that came to +**roughly twice the template's rows, and a second request, per order placed**, not counting the +expiry itself, which runs half an hour later. Writes are the bill, and every `.index()` in a view +and every policy is a line on it. + +The figures come from this test, about 25 lines in workerd. Add it as `tests/cost.test.ts` to the +template and `npm test` checks the 8: + +```ts +// tests/cost.test.ts +import { connect } from "@bounda-dev/cloudflare"; +import { env, runInDurableObject } from "cloudflare:test"; +import { expect, it } from "vitest"; + +const cursors: SqlStorageCursor>[] = []; + +it("counts the rows a command writes", async () => { + const stub = env.STORE.get(env.STORE.idFromName("cost")); + const store = connect(stub); + // The first command creates the schema; measure the ones after it. + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 1 }); + + await runInDurableObject(stub, (_object, state) => { + const sql = Object.getPrototypeOf(state.storage.sql); + const exec = sql.exec; + sql.exec = function (this: SqlStorage, ...args: Parameters) { + const cursor = exec.apply(this, args); + cursors.push(cursor); + return cursor; + }; + }); + + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 42 }); + const written = cursors.reduce((sum, cursor) => sum + cursor.rowsWritten, 0); + expect(written).toBe(8); +}); +``` + +It counts what happens until the command answers, so it still passes after step 4: the policy's +rows arrive later, in the alarm. Every figure here was measured on 7 November 2026 with Bounda +0.3.0, wrangler 4.158 and `@cloudflare/vitest-plugin` 1.6; a later version can write a different +number of rows, and the test is how to know. In production, the Durable Objects metrics in +Cloudflare's dashboard show the rows each namespace reads and writes. + +## When it is the wrong design + +One object per tenant is a strong default when tenants are independent and most of them are +quiet at any given moment. It fits badly when one tenant writes without pause: an object is a +single writer that commits one command at a time, with a soft limit of about 1,000 requests a +second, so a shared order book or a global counter queues behind itself. It fits badly when users +are far from the object, because an object lives in one place and a user on another continent +pays the round trip on every command. Each object's SQLite holds 10 GB on the Paid plan, and since +events are never deleted, a tenant that will keep that much history needs it split by something +the domain already knows, such as a year. And each tenant's read models live in its own object, +so a report across every tenant needs the events copied somewhere else, a pipeline that is yours +to build. + +## Where to go next + +The [live demo](https://demo.bounda.dev) is a theatre box office built the same way, one +Durable Object per visitor. Open the scene **Let a hold run out**: a seat held and not paid +expires on its own, done there with a process deadline instead of a scheduled command. The +[Cloudflare guide](https://docs.bounda.dev/adapters/cloudflare/) covers ports and bindings, +consistency and testing, and +[reacting to events](https://docs.bounda.dev/guides/reacting-to-events/) explains when a policy is +enough and when the work needs a process. diff --git a/src/content/translations/es/event-sourcing-on-cloudflare-durable-objects.md b/src/content/translations/es/event-sourcing-on-cloudflare-durable-objects.md new file mode 100644 index 0000000..7432801 --- /dev/null +++ b/src/content/translations/es/event-sourcing-on-cloudflare-durable-objects.md @@ -0,0 +1,472 @@ +--- +title: Un backend con event sourcing en Durable Objects de Cloudflare +description: De una carpeta vacía a una API con event sourcing desplegada en Cloudflare, de una sentada, con Bounda. Un Durable Object por tenant, una policy en su alarma, tests en workerd y lo que cuesta cada comando. +tryIt: + label: Deja que caduque una reserva en la demo +--- + +Este tutorial construye una pequeña API de pedidos con event sourcing y la despliega en +Cloudflare. Usa [Bounda](https://docs.bounda.dev/getting-started/), un framework de event sourcing +y CQRS para TypeScript, y su adaptador de Cloudflare, que ejecuta cada tenant de tu app en su +propio [Durable Object](https://developers.cloudflare.com/durable-objects/): el event store, los +read models y el trabajo de fondo, todo en el SQLite del objeto. No hay base de datos que +aprovisionar ni cola o cron que mantener. + +Al final habrás creado pedidos, los habrás pagado, habrás dejado que uno sin pagar caduque con un +temporizador, lo habrás probado todo dentro de workerd y lo habrás desplegado con un comando. Lleva +una media hora. Necesitas Node.js 22.18 o posterior y, para el último paso, una cuenta de +Cloudflare; basta con el plan gratuito. + +## 1. Crea el proyecto + +```bash +npm create bounda@latest my-app -- --runtime cloudflare +cd my-app +``` + +Eso escribe el proyecto, inicializa un repositorio git e instala las dependencias. Si prefieres +empezar desde GitHub, el botón +[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/bounda-dev/bounda-cloudflare-template) +hace un fork de [bounda-cloudflare-template](https://github.com/bounda-dev/bounda-cloudflare-template), +el mismo proyecto, en tu cuenta y lo despliega; clona tu fork y sigue desde el paso 2. + +## 2. Lo que tienes + +La app es un agregado `order` con un comando y un evento, y un read model `orders` con una query. +En Bounda, la carpeta y el nombre de un fichero son su declaración, así que el árbol es la app: + +``` +app/domain/order/order-placed.ts the event OrderPlaced +app/domain/order/commands/place-order.ts the command placeOrder +app/read/orders/view.ts the read model's table +app/read/orders/projections/order/order-placed.ts +app/read/orders/queries/list-orders.ts the query listOrders +bounda.config.ts storage: cloudflare() +src/worker.ts the Durable Object class and the HTTP API +wrangler.jsonc the binding and the SQLite migration +tests/orders.test.ts the domain, on an in-memory store +tests/api.test.ts the API, against the Durable Object +``` + +El comando decide: devuelve eventos o un rechazo tipado, y nunca toca la base de datos: + +```ts +// app/domain/order/commands/place-order.ts +import type { Command } from "./+types/place-order"; + +export const payload = ({ z }: Command.PayloadArgs) => + z.object({ orderId: z.uuid(), customerId: z.string().min(1), total: z.number().positive() }); + +export const rejections = ({ command }: Command.RejectionsArgs) => ({ + AlreadyPlaced: `Order ${command.aggregateId} was already placed`, +}); + +export const handler = ({ command, state, events, reject }: Command.HandlerArgs) => { + if (state.status !== undefined) return reject("AlreadyPlaced"); + return [ + events.orderPlaced({ customerId: command.payload.customerId, total: command.payload.total }), + ]; +}; +``` + +El evento dice cuál es su payload y cómo cambia el estado del pedido: + +```ts +// app/domain/order/order-placed.ts +import type { Event } from "./+types/order-placed"; + +export const payload = ({ z }: Event.PayloadArgs) => + z.object({ customerId: z.string(), total: z.number().positive() }); + +export const begin = ({ event }: Event.BeginArgs) => ({ + status: "placed" as const, + customerId: event.payload.customerId, + total: event.payload.total, +}); +``` + +Los imports de `+types` los genera `bounda generate`, que todos los scripts ejecutan primero, así +que el estado, los eventos y los comandos quedan tipados a partir de estos ficheros sin una línea +de cableado. + +La parte de Cloudflare son dos exports en `src/worker.ts`: + +```ts +// src/worker.ts +import { createBoundaObject, createWorker } from "@bounda-dev/cloudflare"; +import { registry } from "../.bounda/registry.ts"; +import config from "../bounda.config.ts"; + +export const Store = createBoundaObject({ registry, config }); + +export default createWorker({ + config, + tenantOf: (request) => request.headers.get("x-bounda-tenant") ?? "default", +}); +``` + +`Store` es la clase del Durable Object, enlazada como `STORE` en `wrangler.jsonc` y creada como +clase SQLite por su migración. `createWorker` es una API JSON delante: `POST /commands/` y +`POST /queries/`, enrutadas al objeto del tenant por la cabecera `x-bounda-tenant`. + +## 3. Ejecútalo + +```bash +npm test +``` + +Los dos ficheros de tests corren dentro de workerd, el runtime en el que despliega Cloudflare, +mediante la [integración de Vitest de Cloudflare](https://developers.cloudflare.com/workers/testing/vitest-integration/). +`tests/orders.test.ts` ejecuta el dominio sobre un store en memoria, y `tests/api.test.ts` envía +peticiones al Worker y llega a un Durable Object real y a su SQLite. + +Después, arráncalo en local: + +```bash +npm run dev +``` + +`http://localhost:8787` sirve una página pequeña que crea y lista pedidos. O desde un terminal: + +```bash +curl -X POST localhost:8787/commands/placeOrder \ + -H 'content-type: application/json' -H 'x-bounda-tenant: acme' \ + -d '{"orderId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","customerId":"ada","total":42}' +``` + +La respuesta nombra el evento que guardó el comando y su posición en el stream global del tenant: + +```json +{"scheduled":false,"aggregateType":"order","aggregateId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","version":1,"eventIds":["01a1b656-1123-748e-932f-2899972e40c3"],"eventTypes":["OrderPlaced"],"position":1} +``` + +## 4. Añade un paso real: pagar, o caducar + +Un pedido que nadie paga no debería retener stock para siempre. La regla: un pedido se puede pagar +mientras está creado, y un pedido que sigue sin pagar 30 minutos después de crearse caduca. Son dos +eventos, dos comandos y una policy. + +Los eventos solo cambian el estado. Cada fichero junto a `order-placed.ts` es un evento del pedido: + +```ts +// app/domain/order/order-paid.ts +import type { Event } from "./+types/order-paid"; + +export const evolve = ({ event }: Event.EvolveArgs) => ({ + status: "paid" as const, + paidAt: event.timestamp, +}); +``` + +```ts +// app/domain/order/order-expired.ts +import type { Event } from "./+types/order-expired"; + +export const evolve = (_: Event.EvolveArgs) => ({ status: "expired" as const }); +``` + +Pagar se rechaza salvo que el pedido esté creado. El rechazo es tipado, y es una respuesta, no un +hecho: cuando ocurre no se guarda nada. + +```ts +// app/domain/order/commands/pay-order.ts +import type { Command } from "./+types/pay-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const rejections = ({ command, state }: Command.RejectionsArgs) => ({ + NotPayable: `Order ${command.aggregateId} is ${state.status ?? "unknown"}, not placed`, +}); + +export const handler = ({ state, events, reject }: Command.HandlerArgs) => { + if (state.status !== "placed") return reject("NotPayable"); + return [events.orderPaid()]; +}; +``` + +Caducar decide con el estado del momento en que se ejecuta. Si el pedido se pagó entretanto, +devuelve cero eventos y no pasa nada: + +```ts +// app/domain/order/commands/expire-order.ts +import type { Command } from "./+types/expire-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const handler = ({ state, events }: Command.HandlerArgs) => + state.status === "placed" ? [events.orderExpired()] : []; +``` + +Y la policy los une. El nombre del fichero dice a qué reacciona, `OrderPlaced`, y programa +`expireOrder` para media hora después: + +```ts +// app/domain/order/policies/expire-on-order-placed.ts +import type { Policy } from "./+types/expire-on-order-placed"; + +export const handler = async ({ event, commands }: Policy.HandlerArgs) => { + await commands.expireOrder({ orderId: event.aggregateId }, { delay: "30m" }); +}; +``` + +`commands` está tipado a partir de tus ficheros de comandos: un comando mal escrito o un payload +equivocado no compila. + +Para ver el estado en `listOrders`, dale al read model una columna y proyecta los dos eventos +nuevos. Añade `status` a la vista: + +```ts +// app/read/orders/view.ts +import type { View } from "./+types/view"; + +export const fields = ({ f }: View.FieldsArgs) => ({ + orderId: f.string().primaryKey(), + customerId: f.string().index(), + total: f.number(), + status: f.string(), + placedAt: f.date(), +}); +``` + +Rellénalo en la proyección que ya existe, añadiendo `status: "placed",` a la fila que hace upsert +`projections/order/order-placed.ts`, y añade una proyección por cada evento nuevo: + +```ts +// app/read/orders/projections/order/order-paid.ts +import type { Projection } from "./+types/order-paid"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "paid" }); +}; +``` + +```ts +// app/read/orders/projections/order/order-expired.ts +import type { Projection } from "./+types/order-expired"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "expired" }); +}; +``` + +Ejecuta `npm run typecheck`. Regenera los tipos y comprueba toda la app, incluido el `status` que +escriben las proyecciones nuevas. + +Si creaste pedidos en el paso 3, o desplegaste con el botón, esas filas vuelven sin estado. Bounda +añade la columna nueva a la tabla cuando arranca el objeto, pero una fila solo recibe un valor +cuando una proyección vuelve a tocarla. Un read model son datos derivados, así que la solución es +proyectar otra vez todo el stream con el código nuevo: `rebuildReadModel("orders")` en el cliente +que te da `connect` lo hace sin dejar el read model fuera de servicio +([rebuilding a read model](https://docs.bounda.dev/guides/deployment/#rebuilding-a-read-model)). +Para el store local de `npm run dev`, basta con borrar `.wrangler/state`. + +## 5. Pruébalo sin esperar media hora + +`createTestApp` te da la app sobre un store en memoria con un reloj que solo avanza cuando tú lo +mueves. Un comando programado vence porque el reloj se movió, nunca porque el test esperó: + +```ts +// tests/expiry.test.ts +import { createTestApp } from "@bounda-dev/core/testing"; +import { describe, expect, it } from "vitest"; +import { registry } from "../.bounda/registry.ts"; + +const orderId = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01"; +const MINUTE = 60_000; + +describe("an order nobody pays", () => { + it("expires 30 minutes after it was placed", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.runUntilIdle(); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "expired" }], + }); + await expect(app.commands.payOrder({ orderId })).rejects.toMatchObject({ + rejected: "NotPayable", + }); + await app.stop(); + }); + + it("stays paid when it was paid in time", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.commands.payOrder({ orderId }); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "paid" }], + }); + await app.stop(); + }); +}); +``` + +Y un caso más en `tests/api.test.ts`, dentro de su `describe`, por HTTP y contra el Durable Object +real. Usa los helpers `post` y `listOrders` del propio fichero: + +```ts +it("pays an order, and the next query sees it paid", async () => { + const orderId = crypto.randomUUID(); + await post("/commands/placeOrder", { orderId, customerId: "ada", total: 42 }, "pays"); + expect((await post("/commands/payOrder", { orderId }, "pays")).status).toBe(200); + expect(await listOrders("pays", "ada")).toMatchObject({ orders: [{ status: "paid" }] }); +}); +``` + +`npm test` ejecuta ahora diez tests. Uno de los de la plantilla falla primero, y con razón: el +`toEqual` de `tests/orders.test.ts` enumera todos los campos de la fila, así que añádele +`status: "placed"`. Es el read model avisándote de que su forma ha cambiado. + +Pagar dos veces por HTTP muestra el rechazo tal como lo devuelve la API, un 409 con el motivo: + +```json +{"error":{"code":"DOMAIN_ERROR","message":"Order 018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01 is paid, not placed","rejected":"NotPayable"}} +``` + +## 6. Despliega + +```bash +npx wrangler login +npm run deploy +``` + +`deploy` genera los tipos y ejecuta `wrangler deploy`, que sube el Worker, crea la clase del +Durable Object a partir de la migración de `wrangler.jsonc` e imprime tu dirección de +`workers.dev`. Úsala en lugar de `localhost:8787` en el `curl` de antes. Para ver qué se subiría +sin desplegar, `npm run check` comprueba los tipos de la app y ejecuta `wrangler deploy --dry-run`. + +Antes de que lleguen usuarios de verdad: la API de `createWorker` **no tiene autenticación**, a +propósito. Es un punto de partida, y cualquiera que conozca la dirección puede elegir cualquier +tenant con la cabecera. Una app con usuarios escribe su propio `fetch`, decide el tenant a partir +de la sesión y llama al objeto con `connect(env.STORE.get(env.STORE.idFromName(tenant)))`, que da +los mismos `commands` y `queries` tipados que los tests. La +[guía de Cloudflare](https://docs.bounda.dev/adapters/cloudflare/) lo muestra. + +## Cómo corre + +Lo justo para razonar sobre lo que has desplegado: + +- **Un Durable Object por tenant.** Cada tenant tiene su propio objeto, con su propio SQLite, que + guarda sus eventos, sus read models, sus comandos programados y el inbox de sus policies. Los + tenants no comparten nada. El SQL es el mismo código que Bounda ejecuta sobre SQLite en + cualquier otro sitio. +- **Un comando actualiza los read models antes de responder.** El objeto guarda los eventos y los + proyecta en la misma petición, así que la query que viene después ya ve el cambio: por eso el + test de la API puede pagar y luego listar sin esperar. +- **Las policies y los temporizadores corren en la alarma del objeto.** Justo después de que el + comando responda, el objeto arma su alarma, y la alarma ejecuta la policy: + `expire-on-order-placed` corre ahí y guarda `expireOrder` como comando programado. Después el + objeto arma su alarma para cuando venza ese comando, 30 minutos más tarde. Una policy lenta nunca + frena al comando, y un objeto sin nada pendiente no arma nada. +- **Una policy se ejecuta al menos una vez, con un inbox.** Su intento confirma todo lo que decidió + en una transacción o nada, y un evento que ya gestionó no se vuelve a gestionar. Los detalles + están en [delivery guarantees](https://docs.bounda.dev/concepts/delivery-guarantees/). + +## Lo que cuesta + +Los Durable Objects con SQLite facturan por fila, así que la medida honesta de un comando es un +recuento de filas. Cada cursor de SQLite en un Durable Object informa de las filas que leyó y +escribió, actualizaciones de índices incluidas, que es lo que factura Cloudflare. Contándolas para +el `placeOrder` de la plantilla, después de que el primer comando haya creado el esquema, salen +**8 filas escritas**: + +| Filas | Qué | +|---|---| +| 4 | El evento, con los índices del event store | +| 3 | La fila del read model, con su clave primaria y su índice `customerId` | +| 1 | El checkpoint del read model | + +Lee 13. Un comando enviado con clave de idempotencia escribe 3 más, para recordar su respuesta. + +Con los precios de Cloudflare, tal como aparecen en la +[página de precios de Durable Objects](https://developers.cloudflare.com/durable-objects/platform/pricing/) +(actualizada el 18 de octubre de 2026) y en la +[de Workers](https://developers.cloudflare.com/workers/platform/pricing/) (2 de octubre de 2026), +comprobadas las dos el 7 de noviembre de 2026: + +- **En el plan Free**, lo primero que se agota son las filas escritas: 100.000 al día son unos + **12.500 comandos al día** como este. A partir de ahí, las escrituras fallan hasta que el día se + reinicia a las 00:00 UTC. +- **En el plan Paid** (5 $ al mes) se incluyen 50 millones de filas escritas al mes, que son + **6,25 millones de comandos**. Pasado eso, un millón más cuesta **unos 8,50 $**: 8,00 $ en filas + escritas, 0,15 $ en peticiones al Durable Object, 0,30 $ en peticiones al Worker y céntimos en + filas leídas y duración. + +El almacenamiento se factura aparte, a 0,20 $ por GB-mes pasados los 5 GB-mes del plan Paid, y un +event store solo crece. + +La app que acabas de construir cuesta más por pedido, porque su policy tiene trabajo en la alarma: +la entrada del inbox que hace que corra una sola vez, el `expireOrder` programado, un checkpoint y +una fila cada vez que el objeto arma su alarma. Medido con la misma instrumentación, sale **más o +menos el doble de filas que la plantilla, y una segunda petición, por pedido creado**, sin contar +la caducidad en sí, que corre media hora después. Las escrituras son la factura, y cada `.index()` +de una vista y cada policy es una línea de ella. + +Las cifras salen de este test, unas 25 líneas en workerd. Añádelo como `tests/cost.test.ts` a la +plantilla y `npm test` comprueba las 8: + +```ts +// tests/cost.test.ts +import { connect } from "@bounda-dev/cloudflare"; +import { env, runInDurableObject } from "cloudflare:test"; +import { expect, it } from "vitest"; + +const cursors: SqlStorageCursor>[] = []; + +it("counts the rows a command writes", async () => { + const stub = env.STORE.get(env.STORE.idFromName("cost")); + const store = connect(stub); + // The first command creates the schema; measure the ones after it. + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 1 }); + + await runInDurableObject(stub, (_object, state) => { + const sql = Object.getPrototypeOf(state.storage.sql); + const exec = sql.exec; + sql.exec = function (this: SqlStorage, ...args: Parameters) { + const cursor = exec.apply(this, args); + cursors.push(cursor); + return cursor; + }; + }); + + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 42 }); + const written = cursors.reduce((sum, cursor) => sum + cursor.rowsWritten, 0); + expect(written).toBe(8); +}); +``` + +Cuenta lo que pasa hasta que el comando responde, así que sigue pasando después del paso 4: las +filas de la policy llegan más tarde, en la alarma. Todas las cifras se midieron el 7 de noviembre +de 2026 con Bounda 0.3.0, wrangler 4.158 y `@cloudflare/vitest-plugin` 1.6; una versión posterior +puede escribir otro número de filas, y el test es la manera de saberlo. En producción, las métricas +de Durable Objects del panel de Cloudflare muestran las filas que lee y escribe cada namespace. + +## Cuándo no es el diseño adecuado + +Un objeto por tenant es un buen punto de partida cuando los tenants son independientes y la mayoría +están tranquilos en cada momento. Encaja mal cuando un tenant escribe sin parar: un objeto es un +único escritor que confirma un comando cada vez, con un límite blando de unas 1.000 peticiones por +segundo, así que un libro de órdenes compartido o un contador global hace cola detrás de sí mismo. +Encaja mal cuando los usuarios están lejos del objeto, porque un objeto vive en un sitio y un +usuario en otro continente paga el viaje de ida y vuelta en cada comando. El SQLite de cada objeto +admite 10 GB en el plan Paid y, como los eventos nunca se borran, un tenant que vaya a acumular +tanta historia necesita partirla por algo que el dominio ya conozca, como el año. Y los read models +de cada tenant viven en su propio objeto, así que un informe sobre todos los tenants necesita +copiar los eventos a otro sitio, un pipeline que te toca construir a ti. + +## Por dónde seguir + +La [demo](https://demo.bounda.dev) es la taquilla de un teatro construida igual, un Durable Object +por visitante. Abre la escena **Let a hold run out**: un asiento reservado y sin pagar caduca solo, +allí con un plazo de un process en lugar de un comando programado. La +[guía de Cloudflare](https://docs.bounda.dev/adapters/cloudflare/) cubre ports y bindings, +consistencia y tests, y +[reacting to events](https://docs.bounda.dev/guides/reacting-to-events/) explica cuándo basta una +policy y cuándo el trabajo necesita un process. diff --git a/src/content/translations/ja/event-sourcing-on-cloudflare-durable-objects.md b/src/content/translations/ja/event-sourcing-on-cloudflare-durable-objects.md new file mode 100644 index 0000000..90aee3d --- /dev/null +++ b/src/content/translations/ja/event-sourcing-on-cloudflare-durable-objects.md @@ -0,0 +1,375 @@ +--- +title: Cloudflare Durable Objects でイベントソーシングのバックエンドを作る +description: 空のフォルダから、Cloudflare にデプロイされたイベントソーシングの API まで、Bounda で一気に。テナントごとの Durable Object、そのアラームで動くポリシー、workerd でのテスト、そしてコマンド一回あたりのコスト。 +tryIt: + label: デモで仮押さえを期限切れにする +--- + +このチュートリアルでは、イベントソーシングで小さな注文 API を作り、Cloudflare にデプロイします。使うのは TypeScript 向けのイベントソーシングと CQRS のフレームワーク [Bounda](https://docs.bounda.dev/getting-started/) と、その Cloudflare アダプターです。アダプターはアプリのテナントごとに専用の [Durable Object](https://developers.cloudflare.com/durable-objects/) を動かし、イベントストアも read model もバックグラウンドの処理も、すべてそのオブジェクトの SQLite に収めます。用意するデータベースも、動かし続けるキューや cron もありません。 + +最後には、注文を作り、支払い、支払われなかった注文をタイマーで期限切れにし、それをすべて workerd の中でテストし、コマンド一つでデプロイしているはずです。所要時間は三十分ほど。必要なのは Node.js 22.18 以降と、最後のステップ用の Cloudflare アカウントです。無料プランで足ります。 + +## 1. プロジェクトを作る + +```bash +npm create bounda@latest my-app -- --runtime cloudflare +cd my-app +``` + +これでプロジェクトが書き出され、git リポジトリが初期化され、依存関係がインストールされます。GitHub から始めたい場合は、[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/bounda-dev/bounda-cloudflare-template) ボタンが同じプロジェクトである [bounda-cloudflare-template](https://github.com/bounda-dev/bounda-cloudflare-template) をあなたのアカウントにフォークしてデプロイします。フォークをクローンして、ステップ 2 から続けてください。 + +## 2. 手に入ったもの + +アプリは、コマンドとイベントを一つずつ持つ `order` 集約と、クエリを一つ持つ `orders` read model です。Bounda ではファイルのフォルダと名前がそのまま宣言なので、ツリーがそのままアプリです。 + +``` +app/domain/order/order-placed.ts the event OrderPlaced +app/domain/order/commands/place-order.ts the command placeOrder +app/read/orders/view.ts the read model's table +app/read/orders/projections/order/order-placed.ts +app/read/orders/queries/list-orders.ts the query listOrders +bounda.config.ts storage: cloudflare() +src/worker.ts the Durable Object class and the HTTP API +wrangler.jsonc the binding and the SQLite migration +tests/orders.test.ts the domain, on an in-memory store +tests/api.test.ts the API, against the Durable Object +``` + +コマンドは判断します。イベントか型付きのリジェクションを返し、データベースには一切触れません。 + +```ts +// app/domain/order/commands/place-order.ts +import type { Command } from "./+types/place-order"; + +export const payload = ({ z }: Command.PayloadArgs) => + z.object({ orderId: z.uuid(), customerId: z.string().min(1), total: z.number().positive() }); + +export const rejections = ({ command }: Command.RejectionsArgs) => ({ + AlreadyPlaced: `Order ${command.aggregateId} was already placed`, +}); + +export const handler = ({ command, state, events, reject }: Command.HandlerArgs) => { + if (state.status !== undefined) return reject("AlreadyPlaced"); + return [ + events.orderPlaced({ customerId: command.payload.customerId, total: command.payload.total }), + ]; +}; +``` + +イベントは、ペイロードが何か、そして注文の状態をどう変えるかを述べます。 + +```ts +// app/domain/order/order-placed.ts +import type { Event } from "./+types/order-placed"; + +export const payload = ({ z }: Event.PayloadArgs) => + z.object({ customerId: z.string(), total: z.number().positive() }); + +export const begin = ({ event }: Event.BeginArgs) => ({ + status: "placed" as const, + customerId: event.payload.customerId, + total: event.payload.total, +}); +``` + +`+types` の import は `bounda generate` が生成し、どのスクリプトも最初にこれを実行します。そのため状態もイベントもコマンドも、配線を一行も書かずにこれらのファイルから型付けされます。 + +Cloudflare 固有の部分は `src/worker.ts` の二つの export です。 + +```ts +// src/worker.ts +import { createBoundaObject, createWorker } from "@bounda-dev/cloudflare"; +import { registry } from "../.bounda/registry.ts"; +import config from "../bounda.config.ts"; + +export const Store = createBoundaObject({ registry, config }); + +export default createWorker({ + config, + tenantOf: (request) => request.headers.get("x-bounda-tenant") ?? "default", +}); +``` + +`Store` は Durable Object のクラスで、`wrangler.jsonc` で `STORE` としてバインドされ、マイグレーションによって SQLite クラスとして作られます。`createWorker` はその前に立つ JSON API で、`POST /commands/` と `POST /queries/` を `x-bounda-tenant` ヘッダーでテナントのオブジェクトに振り分けます。 + +## 3. 動かす + +```bash +npm test +``` + +二つのテストファイルはどちらも、Cloudflare がデプロイする先のランタイムである workerd の中で、[Cloudflare の Vitest 統合](https://developers.cloudflare.com/workers/testing/vitest-integration/)を通じて実行されます。`tests/orders.test.ts` はドメインをインメモリのストアで動かし、`tests/api.test.ts` は Worker にリクエストを送って、本物の Durable Object とその SQLite まで届きます。 + +次にローカルで起動します。 + +```bash +npm run dev +``` + +`http://localhost:8787` では注文を作って一覧するだけの小さなページが表示されます。ターミナルからならこうです。 + +```bash +curl -X POST localhost:8787/commands/placeOrder \ + -H 'content-type: application/json' -H 'x-bounda-tenant: acme' \ + -d '{"orderId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","customerId":"ada","total":42}' +``` + +レスポンスには、コマンドが保存したイベントと、テナントのグローバルストリームにおけるその位置が含まれます。 + +```json +{"scheduled":false,"aggregateType":"order","aggregateId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","version":1,"eventIds":["01a1b656-1123-748e-932f-2899972e40c3"],"eventTypes":["OrderPlaced"],"position":1} +``` + +## 4. 本物のステップを足す: 支払うか、期限切れか + +誰も支払わない注文が在庫をいつまでも押さえていてはいけません。ルールはこうです。注文は作成済みの間だけ支払える。作成から 30 分たっても未払いの注文は期限切れになる。イベント二つ、コマンド二つ、ポリシー一つです。 + +イベントは状態を変えるだけです。`order-placed.ts` の隣にあるファイルはどれも注文のイベントです。 + +```ts +// app/domain/order/order-paid.ts +import type { Event } from "./+types/order-paid"; + +export const evolve = ({ event }: Event.EvolveArgs) => ({ + status: "paid" as const, + paidAt: event.timestamp, +}); +``` + +```ts +// app/domain/order/order-expired.ts +import type { Event } from "./+types/order-expired"; + +export const evolve = (_: Event.EvolveArgs) => ({ status: "expired" as const }); +``` + +支払いは、注文が作成済みでなければ拒否されます。リジェクションは型付きで、事実ではなく返答です。起きても何も保存されません。 + +```ts +// app/domain/order/commands/pay-order.ts +import type { Command } from "./+types/pay-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const rejections = ({ command, state }: Command.RejectionsArgs) => ({ + NotPayable: `Order ${command.aggregateId} is ${state.status ?? "unknown"}, not placed`, +}); + +export const handler = ({ state, events, reject }: Command.HandlerArgs) => { + if (state.status !== "placed") return reject("NotPayable"); + return [events.orderPaid()]; +}; +``` + +期限切れは、実行されるその時点の状態で判断します。その間に注文が支払われていれば、イベントを返さず、何も起きません。 + +```ts +// app/domain/order/commands/expire-order.ts +import type { Command } from "./+types/expire-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const handler = ({ state, events }: Command.HandlerArgs) => + state.status === "placed" ? [events.orderExpired()] : []; +``` + +そしてポリシーがこれらをつなぎます。ファイル名が反応するイベント `OrderPlaced` を示し、三十分後に `expireOrder` を予約します。 + +```ts +// app/domain/order/policies/expire-on-order-placed.ts +import type { Policy } from "./+types/expire-on-order-placed"; + +export const handler = async ({ event, commands }: Policy.HandlerArgs) => { + await commands.expireOrder({ orderId: event.aggregateId }, { delay: "30m" }); +}; +``` + +`commands` はコマンドのファイルから型付けされているので、コマンド名の綴り間違いや誤ったペイロードはコンパイルが通りません。 + +`listOrders` で状態を見られるように、read model に列を一つ足し、新しい二つのイベントを投影します。ビューに `status` を加えます。 + +```ts +// app/read/orders/view.ts +import type { View } from "./+types/view"; + +export const fields = ({ f }: View.FieldsArgs) => ({ + orderId: f.string().primaryKey(), + customerId: f.string().index(), + total: f.number(), + status: f.string(), + placedAt: f.date(), +}); +``` + +既存のプロジェクションでは、`projections/order/order-placed.ts` が upsert する行に `status: "placed",` を加えて値を入れます。そして新しいイベントごとにプロジェクションを一つ足します。 + +```ts +// app/read/orders/projections/order/order-paid.ts +import type { Projection } from "./+types/order-paid"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "paid" }); +}; +``` + +```ts +// app/read/orders/projections/order/order-expired.ts +import type { Projection } from "./+types/order-expired"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "expired" }); +}; +``` + +`npm run typecheck` を実行してください。型を生成し直し、新しいプロジェクションが書き込む `status` も含めてアプリ全体を検査します。 + +ステップ 3 で注文を作っていた場合や、ボタンでデプロイしていた場合、それらの行は状態なしで返ってきます。Bounda はオブジェクトの起動時に新しい列をテーブルに加えますが、行に値が入るのはプロジェクションがその行に再び触れたときだけです。read model は派生データなので、解決策は新しいコードでストリーム全体をもう一度投影することです。`connect` が返すクライアントの `rebuildReadModel("orders")` が、read model を止めずにそれを行います([rebuilding a read model](https://docs.bounda.dev/guides/deployment/#rebuilding-a-read-model))。`npm run dev` のローカルストアなら、`.wrangler/state` を削除すれば足ります。 + +## 5. 三十分待たずにテストする + +`createTestApp` は、インメモリのストアと、あなたが動かしたときだけ進む時計を備えたアプリを返します。予約されたコマンドが期限を迎えるのは時計が進んだからで、テストが待ったからではありません。 + +```ts +// tests/expiry.test.ts +import { createTestApp } from "@bounda-dev/core/testing"; +import { describe, expect, it } from "vitest"; +import { registry } from "../.bounda/registry.ts"; + +const orderId = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01"; +const MINUTE = 60_000; + +describe("an order nobody pays", () => { + it("expires 30 minutes after it was placed", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.runUntilIdle(); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "expired" }], + }); + await expect(app.commands.payOrder({ orderId })).rejects.toMatchObject({ + rejected: "NotPayable", + }); + await app.stop(); + }); + + it("stays paid when it was paid in time", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.commands.payOrder({ orderId }); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "paid" }], + }); + await app.stop(); + }); +}); +``` + +さらに `tests/api.test.ts` の `describe` の中に、HTTP 経由で本物の Durable Object を相手にするケースを一つ足します。ファイルにすでにある `post` と `listOrders` のヘルパーを使います。 + +```ts +it("pays an order, and the next query sees it paid", async () => { + const orderId = crypto.randomUUID(); + await post("/commands/placeOrder", { orderId, customerId: "ada", total: 42 }, "pays"); + expect((await post("/commands/payOrder", { orderId }, "pays")).status).toBe(200); + expect(await listOrders("pays", "ada")).toMatchObject({ orders: [{ status: "paid" }] }); +}); +``` + +これで `npm test` は十個のテストを実行します。最初はテンプレートのテストが一つ失敗しますが、それは正しい挙動です。`tests/orders.test.ts` の `toEqual` は行のフィールドをすべて列挙しているので、そこに `status: "placed"` を加えてください。read model が自分の形が変わったと教えてくれているのです。 + +HTTP で二回支払うと、API が返すとおりのリジェクションが見られます。理由付きの 409 です。 + +```json +{"error":{"code":"DOMAIN_ERROR","message":"Order 018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01 is paid, not placed","rejected":"NotPayable"}} +``` + +## 6. デプロイする + +```bash +npx wrangler login +npm run deploy +``` + +`deploy` は型を生成してから `wrangler deploy` を実行します。Worker をアップロードし、`wrangler.jsonc` のマイグレーションから Durable Object のクラスを作り、あなたの `workers.dev` のアドレスを表示します。上の `curl` で `localhost:8787` の代わりにそれを使ってください。デプロイせずに何がアップロードされるかを確かめるには、`npm run check` がアプリの型を検査し、`wrangler deploy --dry-run` を実行します。 + +本物のユーザーが来る前に一つ。`createWorker` の API には意図的に**認証がありません**。これは出発点であり、アドレスを知っていれば誰でもヘッダーで任意のテナントを選べます。ユーザーのいるアプリは自前の `fetch` を書き、セッションからテナントを決め、`connect(env.STORE.get(env.STORE.idFromName(tenant)))` でオブジェクトを呼び出します。これでテストと同じ型付きの `commands` と `queries` が手に入ります。[Cloudflare ガイド](https://docs.bounda.dev/adapters/cloudflare/)にその例があります。 + +## どう動いているか + +デプロイしたものについて考えるのに必要な分だけ。 + +- **テナントごとに一つの Durable Object。** 各テナントは専用のオブジェクトと専用の SQLite を持ち、そこにイベント、read model、予約されたコマンド、ポリシーの inbox を保存します。テナント間で共有するものはありません。SQL は、Bounda がほかの場所の SQLite で動かしているのと同じコードです。 +- **コマンドは応答する前に read model を更新する。** オブジェクトはイベントを保存し、同じリクエストの中でそれを投影します。だから続くクエリはもう変更を見ています。API のテストが支払ってすぐに待たずに一覧できるのはこのためです。 +- **ポリシーとタイマーはオブジェクトのアラームで動く。** コマンドが応答した直後にオブジェクトはアラームをセットし、アラームがポリシーを実行します。`expire-on-order-placed` はそこで動き、`expireOrder` を予約されたコマンドとして保存します。続いてオブジェクトは、そのコマンドの期限である三十分後にアラームをセットします。遅いポリシーがコマンドを遅くすることはなく、保留中の仕事がないオブジェクトは何もセットしません。 +- **ポリシーは inbox によって少なくとも一回実行される。** 一回の試行は、決めたことをすべて一つのトランザクションで確定させるか、何も確定させないかのどちらかで、処理済みのイベントが再び処理されることはありません。詳しくは [delivery guarantees](https://docs.bounda.dev/concepts/delivery-guarantees/) にあります。 + +## コスト + +SQLite を使う Durable Objects は行単位で課金されるので、コマンドの正直な尺度は行数です。Durable Object の SQLite カーソルはそれぞれ、読み書きした行数をインデックスの更新も含めて報告し、それが Cloudflare の課金対象です。テンプレートの `placeOrder` について、最初のコマンドがスキーマを作ったあとで数えると、**書き込みは 8 行**です。 + +| 行 | 内容 | +|---|---| +| 4 | イベントと、イベントストアのインデックス | +| 3 | read model の行と、その主キーと `customerId` インデックス | +| 1 | read model のチェックポイント | + +読み込みは 13 行です。冪等性キー付きで送ったコマンドは、返答を覚えておくためにさらに 3 行書き込みます。 + +Cloudflare の料金は、[Durable Objects の料金ページ](https://developers.cloudflare.com/durable-objects/platform/pricing/)(2026年10月18日更新)と [Workers の料金ページ](https://developers.cloudflare.com/workers/platform/pricing/)(2026年10月2日更新)に掲載されているもので、どちらも 2026年11月7日に確認しました。 + +- **Free プラン**では、最初に尽きるのは書き込み行数です。一日 100,000 行は、このようなコマンドで**一日約 12,500 回**です。それを超えると、UTC 00:00 に一日がリセットされるまで書き込みは失敗します。 +- **Paid プラン**(月 5 ドル)では、月 5,000 万行の書き込みが含まれ、これは **625 万コマンド**にあたります。それを超えると、百万コマンドごとに**約 8.50 ドル**かかります。内訳は書き込み行に 8.00 ドル、Durable Object へのリクエストに 0.15 ドル、Worker へのリクエストに 0.30 ドル、読み込み行と実行時間に数セントです。 + +ストレージは別に課金され、Paid プランでは 5 GB-月を超えた分が GB-月あたり 0.20 ドルです。そしてイベントストアは増える一方です。 + +いま作ったアプリは、ポリシーがアラームの中で仕事をするぶん、注文一件あたりのコストが高くなります。一回だけ実行させるための inbox のエントリ、予約された `expireOrder`、チェックポイント、そしてオブジェクトがアラームをセットするたびに 1 行です。同じ計測方法で測ると、**作成された注文一件あたり、テンプレートのおよそ二倍の行と、二回目のリクエスト**になりました。三十分後に動く期限切れの処理そのものは含みません。書き込みが請求額を決め、ビューの `.index()` 一つひとつ、ポリシー一つひとつがその明細の一行になります。 + +数字はこのテストから得たもので、workerd で動く 25 行ほどのコードです。テンプレートに `tests/cost.test.ts` として加えれば、`npm test` が 8 行であることを確かめます。 + +```ts +// tests/cost.test.ts +import { connect } from "@bounda-dev/cloudflare"; +import { env, runInDurableObject } from "cloudflare:test"; +import { expect, it } from "vitest"; + +const cursors: SqlStorageCursor>[] = []; + +it("counts the rows a command writes", async () => { + const stub = env.STORE.get(env.STORE.idFromName("cost")); + const store = connect(stub); + // The first command creates the schema; measure the ones after it. + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 1 }); + + await runInDurableObject(stub, (_object, state) => { + const sql = Object.getPrototypeOf(state.storage.sql); + const exec = sql.exec; + sql.exec = function (this: SqlStorage, ...args: Parameters) { + const cursor = exec.apply(this, args); + cursors.push(cursor); + return cursor; + }; + }); + + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 42 }); + const written = cursors.reduce((sum, cursor) => sum + cursor.rowsWritten, 0); + expect(written).toBe(8); +}); +``` + +このテストはコマンドが応答するまでに起きたことを数えるので、ステップ 4 のあとでも通ります。ポリシーの行は後から、アラームの中で書き込まれるからです。ここにある数字はすべて 2026年11月7日に、Bounda 0.3.0、wrangler 4.158、`@cloudflare/vitest-plugin` 1.6 で測定しました。後のバージョンでは書き込む行数が変わる可能性があり、それを知る方法がこのテストです。本番では、Cloudflare ダッシュボードの Durable Objects のメトリクスで、namespace ごとの読み書き行数を確認できます。 + +## この設計が向かないとき + +テナントごとに一つのオブジェクトという設計は、テナントが互いに独立していて、その大半がどの瞬間にも静かな場合によいデフォルトです。向かないのは、一つのテナントが休みなく書き込む場合です。オブジェクトは一度に一つのコマンドを確定させる単一の書き手で、ソフトリミットは毎秒約 1,000 リクエストです。共有の板や全体のカウンターは、自分自身の後ろに行列を作ります。ユーザーがオブジェクトから遠い場合も向きません。オブジェクトは一か所に住んでいるので、別の大陸のユーザーはコマンドのたびに往復の時間を払います。各オブジェクトの SQLite は Paid プランで 10 GB まで入り、イベントは決して削除されないので、それほどの履歴を溜めることになるテナントは、年のようにドメインがすでに知っている何かで履歴を分ける必要があります。そして各テナントの read model はそれぞれのオブジェクトの中にあるので、全テナントを横断するレポートには、イベントを別の場所にコピーする必要があります。そのパイプラインはあなたが作るものです。 + +## 次に進むなら + +[デモ](https://demo.bounda.dev)は同じ作りの劇場のチケット売り場で、訪問者ごとに Durable Object が一つあります。**Let a hold run out** のシーンを開いてください。仮押さえして支払われなかった座席が自然に期限切れになります。デモでは予約コマンドではなく process の期限で実装しています。[Cloudflare ガイド](https://docs.bounda.dev/adapters/cloudflare/)はポートとバインディング、整合性、テストを扱い、[reacting to events](https://docs.bounda.dev/guides/reacting-to-events/) はポリシーで足りるときと process が必要なときを説明しています。 diff --git a/src/content/translations/pt-br/event-sourcing-on-cloudflare-durable-objects.md b/src/content/translations/pt-br/event-sourcing-on-cloudflare-durable-objects.md new file mode 100644 index 0000000..bbe7cd5 --- /dev/null +++ b/src/content/translations/pt-br/event-sourcing-on-cloudflare-durable-objects.md @@ -0,0 +1,470 @@ +--- +title: Um backend com event sourcing em Durable Objects da Cloudflare +description: De uma pasta vazia a uma API com event sourcing publicada na Cloudflare, de uma vez só, com Bounda. Um Durable Object por tenant, uma policy no seu alarme, testes no workerd e quanto custa cada comando. +tryIt: + label: Deixe uma reserva expirar na demo +--- + +Este tutorial constrói uma pequena API de pedidos com event sourcing e faz o deploy dela na +Cloudflare. Ele usa o [Bounda](https://docs.bounda.dev/getting-started/), um framework de event +sourcing e CQRS para TypeScript, e o seu adaptador para a Cloudflare, que roda cada tenant do seu +app no próprio [Durable Object](https://developers.cloudflare.com/durable-objects/): o event store, +os read models e o trabalho em segundo plano, tudo no SQLite do objeto. Não há banco de dados para +provisionar nem fila ou cron para manter. + +No fim você terá criado pedidos, pago esses pedidos, deixado um não pago expirar com um timer, +testado tudo dentro do workerd e feito o deploy com um comando. Leva cerca de meia hora. Você +precisa do Node.js 22.18 ou mais recente e, para o último passo, de uma conta na Cloudflare; o +plano gratuito basta. + +## 1. Crie o projeto + +```bash +npm create bounda@latest my-app -- --runtime cloudflare +cd my-app +``` + +Isso escreve o projeto, inicializa um repositório git e instala as dependências. Se preferir +começar pelo GitHub, o botão +[Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/bounda-dev/bounda-cloudflare-template) +faz um fork do [bounda-cloudflare-template](https://github.com/bounda-dev/bounda-cloudflare-template), +o mesmo projeto, na sua conta e faz o deploy; clone o seu fork e continue a partir do passo 2. + +## 2. O que você ganhou + +O app é um agregado `order` com um comando e um evento, e um read model `orders` com uma query. No +Bounda, a pasta e o nome de um arquivo são a sua declaração, então a árvore é o app: + +``` +app/domain/order/order-placed.ts the event OrderPlaced +app/domain/order/commands/place-order.ts the command placeOrder +app/read/orders/view.ts the read model's table +app/read/orders/projections/order/order-placed.ts +app/read/orders/queries/list-orders.ts the query listOrders +bounda.config.ts storage: cloudflare() +src/worker.ts the Durable Object class and the HTTP API +wrangler.jsonc the binding and the SQLite migration +tests/orders.test.ts the domain, on an in-memory store +tests/api.test.ts the API, against the Durable Object +``` + +O comando decide: devolve eventos ou uma rejeição tipada, e nunca toca no banco de dados: + +```ts +// app/domain/order/commands/place-order.ts +import type { Command } from "./+types/place-order"; + +export const payload = ({ z }: Command.PayloadArgs) => + z.object({ orderId: z.uuid(), customerId: z.string().min(1), total: z.number().positive() }); + +export const rejections = ({ command }: Command.RejectionsArgs) => ({ + AlreadyPlaced: `Order ${command.aggregateId} was already placed`, +}); + +export const handler = ({ command, state, events, reject }: Command.HandlerArgs) => { + if (state.status !== undefined) return reject("AlreadyPlaced"); + return [ + events.orderPlaced({ customerId: command.payload.customerId, total: command.payload.total }), + ]; +}; +``` + +O evento diz qual é o seu payload e como muda o estado do pedido: + +```ts +// app/domain/order/order-placed.ts +import type { Event } from "./+types/order-placed"; + +export const payload = ({ z }: Event.PayloadArgs) => + z.object({ customerId: z.string(), total: z.number().positive() }); + +export const begin = ({ event }: Event.BeginArgs) => ({ + status: "placed" as const, + customerId: event.payload.customerId, + total: event.payload.total, +}); +``` + +Os imports de `+types` são gerados pelo `bounda generate`, que todo script roda primeiro, então o +estado, os eventos e os comandos ficam tipados a partir desses arquivos sem uma linha de fiação. + +A parte da Cloudflare são dois exports em `src/worker.ts`: + +```ts +// src/worker.ts +import { createBoundaObject, createWorker } from "@bounda-dev/cloudflare"; +import { registry } from "../.bounda/registry.ts"; +import config from "../bounda.config.ts"; + +export const Store = createBoundaObject({ registry, config }); + +export default createWorker({ + config, + tenantOf: (request) => request.headers.get("x-bounda-tenant") ?? "default", +}); +``` + +`Store` é a classe do Durable Object, ligada como `STORE` no `wrangler.jsonc` e criada como classe +SQLite pela sua migração. `createWorker` é uma API JSON na frente: `POST /commands/` e +`POST /queries/`, roteadas para o objeto do tenant pelo header `x-bounda-tenant`. + +## 3. Rode + +```bash +npm test +``` + +Os dois arquivos de teste rodam dentro do workerd, o runtime em que a Cloudflare faz o deploy, pela +[integração da Cloudflare com o Vitest](https://developers.cloudflare.com/workers/testing/vitest-integration/). +`tests/orders.test.ts` roda o domínio sobre um store em memória, e `tests/api.test.ts` envia +requisições ao Worker e chega a um Durable Object real e ao seu SQLite. + +Depois, suba localmente: + +```bash +npm run dev +``` + +`http://localhost:8787` serve uma pequena página que cria e lista pedidos. Ou, num terminal: + +```bash +curl -X POST localhost:8787/commands/placeOrder \ + -H 'content-type: application/json' -H 'x-bounda-tenant: acme' \ + -d '{"orderId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","customerId":"ada","total":42}' +``` + +A resposta diz qual evento o comando gravou e a sua posição no stream global do tenant: + +```json +{"scheduled":false,"aggregateType":"order","aggregateId":"018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01","version":1,"eventIds":["01a1b656-1123-748e-932f-2899972e40c3"],"eventTypes":["OrderPlaced"],"position":1} +``` + +## 4. Adicione um passo de verdade: pagar, ou expirar + +Um pedido que ninguém paga não deveria segurar estoque para sempre. A regra: um pedido pode ser +pago enquanto está criado, e um pedido ainda não pago 30 minutos depois de criado expira. São dois +eventos, dois comandos e uma policy. + +Os eventos só mudam o estado. Cada arquivo ao lado de `order-placed.ts` é um evento do pedido: + +```ts +// app/domain/order/order-paid.ts +import type { Event } from "./+types/order-paid"; + +export const evolve = ({ event }: Event.EvolveArgs) => ({ + status: "paid" as const, + paidAt: event.timestamp, +}); +``` + +```ts +// app/domain/order/order-expired.ts +import type { Event } from "./+types/order-expired"; + +export const evolve = (_: Event.EvolveArgs) => ({ status: "expired" as const }); +``` + +Pagar é recusado a menos que o pedido esteja criado. A rejeição é tipada, e é uma resposta, não um +fato: nada é gravado quando ela acontece. + +```ts +// app/domain/order/commands/pay-order.ts +import type { Command } from "./+types/pay-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const rejections = ({ command, state }: Command.RejectionsArgs) => ({ + NotPayable: `Order ${command.aggregateId} is ${state.status ?? "unknown"}, not placed`, +}); + +export const handler = ({ state, events, reject }: Command.HandlerArgs) => { + if (state.status !== "placed") return reject("NotPayable"); + return [events.orderPaid()]; +}; +``` + +Expirar decide com o estado do momento em que roda. Se o pedido foi pago nesse meio-tempo, ele +devolve zero eventos e nada acontece: + +```ts +// app/domain/order/commands/expire-order.ts +import type { Command } from "./+types/expire-order"; + +export const payload = ({ z }: Command.PayloadArgs) => z.object({ orderId: z.uuid() }); + +export const handler = ({ state, events }: Command.HandlerArgs) => + state.status === "placed" ? [events.orderExpired()] : []; +``` + +E a policy junta tudo. O nome do arquivo diz a que ela reage, `OrderPlaced`, e ela agenda +`expireOrder` para meia hora depois: + +```ts +// app/domain/order/policies/expire-on-order-placed.ts +import type { Policy } from "./+types/expire-on-order-placed"; + +export const handler = async ({ event, commands }: Policy.HandlerArgs) => { + await commands.expireOrder({ orderId: event.aggregateId }, { delay: "30m" }); +}; +``` + +`commands` é tipado a partir dos seus arquivos de comando: um comando com nome errado ou um payload +errado não compila. + +Para ver o status em `listOrders`, dê uma coluna ao read model e projete os dois eventos novos. +Adicione `status` à view: + +```ts +// app/read/orders/view.ts +import type { View } from "./+types/view"; + +export const fields = ({ f }: View.FieldsArgs) => ({ + orderId: f.string().primaryKey(), + customerId: f.string().index(), + total: f.number(), + status: f.string(), + placedAt: f.date(), +}); +``` + +Preencha o campo na projeção que já existe, adicionando `status: "placed",` à linha que +`projections/order/order-placed.ts` faz upsert, e adicione uma projeção para cada evento novo: + +```ts +// app/read/orders/projections/order/order-paid.ts +import type { Projection } from "./+types/order-paid"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "paid" }); +}; +``` + +```ts +// app/read/orders/projections/order/order-expired.ts +import type { Projection } from "./+types/order-expired"; + +export const project = async ({ event, table }: Projection.Args) => { + await table.update({ orderId: event.aggregateId }, { status: "expired" }); +}; +``` + +Rode `npm run typecheck`. Ele gera os tipos de novo e verifica o app inteiro, inclusive o `status` +que as projeções novas escrevem. + +Se você criou pedidos no passo 3, ou fez o deploy pelo botão, essas linhas voltam sem status. O +Bounda adiciona a coluna nova à tabela quando o objeto inicia, mas uma linha só recebe um valor +quando uma projeção a toca de novo. Um read model é dado derivado, então a solução é projetar o +stream inteiro de novo com o código novo: `rebuildReadModel("orders")` no cliente que o `connect` +dá faz isso sem tirar o read model do ar +([rebuilding a read model](https://docs.bounda.dev/guides/deployment/#rebuilding-a-read-model)). +Para o store local do `npm run dev`, basta apagar `.wrangler/state`. + +## 5. Teste sem esperar meia hora + +`createTestApp` dá o app sobre um store em memória com um relógio que só anda quando você o move. +Um comando agendado vence porque o relógio andou, nunca porque o teste esperou: + +```ts +// tests/expiry.test.ts +import { createTestApp } from "@bounda-dev/core/testing"; +import { describe, expect, it } from "vitest"; +import { registry } from "../.bounda/registry.ts"; + +const orderId = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01"; +const MINUTE = 60_000; + +describe("an order nobody pays", () => { + it("expires 30 minutes after it was placed", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.runUntilIdle(); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "expired" }], + }); + await expect(app.commands.payOrder({ orderId })).rejects.toMatchObject({ + rejected: "NotPayable", + }); + await app.stop(); + }); + + it("stays paid when it was paid in time", async () => { + const { app, clock } = await createTestApp({ registry }); + await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 }); + await app.commands.payOrder({ orderId }); + + clock.advance(30 * MINUTE); + await app.runUntilIdle(); + + expect(await app.queries.listOrders({ customerId: "ada" })).toMatchObject({ + orders: [{ orderId, status: "paid" }], + }); + await app.stop(); + }); +}); +``` + +E mais um caso em `tests/api.test.ts`, dentro do seu `describe`, por HTTP e contra o Durable +Object real. Ele usa os helpers `post` e `listOrders` do próprio arquivo: + +```ts +it("pays an order, and the next query sees it paid", async () => { + const orderId = crypto.randomUUID(); + await post("/commands/placeOrder", { orderId, customerId: "ada", total: 42 }, "pays"); + expect((await post("/commands/payOrder", { orderId }, "pays")).status).toBe(200); + expect(await listOrders("pays", "ada")).toMatchObject({ orders: [{ status: "paid" }] }); +}); +``` + +Agora `npm test` roda dez testes. Um dos testes do template falha primeiro, e com razão: o +`toEqual` de `tests/orders.test.ts` lista todos os campos da linha, então adicione +`status: "placed"` a ele. É o read model avisando que a sua forma mudou. + +Pagar duas vezes por HTTP mostra a rejeição como a API a devolve, um 409 com o motivo: + +```json +{"error":{"code":"DOMAIN_ERROR","message":"Order 018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01 is paid, not placed","rejected":"NotPayable"}} +``` + +## 6. Faça o deploy + +```bash +npx wrangler login +npm run deploy +``` + +`deploy` gera os tipos e roda `wrangler deploy`, que envia o Worker, cria a classe do Durable +Object a partir da migração do `wrangler.jsonc` e imprime o seu endereço `workers.dev`. Use-o no +lugar de `localhost:8787` no `curl` acima. Para ver o que seria enviado sem fazer o deploy, +`npm run check` verifica os tipos do app e roda `wrangler deploy --dry-run`. + +Antes que usuários de verdade cheguem: a API do `createWorker` **não tem autenticação**, de +propósito. É um ponto de partida, e qualquer um que saiba o endereço pode escolher qualquer tenant +com o header. Um app com usuários escreve o próprio `fetch`, decide o tenant a partir da sessão e +chama o objeto com `connect(env.STORE.get(env.STORE.idFromName(tenant)))`, que dá os mesmos +`commands` e `queries` tipados dos testes. O +[guia da Cloudflare](https://docs.bounda.dev/adapters/cloudflare/) mostra como. + +## Como roda + +Só o bastante para raciocinar sobre o que você publicou: + +- **Um Durable Object por tenant.** Cada tenant tem o próprio objeto, com o próprio SQLite, que + guarda os seus eventos, os seus read models, os seus comandos agendados e o inbox das suas + policies. Os tenants não compartilham nada. O SQL é o mesmo código que o Bounda roda sobre SQLite + em qualquer outro lugar. +- **Um comando atualiza os read models antes de responder.** O objeto grava os eventos e os + projeta na mesma requisição, então a query seguinte já vê a mudança: é por isso que o teste da + API pode pagar e depois listar sem esperar. +- **Policies e timers rodam no alarme do objeto.** Logo depois que o comando responde, o objeto + arma o seu alarme, e o alarme roda a policy: `expire-on-order-placed` roda ali e grava + `expireOrder` como comando agendado. Depois o objeto arma o alarme para quando esse comando + vencer, 30 minutos mais tarde. Uma policy lenta nunca atrasa o comando, e um objeto sem nada + pendente não arma nada. +- **Uma policy roda pelo menos uma vez, com um inbox.** A sua tentativa confirma tudo o que decidiu + numa transação, ou nada, e um evento já tratado não é tratado de novo. Os detalhes estão em + [delivery guarantees](https://docs.bounda.dev/concepts/delivery-guarantees/). + +## Quanto custa + +Durable Objects com SQLite cobram por linha, então a medida honesta de um comando é uma contagem de +linhas. Cada cursor do SQLite num Durable Object informa as linhas que leu e escreveu, incluindo as +atualizações de índices, que é o que a Cloudflare cobra. Contando-as para o `placeOrder` do +template, depois que o primeiro comando criou o esquema, o resultado é **8 linhas escritas**: + +| Linhas | O quê | +|---|---| +| 4 | O evento, com os índices do event store | +| 3 | A linha do read model, com a chave primária e o índice `customerId` | +| 1 | O checkpoint do read model | + +Ele lê 13. Um comando enviado com chave de idempotência escreve mais 3, para lembrar a resposta. + +Com os preços da Cloudflare, como publicados na +[página de preços dos Durable Objects](https://developers.cloudflare.com/durable-objects/platform/pricing/) +(atualizada em 18 de outubro de 2026) e na +[dos Workers](https://developers.cloudflare.com/workers/platform/pricing/) (2 de outubro de 2026), +ambas conferidas em 7 de novembro de 2026: + +- **No plano Free**, o que acaba primeiro são as linhas escritas: 100.000 por dia dão uns + **12.500 comandos por dia** como este. Passado isso, as escritas falham até o dia reiniciar às + 00:00 UTC. +- **No plano Paid** (US$ 5 por mês), 50 milhões de linhas escritas por mês estão incluídas, ou + seja, **6,25 milhões de comandos**. Depois disso, cada milhão a mais custa **cerca de US$ 8,50**: + US$ 8,00 em linhas escritas, US$ 0,15 em requisições ao Durable Object, US$ 0,30 em requisições + ao Worker e centavos em linhas lidas e duração. + +O armazenamento é cobrado à parte, a US$ 0,20 por GB-mês além dos 5 GB-mês do plano Paid, e um +event store só cresce. + +O app que você acabou de construir custa mais por pedido, porque a sua policy tem trabalho a fazer +no alarme: a entrada do inbox que faz ela rodar uma vez só, o `expireOrder` agendado, um checkpoint +e uma linha cada vez que o objeto arma o alarme. Medido com a mesma instrumentação, isso deu **mais +ou menos o dobro das linhas do template, e uma segunda requisição, por pedido criado**, sem contar +a expiração em si, que roda meia hora depois. As escritas são a conta, e cada `.index()` numa view +e cada policy é uma linha dela. + +Os números vêm deste teste, umas 25 linhas no workerd. Adicione-o como `tests/cost.test.ts` ao +template e `npm test` confere as 8: + +```ts +// tests/cost.test.ts +import { connect } from "@bounda-dev/cloudflare"; +import { env, runInDurableObject } from "cloudflare:test"; +import { expect, it } from "vitest"; + +const cursors: SqlStorageCursor>[] = []; + +it("counts the rows a command writes", async () => { + const stub = env.STORE.get(env.STORE.idFromName("cost")); + const store = connect(stub); + // The first command creates the schema; measure the ones after it. + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 1 }); + + await runInDurableObject(stub, (_object, state) => { + const sql = Object.getPrototypeOf(state.storage.sql); + const exec = sql.exec; + sql.exec = function (this: SqlStorage, ...args: Parameters) { + const cursor = exec.apply(this, args); + cursors.push(cursor); + return cursor; + }; + }); + + await store.commands.placeOrder({ orderId: crypto.randomUUID(), customerId: "ada", total: 42 }); + const written = cursors.reduce((sum, cursor) => sum + cursor.rowsWritten, 0); + expect(written).toBe(8); +}); +``` + +Ele conta o que acontece até o comando responder, então continua passando depois do passo 4: as +linhas da policy chegam depois, no alarme. Todos os números foram medidos em 7 de novembro de 2026 +com Bounda 0.3.0, wrangler 4.158 e `@cloudflare/vitest-plugin` 1.6; uma versão posterior pode +escrever outro número de linhas, e o teste é como saber. Em produção, as métricas de Durable +Objects no painel da Cloudflare mostram as linhas que cada namespace lê e escreve. + +## Quando não é o design certo + +Um objeto por tenant é um bom padrão quando os tenants são independentes e a maioria está quieta a +cada momento. Encaixa mal quando um tenant escreve sem parar: um objeto é um único escritor que +confirma um comando por vez, com um limite flexível de cerca de 1.000 requisições por segundo, então +um livro de ofertas compartilhado ou um contador global faz fila atrás de si mesmo. Encaixa mal +quando os usuários estão longe do objeto, porque um objeto vive num lugar só e um usuário em outro +continente paga a ida e volta a cada comando. O SQLite de cada objeto comporta 10 GB no plano Paid +e, como os eventos nunca são apagados, um tenant que vá acumular tanto histórico precisa dividi-lo +por algo que o domínio já conhece, como o ano. E os read models de cada tenant vivem no próprio +objeto, então um relatório sobre todos os tenants precisa copiar os eventos para outro lugar, um +pipeline que fica por sua conta. + +## Para onde ir agora + +A [demo](https://demo.bounda.dev) é a bilheteria de um teatro construída do mesmo jeito, um +Durable Object por visitante. Abra a cena **Let a hold run out**: um assento reservado e não pago +expira sozinho, lá com um prazo de um process em vez de um comando agendado. O +[guia da Cloudflare](https://docs.bounda.dev/adapters/cloudflare/) cobre ports e bindings, +consistência e testes, e +[reacting to events](https://docs.bounda.dev/guides/reacting-to-events/) explica quando uma policy +basta e quando o trabalho precisa de um process.