writeonce/docs/examples/ecommerce/README.md

251 lines
13 KiB
Markdown

# `ecommerce` — a sample writeonce e-commerce app
A storefront + checkout + live ops dashboard in **~300 lines of `.wo`**. Exercises the features that make `.wo` distinct from a plain REST app: **cross-paradigm ACID transactions**, **type-attached lifecycle triggers**, **link types with properties**, and a **live-updating operations table**.
> Like the [blog sample](../blog/), this is a **docs artifact** — illustrative `.wo` source showing the shape of a real `wo init`'d project. Toolchain specified in [`../../runtime/wo-language.md`](../../runtime/wo-language.md).
## What's here
| File | What it shows |
| --- | --- |
| [`types/product.wo`](./types/product.wo) | Relational scalars + embedded doc (`meta`, `inventory`) + computed field (`available`) + graph edge (`similar_to`) + inventory-low trigger |
| [`types/order.wo`](./types/order.wo) | Tagged union status, array-of-struct `line_items`, computed `total`, four lifecycle triggers setting timestamp columns atomically |
| [`types/customer.wo`](./types/customer.wo) | Role union + `multi Product via Purchase` (link with properties) + `backlink Order.customer` |
| [`types/purchase.wo`](./types/purchase.wo) | `link Customer -> Product` — a graph edge **type** with its own columns (`order`, `qty`, `unit_price`) |
| [`logic/checkout.wo`](./logic/checkout.wo) | The canonical cross-paradigm transaction: reserve inventory + insert order + create graph edge, atomic across all three engines |
| [`ui/admin_orders.wo`](./ui/admin_orders.wo) | **The live order-ops table** — role-gated, auto-subscribes, delta-in-place updates |
| [`ui/storefront.wo`](./ui/storefront.wo) | Customer-facing product list with live inventory |
| [`ui/order_tracker.wo`](./ui/order_tracker.wo) | Customer-facing order history, same live engine, policy-filtered source |
| [`app.wo`](./app.wo) | Route table, Admin/Ops bypass policy, idempotent `seed()` |
| [`tests/checkout_test.wo`](./tests/checkout_test.wo) | Three tests covering the atomic checkout, the abort-without-partial-state guarantee, and the live-subscription delta stream |
## Project layout
```
ecommerce/
├── wo.toml
├── app.wo
├── types/
│ ├── customer.wo
│ ├── product.wo
│ ├── order.wo
│ └── purchase.wo # link type — graph edge with properties
├── logic/
│ └── checkout.wo # transactional functions (fn … in txn snapshot)
├── ui/
│ ├── storefront.wo
│ ├── order_tracker.wo
│ └── admin_orders.wo # the live ops table
└── tests/
└── checkout_test.wo
```
## Run it
```bash
$ cd docs/examples/ecommerce
$ wo run
[wo] parsing: 10 files, 4 types + 1 link type, 3 ui screens, 4 fns
[wo] compiling schema: 3 sql tables, 2 doc collections, 2 graph edge types
[wo] starting runtime (engine: in-memory, data_dir: ./data, isolation: snapshot)
[wo] on startup: seed() — 1 customer, 2 products
[wo] HTTP listening on :8080
GET /api/products list
GET /api/products/:id get
WS /api/products/live subscribe
GET /api/orders list
GET /api/orders/:id get
WS /api/orders/live subscribe
GET /api/customers/:id get
GET /api/customers/me me
PATCH /api/customers/:id update
WS /api/customers/live subscribe
POST /api/fn/checkout fn checkout(customer, product, qty) -> Order
POST /api/fn/mark_paid fn mark_paid(order)
POST /api/fn/mark_shipped fn mark_shipped(order)
GET / ui.storefront
GET /product/:sku ui.product-detail
GET /orders ui.order-tracker
GET /admin/orders ui.admin-orders (Admin | Ops)
```
> **Runtime model.** The engine is a single-threaded event loop today ([Phase 2 concurrency](../../runtime/database/02-wo-language.md#concurrency-model)). Snapshot isolation is trivially correct because there are no concurrent writers — the checkout, mark_paid, and mark_shipped fns run sequentially even when fired in quick succession. The throughput ceiling is ~one core (plenty for the sample); sharding across independent engine processes is the horizontal-scale path.
## Exercise the cross-paradigm checkout
The `fn checkout(...)` in [`logic/checkout.wo`](./logic/checkout.wo) is the canonical Phase 2 test case: one transaction that mutates relational, document, and graph state atomically.
```bash
# Place an order — one HTTP call runs the whole BEGIN ... COMMIT block
$ curl -X POST localhost:8080/api/fn/checkout \
-H "Authorization: Bearer $CUSTOMER_TOKEN" \
-d '{"customer":1, "product":2, "qty":3}'
{"id":1, "status":"Pending", "total":5997, "line_items":[{...}], "placed_at":"..."}
# Verify inventory was reserved (not yet decremented)
$ curl localhost:8080/api/products/2
{"sku":"SKU-GIZMO", "inventory":{"on_hand":12, "reserved":3, "reorder_at":3}, "available":9, ...}
# Verify the graph edge was created in the same transaction
$ curl localhost:8080/api/customers/1/purchased
[{"target":{"sku":"SKU-GIZMO"}, "order":1, "qty":3, "unit_price":1999, "at":"..."}]
```
If the inventory check failed inside `checkout`, **none** of the above writes happen — the order isn't created, the reservation isn't made, and the graph edge doesn't exist. That atomicity is the whole point of building your own engine instead of stitching Postgres + Neo4j.
## Watch the live admin ops table
Open the admin orders UI in a browser:
```bash
$ open http://localhost:8080/admin/orders # authenticated as Admin or Ops
```
The page renders a table with the columns declared in [`ui/admin_orders.wo`](./ui/admin_orders.wo). Behind the scenes, the client runtime has opened one WebSocket to the engine's subscription endpoint:
```
WS /api/orders/live ? status!=Cancelled
```
Now, from another terminal, fire a sequence of state changes:
```bash
# 1. New customer places an order — admin table gains a row, highlighted for 2s
$ curl -X POST localhost:8080/api/fn/checkout -d '{"customer":2,"product":1,"qty":1}'
# 2. Payment webhook flips status Pending → Paid — row updates in place, paid_at fills in
$ curl -X POST localhost:8080/api/fn/mark_paid -d '{"order":2}'
# 3. Ops ships the order — status → Shipped, shipped_at fills in
$ curl -X POST localhost:8080/api/fn/mark_shipped -d '{"order":2}'
```
The browser table re-renders each row delta as it arrives, without a full list refetch. `status` cell swaps its pill colour; timestamp cells populate. No polling anywhere in the path — the deltas are emitted by the transaction coordinator on commit, routed through the subscription registry, and pushed down the socket ([Phase 4](../../runtime/database/04-client-api.md)).
A filtered subscription — the admin clicking the **"Ready to ship"** quick-filter — doesn't rebuild state client-side. It sends the new predicate to the server, which replies with a `SNAPSHOT` frame of just the matching rows, then streams deltas that match the new predicate. Also zero-polling.
## Generate a typed Go client
```bash
$ wo gen sdk --lang go --out ./client
[wo] reading types from ./types/ and fns from ./logic/
[wo] writing ./client/sdk.go (4 types, 13 endpoints, 4 fns, 3 subscriptions)
```
The generated client speaks the native wire protocol:
```go
import "myshop/client"
c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))
// Typed transactional function call
order, err := c.Checkout(ctx, client.CheckoutArgs{
Customer: 1,
Product: 2,
Qty: 3,
})
// Typed live subscription — same wire as the admin UI uses
sub, _ := c.Orders.Subscribe(ctx, client.Where{Status: client.Ne(client.Cancelled)})
for d := range sub.C {
switch d.Kind {
case client.Insert:
fmt.Printf("new order #%d from %s — $%.2f\n", d.Row.ID, d.Row.Customer.Name, float64(d.Row.Total)/100)
case client.Update:
fmt.Printf("order #%d → %s\n", d.Row.ID, d.Row.Status)
}
}
```
## Checkout from Go without codegen
For ad-hoc scripts, admin tools, or client paths not on the app's hot loop, send raw `.wo` DML with `client.Wo(...)`. The server parses the block exactly like `wo run` would — same parser, same transaction coordinator, same `RETURNING` alias table — so the **cross-paradigm checkout runs in one round trip**:
```go
import "go.writeonce.dev/wo"
c, _ := wo.Connect(ctx, "wo://localhost:8080", wo.WithToken(token))
// Same logic as fn checkout(), but authored at the Go call site.
// BEGIN SNAPSHOT ... COMMIT runs server-side; RETURNING aliases ($pid, $oid)
// thread from the SQL UPDATE/INSERT into the Cypher CREATE within the txn.
result, err := c.Wo(ctx, `
BEGIN SNAPSHOT;
UPDATE products
SET inventory.reserved = inventory.reserved + $qty
WHERE id = $pid AND available >= $qty
RETURNING id AS pid;
INSERT INTO orders (customer, status, line_items)
VALUES ($uid, 'Pending', [{product: $pid, qty: $qty, unit_price: $unit}])
RETURNING id AS oid;
MATCH (u:Customer {id: $uid}), (p:Product {id: $pid})
CREATE (u)-[:PURCHASED {order: $oid, qty: $qty, unit_price: $unit}]->(p);
COMMIT;
`, wo.Params{"uid": 1, "pid": 2, "qty": 3, "unit": 4999})
if err != nil { log.Fatal(err) }
orderID := result.Aliases["oid"].(int64)
fmt.Printf("created order #%d\n", orderID)
```
**When to reach for this form** — see the [raw-vs-typed guidance in the Go SDK doc](../../runtime/database/05-go-sdk.md#when-to-use-raw-wo-vs-typed-codegen). Rule of thumb: typed `c.Checkout(...)` for the app's storefront; raw `c.Wo(...)` for an ops console that runs a custom report, or when you want to paste a block from [`logic/checkout.wo`](./logic/checkout.wo) straight into Go.
## Run the tests
```bash
$ wo test
=== tests/checkout_test.wo ===
checkout atomically reserves inventory, creates order, and creates graph edge OK (8ms)
checkout aborts without partial state when inventory is insufficient OK (4ms)
admin live-orders subscription receives deltas across the order lifecycle OK (14ms)
PASS 3/3 tests, 0 failures (26ms)
```
The third test is the important one for the docs: it proves that the same engine that serves `/admin/orders` in the browser delivers deltas in commit order through a programmatic `subscribe live` handle. One engine, one delta stream, two consumers (the browser and the test).
## Build a production binary
```bash
$ wo build --target linux-amd64 --out bin/shop
[wo] static binary: bin/shop (15 MB — database + HTTP + subscription engine embedded)
$ ./bin/shop
[wo] HTTP listening on :8080
```
Drop the binary on a server, give it a writable directory for `./data/` (WAL + engine state), run it behind nginx or let it terminate TLS itself. The admin ops table works on the first page-load — no Redis, no Kafka, no separate DB process, no ORM-and-migration dance.
## Compare to the blog sample
Both projects use the same language and runtime. They showcase different slices:
| Feature | [blog](../blog/) | ecommerce (this project) |
| --- | --- | --- |
| Embedded document | `article.meta` | `product.meta`, `product.inventory` |
| Graph edges (zero-prop) | tags, related | similar_to |
| Graph edges **with** properties | — | `type Purchase link Customer -> Product` |
| Tagged union | — | `Pending \| Paid \| Shipped \| ...` |
| Computed field | `word_count` | `available`, `total` (sum over line_items) |
| Array-of-struct column | — | `line_items: [{product, qty, unit_price}]` |
| Stored procedure (`fn ... in txn`) | seed only | full checkout + fulfillment |
| Cross-paradigm transaction | — | `checkout` (relational + doc + graph atomic) |
| Live subscription | list view | live **ops** table with in-place delta updates |
| Row-level policy | draft hiding | customer sees own orders; ops/admin sees all |
If the blog shows **what a CRUD app looks like in `.wo`**, the ecommerce sample shows **what a transactional business app looks like in `.wo`** — and why building the engine as part of the language is the differentiator.
## What to read next
- [`../../runtime/wo-language.md`](../../runtime/wo-language.md) — language overview and toolchain
- [`../../runtime/database/02-wo-language.md`](../../runtime/database/02-wo-language.md) — the schema/query two-layer spec
- [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md) — the wire protocol and subscription engine behind the live ops table
- [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) — `##ui` / `##app` block spec
- [`../../../prototypes/wo-db/tests/checkout.wo`](../../../prototypes/wo-db/tests/checkout.wo) — the C++ prototype's smoke test that exercises the same cross-paradigm transaction at the query layer