writeonce language prototype for simple blog and ecommerce

This commit is contained in:
shoney.arickathil 2026-04-21 02:53:15 +02:00
parent c93915a3cd
commit ccf95a8329
20 changed files with 892 additions and 0 deletions

41
docs/examples/blog/app.wo Normal file
View file

@ -0,0 +1,41 @@
-- Root manifest. Names the app, maps URL paths to UI screens, and configures
-- project-wide concerns (theme, i18n). The compiler uses this to assemble the
-- static route table and the SSR renderer.
##app
name: "blog"
version: 1
theme: "light"
i18n: [en]
-- URL → UI screen binding. `:slug` is a dynamic path segment that binds to a
-- parameter visible inside the screen as `$slug`.
routes:
/ -> ui.article-list
/article/:slug -> ui.article-detail { key: $slug }
/tag/:slug -> ui.article-list { filter: { tags.slug == $slug } }
/admin -> ui.article-list { role: Admin }
-- Project-wide policies — applied on every query, AND-ed with any type-level
-- policy. Useful for ops-level toggles like admin bypass.
policy admin-bypass
applies_to: Article, Comment, Author, Tag
when: $session.role == Admin
effect: skip-row-filters
-- Lifecycle hooks. `on startup` runs once before the HTTP server binds;
-- convenient for idempotent seeding.
on startup
do: seed_admin()
-- Inline function — available to triggers and lifecycle hooks.
fn seed_admin() {
if count(Author{ role == Admin }) == 0 {
insert Author {
email: "admin@example.com",
handle: "admin",
display: "Admin",
role: Admin
};
}
}

View file

@ -0,0 +1,83 @@
-- Tests live under tests/ and are picked up by `wo test`.
-- Each `test` block runs in an isolated database sandbox that's rolled back
-- at the end of the block — no cleanup code needed.
test "create and fetch by slug" {
let a = insert Author {
email: "alice@example.com",
handle: "alice",
display: "Alice",
role: Author
};
let art = insert Article {
slug: "hello",
title: "Hello, writeonce",
author: a,
meta: {
excerpt: "A first post.",
body_md: "# Hello\n\nThis is writeonce."
},
published: true
};
let fetched = select Article{ slug == "hello" };
assert fetched.id == art.id;
assert fetched.title == "Hello, writeonce";
assert fetched.published_at != null; -- set by the on-update trigger
assert fetched.word_count > 0; -- computed field
}
test "policy blocks public read of unpublished drafts" {
let a = insert Author { email: "bob@example.com", handle: "bob", display: "Bob", role: Author };
insert Article {
slug: "draft", title: "Draft", author: a,
meta: { excerpt: "", body_md: "" },
published: false
};
-- Anonymous session: the `anyone when published == true` policy applies.
as session anonymous {
let rows = select Article{ slug == "draft" };
assert len(rows) == 0;
}
-- As the author, the row is visible.
as session $a {
let rows = select Article{ slug == "draft" };
assert len(rows) == 1;
}
}
test "graph traversal: related articles" {
let a = insert Author { email: "c@x", handle: "carol", display: "Carol", role: Author };
let a1 = insert Article { slug: "one", title: "One", author: a, meta: { excerpt: "", body_md: "" }, published: true };
let a2 = insert Article { slug: "two", title: "Two", author: a, meta: { excerpt: "", body_md: "" }, published: true };
let a3 = insert Article { slug: "three", title: "Three", author: a, meta: { excerpt: "", body_md: "" }, published: true };
-- Create :RELATED_TO edges: one → two, one → three
link Article{ slug == "one" } -[:RELATED_TO]-> Article{ slug == "two" };
link Article{ slug == "one" } -[:RELATED_TO]-> Article{ slug == "three" };
let related = Article{ slug == "one" }.related;
assert len(related) == 2;
assert contains(related, slug == "two");
assert contains(related, slug == "three");
}
test "live subscription receives delta on commit" {
let sub = subscribe live Article{ published == true };
let a = insert Author { email: "d@x", handle: "dave", display: "Dave", role: Author };
insert Article {
slug: "live-test", title: "Live", author: a,
meta: { excerpt: "", body_md: "" },
published: true
};
-- receive(sub) blocks until the next delta or the test-default timeout.
let delta = receive(sub);
assert delta.kind == Insert;
assert delta.row.slug == "live-test";
}

