From ccf95a83296a48ac2b50945c7e68345a208578ee Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Tue, 21 Apr 2026 02:53:15 +0200 Subject: [PATCH] writeonce language prototype for simple blog and ecommerce --- docs/examples/blog/app.wo | 41 ++++++++ docs/examples/blog/tests/article_test.wo | 83 ++++++++++++++++ docs/examples/blog/types/article.wo | 68 +++++++++++++ docs/examples/blog/types/author.wo | 26 +++++ docs/examples/blog/types/comment.wo | 31 ++++++ docs/examples/blog/types/tag.wo | 20 ++++ docs/examples/blog/ui/article_detail.wo | 36 +++++++ docs/examples/blog/ui/article_list.wo | 28 ++++++ docs/examples/blog/wo.toml | 31 ++++++ docs/examples/ecommerce/app.wo | 58 +++++++++++ docs/examples/ecommerce/logic/checkout.wo | 95 +++++++++++++++++++ .../examples/ecommerce/tests/checkout_test.wo | 95 +++++++++++++++++++ docs/examples/ecommerce/types/customer.wo | 33 +++++++ docs/examples/ecommerce/types/order.wo | 59 ++++++++++++ docs/examples/ecommerce/types/product.wo | 45 +++++++++ docs/examples/ecommerce/types/purchase.wo | 20 ++++ docs/examples/ecommerce/ui/admin_orders.wo | 54 +++++++++++ docs/examples/ecommerce/ui/order_tracker.wo | 28 ++++++ docs/examples/ecommerce/ui/storefront.wo | 25 +++++ docs/examples/ecommerce/wo.toml | 16 ++++ 20 files changed, 892 insertions(+) create mode 100644 docs/examples/blog/app.wo create mode 100644 docs/examples/blog/tests/article_test.wo create mode 100644 docs/examples/blog/types/article.wo create mode 100644 docs/examples/blog/types/author.wo create mode 100644 docs/examples/blog/types/comment.wo create mode 100644 docs/examples/blog/types/tag.wo create mode 100644 docs/examples/blog/ui/article_detail.wo create mode 100644 docs/examples/blog/ui/article_list.wo create mode 100644 docs/examples/blog/wo.toml create mode 100644 docs/examples/ecommerce/app.wo create mode 100644 docs/examples/ecommerce/logic/checkout.wo create mode 100644 docs/examples/ecommerce/tests/checkout_test.wo create mode 100644 docs/examples/ecommerce/types/customer.wo create mode 100644 docs/examples/ecommerce/types/order.wo create mode 100644 docs/examples/ecommerce/types/product.wo create mode 100644 docs/examples/ecommerce/types/purchase.wo create mode 100644 docs/examples/ecommerce/ui/admin_orders.wo create mode 100644 docs/examples/ecommerce/ui/order_tracker.wo create mode 100644 docs/examples/ecommerce/ui/storefront.wo create mode 100644 docs/examples/ecommerce/wo.toml diff --git a/docs/examples/blog/app.wo b/docs/examples/blog/app.wo new file mode 100644 index 0000000..f288d95 --- /dev/null +++ b/docs/examples/blog/app.wo @@ -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 + }; + } +} diff --git a/docs/examples/blog/tests/article_test.wo b/docs/examples/blog/tests/article_test.wo new file mode 100644 index 0000000..d3f2b35 --- /dev/null +++ b/docs/examples/blog/tests/article_test.wo @@ -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"; +} diff --git a/docs/examples/blog/types/article.wo b/docs/examples/blog/types/article.wo new file mode 100644 index 0000000..2ee941a --- /dev/null +++ b/docs/examples/blog/types/article.wo @@ -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 +} diff --git a/docs/examples/blog/types/author.wo b/docs/examples/blog/types/author.wo new file mode 100644 index 0000000..28f4a10 --- /dev/null +++ b/docs/examples/blog/types/author.wo @@ -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 +} diff --git a/docs/examples/blog/types/comment.wo b/docs/examples/blog/types/comment.wo new file mode 100644 index 0000000..a51466a --- /dev/null +++ b/docs/examples/blog/types/comment.wo @@ -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 +} diff --git a/docs/examples/blog/types/tag.wo b/docs/examples/blog/types/tag.wo new file mode 100644 index 0000000..d0a8922 --- /dev/null +++ b/docs/examples/blog/types/tag.wo @@ -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 +} diff --git a/docs/examples/blog/ui/article_detail.wo b/docs/examples/blog/ui/article_detail.wo new file mode 100644 index 0000000..5fbd081 --- /dev/null +++ b/docs/examples/blog/ui/article_detail.wo @@ -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 diff --git a/docs/examples/blog/ui/article_list.wo b/docs/examples/blog/ui/article_list.wo new file mode 100644 index 0000000..a5c314f --- /dev/null +++ b/docs/examples/blog/ui/article_list.wo @@ -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 diff --git a/docs/examples/blog/wo.toml b/docs/examples/blog/wo.toml new file mode 100644 index 0000000..4b57661 --- /dev/null +++ b/docs/examples/blog/wo.toml @@ -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 diff --git a/docs/examples/ecommerce/app.wo b/docs/examples/ecommerce/app.wo new file mode 100644 index 0000000..e24b840 --- /dev/null +++ b/docs/examples/ecommerce/app.wo @@ -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 } + }; + } +} diff --git a/docs/examples/ecommerce/logic/checkout.wo b/docs/examples/ecommerce/logic/checkout.wo new file mode 100644 index 0000000..ca09260 --- /dev/null +++ b/docs/examples/ecommerce/logic/checkout.wo @@ -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; +} diff --git a/docs/examples/ecommerce/tests/checkout_test.wo b/docs/examples/ecommerce/tests/checkout_test.wo new file mode 100644 index 0000000..9b996cd --- /dev/null +++ b/docs/examples/ecommerce/tests/checkout_test.wo @@ -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; +} diff --git a/docs/examples/ecommerce/types/customer.wo b/docs/examples/ecommerce/types/customer.wo new file mode 100644 index 0000000..482bd8c --- /dev/null +++ b/docs/examples/ecommerce/types/customer.wo @@ -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 +} diff --git a/docs/examples/ecommerce/types/order.wo b/docs/examples/ecommerce/types/order.wo new file mode 100644 index 0000000..1fbe4fe --- /dev/null +++ b/docs/examples/ecommerce/types/order.wo @@ -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 +} diff --git a/docs/examples/ecommerce/types/product.wo b/docs/examples/ecommerce/types/product.wo new file mode 100644 index 0000000..cb1168a --- /dev/null +++ b/docs/examples/ecommerce/types/product.wo @@ -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 +} diff --git a/docs/examples/ecommerce/types/purchase.wo b/docs/examples/ecommerce/types/purchase.wo new file mode 100644 index 0000000..7addc11 --- /dev/null +++ b/docs/examples/ecommerce/types/purchase.wo @@ -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 +} diff --git a/docs/examples/ecommerce/ui/admin_orders.wo b/docs/examples/ecommerce/ui/admin_orders.wo new file mode 100644 index 0000000..b9bde75 --- /dev/null +++ b/docs/examples/ecommerce/ui/admin_orders.wo @@ -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 diff --git a/docs/examples/ecommerce/ui/order_tracker.wo b/docs/examples/ecommerce/ui/order_tracker.wo new file mode 100644 index 0000000..7d37007 --- /dev/null +++ b/docs/examples/ecommerce/ui/order_tracker.wo @@ -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: "/" } diff --git a/docs/examples/ecommerce/ui/storefront.wo b/docs/examples/ecommerce/ui/storefront.wo new file mode 100644 index 0000000..3caae4a --- /dev/null +++ b/docs/examples/ecommerce/ui/storefront.wo @@ -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 diff --git a/docs/examples/ecommerce/wo.toml b/docs/examples/ecommerce/wo.toml new file mode 100644 index 0000000..aaf6aa0 --- /dev/null +++ b/docs/examples/ecommerce/wo.toml @@ -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