Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 36 additions & 33 deletions rest/nodejs/src/api/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import {
getOrder,
getProduct,
logRequest,
releaseStock,
reserveStock,
runInTransaction,
saveCheckout,
saveIdempotencyRecord,
saveOrder,
Expand Down Expand Up @@ -1067,30 +1067,9 @@ export class CheckoutService {

// Atomic Inventory Reservation and Completion
try {
const reservedItems: Array<{ id: string; qty: number }> = [];

for (const line of checkout.line_items) {
const product = getProduct(line.item.id);
if (product) {
const success = reserveStock(line.item.id, line.quantity);
if (!success) {
// Rollback
for (const reserved of reservedItems) {
releaseStock(reserved.id, reserved.qty);
}
return ucpErrorResponse(
c,
new OutOfStockError(`Item ${line.item.id} is out of stock`, 409)
);
}
reservedItems.push({ id: line.item.id, qty: line.quantity });
}
}

// Success
checkout.status = CheckoutResponseStatusSchema.enum.completed;

// Create Order
// Build the order before opening the transaction: constructing it is
// pure computation, while the transaction must stay synchronous so the
// SQLite write lock never spans external I/O.
const orderId = `ord_${uuidv4()}`;

// Order Fulfillment
Expand Down Expand Up @@ -1184,17 +1163,41 @@ export class CheckoutService {
currency: checkout.currency,
};

saveOrder(order.id, order);
// Reserve stock, persist the order and mark the checkout completed as
// one SQLite transaction: a failure at any step (including an order or
// checkout write) rolls the reservations back instead of leaving stock
// decremented behind a failed 500.
const outcome = runInTransaction(() => {
for (const line of checkout.line_items) {
const product = getProduct(line.item.id);
if (product && !reserveStock(line.item.id, line.quantity)) {
return { outOfStockItem: line.item.id };
}
}

checkout.status = CheckoutResponseStatusSchema.enum.completed;
checkout.order = {
id: orderId,
permalink_url: order.permalink_url,
};

// Save Checkout
checkout.order = {
id: orderId,
permalink_url: order.permalink_url,
};
saveOrder(order.id, order);
saveCheckout(id, checkout.status, checkout);
return {};
});

saveCheckout(id, checkout.status, checkout);
if (outcome.outOfStockItem) {
return ucpErrorResponse(
c,
new OutOfStockError(
`Item ${outcome.outOfStockItem} is out of stock`,
409
)
);
}

// Notify webhook
// Notify webhook (outside the transaction: external I/O must not hold
// the SQLite write lock).
await this.notifyWebhook(
checkout,
"order_placed",
Expand Down
17 changes: 17 additions & 0 deletions rest/nodejs/src/data/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,20 @@ export function saveIdempotencyRecord(
);
stmt.run(key, requestHash, status, responseBody);
}

/**
* Runs `fn` as a single SQLite transaction on the transactions database.
*
* Every write performed by `fn` (inventory, checkouts, orders) commits
* together; if `fn` throws, SQLite rolls all of them back and the error is
* rethrown so callers keep their existing error handling.
*
* @param fn The synchronous unit of work to commit atomically. It must not
* await: an external I/O call inside the transaction would hold the write
* lock across a network round trip.
* @returns Whatever `fn` returns.
*/
export function runInTransaction<T>(fn: () => T): T {
const db = getTransactionsDb();
return db.transaction(fn)();
}
98 changes: 98 additions & 0 deletions rest/nodejs/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";

import { CheckoutService } from "../src/api/checkout";
import { getInventory, getOrder } from "../src/data";
import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db";
import {
CheckoutCompleteRequestSchema,
Expand Down Expand Up @@ -374,3 +375,100 @@ test("an idempotent create replay still carries ucp.payment_handlers", async ()
const replayed = (await replay.json()) as { ucp?: unknown };
assertPaymentHandlers("idempotent replay", replayed.ucp);
});

// Completion writes three tables (inventory, orders, checkouts). A technical
// failure partway through must not leave stock decremented behind a 500: the
// whole unit has to roll back so the client can retry safely.
function countRows(table: "orders"): number {
const row = getTransactionsDb()
.prepare(`SELECT COUNT(*) AS n FROM ${table}`)
.get() as { n: number };
return row.n;
}

function failOnWrite(table: "orders" | "checkouts"): void {
// Fail only the completion writes, so the checkout can still be created and
// updated before the fault is armed.
const sql =
table === "orders"
? `CREATE TRIGGER fail_completion BEFORE INSERT ON orders
BEGIN SELECT RAISE(ABORT, 'injected order failure'); END`
: `CREATE TRIGGER fail_completion BEFORE UPDATE ON checkouts
WHEN NEW.status = 'completed'
BEGIN SELECT RAISE(ABORT, 'injected checkout failure'); END`;
getTransactionsDb().exec(sql);
}

function clearFault(): void {
getTransactionsDb().exec("DROP TRIGGER IF EXISTS fail_completion");
}

test("an order write failure rolls back the inventory reservation", async () => {
const app = buildApp();
const id = await createReadyToComplete(app);
const stockBefore = getInventory("bouquet_roses");
const ordersBefore = countRows("orders");

failOnWrite("orders");
try {
const res = await complete(app, id);
assert.equal(res.status, 500);
} finally {
clearFault();
}

assert.equal(
getInventory("bouquet_roses"),
stockBefore,
"a failed completion must not consume stock"
);
assert.equal(countRows("orders"), ordersBefore, "no order may be persisted");
const checkout = (await (
await app.request(`/checkout-sessions/${id}`)
).json()) as { status: string };
assert.equal(
checkout.status,
"ready_for_complete",
"the checkout must remain completable after the rollback"
);
});

test("a checkout write failure rolls back the order and the reservation", async () => {
const app = buildApp();
const id = await createReadyToComplete(app);
const stockBefore = getInventory("bouquet_roses");
const ordersBefore = countRows("orders");

failOnWrite("checkouts");
try {
const res = await complete(app, id);
assert.equal(res.status, 500);
} finally {
clearFault();
}

assert.equal(getInventory("bouquet_roses"), stockBefore);
assert.equal(
countRows("orders"),
ordersBefore,
"the order inserted before the failing checkout update must roll back"
);
const checkout = (await (
await app.request(`/checkout-sessions/${id}`)
).json()) as { status: string; order?: unknown };
assert.equal(checkout.status, "ready_for_complete");
assert.equal(checkout.order, undefined, "no order reference may survive");
});

test("a successful completion reserves stock exactly once", async () => {
const app = buildApp();
const id = await createReadyToComplete(app);
const stockBefore = getInventory("bouquet_roses");

const res = await complete(app, id);
assert.equal(res.status, 200);
const body = (await res.json()) as { order?: { id: string } };
assert.ok(body.order?.id);
assert.equal(getInventory("bouquet_roses"), (stockBefore ?? 0) - 1);
assert.ok(getOrder(body.order.id), "the order must be readable");
});
Loading