Rebasely Beta: data may be purged

examples/storefront Public

Announce paid and cancelled orders on an order-events queue

OrdersService sends order.paid and order.cancelled after the D1 write. order.paid goes out whenever the order ends up paid, so a send that failed is repeated by Stripe's retry of the webhook; consumers dedupe by order id. Fulfilment is the first consumer.
5324453ad3046f51a425b378a2a2ad1bc515f94d
Parent 82cc041 Browse filesHistory

Changed files

13 files changed102 additions17 deletionsagainst 82cc041

  1. Changed.rebasely/architecture/system.yaml+7−0
  2. Changed.rebasely/kb/concepts/checkout.md+2−2
  3. Added.rebasely/kb/concepts/order-events.md+32−0
  4. Changed.rebasely/kb/guides/testing.md+3−2
  5. Changed.rebasely/kb/index.md+1−0
  6. Changedsrc/env.ts+3−0
  7. Addedsrc/events.ts+7−0
  8. Changedsrc/index.ts+2−2
  9. Changedsrc/orders.ts+12−4
  10. Changedtest/helpers/app.ts+5−2
  11. Addedtest/helpers/queue.ts+16−0
  12. Changedtest/stripe-webhook.test.ts+8−4
  13. Changedwrangler.jsonc+4−1
Changed.rebasely/architecture/system.yaml+7−0
@@ -11,6 +11,7 @@
- { to: db, via: d1 }
- { to: images, via: r2 }
- { to: stripe, via: https }
+ - { to: order-events, via: queue }
stripe-webhook:
kind: worker
description: >-
@@ -33,6 +34,12 @@
Bucket storefront-images: product images under products/<product>/<hash>.<ext>. A key is
never overwritten, so every image URL can be cached for a year.
binding: IMAGES
+ order-events:
+ kind: queue
+ description: >-
+ Queue order-events: order.paid and order.cancelled, sent by OrdersService when an order
+ changes state. At least once, so consumers dedupe by order id.
+ binding: ORDER_EVENTS
stripe:
kind: external
description: >-
View file
Changed.rebasely/kb/concepts/checkout.md+2−2
@@ -8,7 +8,7 @@
- src/orders.ts
- src/payments.ts
- migrations/0003_orders.sql
-reviewed: 2026-10-05
+reviewed: 2026-10-06
---
# Checkout
@@ -39,4 +39,4 @@
| `cancelled` | Not paid; the stock went back in the same batch |
| `fulfilled` | Handed to the warehouse |
-State changes after checkout are in `src/orders.ts` (`markPaid`, `cancelOrder`) and only move forward from `pending_payment`, so repeating one is harmless. `GET /orders/:id` returns the order. The id is a random UUID that only the shopper who checked out has.
+State changes after checkout are in `src/orders.ts` (`markPaid`, `cancelOrder`) and only move forward from `pending_payment`, so repeating one is harmless. Each change is announced on the queue ([[order-events]]). `GET /orders/:id` returns the order. The id is a random UUID that only the shopper who checked out has.
View file
Added.rebasely/kb/concepts/order-events.md+32−0
@@ -0,0 +1,32 @@
+---
+id: order-events
+type: concept
+owner: justin
+component: order-events
+describes:
+ - src/events.ts
+ - src/orders.ts
+reviewed: 2026-10-06
+---
+# Order events
+
+When an order changes state, storefront-api puts a message on the `order-events` queue so other parts of the shop can react without being called from the payment path.
+
+```json
+{ "type": "order.paid", "order_id": "5d0c...", "at": "2026-10-06T17:02:11.000Z" }
+```
+
+| Type | Sent by | When |
+| --- | --- | --- |
+| `order.paid` | `markPaid` | the order is paid after the call |
+| `order.cancelled` | `cancelOrder` | the call cancelled the order |
+
+Both are sent from `src/orders.ts`, after the D1 write, inside the OrdersService call the webhook makes ([[payment-webhook]]).
+
+## At least once
+
+The D1 write and the queue send are two steps, and either can fail on its own. The order is the source of truth, so the write goes first. `markPaid` then sends `order.paid` whenever the order ends up paid, including when an earlier delivery already marked it. If the send fails, the call throws, the webhook answers 500, Stripe retries, and the retry sends the event.
+
+The price is duplicates: a consumer can see `order.paid` for the same order more than once, and must act on it once. The order id is the dedupe key.
+
+Messages are small and carry no order details. A consumer that needs the address or the lines asks OrdersService for them when it handles the message, so it always works from the current order.
View file
Changed.rebasely/kb/guides/testing.md+3−2
@@ -4,7 +4,7 @@
owner: justin
describes:
- test/helpers/**
-reviewed: 2026-10-02
+reviewed: 2026-10-06
---
# Running and writing tests
@@ -21,10 +21,11 @@
- `DB` is `TestD1` (`test/helpers/d1.ts`), a small D1 stand-in over `node:sqlite` with every file in `migrations/` applied in order and foreign keys on. It covers `prepare`, `bind`, `first`, `all`, `run` and `batch`, and a batch is a transaction, as in D1.
- `IMAGES` is `TestR2` (`test/helpers/r2.ts`), an in-memory bucket.
+- `ORDER_EVENTS` is `TestQueue` (`test/helpers/queue.ts`), which keeps every message in `sent`.
- Payments are `FakePayments` (`test/helpers/payments.ts`), which records every charge and fails the next one when `failNext` is set. `test/stripe-payments.test.ts` checks the real Stripe adapter against a fake `fetch`, so no test calls Stripe.
- The clock is fixed and ids count up (`id-1`, `id-2`), so assertions can name them.
-Tests talk HTTP to the handler through `call(method, path, body, headers)`, which returns the status, headers and parsed JSON. `addProduct` creates a product with the admin token, and `payments` is the fake the app was given.
+Tests talk HTTP to the handler through `call(method, path, body, headers)`, which returns the status, headers and parsed JSON. `addProduct` creates a product with the admin token, `payments` is the fake the app was given and `events` is its queue.
## Writing one
View file
Changed.rebasely/kb/index.md+1−0
@@ -9,6 +9,7 @@
- [[product-images]]: how images get into R2 and back out.
- [[checkout]]: how a cart becomes an order, and how stock and payment are handled.
- [[payment-webhook]]: how Stripe's events mark orders paid or cancelled.
+- [[order-events]]: the queue that announces paid and cancelled orders.
## Decisions
View file
Changedsrc/env.ts+3−0
@@ -1,7 +1,10 @@
// The bindings and secrets of the storefront-api Worker (wrangler.jsonc).
+import type { OrderEvent } from "./events.ts";
+
export interface Env {
DB: D1Database;
IMAGES: R2Bucket;
+ ORDER_EVENTS: Queue<OrderEvent>;
ADMIN_TOKEN: string;
STRIPE_SECRET_KEY: string;
}
View file
Addedsrc/events.ts+7−0
@@ -0,0 +1,7 @@
+// Messages on the order-events queue. Delivery is at least once: a consumer can see the same
+// event twice and must act on it once.
+export type OrderEvent = {
+ type: "order.paid" | "order.cancelled";
+ order_id: string;
+ at: string; // ISO time the order reached this state
+};
View file
Changedsrc/index.ts+2−2
@@ -8,11 +8,11 @@
// .rebasely/kb/decisions/0006-one-writer-for-the-database.md.
export class OrdersService extends WorkerEntrypoint<Env> {
markPaid(orderId: string, paymentIntentId: string): Promise<Settled> {
- return markPaid(this.env.DB, orderId, paymentIntentId, Date.now());
+ return markPaid(this.env.DB, this.env.ORDER_EVENTS, orderId, paymentIntentId, Date.now());
}
cancel(orderId: string): Promise<Settled> {
- return cancelOrder(this.env.DB, orderId, Date.now());
+ return cancelOrder(this.env.DB, this.env.ORDER_EVENTS, orderId, Date.now());
}
}
View file
Changedsrc/orders.ts+12−4
@@ -1,6 +1,7 @@
// Orders: what a cart becomes at checkout. An order starts as pending_payment and moves on when
// the payment provider reports the outcome.
+import type { OrderEvent } from "./events.ts";
import type { Totals } from "./money.ts";
export type OrderStatus = "pending_payment" | "paid" | "cancelled" | "fulfilled";
@@ -65,17 +66,22 @@
// changed it. Both calls are idempotent, because Stripe delivers webhooks at least once.
export type Settled = { order_id: string; status: OrderStatus; changed: boolean };
-export async function markPaid(db: D1Database, orderId: string, paymentIntentId: string, now: number): Promise<Settled> {
+// Marks a pending order paid and announces it on the order-events queue. The event is sent
+// whenever the order ends up paid, even if an earlier call already marked it: if that call's
+// send failed, Stripe's retry is what gets the event out. Consumers dedupe by order id.
+export async function markPaid(db: D1Database, events: Queue<OrderEvent>, orderId: string, paymentIntentId: string, now: number): Promise<Settled> {
const r = await db
.prepare("UPDATE orders SET status = 'paid', updated_at = ? WHERE id = ? AND payment_intent_id = ? AND status = 'pending_payment'")
.bind(now, orderId, paymentIntentId)
.run();
- return settled(db, orderId, r.meta.changes > 0);
+ const result = await settled(db, orderId, r.meta.changes > 0);
+ if (result.status === "paid") await events.send({ type: "order.paid", order_id: orderId, at: new Date(now).toISOString() });
+ return result;
}
// Cancels an order that is still waiting for payment and puts its stock back, in one batch. The
// stock update runs first and only while the order is pending, so a second call changes nothing.
-export async function cancelOrder(db: D1Database, orderId: string, now: number): Promise<Settled> {
+export async function cancelOrder(db: D1Database, events: Queue<OrderEvent>, orderId: string, now: number): Promise<Settled> {
const [, cancel] = await db.batch([
db
.prepare(
@@ -86,7 +92,9 @@
.bind(orderId, now),
db.prepare("UPDATE orders SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending_payment'").bind(now, orderId),
]);
- return settled(db, orderId, (cancel?.meta.changes ?? 0) > 0);
+ const result = await settled(db, orderId, (cancel?.meta.changes ?? 0) > 0);
+ if (result.changed) await events.send({ type: "order.cancelled", order_id: orderId, at: new Date(now).toISOString() });
+ return result;
}
async function settled(db: D1Database, orderId: string, changed: boolean): Promise<Settled> {
View file
Changedtest/helpers/app.ts+5−2
@@ -2,15 +2,18 @@
import { createApp } from "../../src/app.ts";
import type { Env } from "../../src/env.ts";
+import type { OrderEvent } from "../../src/events.ts";
import { testDb } from "./d1.ts";
import { FakePayments } from "./payments.ts";
+import { testQueue } from "./queue.ts";
import { testBucket } from "./r2.ts";
export const ADMIN_TOKEN = "test-admin-token";
export const NOW = Date.UTC(2026, 9, 1, 12, 0, 0);
export function setup() {
- const env: Env = { DB: testDb(), IMAGES: testBucket(), ADMIN_TOKEN, STRIPE_SECRET_KEY: "sk_test_unused" };
+ const events = testQueue<OrderEvent>();
+ const env: Env = { DB: testDb(), IMAGES: testBucket(), ORDER_EVENTS: events, ADMIN_TOKEN, STRIPE_SECRET_KEY: "sk_test_unused" };
const payments = new FakePayments();
let n = 0;
const app = createApp(env, { now: () => NOW, newId: () => `id-${++n}`, payments: () => payments });
@@ -37,5 +40,5 @@
return res.body.product;
}
- return { env, app, call, admin, addProduct, payments };
+ return { env, app, call, admin, addProduct, payments, events };
}
View file
Addedtest/helpers/queue.ts+16−0
@@ -0,0 +1,16 @@
+// A Queue producer stand-in that keeps what was sent.
+export class TestQueue<T> {
+ readonly sent: T[] = [];
+
+ async send(body: T): Promise<void> {
+ this.sent.push(body);
+ }
+
+ async sendBatch(messages: Iterable<{ body: T }>): Promise<void> {
+ for (const m of messages) this.sent.push(m.body);
+ }
+}
+
+export function testQueue<T>(): Queue<T> & TestQueue<T> {
+ return new TestQueue<T>() as unknown as Queue<T> & TestQueue<T>;
+}
View file
Changedtest/stripe-webhook.test.ts+8−4
@@ -19,8 +19,8 @@
const env: Env = {
STRIPE_WEBHOOK_SECRET: SECRET,
ORDERS: {
- markPaid: (id, intent) => markPaid(s.env.DB, id, intent, NOW),
- cancel: (id) => cancelOrder(s.env.DB, id, NOW),
+ markPaid: (id, intent) => markPaid(s.env.DB, s.events, id, intent, NOW),
+ cancel: (id) => cancelOrder(s.env.DB, s.events, id, NOW),
},
};
const deliver = async (type: string, intent: Record<string, unknown>, secret = SECRET) => {
@@ -33,25 +33,28 @@
return { ...s, order, deliver, status };
}
-test("payment_intent.succeeded marks the order paid", async () => {
+test("payment_intent.succeeded marks the order paid and announces it", async () => {
const s = await pendingOrder();
const res = await s.deliver("payment_intent.succeeded", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } });
assert.equal(res.status, 200);
assert.equal(await s.status(), "paid");
+ assert.deepEqual(s.events.sent, [{ type: "order.paid", order_id: s.order.id, at: new Date(NOW).toISOString() }]);
});
-test("a repeated delivery changes nothing", async () => {
+test("a repeated delivery changes nothing but sends the event again", async () => {
const s = await pendingOrder();
const intent = { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } };
await s.deliver("payment_intent.succeeded", intent);
assert.equal((await s.deliver("payment_intent.succeeded", intent)).status, 200);
assert.equal(await s.status(), "paid");
+ assert.equal(s.events.sent.length, 2, "at least once: fulfilment dedupes by order id");
});
test("an intent that is not the order's own does not pay it", async () => {
const s = await pendingOrder();
await s.deliver("payment_intent.succeeded", { id: "pi_someone_else", metadata: { order_id: s.order.id } });
assert.equal(await s.status(), "pending_payment");
+ assert.deepEqual(s.events.sent, []);
});
test("payment_intent.canceled cancels the order and returns the stock", async () => {
@@ -61,6 +64,7 @@
assert.equal((await s.call("GET", "/products/wool-socks")).body.product.stock, 3);
await s.deliver("payment_intent.canceled", { id: `pi_${s.order.id}`, metadata: { order_id: s.order.id } });
assert.equal((await s.call("GET", "/products/wool-socks")).body.product.stock, 3, "only once");
+ assert.deepEqual(s.events.sent.map((e) => e.type), ["order.cancelled"]);
});
test("refuses a payload that Stripe did not sign", async () => {
View file
Changedwrangler.jsonc+4−1
@@ -14,5 +14,8 @@
"migrations_dir": "migrations"
}
],
- "r2_buckets": [{ "binding": "IMAGES", "bucket_name": "storefront-images" }]
+ "r2_buckets": [{ "binding": "IMAGES", "bucket_name": "storefront-images" }],
+ "queues": {
+ "producers": [{ "binding": "ORDER_EVENTS", "queue": "order-events" }]
+ }
}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.