View file

@ -0,0 +1,68 @@
-- The heart of the app. Article combines all three paradigms in one type:
-- * relational scalars (slug, title, published, published_at)
-- * an embedded document (meta.body_md, meta.excerpt, meta.hero_image)
-- * graph edges (tags, related, prerequisites)
-- The compiler picks storage per field: scalars → relational row, meta → doc,
-- multi/ref → graph edges + FK columns.
type Article {
id: Id
slug: Slug @unique -- URL path component
title: Text
author: ref Author -- foreign key into Author
published: Bool = false
published_at: Timestamp? -- set by the trigger below
created_at: Timestamp = now()
updated_at: Timestamp = now()
-- Embedded document. All fields stored together; no join on read.
meta: {
excerpt: Text
body_md: Markdown
hero_image: Url?
reading_min: Int? -- computed by a pre-commit hook
}
-- Tags: many-to-many graph edge with a label, no edge properties.
tags: multi Tag @edge(:TAGGED_AS)
-- "You might also like" — directed graph edge between articles.
related: multi Article @edge(:RELATED_TO)
-- Prerequisite reading, also a directed edge; distinct label so queries
-- can traverse tags vs prereqs independently.
prerequisites: multi Article @edge(:PREREQUISITE)
-- Inverse of Comment.article. Read-only from this side.
comments: backlink Comment.article
-- Computed: word count, derived from the markdown body.
word_count: Int = words(meta.body_md)
-- Policies — row-level access rules. The planner AND-s them into every
-- query so they can't be bypassed by a poorly-scoped handler.
policy read anyone when published == true
policy read for role Admin
policy read for role Author when author == $session.user
policy write for role Admin
policy write for role Author when author == $session.user
policy delete for role Admin
-- Pre-commit trigger: set published_at when the article is first published.
-- Fires inside the transaction, so the set is atomic with the update.
on update
when old.published == false and new.published == true
do set self.published_at = now()
do emit "article.published"(self)
do enqueue "send-subscriber-emails" with { article_id: self.id }
-- Bump updated_at on every mutation — except the insert, where created_at
-- already covers it.
on update
do set self.updated_at = now()
-- REST surface. `subscribe` is the LIVE query endpoint — WebSocket that
-- pushes a delta on every commit matching the predicate.
service rest "/api/articles"
expose list, get, create, update, delete, subscribe
}

View file

@ -0,0 +1,26 @@
-- Authors write articles. Keeping the type small: one author is one writer
-- with a session-bindable identity (role == "author" or "admin").
type Author {
id: Id
email: Email @unique -- primary identity; uniqueness is planner-enforced
handle: Slug @unique -- URL-friendly (e.g. "alice")
display: Text
bio: Markdown? -- optional
avatar: Url?
joined_at: Timestamp = now()
role: Reader | Author | Admin = Reader -- tagged union, stored as enum
-- Inverse link: computed from Article.author. No storage column; resolved at query time.
articles: backlink Article.author
-- Policies are expressed next to the type; the planner AND-s them into every query.
policy read anyone
policy write for role Admin
policy write for role Author when self == $session.user -- authors can edit their own profile
-- Expose a small REST surface. `me` is a virtual endpoint the runtime wires
-- to the current session user.
service rest "/api/authors"
expose list, get, me, subscribe
}

View file

@ -0,0 +1,31 @@
-- Reader comments on an article. Kept small for the sample, but demonstrates
-- cascading policies (a reader can only edit their own comment) and two-way
-- live subscription (both articles and comments push deltas).
type Comment {
id: Id
article: ref Article
author: ref Author
body: Markdown
created_at: Timestamp = now()
edited_at: Timestamp?
-- Anyone can read a comment on a visible article. The engine threads the
-- Article.read policy through this relationship automatically because of
-- the `ref Article` above.
policy read anyone
-- Writing is by role; the owner-check is the row-level rule.
policy write for role Author when author == $session.user
policy write for role Admin
policy delete for role Author when author == $session.user
policy delete for role Admin
-- Stamp edited_at whenever the body changes post-insert.
on update
when old.body != new.body
do set self.edited_at = now()
service rest "/api/comments"
expose list, get, create, update, delete, subscribe
}

