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