251 lines
13 KiB
Markdown
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
|