View file

@ -0,0 +1,20 @@
-- Tags are a small taxonomy. A separate type (rather than a string array on
-- Article) because we want to query by tag cheaply AND attach per-tag metadata
-- (colour, description) later without a migration that rewrites articles.
type Tag {
id: Id
slug: Slug @unique
label: Text
description: Markdown?
colour: Text = "#888"
-- Computed field: count of articles with this tag.
-- Re-evaluated on read; if it gets hot, flip to `@materialized` to cache.
article_count: Int = count(Article{ tags contains self })
policy read anyone
policy write for role Admin
service rest "/api/tags" expose list, get, subscribe
}

View file

@ -0,0 +1,36 @@
-- Detail page: one article, its body, its comments, and a "related" section
-- that traverses the :RELATED_TO graph edge. Every section can be live-bound.
##ui
#article-detail
title: $article.title
source: Article
key: slug
live: true
sections:
- header:
fields: [title, author.display, published_at, tags]
- body:
renderer: markdown
source: meta.body_md
-- Graph traversal: one hop along :RELATED_TO, rendered inline.
- related:
title: "You might also like"
renderer: list
source: Article{ slug == $key }.related
columns: [title, meta.excerpt, published_at]
live: true
-- Reader comments. Two-way live: new comments appear without refresh.
- comments:
title: "Comments"
renderer: list
source: Comment{ article.slug == $key }
columns: [author.display, body, created_at]
sort: { default: created_at asc }
live: true
actions:
create: /api/comments role: Author | Admin

View file

@ -0,0 +1,28 @@
-- Home page: list of published articles, newest first. The `live: true` flag
-- makes the runtime auto-register a LIVE query matching the displayed columns;
-- the rendered HTML ships with a small client that binds deltas to the DOM.
##ui
#article-list
title: "Articles"
source: Article
live: true
filter:
published == true
columns:
- slug label: "Slug" renderer: code
- title label: "Title" searchable
- author.display label: "Author"
- published_at label: "Published" renderer: relative-date
- tags label: "Tags" renderer: tag-chips
sort:
default: published_at desc
actions:
row-click: /article/:slug
create: /article/new role: Author | Admin
pagination: 20

View file

@ -0,0 +1,31 @@
# Project manifest — the `wo.toml` is the .wo equivalent of Go's `go.mod`.
# `wo run` and `wo build` both start by reading this file.
name = "blog"
version = "0.1.0"
description = "A sample writeonce blogging app: DB + REST + live subscriptions in ~200 lines of .wo"
# Runtime constraint — which wo toolchain this project targets.
[runtime]
wo = ">= 0.1"
# HTTP server configuration.
# Endpoints come from `service rest` blocks on types + routes in app.wo.
[server]
listen = ":8080"
# Database configuration. For this sample the engine persists to ./data/
# via the WAL + in-memory engine from Phase 3.
[database]
data_dir = "./data"
isolation = "snapshot" # default isolation for transactions
# External .wo modules would go here, mirroring `go.mod`'s require block.
# Locked versions land in `wo.lock` (not shown — auto-generated).
[dependencies]
# wo-stdlib = ">= 0.1" # implicit, always imported
# Commands `wo test` picks up. Each test file lives under tests/ and
# matches `*_test.wo`.
[test]
parallel = true

View file

