Rebasely Beta: data may be purged

examples/storefront Public

Map the architecture, start the knowledge base and run CI

1 check passed
system.yaml describes the Worker, the database and the bucket, with a catalog view and the image upload flow. The knowledge base explains the catalog, carts and images and records the first three decisions. CI typechecks and runs the tests on every push.
0d7e6e2f7700a2c612d0399deee62b45e6016b7b
Parent 49aabbc Browse filesHistory

Changed files

13 files changed307 additions1 deletionagainst 49aabbc

  1. Added.rebasely/architecture/flows/image-upload.yaml+14−0
  2. Added.rebasely/architecture/system.yaml+23−0
  3. Added.rebasely/architecture/views/catalog.yaml+2−0
  4. Added.rebasely/kb/concepts/carts.md+30−0
  5. Added.rebasely/kb/concepts/catalog.md+30−0
  6. Added.rebasely/kb/concepts/product-images.md+26−0
  7. Added.rebasely/kb/decisions/0001-d1-for-shop-data.md+22−0
  8. Added.rebasely/kb/decisions/0002-serve-images-through-the-api.md+22−0
  9. Added.rebasely/kb/decisions/0003-money-in-whole-cents.md+21−0
  10. Added.rebasely/kb/guides/testing.md+30−0
  11. Added.rebasely/kb/index.md+19−0
  12. Added.rebasely/workflows/ci.yml+14−0
  13. ChangedREADME.md+54−1
