writeonce language prototype for simple blog and ecommerce
This commit is contained in:
parent
c93915a3cd
commit
ccf95a8329
20 changed files with 892 additions and 0 deletions
41
docs/examples/blog/app.wo
Normal file
41
docs/examples/blog/app.wo
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
83
docs/examples/blog/tests/article_test.wo
Normal file
83
docs/examples/blog/tests/article_test.wo
Normal 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";
|
||||
}
|
||||
68
docs/examples/blog/types/article.wo
Normal file
68
docs/examples/blog/types/article.wo
Normal 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
|
||||
}
|
||||
26
docs/examples/blog/types/author.wo
Normal file
26
docs/examples/blog/types/author.wo
Normal 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
|
||||
}
|
||||
31
docs/examples/blog/types/comment.wo
Normal file
31
docs/examples/blog/types/comment.wo
Normal 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
|
||||
}
|
||||
20
docs/examples/blog/types/tag.wo
Normal file
20
docs/examples/blog/types/tag.wo
Normal 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
|
||||
}
|
||||
36
docs/examples/blog/ui/article_detail.wo
Normal file
36
docs/examples/blog/ui/article_detail.wo
Normal 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
|
||||
28
docs/examples/blog/ui/article_list.wo
Normal file
28
docs/examples/blog/ui/article_list.wo
Normal 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
|
||||
31
docs/examples/blog/wo.toml
Normal file
31
docs/examples/blog/wo.toml
Normal 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
|
||||
58
docs/examples/ecommerce/app.wo
Normal file
58
docs/examples/ecommerce/app.wo
Normal 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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
95
docs/examples/ecommerce/logic/checkout.wo
Normal file
95
docs/examples/ecommerce/logic/checkout.wo
Normal 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;
|
||||
}
|
||||
95
docs/examples/ecommerce/tests/checkout_test.wo
Normal file
95
docs/examples/ecommerce/tests/checkout_test.wo
Normal 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;
|
||||
}
|
||||
33
docs/examples/ecommerce/types/customer.wo
Normal file
33
docs/examples/ecommerce/types/customer.wo
Normal 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
|
||||
}
|
||||
59
docs/examples/ecommerce/types/order.wo
Normal file
59
docs/examples/ecommerce/types/order.wo
Normal 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
|
||||
}
|
||||
45
docs/examples/ecommerce/types/product.wo
Normal file
45
docs/examples/ecommerce/types/product.wo
Normal 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
|
||||
}
|
||||
20
docs/examples/ecommerce/types/purchase.wo
Normal file
20
docs/examples/ecommerce/types/purchase.wo
Normal 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
|
||||
}
|
||||
54
docs/examples/ecommerce/ui/admin_orders.wo
Normal file
54
docs/examples/ecommerce/ui/admin_orders.wo
Normal 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
|
||||
28
docs/examples/ecommerce/ui/order_tracker.wo
Normal file
28
docs/examples/ecommerce/ui/order_tracker.wo
Normal 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: "/" }
|
||||
25
docs/examples/ecommerce/ui/storefront.wo
Normal file
25
docs/examples/ecommerce/ui/storefront.wo
Normal 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
|
||||
16
docs/examples/ecommerce/wo.toml
Normal file
16
docs/examples/ecommerce/wo.toml
Normal 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
|
||||
Loading…
Reference in a new issue