Rebasely Beta: data may be purged

examples/storefront Public

Page through the product list with a cursor #1

Changed files

5 files changed98 additions10 deletionssince main at c6a00ae

  1. Changed.rebasely/kb/concepts/catalog.md+15−3
  2. ChangedREADME.md+1−1
  3. Changedsrc/app.ts+5−2
  4. Changedsrc/catalog.ts+42−4
  5. Changedtest/catalog.test.ts+35−0
Changed.rebasely/kb/concepts/catalog.md+15−3
@@ -6,7 +6,7 @@
describes:
- src/catalog.ts
- migrations/0001_products.sql
-reviewed: 2026-10-08
+reviewed: 2026-10-13
---
# Catalog
@@ -14,9 +14,21 @@
## Reading
-Shoppers see only active products. `GET /products` lists them by name, then id, so the order is stable when two products share a name. `GET /products/:id` answers 404 for a product that is inactive, the same as for one that does not exist, so a retired product disappears from the shop without breaking old links into an error page.
+Shoppers see only active products. `GET /products/:id` answers 404 for a product that is inactive, the same as for one that does not exist, so a retired product disappears from the shop without breaking old links into an error page.
-The list returns the first 100 active products. That covers the current catalog; a larger one needs paging.
+## Paging
+
+`GET /products` returns active products by name, then id, so the order is stable when two products share a name. It returns a page at a time:
+
+```
+GET /products?limit=24
+{ "products": [...], "next": "WyJMaW5lbiB3b3JrIGFwcm9uIiwibGluZW4tYXByb24iXQ" }
+GET /products?limit=24&after=WyJMaW5lbiB3b3JrIGFwcm9uIiwibGluZW4tYXByb24iXQ
+```
+
+`limit` is 1 to 100 and defaults to 24, one catalog page. `next` is null on the last page. The cursor is the name and id of the last product on the page, base64url encoded; clients should pass it back unchanged and not build their own.
+
+A cursor rather than `?page=3` because the query starts right after the last row (`(name, id) > (?, ?)`) on the `products_by_name` index, so page 40 costs the same as page 1, and a product added or retired between two requests does not shift the next page by one.
## Writing
View file
ChangedREADME.md+1−1
@@ -44,7 +44,7 @@
| Method and path | What it does |
| --- | --- |
-| `GET /products` | Active products, by name |
+| `GET /products` | Active products by name, a page at a time: `?limit=` (1 to 100, default 24) and `?after=` the previous page's `next` |
| `GET /products/:id` | One active product |
| `PUT /admin/products/:id` | Create or replace a product (admin token) |
| `PUT /admin/products/:id/image` | Upload the product's image (admin token) |
View file
Changedsrc/app.ts+5−2
@@ -2,7 +2,7 @@
// (the clock and id generation) and returns a fetch handler.
import { createCart, getCart, setQuantity } from "./carts.ts";
-import { getProduct, listProducts, parseProductInput, upsertProduct } from "./catalog.ts";
+import { getProduct, listProducts, parseLimit, parseProductInput, upsertProduct } from "./catalog.ts";
import { checkout } from "./checkout.ts";
import type { Env } from "./env.ts";
import { errorResponse, HttpError, json, readJson, requireAdmin } from "./http.ts";
@@ -27,7 +27,10 @@
const r = new Router();
const images = imageUrls(env.IMAGES_URL);
- r.on("GET", "/products", async () => json({ products: await listProducts(env.DB, images) }));
+ r.on("GET", "/products", async (req) => {
+ const q = new URL(req.url).searchParams;
+ return json(await listProducts(env.DB, images, parseLimit(q.get("limit")), q.get("after")));
+ });
r.on("GET", "/products/:id", async (_req, p) => {
const product = await getProduct(env.DB, p.id!, images);
View file
Changedsrc/catalog.ts+42−4
@@ -38,12 +38,50 @@
};
}
-// Active products by name. The first 100 for now; a catalog bigger than that needs pages.
-export async function listProducts(db: D1Database, images: ImageUrls): Promise<Product[]> {
+export const DEFAULT_PAGE = 24;
+export const MAX_PAGE = 100;
+
+export type Page = { products: Product[]; next: string | null };
+
+// Active products by name, a page at a time. The cursor is the (name, id) of the last product on
+// the previous page, so pages stay correct while products are added or retired, and the query
+// walks the products_by_name index instead of skipping rows with OFFSET.
+export async function listProducts(db: D1Database, images: ImageUrls, limit = DEFAULT_PAGE, after: string | null = null): Promise<Page> {
+ const from = after === null ? null : decodeCursor(after);
const { results } = await db
- .prepare(`SELECT ${COLUMNS} FROM products WHERE active = 1 ORDER BY name, id LIMIT 100`)
+ .prepare(
+ `SELECT ${COLUMNS} FROM products
+ WHERE active = 1 AND (?1 IS NULL OR (name, id) > (?1, ?2))
+ ORDER BY name, id LIMIT ?3`,
+ )
+ .bind(from?.[0] ?? null, from?.[1] ?? null, limit + 1)
.all<Row>();
- return results.map((r) => toProduct(r, images));
+ const page = results.slice(0, limit);
+ const last = page[page.length - 1];
+ return { products: page.map((r) => toProduct(r, images)), next: results.length > limit && last ? encodeCursor(last.name, last.id) : null };
+}
+
+export function parseLimit(value: string | null): number {
+ if (value === null) return DEFAULT_PAGE;
+ const n = Number(value);
+ if (!Number.isInteger(n) || n < 1 || n > MAX_PAGE) throw new HttpError(400, "bad_limit", `limit is a whole number from 1 to ${MAX_PAGE}.`);
+ return n;
+}
+
+function encodeCursor(name: string, id: string): string {
+ const bytes = new TextEncoder().encode(JSON.stringify([name, id]));
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function decodeCursor(cursor: string): [string, string] {
+ try {
+ const bin = atob(cursor.replace(/-/g, "+").replace(/_/g, "/"));
+ const v: unknown = JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
+ if (Array.isArray(v) && v.length === 2 && typeof v[0] === "string" && typeof v[1] === "string") return [v[0], v[1]];
+ } catch {
+ // fall through to the error below
+ }
+ throw new HttpError(400, "bad_cursor", "after is a cursor from a previous page's next.");
}
// Any product, active or not. Shopper routes check `active` themselves.
View file
Changedtest/catalog.test.ts+35−0
@@ -54,3 +54,38 @@
assert.equal((await s.call("DELETE", "/products")).status, 405);
assert.equal((await s.call("GET", "/products/%E0%A4%A")).status, 400);
});
+
+test("pages through the catalog with a cursor", async () => {
+ const s = setup();
+ for (const id of ["a-mug", "b-tote", "c-socks", "d-apron", "e-candle"]) await s.addProduct(id, { name: id });
+ const first = (await s.call("GET", "/products?limit=2")).body;
+ assert.deepEqual(first.products.map((p: { id: string }) => p.id), ["a-mug", "b-tote"]);
+ const second = (await s.call("GET", `/products?limit=2&after=${first.next}`)).body;
+ assert.deepEqual(second.products.map((p: { id: string }) => p.id), ["c-socks", "d-apron"]);
+ const last = (await s.call("GET", `/products?limit=2&after=${second.next}`)).body;
+ assert.deepEqual(last.products.map((p: { id: string }) => p.id), ["e-candle"]);
+ assert.equal(last.next, null);
+});
+
+test("products that share a name are ordered by id and none is skipped", async () => {
+ const s = setup();
+ for (const id of ["mug-blue", "mug-green", "mug-red"]) await s.addProduct(id, { name: "Enamel mug" });
+ const first = (await s.call("GET", "/products?limit=2")).body;
+ const rest = (await s.call("GET", `/products?limit=2&after=${first.next}`)).body;
+ assert.deepEqual([...first.products, ...rest.products].map((p: { id: string }) => p.id), ["mug-blue", "mug-green", "mug-red"]);
+});
+
+test("a page boundary survives a product being added before it", async () => {
+ const s = setup();
+ for (const id of ["b-tote", "c-socks", "d-apron"]) await s.addProduct(id, { name: id });
+ const first = (await s.call("GET", "/products?limit=2")).body;
+ await s.addProduct("a-mug", { name: "a-mug" });
+ const second = (await s.call("GET", `/products?limit=2&after=${first.next}`)).body;
+ assert.deepEqual(second.products.map((p: { id: string }) => p.id), ["d-apron"]);
+});
+
+test("refuses a bad limit or cursor", async () => {
+ const s = setup();
+ for (const q of ["limit=0", "limit=101", "limit=ten"]) assert.equal((await s.call("GET", `/products?${q}`)).body.error, "bad_limit", q);
+ assert.equal((await s.call("GET", "/products?after=not-a-cursor")).body.error, "bad_cursor");
+});
View file

Back to the conversation