| .. | ||
| README.md | ||
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, this is a docs artifact — illustrative
.wosource showing the shape of a realwo init'd project. Toolchain specified in../../runtime/wo-language.md.
What's here
| File | What it shows |
|---|---|
types/product.wo |
Relational scalars + embedded doc (meta, inventory) + computed field (available) + graph edge (similar_to) + inventory-low trigger |
types/order.wo |
Tagged union status, array-of-struct line_items, computed total, four lifecycle triggers setting timestamp columns atomically |
types/customer.wo |
Role union + multi Product via Purchase (link with properties) + backlink Order.customer |
types/purchase.wo |
link Customer -> Product — a graph edge type with its own columns (order, qty, unit_price) |
logic/checkout.wo |
The canonical cross-paradigm transaction: reserve inventory + insert order + create graph edge, atomic across all three engines |
ui/admin_orders.wo |
The live order-ops table — role-gated, auto-subscribes, delta-in-place updates |
ui/storefront.wo |
Customer-facing product list with live inventory |
ui/order_tracker.wo |
Customer-facing order history, same live engine, policy-filtered source |
app.wo |
Route table, Admin/Ops bypass policy, idempotent seed() |
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
$ 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). 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 is the canonical Phase 2 test case: one transaction that mutates relational, document, and graph state atomically.
# 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:
$ 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. 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:
# 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).
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
$ 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:
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:
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. 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 straight into Go.
Run the tests
$ 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
$ 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 | 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— language overview and toolchain../../runtime/database/02-wo-language.md— the schema/query two-layer spec../../runtime/database/04-client-api.md— the wire protocol and subscription engine behind the live ops table../../runtime/database/06-lowcode-fullstack.md—##ui/##appblock spec../../../prototypes/wo-db/tests/checkout.wo— the C++ prototype's smoke test that exercises the same cross-paradigm transaction at the query layer