@ -0,0 +1,58 @@
##app
name: "ecommerce"
version: 1
theme: "light"
i18n: [en]
routes:
/ -> ui.storefront
/product/:sku -> ui.product-detail { key: $sku }
/cart -> ui.cart
/orders -> ui.order-tracker
/admin/orders -> ui.admin-orders role: Admin | Ops
-- Cross-entity policy — Admin + Ops bypass row-level filters so they can see
-- every customer's orders in the live ops table.
policy admin-ops-bypass
applies_to: Order, Product, Customer, Purchase
when: $session.role == Admin or $session.role == Ops
effect: skip-row-filters
-- Startup seed: idempotent demo data so `wo run` shows something useful.
on startup
do: seed()
fn seed() {
if count(Customer{ email == "admin@shop.test" }) == 0 {
insert Customer {
email: "admin@shop.test",
name: "Ops Admin",
role: Admin
};
}
if count(Product{ sku == "SKU-WIDGET" }) == 0 {
insert Product {
sku: "SKU-WIDGET",
name: "Widget",
price: 1999,
meta: {
description: "A classic widget.",
images: ["/static/widget.jpg"],
attributes: { colour: "blue", size: "M", material: "steel" }
},
inventory: { on_hand: 50, reserved: 0, reorder_at: 10 }
};
insert Product {
sku: "SKU-GIZMO",
name: "Gizmo",
price: 4999,
meta: {
description: "A premium gizmo.",
images: ["/static/gizmo.jpg"],
attributes: { colour: "black", size: "L", material: "aluminium" }
},
inventory: { on_hand: 12, reserved: 0, reorder_at: 3 }
};
}
}

View file

@ -0,0 +1,95 @@
-- The canonical cross-paradigm transaction from the Phase 2 language spec.
-- `checkout` touches three storage paradigms atomically:
-- 1. relational row — insert into Order
-- 2. document field — decrement Product.inventory (embedded doc)
-- 3. graph edge — create a Purchase edge linking Customer → Product
--
-- The whole function runs inside `txn snapshot` (snapshot isolation). If any
-- step fails, the transaction coordinator rolls back every partial write
-- across all three engines. No partial orders, no phantom inventory drift.
fn checkout(
customer: ref Customer,
product: ref Product,
qty: Int
) -> Order in txn snapshot
{
-- Load the current product row in-transaction (so we see a consistent
-- snapshot for the inventory check).
let p = select Product{ id == product.id };
-- Domain invariant. `otherwise abort` rolls the transaction back.
assert p.available >= qty
otherwise abort "insufficient inventory for " + p.sku;
-- Reserve inventory. Document path update inside the relational Product row.
update Product{ id == p.id }
set inventory.reserved = inventory.reserved + qty;
-- Create the order. `insert ... returning self` binds the inserted row to
-- the let-binding so downstream statements can refer to its id without
-- the legacy `LAST_INSERT_ID()` dance.
let o = insert Order {
customer: customer,
status: Pending,
line_items: [{
product: p,
qty: qty,
unit_price: p.price
}]
};
-- Graph edge: (customer)-[:PURCHASED {order, qty, unit_price}]->(product).
-- References the order id that was minted by the insert above — the
-- transaction-scoped alias table threads `o.id` through to the graph store
-- without a round-trip to the client.
link customer
-[Purchase { order: o, qty: qty, unit_price: p.price }]->
p;
return o;
}
-- Mark an order paid. Called by the payment-webhook handler. The inventory
-- flip (reserved → on_hand-delta) runs atomically with the status change.
fn mark_paid(o: ref Order) in txn snapshot
{
update Order{ id == o.id }
set status = Paid;
-- Draw down on_hand for each line item; clear the reservation.
for line in Order{ id == o.id }.line_items {
update Product{ id == line.product.id }
set inventory.on_hand = inventory.on_hand - line.qty,
inventory.reserved = inventory.reserved - line.qty;
}
}
-- Cancellation or refund: release the inventory reservation.
-- Called from the `on update when ... status == Cancelled` trigger in order.wo.
fn release_inventory(o: ref Order) in txn snapshot
{
for line in o.line_items {
if o.status == Paid or o.status == Shipped or o.status == Delivered {
-- Already decremented on_hand; put it back.
update Product{ id == line.product.id }
set inventory.on_hand = inventory.on_hand + line.qty;
} else {
-- Still reserved; release the reservation.
update Product{ id == line.product.id }
set inventory.reserved = inventory.reserved - line.qty;
}
}
}
-- Lifecycle advance: ship an order. Ops triggers this from the admin UI.
fn mark_shipped(o: ref Order) in txn snapshot
{
assert o.status == Paid
otherwise abort "can only ship paid orders (current: " + o.status + ")";
update Order{ id == o.id }
set status = Shipped;
}