Added.rebasely/architecture/flows/image-upload.yaml+14−0
@@ -0,0 +1,14 @@
+title: Uploading a product image
+description: >-
+ The merchant sends the image bytes to the API, which stores them in R2 under a hash of their
+ content and points the product at the new key. Shoppers then load the image through the API.
+actors: [merchant, shopper]
+steps:
+ - { from: merchant, to: storefront-api, label: "PUT /admin/products/:id/image, admin token" }
+ - { from: storefront-api, to: db, label: check that the product exists }
+ - { from: storefront-api, to: images, label: "put products/<id>/<hash>.<ext>, cached a year" }
+ - { from: storefront-api, to: db, label: set the product's image_key }
+ - { from: storefront-api, to: merchant, label: the product with its new image_url }
+ - { from: shopper, to: storefront-api, label: GET /images/<key> }
+ - { from: storefront-api, to: images, label: get the object }
+ - { from: storefront-api, to: shopper, label: "the image, or 304 when the ETag matches" }
View file
Added.rebasely/architecture/system.yaml+23−0
@@ -0,0 +1,23 @@
+components:
+ storefront-api:
+ kind: worker
+ description: >-
+ The public HTTP API (wrangler.jsonc, Worker storefront-api). Shoppers browse the catalog and
+ fill carts; the merchant edits products and uploads their images with the admin token.
+ owns: [src/**]
+ talks_to:
+ - { to: db, via: d1 }
+ - { to: images, via: r2 }
+ db:
+ kind: d1
+ description: >-
+ The storefront database: products, carts and cart items. Every schema change is a numbered
+ file in migrations/, applied with wrangler d1 migrations apply.
+ binding: DB
+ owns: [migrations/**]
+ images:
+ kind: r2-bucket
+ description: >-
+ 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
View file
Added.rebasely/architecture/views/catalog.yaml+2−0
@@ -0,0 +1,2 @@
+description: What browsing the shop touches, and where product images come from.
+components: [storefront-api, db, images]
View file
Added.rebasely/kb/concepts/carts.md+30−0
@@ -0,0 +1,30 @@
+---
+id: carts
+type: concept
+owner: justin
+component: storefront-api
+describes:
+ - src/carts.ts
+ - src/money.ts
+ - migrations/0002_carts.sql
+reviewed: 2026-10-01
+---
+# Carts
+
+A cart is a row in `carts` and its lines in `cart_items`. Anyone can create one with `POST /carts`; the id is a random UUID, and knowing it is what gives access to the cart.
+
+## Setting quantities
+
+`PUT /carts/:id/items/:product` with `{ "quantity": n }` sets how many of a product the cart holds. It sets rather than adds, so a client that retries after a timeout does not double the quantity. Zero, or `DELETE` on the same path, removes the line.
+
+The limits:
+
+- at most 10 of one product and 50 different products per cart;
+- never more than the product's current stock (the error says how many are left);
+- only active products, and only while the cart is open.
+
+Setting a quantity checks stock but does not hold it. Stock is only taken when the cart is checked out.
+
+## Prices and totals
+
+Each line stores `unit_price_cents`, the product's price when the quantity was last set. `totals()` in `src/money.ts` adds the lines and a flat shipping rate of $5.95 for subtotals under $50; from $50 shipping is free. All of it is whole cents ([[0003-money-in-whole-cents]]).
View file
Added.rebasely/kb/concepts/catalog.md+30−0
@@ -0,0 +1,30 @@
+---
+id: catalog
+type: concept
+owner: justin
+component: storefront-api
+describes:
+ - src/catalog.ts
+ - migrations/0001_products.sql
+reviewed: 2026-10-01
+---
+# Catalog
+
+The catalog is the `products` table in D1 ([[0001-d1-for-shop-data]]). A product has a slug id, a name, a description, a price in whole cents ([[0003-money-in-whole-cents]]), a stock count and an optional image.
+
+## 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.
+
+The list returns the first 100 active products. That covers the current catalog; a larger one needs paging.
+
+## Writing
+
+The merchant writes products with `PUT /admin/products/:id` and the admin token. The call is an upsert: the same request creates the product or replaces its fields, which makes it safe to retry and easy to drive from a spreadsheet export. Validation lives in `parseProductInput`:
+
+- the id is a lowercase slug of at most 64 characters, because it appears in URLs and image keys;
+- `price_cents` is a whole number above zero;
+- `stock` is a whole number, zero or more (the table also has a `CHECK` for it);
+- `active` defaults to true.
+
+To retire a product, set `active: false`. Rows are never deleted, because cart items and orders refer to them.
View file
Added.rebasely/kb/concepts/product-images.md+26−0
@@ -0,0 +1,26 @@
+---
+id: product-images
+type: concept
+owner: justin
+component: storefront-api
+describes:
+ - src/images.ts
+reviewed: 2026-10-01
+---
+# Product images
+
+Each product has at most one image. It lives in the `storefront-images` R2 bucket, and the product row keeps its key in `image_key`. The API turns the key into `image_url`.
+
+## Upload
+
+`PUT /admin/products/:id/image` takes the raw bytes with a `content-type` of JPEG, PNG, WebP or AVIF, up to 5 MiB. The key is `products/<id>/<hash>.<ext>`, where the hash is the first 8 bytes of the SHA-256 of the image. Because the key depends on the content:
+
+- a new image always gets a new URL, so nothing has to be purged from a cache;
+- the object can be stored with `cache-control: public, max-age=31536000, immutable`;
+- uploading the same file twice is harmless.
+
+The previous object is left in the bucket. A cart page or a CDN may still hold its URL, and images are small enough that keeping them costs little.
+
+## Serving
+
+`GET /images/<key>` reads the object from R2 and returns it with the stored headers and its ETag, or a 304 when `If-None-Match` matches. Only keys under `products/` are served. See [[0002-serve-images-through-the-api]] for why the Worker serves them.
View file
Added.rebasely/kb/decisions/0001-d1-for-shop-data.md+22−0
@@ -0,0 +1,22 @@
+---
+id: 0001-d1-for-shop-data
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-01
+---
+# 0001 One D1 database for the shop's data
+
+## Context
+
+The storefront needs products, carts and, soon, orders. Checkout has to read a cart, take stock and create an order as one step that either happens completely or not at all.
+
+## Decision
+
+All of it lives in one D1 database, `storefront`. Tables are created and changed only by numbered migrations in `migrations/`. Multi-statement writes go through `db.batch()`, which D1 runs as a single transaction.
+
+## Consequences
+
+- Checkout can take stock and create the order in one batch, and a `CHECK` constraint can refuse the whole batch.
+- The tests run the same migrations against SQLite (see [[testing]]), so the SQL the tests exercise is the SQL that ships.
+- One database is one write path. A shop of this size is far from D1's limits; if that changes, carts are the first table to move out.
View file
Added.rebasely/kb/decisions/0002-serve-images-through-the-api.md+22−0
@@ -0,0 +1,22 @@
+---
+id: 0002-serve-images-through-the-api
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-01
+---
+# 0002 Serve images through storefront-api
+
+## Context
+
+Product images are in R2 (see [[product-images]]). Browsers need a URL for each one. R2 can serve a bucket publicly, on its r2.dev subdomain or a custom domain, or a Worker can read the object and return it.
+
+## Decision
+
+storefront-api serves images at `GET /images/<key>`. The bucket stays private.
+
+## Consequences
+
+- One hostname for the whole shop while we are still on workers.dev, and nothing to set up in DNS.
+- Only keys under `products/` are reachable, whatever else ends up in the bucket.
+- Every image request runs the Worker, even though the response is the same for a year. That is fine at launch traffic. If image requests start to dominate the Worker's requests, move them to a custom domain on the bucket.
View file
Added.rebasely/kb/decisions/0003-money-in-whole-cents.md+21−0
@@ -0,0 +1,21 @@
+---
+id: 0003-money-in-whole-cents
+type: decision
+owner: justin
+status: accepted
+reviewed: 2026-10-01
+---
+# 0003 Money is whole cents
+
+## Context
+
+Prices, line totals, shipping and order totals are added and compared in SQL and in TypeScript. Floating point dollars round differently in each place, and payment providers take integer amounts anyway.
+
+## Decision
+
+Every amount is an integer number of cents, in columns and fields named `*_cents`, in one currency (`usd`, `CURRENCY` in `src/money.ts`). Input that is not a whole number of cents is refused, not rounded. Formatting for people happens only at the edge, with `formatCents`.
+
+## Consequences
+
+- Totals add up the same in D1, in the Worker and at the payment provider.
+- A second currency is a schema change: a `currency` column on products and carts, and totals per currency.
View file
Added.rebasely/kb/guides/testing.md+30−0
@@ -0,0 +1,30 @@
+---
+id: testing
+type: guide
+owner: justin
+describes:
+ - test/helpers/**
+reviewed: 2026-10-01
+---
+# Running and writing tests
+
+```bash
+npm test # node --test over test/**/*.test.ts
+npm run typecheck
+```
+
+The tests need only Node 24. They do not start workerd or wrangler.
+
+## How it works
+
+`test/helpers/app.ts` builds the real fetch handler from `createApp` with a fresh environment per test:
+
+- `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.
+- 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.
+
+## Writing one
+
+Test behavior through the API rather than the modules, the way a client sees it. When code calls a D1 method the stand-in does not have yet, add it to `TestD1` in the same change. A new migration needs nothing: the stand-in applies whatever is in `migrations/`. See [[0001-d1-for-shop-data]] for why the SQL is shared.
View file
Added.rebasely/kb/index.md+19−0
@@ -0,0 +1,19 @@
+# storefront knowledge base
+
+How the storefront works and why it is built this way. Start with the concept notes, then read the decisions for the reasoning behind them. The architecture tab has the map of Workers and storage these notes refer to.
+
+## Concepts
+
+- [[catalog]]: products, prices and stock, and how the merchant edits them.
+- [[carts]]: what a cart holds and the limits it enforces.
+- [[product-images]]: how images get into R2 and back out.
+
+## Decisions
+
+- [[0001-d1-for-shop-data]]: one D1 database for everything the shop sells.
+- [[0002-serve-images-through-the-api]]: images are served by storefront-api.
+- [[0003-money-in-whole-cents]]: prices and totals are integers.
+
+## Guides
+
+- [[testing]]: how the tests run the real SQL without workerd.
View file
Added.rebasely/workflows/ci.yml+14−0
@@ -0,0 +1,14 @@
+# Typecheck and test every push, on every branch.
+name: ci
+on:
+ push:
+ branches: ["**"]
+jobs:
+ test:
+ runs-on: { image: node24, instance: lite }
+ timeout-minutes: 10
+ steps:
+ - uses: duragit/checkout@v1
+ - run: npm ci --no-audit --no-fund
+ - run: npm run typecheck
+ - run: npm test
View file
ChangedREADME.md+54−1
@@ -1,8 +1,61 @@
# storefront
-A storefront API on Cloudflare Workers. Products live in D1.
+The API behind a small online shop, running on Cloudflare Workers. Products, carts and orders live in D1, product images in R2.
+It is a plain fetch handler with a small router, no framework, and the tests run the real SQL migrations on Node's built-in SQLite, so `npm test` needs nothing but Node 24.
+
+## Running it
+
```bash
npm install
npm test
+npm run typecheck
```
+
+To run the Worker locally:
+
+```bash
+npx wrangler d1 migrations apply storefront --local
+echo 'ADMIN_TOKEN=dev-admin-token' > .dev.vars
+npm run dev
+```
+
+Then add a product and look at the catalog:
+
+```bash
+curl -X PUT localhost:8787/admin/products/wool-socks \
+ -H 'authorization: Bearer dev-admin-token' -H 'content-type: application/json' \
+ -d '{"name":"Wool socks","price_cents":1800,"stock":40}'
+curl localhost:8787/products
+```
+
+## API
+
+| Method and path | What it does |
+| --- | --- |
+| `GET /products` | Active products, by name |
+| `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) |
+| `GET /images/<key>` | A product image |
+| `POST /carts` | A new, empty cart |
+| `GET /carts/:id` | A cart with its lines and totals |
+| `PUT /carts/:id/items/:product` | Set a quantity, `{ "quantity": n }` |
+| `DELETE /carts/:id/items/:product` | Remove a line |
+
+Errors are JSON, `{ "error": "<code>", "message": "..." }`, with extra fields where they help (an `out_of_stock` error says how many are `available`).
+
+## Layout
+
+```
+src/ storefront-api: router, catalog, carts, images
+migrations/ D1 schema, applied in order
+test/ node --test suites and the D1 and R2 stand-ins
+.rebasely/ architecture map, knowledge base and CI workflow
+```
+
+The knowledge base in `.rebasely/kb` explains how each part works and why; start at its index.
+
+## License
+
+MIT
View file

Checks

1 check passed
StatusCheckReported byDescriptionDetails
Successduragit/ci/testRebasely ActionssucceededRun

Runs

StatusWorkflowEventStarted
SuccessciPush