View file

@ -0,0 +1,95 @@
-- Three tests covering the cross-paradigm checkout and the live ops table.
-- Each `test` runs against an isolated snapshot that rolls back at the end.
test "checkout atomically reserves inventory, creates order, and creates graph edge" {
let c = insert Customer { email: "alice@shop.test", name: "Alice" };
let p = insert Product {
sku: "T-1", name: "T1", price: 999,
meta: { description: "", images: [], attributes: {} },
inventory: { on_hand: 10, reserved: 0, reorder_at: 2 }
};
let o = checkout(c, p, 3);
-- relational: order exists with the right shape
assert o.status == Pending;
assert o.total == 2997;
assert len(o.line_items) == 1;
assert o.line_items[0].qty == 3;
-- document: inventory reserved, on_hand untouched (reservation only)
let refetched = select Product{ sku == "T-1" };
assert refetched.inventory.reserved == 3;
assert refetched.inventory.on_hand == 10;
assert refetched.available == 7;
-- graph: Purchase edge threads the new order id into the graph store
let edges = Customer{ id == c.id }.purchased;
assert len(edges) == 1;
assert edges[0].target.sku == "T-1";
assert edges[0].qty == 3;
assert edges[0].order == o.id; -- cross-paradigm ref
}
test "checkout aborts without partial state when inventory is insufficient" {
let c = insert Customer { email: "bob@shop.test", name: "Bob" };
let p = insert Product {
sku: "T-2", name: "T2", price: 499,
meta: { description: "", images: [], attributes: {} },
inventory: { on_hand: 2, reserved: 0, reorder_at: 0 }
};
expect_abort "insufficient" {
checkout(c, p, 5);
}
-- nothing was written: no order, no reservation, no edge
let still = select Product{ sku == "T-2" };
assert still.inventory.reserved == 0;
assert len(select Order{ customer == c.id }) == 0;
assert len(Customer{ id == c.id }.purchased) == 0;
}
test "admin live-orders subscription receives deltas across the order lifecycle" {
let c = insert Customer { email: "carol@shop.test", name: "Carol" };
let p = insert Product {
sku: "T-3", name: "T3", price: 1500,
meta: { description: "", images: [], attributes: {} },
inventory: { on_hand: 5, reserved: 0, reorder_at: 0 }
};
-- Subscribe with the same predicate the admin UI uses.
let sub = subscribe live Order{ status != Cancelled };
-- Snapshot first (zero rows — test starts clean).
let snap = receive(sub);
assert snap.kind == Snapshot;
assert len(snap.rows) == 0;
-- Checkout → expect an Insert delta (status Pending, total set).
let o = checkout(c, p, 1);
let d1 = receive(sub);
assert d1.kind == Insert;
assert d1.row.id == o.id;
assert d1.row.status == Pending;
assert d1.row.total == 1500;
-- mark_paid flips status → expect an Update delta. paid_at is set by the
-- type-attached `on update` trigger inside the same transaction, so it
-- arrives in the same delta — never observable half-way.
mark_paid(o);
let d2 = receive(sub);
assert d2.kind == Update;
assert d2.row.status == Paid;
assert d2.row.paid_at != null;
-- mark_shipped → Update with shipped_at set.
mark_shipped(o);
let d3 = receive(sub);
assert d3.kind == Update;
assert d3.row.status == Shipped;
assert d3.row.shipped_at != null;
}

View file

@ -0,0 +1,33 @@
-- Customer is both the public-facing shopper and (via role) the ops/admin
-- identity. Roles gate who sees the /admin/orders live table.
type Customer {
id: Id
email: Email @unique
name: Text
role: Guest | Customer | Ops | Admin = Customer
addr: {
line1: Text
city: Text
postal: Text
country: Text
}? -- optional shipping address
joined_at: Timestamp = now()
-- Graph edge with properties: one PURCHASED edge per line item per order.
-- The `Purchase` link type lives in types/purchase.wo.
purchased: multi Product via Purchase
-- Inverse of Order.customer — read-only, no storage column.
orders: backlink Order.customer
policy read for role Admin
policy read for role Ops
policy read when self == $session.user
policy write for role Admin
policy write when self == $session.user
service rest "/api/customers"
expose get, me, update, subscribe
}

View file

@ -0,0 +1,59 @@
-- The Order type shows three advanced schema-layer features working together:
-- * a tagged union for `status` (compiled to an enum column)
-- * an array of inline structs for `line_items` (stored as a doc column)
-- * a computed `total` field aggregating over the array
-- Status transitions each fire their own `on update` trigger — the timestamp
-- columns (paid_at, shipped_at, …) are set by those triggers, not the caller.
type Order {
id: Id
customer: ref Customer
status: Pending | Paid | Shipped | Delivered | Refunded | Cancelled = Pending
-- Array of embedded line-item objects. `product: ref Product` lets the
-- planner enforce FK integrity even inside the doc column.
line_items: [{
product: ref Product
qty: Int @check(> 0)
unit_price: Money -- captured at checkout time
}]
-- Computed: re-evaluated on read. Flip to `@materialized` if it gets hot.
total: Money = sum(line_items.*.qty * line_items.*.unit_price)
placed_at: Timestamp = now()
paid_at: Timestamp?
shipped_at: Timestamp?
delivered_at: Timestamp?
cancelled_at: Timestamp?
-- Customers see their own orders. Ops + Admin see all.
policy read for role Admin
policy read for role Ops
policy read when customer == $session.user
policy write for role Admin
policy write for role Ops
-- Lifecycle triggers. Each fires inside the committing transaction — the
-- timestamp update is atomic with the status change, never observable half-way.
on update when old.status != Paid and new.status == Paid
do set self.paid_at = now()
do emit "order.paid"(self)
do enqueue "fulfill" with { order_id: self.id }
on update when old.status != Shipped and new.status == Shipped
do set self.shipped_at = now()
do emit "order.shipped"(self)
on update when old.status != Delivered and new.status == Delivered
do set self.delivered_at = now()
on update when old.status != Cancelled and new.status == Cancelled
do set self.cancelled_at = now()
do call release_inventory(self) -- defined in logic/checkout.wo
service rest "/api/orders"
expose list, get, subscribe
}

View file

@ -0,0 +1,45 @@
-- Product combines relational scalars (sku, price), an embedded document
-- (meta: description/images/attributes), and a graph edge (similar_to).
-- Inventory is an embedded doc so a checkout atomically updates it together
-- with the order row inside one transaction.
type Product {
id: Id
sku: SKU @unique
name: Text
price: Money -- minor units (cents); `Money` is a stdlib scalar
meta: { -- embedded document
description: Markdown
images: [Url]
attributes: { colour: Text?, size: Text?, material: Text? }
}
inventory: { -- embedded document with constraints
on_hand: Int = 0 @check(>= 0) -- total units physically in stock
reserved: Int = 0 @check(>= 0) -- units held by pending orders
reorder_at: Int = 5
}
-- Computed read-only field: the inventory the storefront actually shows.
available: Int = inventory.on_hand - inventory.reserved
in_stock: Bool = available > 0
-- Graph edge — recommendation surface used by `/product/:sku` pages.
similar_to: multi Product @edge(:SIMILAR_TO)
policy read anyone
policy write for role Admin
policy write for role Ops
-- Pre-commit trigger: fire a reorder job when inventory crosses the threshold.
-- `old` and `new` reference the row state before and after the current txn.
on update
when new.inventory.on_hand <= new.inventory.reorder_at
and old.inventory.on_hand > new.inventory.reorder_at
do emit "inventory.low"(self)
do enqueue "reorder" with { product_id: self.id, current: new.inventory.on_hand }
service rest "/api/products"
expose list, get, subscribe
}

View file

@ -0,0 +1,20 @@
-- Graph edge **with properties**: one record per line item per order.
-- The `link Customer -> Product` form tells the compiler this is a directed
-- graph edge type, stored in the graph engine but queryable in both directions
-- (via Customer.purchased and the inverse relation).
--
-- Phase 2 calls this "link-with-properties" — it's the feature that makes
-- graph recommendations queryable alongside relational order data without
-- stitching two stores.
type Purchase link Customer -> Product {
order: ref Order -- FK so you can query
-- `Customer.purchased{ order.status == Paid }`
qty: Int @check(> 0)
unit_price: Money -- captured at checkout time
at: Timestamp = now()
policy read for role Admin
policy read for role Ops
policy read when source == $session.user -- `source` = the edge's from-node
}

View file

@ -0,0 +1,54 @@
-- THE live order-ops table. This is what an ops/fulfillment team watches all day.
-- `live: true` + `source: Order` auto-registers a LIVE query matching the columns
-- shown; every commit (checkout, status flip, trigger-updated timestamp) pushes
-- a delta down the WebSocket and the client runtime swaps the row in place.
-- No polling, no refresh button.
##ui
#admin-orders
title: "Orders — Live"
source: Order
live: true
role: Admin | Ops -- gated by role; customers can't reach /admin/orders
-- Sidebar filter controls bind to these predicates at render time.
filter:
status != Cancelled -- default view hides cancelled
-- Named predicate chips: operators click these to narrow the view.
quick-filters:
- "Needs payment": status == Pending
- "Ready to ship": status == Paid
- "In transit": status == Shipped
- "Today": placed_at >= today()
- "Above $100": total > 10000 -- minor units (cents)
columns:
- id label: "#" renderer: code
- status renderer: pill -- coloured by enum variant
- customer.name label: "Customer"
- customer.email label: "Email"
- total renderer: money
- len(line_items) label: "Items" computed
- placed_at renderer: relative-date
- paid_at renderer: relative-date
- shipped_at renderer: relative-date
sort:
default: placed_at desc
-- Single-row + bulk actions. Each one calls a `fn` from logic/checkout.wo
-- (or a stdlib action like `email`). The compiler wires them to endpoints.
actions:
row-click: /admin/orders/:id
row-mark-paid: mark_paid(self) role: Admin | Ops
row-mark-shipped: mark_shipped(self) role: Admin | Ops
row-cancel: update self set status = Cancelled role: Admin
bulk-export-csv: export_orders_csv role: Admin | Ops
-- Delta behaviour: `instant` swaps cells in place on UPDATE deltas (no fade);
-- new rows slide in at their sort position; deleted rows fade out.
refresh: instant
highlight-new: 2s
pagination: 100

View file

@ -0,0 +1,28 @@
-- Customer-facing live order list. Same engine, same wire, same delta stream
-- as the admin table — but the row-level policy on `Order` narrows the source
-- to `customer == $session.user`, so a user only ever sees their own orders.
##ui
#order-tracker
title: "Your Orders"
source: Order{ customer == $session.user }
live: true
columns:
- id label: "Order #" renderer: code
- status renderer: pill
- total renderer: money
- placed_at renderer: relative-date
- shipped_at renderer: relative-date label: "Shipped"
- delivered_at renderer: relative-date label: "Delivered"
sort:
default: placed_at desc
actions:
row-click: /orders/:id
cancel: update self set status = Cancelled when status == Pending
empty-state:
message: "You haven't placed any orders yet."
cta: { label: "Shop", link: "/" }

View file

@ -0,0 +1,25 @@
-- Public product list. Live inventory — when a checkout reserves the last
-- unit, the "In stock" badge flips to "Out of stock" on every open browser
-- without refresh.
##ui
#storefront
title: "Shop"
source: Product
live: true
columns:
- meta.images[0] label: "" renderer: image
- name
- sku renderer: code
- price renderer: money
- available label: "In stock" renderer: stock-badge -- computed on Product
sort:
default: name asc
actions:
row-click: /product/:sku
add-to-cart: add_to_cart(self, 1) role: Customer | Ops | Admin
pagination: 24

View file

@ -0,0 +1,16 @@
name = "ecommerce"
version = "0.1.0"
description = "A sample writeonce e-commerce app: cross-paradigm ACID checkout + live order-ops table"
[runtime]
wo = ">= 0.1"
[server]
listen = ":8080"
[database]
data_dir = "./data"
isolation = "snapshot" # snapshot isolation for the canonical checkout flow
[test]
parallel = false # ordering tests touch the same rows; serialize