scaffold sibling crates, multi-app ecommerce, REST + concurrency docs

- scaffold 14 placeholder crates (app, db, engine, gen, http, logic,
  policy, ql, service, sub, txn, ui, value, wal) — empty Cargo.toml +
  src/lib.rs to receive code phase-by-phase from `rt`
- restructure docs/examples/ecommerce into multi-app layout: apps/admin
  and apps/storefront, with shared/ types/logic/components, per-app
  app.wo + wo.toml, and reusable .htmlx components (layout, money,
  order-row)
- add reference/rest/{blog,ecommerce}.rest — VS Code/JetBrains HTTP
  request files driving the running prototype, including 501/404/405
  expectations for stubbed endpoints
- add docs/plan/09-concurrency-scaleout.md and docs/plan/ui/00-overview.md;
  refine docs/plan/assembly/02-writeonce-stance.md
- refresh templates (about, article, header/footer, home, layout, styles)
  and add static favicon/logo
- add infra/sync.sh and tighten .gitignore for reference/ symlinks
This commit is contained in:
shoney.arickathil 2026-05-04 13:39:58 +02:00
parent 2af90a5dd1
commit 9279175f26
64 changed files with 1511 additions and 289 deletions

8
.gitignore vendored
View file

@ -13,10 +13,12 @@
# Legacy blog content and data (v1 writeonce storage) # Legacy blog content and data (v1 writeonce storage)
/content /content
# Symlink to the Linux kernel source tree for research — user-specific # Symlinks to research source trees — user-specific absolute paths.
# absolute path; each contributor sets their own via # Each contributor sets their own via:
# ln -s <path-to-linux-src> reference/linux # ln -s <path-to-linux-src> reference/linux
# ln -s <path-to-go-src> reference/go
/reference/linux /reference/linux
/reference/go
# Editor / OS noise — left broad on purpose so a contributor doesn't # Editor / OS noise — left broad on purpose so a contributor doesn't
# accidentally commit their IDE scratch or macOS metadata. # accidentally commit their IDE scratch or macOS metadata.
@ -27,3 +29,5 @@
/.vscode/* /.vscode/*
!/.vscode/settings.json.example !/.vscode/settings.json.example
!/.vscode/extensions.json !/.vscode/extensions.json
/prototypes

9
crates/app/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "app"
version = "0.1.0"
edition = "2021"
description = "writeonce app manifest — `##app` routes, startup hooks, theme, i18n"
[lib]
name = "app"
path = "src/lib.rs"

20
crates/app/src/lib.rs Normal file
View file

@ -0,0 +1,20 @@
//! `app` — `##app` manifest: routes, theme, i18n, startup hooks.
//!
//! **Status: placeholder.** Phase 6 (see
//! [06-lowcode-fullstack.md § `##app`](
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
//!
//! One `##app` block per project. Declares:
//! * the static route table (URL path → `ui.<screen>` binding, possibly
//! parameterised by dynamic segments like `/article/:slug`)
//! * cross-entity policies ("Admin bypasses row-level filters on every type")
//! * `on startup do …` hooks — idempotent seed code that runs once before
//! [`http`](../http/index.html) binds a listening socket
//! * theme tokens, i18n locale set, project metadata
//!
//! Consumed by [`ui`](../ui/index.html) for route rendering and by
//! [`policy`](../policy/index.html) for the cross-entity rules block.
//!
//! The [`docs/examples/blog/app.wo`](../../../docs/examples/blog/app.wo) and
//! [`docs/examples/ecommerce/app.wo`](../../../docs/examples/ecommerce/app.wo)
//! files are the reference shapes.

9
crates/db/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "db"
version = "0.1.0"
edition = "2021"
description = "writeonce top-level database facade — `open()`, `Tx`, `Query`, `Subscribe` over the engine"
[lib]
name = "db"
path = "src/lib.rs"

27
crates/db/src/lib.rs Normal file
View file

@ -0,0 +1,27 @@
//! `wo-db` — the Rust-facing database SDK.
//!
//! **Status: placeholder.** Phases 2–4 integrator.
//!
//! The top-level crate that the rest of the workspace (`wo-rt`, `wo-http`,
//! future `wo-serve` integration glue) depends on. Composes:
//!
//! | | |
//! | --- | --- |
//! | [`ql`](../ql/index.html) | parse |
//! | [`value`](../value/index.html) | row representation |
//! | [`engine`](../engine/index.html) | storage + execution |
//! | [`txn`](../txn/index.html) | transaction coordinator |
//! | [`wal`](../wal/index.html) | durability (Phase 3) |
//! | [`sub`](../sub/index.html) | live subscriptions (Phase 4) |
//!
//! Public surface the rest of the workspace will call:
//!
//! ```text
//! let db = db::open(&catalog, &options)?;
//! db.wo(ctx, ".wo source", params).await?; // raw DML
//! db.tx(ctx, |tx| async move { ... }).await?;
//! let sub = db.subscribe(ctx, "LIVE SELECT ...", params).await?;
//! ```
//!
//! See [05-go-sdk.md](../../../docs/runtime/database/05-go-sdk.md) — the Go
//! SDK's shape mirrors this one; both speak the same `.wo` wire protocol.

9
crates/engine/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "engine"
version = "0.1.0"
edition = "2021"
description = "writeonce in-memory executor — relational, document, and graph storage over a shared catalog"
[lib]
name = "engine"
path = "src/lib.rs"

19
crates/engine/src/lib.rs Normal file
View file

@ -0,0 +1,19 @@
//! `wo-engine` — the in-memory executor.
//!
//! **Status: placeholder.** Phase 2 (see
//! [02-wo-language.md](../../../docs/runtime/database/02-wo-language.md))
//! designs the three storage paradigms; Phase 3
//! ([03-inmemory-engine.md](../../../docs/runtime/database/03-inmemory-engine.md))
//! details the RAM-primary layout, arenas, MVCC version chains.
//!
//! The engine owns:
//! * relational tables — per-type `BTreeMap<id, Row>` today, B+ tree when
//! sized appropriately
//! * document collections — shape-free `Value::Object` rows; LSM when Phase 3
//! activates
//! * graph nodes + edges — adjacency over the type catalog
//! * a schema catalog compiled from `wo-ql` output
//!
//! Stage 2 ships this as a module inside [`rt`](../rt/index.html).
//! It extracts here when `wo-wal` and `wo-txn` need to reach into the same
//! structures.

9
crates/gen/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "gen"
version = "0.1.0"
edition = "2021"
description = "writeonce codegen — `.wo` schema → typed Go / TypeScript / Rust / Python clients"
[lib]
name = "gen"
path = "src/lib.rs"

21
crates/gen/src/lib.rs Normal file
View file

@ -0,0 +1,21 @@
//! `wo-gen` — client-SDK codegen.
//!
//! **Status: placeholder.** Phase 5 (see
//! [05-go-sdk.md § Typed SDK via `.wo` Schema Codegen](
//! ../../../docs/runtime/database/05-go-sdk.md)).
//!
//! Reads a compiled catalog (the same output [`ql`](../ql/index.html) +
//! [`engine`](../engine/index.html) produce from a `.wo` source tree)
//! and emits typed client code:
//!
//! * **Go** — structs with `wo:"column"` tags, `*Client`,
//! `TypedSubscription[T]` generics over channels
//! * **TypeScript** — types + `fetch` + WebSocket subscriptions
//! * **Rust** — `#[derive(Deserialize)]` structs + `impl Stream<Item = Delta>`
//! * **Python** — dataclasses + `async for delta in sub`
//! * **OpenAPI / GraphQL SDL** — machine-generated contracts served by
//! [`http`](../http/index.html)
//!
//! Invoked as `wo gen sdk --lang go --out ./client` via the toolchain; a bin
//! target will be added when the crate has real behaviour. Until then this is
//! an empty library scaffold.

9
crates/http/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "http"
version = "0.1.0"
edition = "2021"
description = "writeonce wire protocol — native binary + GraphQL over WebSocket + REST dispatch"
[lib]
name = "http"
path = "src/lib.rs"

20
crates/http/src/lib.rs Normal file
View file

@ -0,0 +1,20 @@
//! `wo-http` — HTTP + WebSocket + native wire protocol for the `.wo` runtime.
//!
//! **Status: placeholder.** Phase 4 (see
//! [04-client-api.md](../../../docs/runtime/database/04-client-api.md)).
//!
//! Three protocol surfaces, one dispatch:
//! * **REST** — JSON in/out, one route per `service rest` operation.
//! * **GraphQL over WebSocket** — SDL auto-generated from the schema; query,
//! mutation, `subscription` all routed through the same planner.
//! * **Native binary** — typed wire codec for `wo-db` and the Go SDK.
//!
//! Authentication middleware (`WithAPIKey`, `WithJWT`, `WithMTLS`) lives in
//! this crate — cross-cutting over every protocol surface.
//!
//! Stage 2 ships a minimal axum-based REST server inside
//! [`rt::server`](../rt/server/index.html). It migrates here when
//! Phase 4 activates and GraphQL / native surfaces join REST.
//!
//! Not the v1 `reference/crates/wo-http/` — that was a from-scratch HTTP
//! parser for the old single-threaded event loop.

9
crates/logic/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "logic"
version = "0.1.0"
edition = "2021"
description = "writeonce trigger + stored-fn compiler — type-attached `on <event>` and cross-entity `##logic`"
[lib]
name = "logic"
path = "src/lib.rs"

19
crates/logic/src/lib.rs Normal file
View file

@ -0,0 +1,19 @@
//! `wo-logic` — triggers and stored-function interpreter.
//!
//! **Status: placeholder.** Phase 6 (see
//! [06-lowcode-fullstack.md § `##logic`](
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
//!
//! Two sources of runtime code:
//! * **Type-attached triggers** — `on insert | update | delete [when <pred>]
//! do <action>` declared inside a `type` body. Fires inside the committing
//! transaction so timestamp stamps (`paid_at`, `shipped_at`) are atomic
//! with the state change, never observable half-way.
//! * **Standalone `##logic`** — cross-entity workflows like the ecommerce
//! on-order-placed hook that decrements inventory on every line item.
//! * **`fn name(args) in txn ... { ... }`** — transactional stored
//! functions (the `checkout` in `docs/examples/ecommerce/logic/`).
//!
//! All three compile to the same intermediate form that [`txn`](../txn/index.html)
//! invokes during commit (for triggers) or directly on the main loop (for
//! `fn` calls from REST/native wire).

9
crates/policy/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "policy"
version = "0.1.0"
edition = "2021"
description = "writeonce policy engine — RBAC + row-level rules compiled into planner rewrites"
[lib]
name = "policy"
path = "src/lib.rs"

25
crates/policy/src/lib.rs Normal file
View file

@ -0,0 +1,25 @@
//! `wo-policy` — RBAC + row-level policies.
//!
//! **Status: placeholder.** Phase 6 (see
//! [06-lowcode-fullstack.md](../../../docs/runtime/database/06-lowcode-fullstack.md)).
//!
//! Compiles type-attached `policy` blocks (and standalone `##policy` blocks
//! for cross-entity rules) into predicates that [`engine`](../engine/index.html)
//! AND-s into every query at plan time. The rule is enforced once, at the
//! planner, so no caller can bypass it — not the REST handler, not the
//! `wo-db` SDK, not an admin script calling `fn` directly.
//!
//! Shape of a compiled policy:
//!
//! ```text
//! for type Article:
//! read: published == true OR $session.role == Admin
//! OR author == $session.user
//! write: $session.role == Admin OR ($session.role == Author && author == $session.user)
//! delete: $session.role == Admin
//! ```
//!
//! The planner merges the read predicate as a `WHERE` conjunct, the write
//! predicate as an assertion inside UPDATE/INSERT codegen, and delete as a
//! DELETE guard. Same mechanism as Postgres RLS — just authored declaratively
//! in the `type` block instead of via SQL migrations.

9
crates/ql/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "ql"
version = "0.1.0"
edition = "2021"
description = "writeonce query language — lexer, parser, AST for the `.wo` grammar"
[lib]
name = "ql"
path = "src/lib.rs"

13
crates/ql/src/lib.rs Normal file
View file

@ -0,0 +1,13 @@
//! `wo-ql` — the `.wo` grammar: lexer, parser, AST.
//!
//! **Status: placeholder.** Phase 2 of the [`.wo` runtime design](
//! ../../../docs/runtime/database/02-wo-language.md).
//!
//! Stage 2 of the runtime (shipped) carries the lexer/parser/AST inside
//! [`rt`](../rt/index.html) as part of its monolithic cut. This crate
//! is where those modules move to when Stage 3+ needs the grammar from
//! multiple places — the HTTP raw-query handler, `wo-gen`, the server-side
//! `fn` interpreter.
//!
//! Reference implementation: the C++ prototype at
//! [`prototypes/wo-db/`](../../../prototypes/wo-db/).

View file

@ -0,0 +1,9 @@
[package]
name = "service"
version = "0.1.0"
edition = "2021"
description = "writeonce service dispatcher — type-attached `service rest/graphql/native` and standalone `##service` bundles"
[lib]
name = "service"
path = "src/lib.rs"

19
crates/service/src/lib.rs Normal file
View file

@ -0,0 +1,19 @@
//! `service` — endpoint registration and dispatch.
//!
//! **Status: placeholder.** Phase 6 (see
//! [06-lowcode-fullstack.md § `##service`](
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
//!
//! Walks every `service rest "/api/..." expose ...` block declared on a type
//! (and every standalone `##service` bundle for multi-entity APIs) and binds
//! handlers on [`http`](../http/index.html)'s router. Each exposed operation
//! (`list` / `get` / `create` / `update` / `delete` / `subscribe` / `me`)
//! maps to a predetermined shape — declarative CRUD is the whole point.
//!
//! `fn` endpoints (`POST /api/fn/checkout`) are also dispatched from here —
//! the service declares the function-as-endpoint wiring, [`logic`](../logic/index.html)
//! executes.
//!
//! Stage 2 does this work inline inside [`rt::server`](../rt/server/index.html).
//! It extracts here when Phase 6 adds GraphQL + native-protocol surfaces that
//! share the same service-block source of truth.

9
crates/sub/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "sub"
version = "0.1.0"
edition = "2021"
description = "writeonce live subscriptions — LIVE query registry + delta frames on commit"
[lib]
name = "sub"
path = "src/lib.rs"

21
crates/sub/src/lib.rs Normal file
View file

@ -0,0 +1,21 @@
//! `wo-sub` — the live-subscription engine.
//!
//! **Status: placeholder.** Phase 4 (see
//! [04-client-api.md](../../../docs/runtime/database/04-client-api.md)).
//!
//! Responsibilities:
//! * per-connection subscription registry — predicate + bound parameters
//! * commit-path dispatch: each [`txn`](../txn/index.html) commit
//! walks the registry, matches predicates against the delta set, and
//! pushes `Insert` / `Update` / `Delete` frames into per-subscription
//! ring buffers
//! * back-pressure policy (drop-and-resync / coalesce / disconnect) —
//! mirrors the client-side policy in [`wo-db`](../db/index.html)
//! / the Go SDK
//!
//! Stage 2 stubs the `/api/<type>/live` endpoint to 501 in
//! [`rt::server`](../rt/server/index.html). Stage 3 wires this crate
//! behind that route and upgrades the connection to WebSocket.
//!
//! Not related to the v1 `reference/crates/wo-sub/` crate — that one did
//! polling-based diff delivery over SSE for the old flat-file blog.

9
crates/txn/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "txn"
version = "0.1.0"
edition = "2021"
description = "writeonce transaction coordinator — MVCC, snapshot isolation, `RETURNING` alias table"
[lib]
name = "txn"
path = "src/lib.rs"

19
crates/txn/src/lib.rs Normal file
View file

@ -0,0 +1,19 @@
//! `wo-txn` — the cross-paradigm transaction coordinator.
//!
//! **Status: placeholder.** Phase 2 milestone 3 (see
//! [02-wo-language.md § Cross-Paradigm Transaction Coordinator](
//! ../../../docs/runtime/database/02-wo-language.md)).
//!
//! Responsibilities:
//! * `txn_id` allocation + snapshot timestamps
//! * ordering commits across `wo-engine`'s three storage paradigms
//! * the transaction-scoped **`RETURNING` alias table** — the state that
//! lets `INSERT … RETURNING id AS oid` thread `$oid` into a later
//! `CREATE (u)-[:PURCHASED {order_id: $oid}]->(p)` in the same
//! `BEGIN … COMMIT` block
//! * driving the in-process 2PC between the three engines on commit
//!
//! Under the single-threaded event-loop design (see
//! [§ Concurrency Model](../../../docs/runtime/database/02-wo-language.md#concurrency-model))
//! this reduces to a sequential counter + a per-txn name table. When the
//! runtime grows past one core via sharding, this crate owns cross-shard 2PC.

9
crates/ui/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "ui"
version = "0.1.0"
edition = "2021"
description = "writeonce UI renderer — `##ui` screens compile to SSR HTML + thin client runtime"
[lib]
name = "ui"
path = "src/lib.rs"

24
crates/ui/src/lib.rs Normal file
View file

@ -0,0 +1,24 @@
//! `ui` — `##ui` screens → SSR HTML + client-side delta runtime.
//!
//! **Status: placeholder.** Phase 6 (see
//! [06-lowcode-fullstack.md § `##ui`](
//! ../../../docs/runtime/database/06-lowcode-fullstack.md)).
//!
//! Declarative screens compile to:
//! * a **server-rendered HTML** tree — a compact template + data-binding
//! form computed from the screen's `source:` projection and `columns:` /
//! `sections:` declarations
//! * a **client-side runtime** (~50 KB vanilla JS) — opens a WebSocket to
//! the engine's [`sub`](../sub/index.html) endpoint, binds incoming delta
//! frames to DOM fragments by row key, handles sort / filter / paginate
//! without refetching
//! * an **auto-admin fallback** — any declared `type` without an explicit
//! `##ui` gets a generic CRUD screen
//!
//! `live: true` on a screen auto-generates a matching `LIVE SELECT` that the
//! client runtime subscribes to — no hand-written subscription code on the UI
//! side.
//!
//! The [`docs/examples/blog/ui/`](../../../docs/examples/blog/ui/) and
//! [`docs/examples/ecommerce/ui/`](../../../docs/examples/ecommerce/ui/)
//! directories are the reference shapes this crate has to handle.

9
crates/value/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "value"
version = "0.1.0"
edition = "2021"
description = "writeonce tagged `Value` + dotted-path utilities"
[lib]
name = "value"
path = "src/lib.rs"

14
crates/value/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
//! `wo-value` — the runtime's tagged `Value` type plus dotted-path helpers.
//!
//! **Status: placeholder.** Phase 2 foundation.
//!
//! Carries the union of all scalar + document + array shapes the engines pass
//! around: `Null`, `Bool`, `Int`, `Str`, `Array`, `Object`. Plus `fetch_path`
//! / `assign_path` utilities that drive the dotted-access semantics shared by
//! SQL expressions, document `UPDATE SET meta.x.y = z`, and Cypher projections
//! (see [the two-layer language spec](
//! ../../../docs/runtime/database/02-wo-language.md)).
//!
//! Stage 2 keeps these inside [`rt`](../rt/index.html)'s `engine` module;
//! they extract here once a second consumer (the WAL serializer, the wire codec)
//! needs them.

9
crates/wal/Cargo.toml Normal file
View file

@ -0,0 +1,9 @@
[package]
name = "wal"
version = "0.1.0"
edition = "2021"
description = "writeonce write-ahead log — io_uring + fsync + crash recovery"
[lib]
name = "wal"
path = "src/lib.rs"

17
crates/wal/src/lib.rs Normal file
View file

@ -0,0 +1,17 @@
//! `wo-wal` — the write-ahead log.
//!
//! **Status: placeholder.** Phase 3 (see
//! [03-inmemory-engine.md](../../../docs/runtime/database/03-inmemory-engine.md)).
//!
//! Responsibilities:
//! * append committed txn records to an on-disk ring buffer via `io_uring`
//! * issue `IORING_OP_FSYNC` with a linked SQE for durability
//! * batch concurrent commits into one fsync per tick (group commit)
//! * drive crash recovery by replaying the log from the last checkpoint
//!
//! Under the single-threaded event-loop design, the WAL is owned directly by
//! the main loop — no separate WAL-writer thread. `io_uring`'s kernel-owned
//! SQPOLL handler does the draining; userland just submits and parks on CQEs.
//!
//! This is the crate that turns the RAM-primary engine from "cache" into
//! "database": commits aren't acked until their WAL record is on the SSD.

View file

@ -1,251 +1,116 @@
# `ecommerce` — a sample writeonce e-commerce app # `ecommerce` — a sample writeonce **monorepo**
A storefront + checkout + live ops dashboard in **~300 lines of `.wo`**. Exercises the features that make `.wo` distinct from a plain REST app: **cross-paradigm ACID transactions**, **type-attached lifecycle triggers**, **link types with properties**, and a **live-updating operations table**. Two apps (customer **storefront** + ops **admin**) sharing one database, built from a common pool of types + business logic. Mirrors the Nx / Angular workspace pattern: `apps/*` for deployable binaries, `shared/*` for libraries imported across apps.
> Like the [blog sample](../blog/), this is a **docs artifact** — illustrative `.wo` source showing the shape of a real `wo init`'d project. Toolchain specified in [`../../runtime/wo-language.md`](../../runtime/wo-language.md). > This project is a **docs artifact** — illustrative `.wo` source showing what a production-shaped writeonce workspace looks like. The master plan for the compiler + client runtime + per-app build is at [`../../plan/ui/00-overview.md`](../../plan/ui/00-overview.md). Sub-phases UI/01–07 implement each piece.
## What's here ## Layout
| File | What it shows |
| --- | --- |
| [`types/product.wo`](./types/product.wo) | Relational scalars + embedded doc (`meta`, `inventory`) + computed field (`available`) + graph edge (`similar_to`) + inventory-low trigger |
| [`types/order.wo`](./types/order.wo) | Tagged union status, array-of-struct `line_items`, computed `total`, four lifecycle triggers setting timestamp columns atomically |
| [`types/customer.wo`](./types/customer.wo) | Role union + `multi Product via Purchase` (link with properties) + `backlink Order.customer` |
| [`types/purchase.wo`](./types/purchase.wo) | `link Customer -> Product` — a graph edge **type** with its own columns (`order`, `qty`, `unit_price`) |
| [`logic/checkout.wo`](./logic/checkout.wo) | The canonical cross-paradigm transaction: reserve inventory + insert order + create graph edge, atomic across all three engines |
| [`ui/admin_orders.wo`](./ui/admin_orders.wo) | **The live order-ops table** — role-gated, auto-subscribes, delta-in-place updates |
| [`ui/storefront.wo`](./ui/storefront.wo) | Customer-facing product list with live inventory |
| [`ui/order_tracker.wo`](./ui/order_tracker.wo) | Customer-facing order history, same live engine, policy-filtered source |
| [`app.wo`](./app.wo) | Route table, Admin/Ops bypass policy, idempotent `seed()` |
| [`tests/checkout_test.wo`](./tests/checkout_test.wo) | Three tests covering the atomic checkout, the abort-without-partial-state guarantee, and the live-subscription delta stream |
## Project layout
``` ```
ecommerce/ ecommerce/
├── wo.toml ├── wo.toml # workspace manifest (apps[] + shared[] + database)
├── app.wo ├── README.md # this file
├── types/ │
│ ├── customer.wo ├── shared/ # code imported by one or more apps
│ ├── product.wo │ ├── types/
│ ├── order.wo │ │ ├── customer.wo # role union, policy, service rest expose
│ └── purchase.wo # link type — graph edge with properties │ │ ├── product.wo # inventory + similar_to graph
├── logic/ │ │ ├── order.wo # tagged-union status + line_items array
│ └── checkout.wo # transactional functions (fn … in txn snapshot) │ │ └── purchase.wo # link Customer -> Product
├── ui/ │ ├── logic/
│ ├── storefront.wo │ │ ├── checkout.wo # fn checkout / mark_paid / mark_shipped
│ ├── order_tracker.wo │ │ └── seed.wo # on-startup demo seed + admin-ops bypass policy
│ └── admin_orders.wo # the live ops table │ └── components/ # reusable .htmlx partials (forward-looking — UI track)
└── tests/ │ ├── layout.htmlx # page chrome shared across apps
│ ├── money.htmlx # {{> money amount=total}}
│ └── order-row.htmlx # used by both orders tables
│
├── apps/ # one binary per app
│ ├── storefront/ # customer-facing
│ │ ├── wo.toml # listen :8080, connect WO_DB
│ │ ├── app.wo # routes: / → home, /product/:sku, /cart, /orders
│ │ └── ui/
│ │ ├── home/
│ │ │ └── home.wo # product list, live inventory
│ │ ├── product-detail/ # (future)
│ │ └── orders/
│ │ └── orders.wo # customer's own orders
│ │
│ └── admin/ # ops dashboard
│ ├── wo.toml # listen :8081, connect WO_DB
│ ├── app.wo # role: Admin | Ops; routes: /orders
│ └── ui/
│ └── orders/
│ └── orders.wo # live ops table with fulfillment actions
│
└── tests/ # workspace-level integration
└── checkout_test.wo └── checkout_test.wo
``` ```
## Run it Each app's UI screens live in their own directory (`apps/<app>/ui/<screen>/`) with the Angular-style one-directory-per-component pattern — `.wo` declarative spec today, `.htmlx` template + `.css` stylesheet once the UI track's sub-phase 01–02 land.
## What the two apps share
- **Types** (`shared/types/`). Both apps see the same `Customer` / `Product` / `Order` / `Purchase` definitions. Row-level policies inside each `type` block control who sees what — the storefront's authenticated customer sees their own orders; the admin app's Admin/Ops role sees everyone's.
- **Logic** (`shared/logic/`). The `checkout`, `mark_paid`, `mark_shipped`, and `release_inventory` functions in `shared/logic/checkout.wo` are callable from either app (subject to role policy). `shared/logic/seed.wo` runs once when the shared DB daemon starts.
- **Components** (`shared/components/`). `.htmlx` partials — layout chrome, money formatting, an order-row renderer — reusable from either app's templates.
## What's **not** shared (per-app)
- **`app.wo`** declares app-specific routes + role gate. Storefront has no `/admin/*` routes; admin has no `/cart` or `/product/:sku`.
- **`ui/`** is per-app. The storefront's `orders/orders.wo` (customer's own orders, filtered by session) and the admin's `orders/orders.wo` (all orders with fulfillment actions) are different screens — same underlying `Order` type, different UI + policy scope.
- Each app's `wo.toml` names its own listen port and its own DB API key.
## Running it
```bash ```bash
$ cd docs/examples/ecommerce # 1. Start the shared DB daemon — headless, just the engine + wire protocol
$ wo run wo db serve --data-dir ./data # listens on wo://127.0.0.1:5555
[wo] parsing: 10 files, 4 types + 1 link type, 3 ui screens, 4 fns
[wo] compiling schema: 3 sql tables, 2 doc collections, 2 graph edge types
[wo] starting runtime (engine: in-memory, data_dir: ./data, isolation: snapshot)
[wo] on startup: seed() — 1 customer, 2 products
[wo] HTTP listening on :8080
GET /api/products list # 2. Start each app, pointing at the daemon
GET /api/products/:id get WO_DB=wo://127.0.0.1:5555 \
WS /api/products/live subscribe STOREFRONT_DB_KEY=$ADMIN_TOKEN \
GET /api/orders list wo run apps/storefront # HTTP on :8080
GET /api/orders/:id get
WS /api/orders/live subscribe
GET /api/customers/:id get
GET /api/customers/me me
PATCH /api/customers/:id update
WS /api/customers/live subscribe
POST /api/fn/checkout fn checkout(customer, product, qty) -> Order
POST /api/fn/mark_paid fn mark_paid(order)
POST /api/fn/mark_shipped fn mark_shipped(order)
GET / ui.storefront WO_DB=wo://127.0.0.1:5555 \
GET /product/:sku ui.product-detail ADMIN_DB_KEY=$ADMIN_TOKEN \
GET /orders ui.order-tracker wo run apps/admin # HTTP on :8081
GET /admin/orders ui.admin-orders (Admin | Ops)
``` ```
> **Runtime model.** The engine is a single-threaded event loop today ([Phase 2 concurrency](../../runtime/database/02-wo-language.md#concurrency-model)). Snapshot isolation is trivially correct because there are no concurrent writers — the checkout, mark_paid, and mark_shipped fns run sequentially even when fired in quick succession. The throughput ceiling is ~one core (plenty for the sample); sharding across independent engine processes is the horizontal-scale path. Browser:
- `http://localhost:8080/` → storefront home (product list, live inventory)
- `http://localhost:8081/orders` → admin live orders table
## Exercise the cross-paradigm checkout ## Building binaries
The `fn checkout(...)` in [`logic/checkout.wo`](./logic/checkout.wo) is the canonical Phase 2 test case: one transaction that mutates relational, document, and graph state atomically.
```bash ```bash
# Place an order — one HTTP call runs the whole BEGIN ... COMMIT block wo build apps/storefront # → target/wo/storefront
$ curl -X POST localhost:8080/api/fn/checkout \ wo build apps/admin # → target/wo/admin
-H "Authorization: Bearer $CUSTOMER_TOKEN" \ wo build --all # everything in apps/
-d '{"customer":1, "product":2, "qty":3}'
{"id":1, "status":"Pending", "total":5997, "line_items":[{...}], "placed_at":"..."}
# Verify inventory was reserved (not yet decremented)
$ curl localhost:8080/api/products/2
{"sku":"SKU-GIZMO", "inventory":{"on_hand":12, "reserved":3, "reorder_at":3}, "available":9, ...}
# Verify the graph edge was created in the same transaction
$ curl localhost:8080/api/customers/1/purchased
[{"target":{"sku":"SKU-GIZMO"}, "order":1, "qty":3, "unit_price":1999, "at":"..."}]
``` ```
If the inventory check failed inside `checkout`, **none** of the above writes happen — the order isn't created, the reservation isn't made, and the graph edge doesn't exist. That atomicity is the whole point of building your own engine instead of stitching Postgres + Neo4j. Each binary is a static ELF with only the app's own `app.wo` + `ui/` + the `shared/` it imports baked in. Drop any binary on a server next to a running `wo db serve` and it works.
## Watch the live admin ops table ## Stage 2 caveat
Open the admin orders UI in a browser: The current runtime at [`crates/rt/`](../../../crates/rt/) is a single-process Stage 2 prototype — it doesn't yet know about:
```bash - `[workspace]` manifests (per-app build — UI sub-phase 05)
$ open http://localhost:8080/admin/orders # authenticated as Admin or Ops - The `wo db serve` daemon split (UI sub-phase 06)
``` - Per-app `api_key_env` authorisation scope (UI sub-phase 07)
- `.htmlx` compilation from `##ui` blocks (UI sub-phases 01–03)
- Per-app policy composition (UI sub-phase 07)
The page renders a table with the columns declared in [`ui/admin_orders.wo`](./ui/admin_orders.wo). Behind the scenes, the client runtime has opened one WebSocket to the engine's subscription endpoint: So `cargo run --bin wo -- run docs/examples/ecommerce` today walks the whole tree, finds every `.wo` file under `shared/` + `apps/`, parses the types, and serves the union REST API on :8080 — treating the monorepo as one giant app. Useful for exercising the types; not reflective of the production shape. See [`docs/plan/ui/00-overview.md`](../../plan/ui/00-overview.md) for the sub-phase sequence that gets each piece online.
``` ## Comparison with the blog sample
WS /api/orders/live ? status!=Cancelled
```
Now, from another terminal, fire a sequence of state changes: The [`blog` sample](../blog/) is still a single-app layout (`types/`, `ui/`, `logic/` at the root) because the blog has exactly one front-end surface — there's no customer-vs-admin split. Both patterns are first-class; pick based on whether your schema serves one app or many. A flat single-app layout is a degenerate workspace with one `apps/` entry.
```bash ## Source pointers
# 1. New customer places an order — admin table gains a row, highlighted for 2s
$ curl -X POST localhost:8080/api/fn/checkout -d '{"customer":2,"product":1,"qty":1}'
# 2. Payment webhook flips status Pending → Paid — row updates in place, paid_at fills in - **Master plan:** [`../../plan/ui/00-overview.md`](../../plan/ui/00-overview.md)
$ curl -X POST localhost:8080/api/fn/mark_paid -d '{"order":2}' - **Language spec the `##ui`/`##app`/`policy` blocks obey:** [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md)
- **Wire protocol the app binaries speak to the DB daemon:** [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md)
# 3. Ops ships the order — status → Shipped, shipped_at fills in - **v1 template engine that `.htmlx` compilation will reuse:** [`../../../reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/)
$ curl -X POST localhost:8080/api/fn/mark_shipped -d '{"order":2}' - **Checkout transaction that's the canonical cross-paradigm test:** [`shared/logic/checkout.wo`](shared/logic/checkout.wo)
```
The browser table re-renders each row delta as it arrives, without a full list refetch. `status` cell swaps its pill colour; timestamp cells populate. No polling anywhere in the path — the deltas are emitted by the transaction coordinator on commit, routed through the subscription registry, and pushed down the socket ([Phase 4](../../runtime/database/04-client-api.md)).
A filtered subscription — the admin clicking the **"Ready to ship"** quick-filter — doesn't rebuild state client-side. It sends the new predicate to the server, which replies with a `SNAPSHOT` frame of just the matching rows, then streams deltas that match the new predicate. Also zero-polling.
## Generate a typed Go client
```bash
$ wo gen sdk --lang go --out ./client
[wo] reading types from ./types/ and fns from ./logic/
[wo] writing ./client/sdk.go (4 types, 13 endpoints, 4 fns, 3 subscriptions)
```
The generated client speaks the native wire protocol:
```go
import "myshop/client"
c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))
// Typed transactional function call
order, err := c.Checkout(ctx, client.CheckoutArgs{
Customer: 1,
Product: 2,
Qty: 3,
})
// Typed live subscription — same wire as the admin UI uses
sub, _ := c.Orders.Subscribe(ctx, client.Where{Status: client.Ne(client.Cancelled)})
for d := range sub.C {
switch d.Kind {
case client.Insert:
fmt.Printf("new order #%d from %s — $%.2f\n", d.Row.ID, d.Row.Customer.Name, float64(d.Row.Total)/100)
case client.Update:
fmt.Printf("order #%d → %s\n", d.Row.ID, d.Row.Status)
}
}
```
## Checkout from Go without codegen
For ad-hoc scripts, admin tools, or client paths not on the app's hot loop, send raw `.wo` DML with `client.Wo(...)`. The server parses the block exactly like `wo run` would — same parser, same transaction coordinator, same `RETURNING` alias table — so the **cross-paradigm checkout runs in one round trip**:
```go
import "go.writeonce.dev/wo"
c, _ := wo.Connect(ctx, "wo://localhost:8080", wo.WithToken(token))
// Same logic as fn checkout(), but authored at the Go call site.
// BEGIN SNAPSHOT ... COMMIT runs server-side; RETURNING aliases ($pid, $oid)
// thread from the SQL UPDATE/INSERT into the Cypher CREATE within the txn.
result, err := c.Wo(ctx, `
BEGIN SNAPSHOT;
UPDATE products
SET inventory.reserved = inventory.reserved + $qty
WHERE id = $pid AND available >= $qty
RETURNING id AS pid;
INSERT INTO orders (customer, status, line_items)
VALUES ($uid, 'Pending', [{product: $pid, qty: $qty, unit_price: $unit}])
RETURNING id AS oid;
MATCH (u:Customer {id: $uid}), (p:Product {id: $pid})
CREATE (u)-[:PURCHASED {order: $oid, qty: $qty, unit_price: $unit}]->(p);
COMMIT;
`, wo.Params{"uid": 1, "pid": 2, "qty": 3, "unit": 4999})
if err != nil { log.Fatal(err) }
orderID := result.Aliases["oid"].(int64)
fmt.Printf("created order #%d\n", orderID)
```
**When to reach for this form** — see the [raw-vs-typed guidance in the Go SDK doc](../../runtime/database/05-go-sdk.md#when-to-use-raw-wo-vs-typed-codegen). Rule of thumb: typed `c.Checkout(...)` for the app's storefront; raw `c.Wo(...)` for an ops console that runs a custom report, or when you want to paste a block from [`logic/checkout.wo`](./logic/checkout.wo) straight into Go.
## Run the tests
```bash
$ wo test
=== tests/checkout_test.wo ===
checkout atomically reserves inventory, creates order, and creates graph edge OK (8ms)
checkout aborts without partial state when inventory is insufficient OK (4ms)
admin live-orders subscription receives deltas across the order lifecycle OK (14ms)
PASS 3/3 tests, 0 failures (26ms)
```
The third test is the important one for the docs: it proves that the same engine that serves `/admin/orders` in the browser delivers deltas in commit order through a programmatic `subscribe live` handle. One engine, one delta stream, two consumers (the browser and the test).
## Build a production binary
```bash
$ wo build --target linux-amd64 --out bin/shop
[wo] static binary: bin/shop (15 MB — database + HTTP + subscription engine embedded)
$ ./bin/shop
[wo] HTTP listening on :8080
```
Drop the binary on a server, give it a writable directory for `./data/` (WAL + engine state), run it behind nginx or let it terminate TLS itself. The admin ops table works on the first page-load — no Redis, no Kafka, no separate DB process, no ORM-and-migration dance.
## Compare to the blog sample
Both projects use the same language and runtime. They showcase different slices:
| Feature | [blog](../blog/) | ecommerce (this project) |
| --- | --- | --- |
| Embedded document | `article.meta` | `product.meta`, `product.inventory` |
| Graph edges (zero-prop) | tags, related | similar_to |
| Graph edges **with** properties | — | `type Purchase link Customer -> Product` |
| Tagged union | — | `Pending \| Paid \| Shipped \| ...` |
| Computed field | `word_count` | `available`, `total` (sum over line_items) |
| Array-of-struct column | — | `line_items: [{product, qty, unit_price}]` |
| Stored procedure (`fn ... in txn`) | seed only | full checkout + fulfillment |
| Cross-paradigm transaction | — | `checkout` (relational + doc + graph atomic) |
| Live subscription | list view | live **ops** table with in-place delta updates |
| Row-level policy | draft hiding | customer sees own orders; ops/admin sees all |
If the blog shows **what a CRUD app looks like in `.wo`**, the ecommerce sample shows **what a transactional business app looks like in `.wo`** — and why building the engine as part of the language is the differentiator.
## What to read next
- [`../../runtime/wo-language.md`](../../runtime/wo-language.md) — language overview and toolchain
- [`../../runtime/database/02-wo-language.md`](../../runtime/database/02-wo-language.md) — the schema/query two-layer spec
- [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md) — the wire protocol and subscription engine behind the live ops table
- [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) — `##ui` / `##app` block spec
- [`../../../prototypes/wo-db/tests/checkout.wo`](../../../prototypes/wo-db/tests/checkout.wo) — the C++ prototype's smoke test that exercises the same cross-paradigm transaction at the query layer

View file

@ -0,0 +1,19 @@
-- Admin app manifest: the ops/fulfillment dashboard binary.
-- Entire app is gated to role `Admin | Ops`; the storefront's public routes
-- don't live here at all. The cross-entity "admin/ops bypass row filters"
-- policy is global (shared/logic/seed.wo) — this app inherits it via the
-- shared DB connection.
##app
name: "admin"
version: 1
theme: "dark"
i18n: [en]
role: Admin | Ops -- app-level gate
routes:
/orders -> ui.orders -- apps/admin/ui/orders/
-- future:
-- /inventory -> ui.inventory
-- /customers -> ui.customers
-- /reports -> ui.reports

View file

@ -3,13 +3,19 @@
-- shown; every commit (checkout, status flip, trigger-updated timestamp) pushes -- shown; every commit (checkout, status flip, trigger-updated timestamp) pushes
-- a delta down the WebSocket and the client runtime swaps the row in place. -- a delta down the WebSocket and the client runtime swaps the row in place.
-- No polling, no refresh button. -- No polling, no refresh button.
--
-- Lives in the admin app; the app's own `app.wo` gates it to role Admin | Ops,
-- so customers never hit this route. Same underlying Order table as the
-- storefront's customer-facing order-tracker — row-level policies decide who
-- sees which rows; delta fanout is cross-app (see docs/plan/ui/00-overview.md
-- § Sub-phase 13).
##ui ##ui
#admin-orders #orders
title: "Orders — Live" title: "Orders — Live"
source: Order source: Order
live: true live: true
role: Admin | Ops -- gated by role; customers can't reach /admin/orders role: Admin | Ops -- secondary gate (app-level gate in app.wo)
-- Sidebar filter controls bind to these predicates at render time. -- Sidebar filter controls bind to these predicates at render time.
filter: filter:
@ -37,10 +43,10 @@
sort: sort:
default: placed_at desc default: placed_at desc
-- Single-row + bulk actions. Each one calls a `fn` from logic/checkout.wo -- Single-row + bulk actions. Each one calls a `fn` from
-- (or a stdlib action like `email`). The compiler wires them to endpoints. -- shared/logic/checkout.wo or an app-local fulfillment fn.
actions: actions:
row-click: /admin/orders/:id row-click: /orders/:id
row-mark-paid: mark_paid(self) role: Admin | Ops row-mark-paid: mark_paid(self) role: Admin | Ops
row-mark-shipped: mark_shipped(self) role: Admin | Ops row-mark-shipped: mark_shipped(self) role: Admin | Ops
row-cancel: update self set status = Cancelled role: Admin row-cancel: update self set status = Cancelled role: Admin

View file

@ -0,0 +1,24 @@
name = "admin"
version = "0.1.0"
description = "Ops/fulfillment dashboard binary. Gated to role Admin | Ops."
app_kind = "app"
[runtime]
wo = ">= 0.1"
[dependencies]
shared = [
"../../shared/types",
"../../shared/logic",
"../../shared/components",
]
[server]
listen = ":8081" # admin runs on a separate port from storefront
[database]
# Same daemon as storefront, different API key → daemon's policy table gives
# this app Admin-level row visibility (per docs/plan/ui/07-per-app-policies.md
# when that phase lands).
url = "wo://127.0.0.1:5555"
api_key_env = "ADMIN_DB_KEY"

View file

@ -0,0 +1,19 @@
-- Storefront app manifest: the customer-facing ecommerce binary.
-- Routes serve to anonymous visitors + authenticated customers;
-- admin/ops users exist as DB rows but have no route in this binary.
--
-- The app binary connects to the shared `wo db` daemon (see
-- ../../wo.toml [database] + apps/storefront/wo.toml WO_DB env var).
-- Row-level policies on shared/types/*.wo scope what each session sees.
##app
name: "storefront"
version: 1
theme: "light"
i18n: [en]
routes:
/ -> ui.home -- apps/storefront/ui/home/
/product/:sku -> ui.product-detail { key: $sku } -- apps/storefront/ui/product-detail/
/cart -> ui.cart
/orders -> ui.orders -- apps/storefront/ui/orders/

View file

@ -1,9 +1,15 @@
-- Public product list. Live inventory — when a checkout reserves the last -- Storefront home — public product list. Live inventory: when a checkout
-- unit, the "In stock" badge flips to "Out of stock" on every open browser -- reserves the last unit, the "In stock" badge flips to "Out of stock" on
-- without refresh. -- every open browser without refresh.
--
-- Angular-component-style layout: home/{home.wo, home.htmlx, home.css}.
-- home.wo is the declarative spec; home.htmlx (when added) lets an author
-- hand-tune the template while the compiler still generates the
-- <wo:live> + wo:bind subscription glue. See
-- [docs/plan/ui/00-overview.md](../../../../../plan/ui/00-overview.md).
##ui ##ui
#storefront #home
title: "Shop" title: "Shop"
source: Product source: Product
live: true live: true

View file

@ -3,7 +3,7 @@
-- to `customer == $session.user`, so a user only ever sees their own orders. -- to `customer == $session.user`, so a user only ever sees their own orders.
##ui ##ui
#order-tracker #orders
title: "Your Orders" title: "Your Orders"
source: Order{ customer == $session.user } source: Order{ customer == $session.user }
live: true live: true

View file

@ -0,0 +1,26 @@
name = "storefront"
version = "0.1.0"
description = "Customer-facing ecommerce binary. Connects to the shared wo-db daemon."
app_kind = "app" # not a library; produces a binary under target/wo/storefront
[runtime]
wo = ">= 0.1"
# The storefront depends on the shared type + logic sources one workspace
# level up. Paths are workspace-relative.
[dependencies]
shared = [
"../../shared/types",
"../../shared/logic",
"../../shared/components",
]
[server]
listen = ":8080" # what this app exposes to browsers
[database]
# Pointer to the shared DB daemon. At runtime, `WO_DB=wo://...` overrides.
url = "wo://127.0.0.1:5555"
# The app presents this API key on connect; the daemon's per-app policy
# table scopes what rows it can see.
api_key_env = "STOREFRONT_DB_KEY"

View file

@ -0,0 +1,42 @@
{{!--
Top-level page chrome shared across every app in the workspace.
Both storefront and admin inherit this layout and override the `{{> slot.content}}`
partial with their own screen's body.
The `wo:bind="session.user.name"` in the header is a field-level live binding:
when the customer updates their profile, every open tab's header repaints
without a page reload — the same delta-dispatch mechanism the live tables use,
just on a singleton subscription.
See docs/plan/ui/01-htmlx-format-spec.md (to land in a future sub-phase)
for the full grammar.
--}}
<!doctype html>
<html lang="{{app.i18n}}">
<head>
<meta charset="utf-8">
<title>{{page.title}} — {{app.name}}</title>
<link rel="stylesheet" href="/static/{{app.name}}.css">
{{> slot.head}}
</head>
<body class="theme-{{app.theme}}">
<header class="page-header">
<a href="/" class="brand">{{app.name}}</a>
<nav>{{> slot.nav}}</nav>
{{#if session.user}}
<span class="session" wo:bind="session.user.name">{{session.user.name}}</span>
{{/if}}
</header>
<main class="page-body">
{{> slot.content}}
</main>
<footer class="page-footer">
<small>Built with writeonce · {{app.version}}</small>
</footer>
{{!-- Client runtime + subscription manifest are injected automatically. --}}
<script data-wo-runtime src="/_wo/runtime.js"></script>
</body>
</html>

View file

@ -0,0 +1,6 @@
{{!--
Tiny partial: renders a Money value (minor units — cents) as a localised
currency string. Called from any screen via `{{> money amount=total}}`.
See apps/admin/ui/orders/orders.wo columns block for a live example.
--}}
<span class="money">${{divide amount by 100}}.{{mod amount by 100 padleft 2 with "0"}}</span>

View file

@ -0,0 +1,23 @@
{{!--
One order row — used by both apps' order tables. Different columns are
displayed depending on the caller, via conditional sub-partials; the
`wo:bind="status"` attribute makes the status cell live-update on delta
frames regardless of which table embeds this row.
Customer view (storefront/ui/orders/orders.wo): {{> order-row for="customer"}}
Admin view (admin/ui/orders/orders.wo): {{> order-row for="ops"}}
--}}
<tr class="order-row status-{{status}}" data-key="{{id}}">
<td class="order-id" wo:bind="id">{{id}}</td>
<td class="order-status" wo:bind="status">{{status}}</td>
{{#if (eq for "ops")}}
<td class="customer-name">{{customer.name}}</td>
<td class="customer-email">{{customer.email}}</td>
{{/if}}
<td class="order-total" wo:bind="total">{{> money amount=total}}</td>
<td class="order-placed" wo:bind="placed_at">{{relative placed_at}}</td>
{{#if (eq for "ops")}}
<td class="order-paid" wo:bind="paid_at">{{relative paid_at}}</td>
<td class="order-shipped" wo:bind="shipped_at">{{relative shipped_at}}</td>
{{/if}}
</tr>

View file

@ -1,24 +1,17 @@
##app -- Idempotent demo seed + the cross-entity Admin/Ops bypass policy.
name: "ecommerce" -- Both belong to the *shared* layer rather than to any one app:
version: 1 -- * The seed primes the shared database so either the storefront or the
theme: "light" -- admin app shows something on first boot.
i18n: [en] -- * The bypass policy applies across every type, so it has to live where
-- every app can see it.
-- Runs once when the shared `wo db serve` daemon comes up — each app connects
-- over the wire and inherits the already-seeded state.
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 policy admin-ops-bypass
applies_to: Order, Product, Customer, Purchase applies_to: Order, Product, Customer, Purchase
when: $session.role == Admin or $session.role == Ops when: $session.role == Admin or $session.role == Ops
effect: skip-row-filters effect: skip-row-filters
-- Startup seed: idempotent demo data so `wo run` shows something useful.
on startup on startup
do: seed() do: seed()

View file

@ -1,16 +1,32 @@
name = "ecommerce" name = "ecommerce-workspace"
version = "0.1.0" version = "0.1.0"
description = "A sample writeonce e-commerce app: cross-paradigm ACID checkout + live order-ops table" description = "Monorepo: shared types + logic powering two apps (storefront, admin) against one DB"
kind = "workspace" # not a single app — see [workspace] below
[runtime] [runtime]
wo = ">= 0.1" wo = ">= 0.1"
[server] # Apps = one binary each; shared = libraries imported across apps by path.
listen = ":8080" # Mirrors Angular/Nx workspaces (apps/* + libs/*). See
# docs/plan/ui/00-overview.md § Target layout.
[workspace]
apps = [
"apps/storefront",
"apps/admin",
]
shared = [
"shared/types",
"shared/logic",
"shared/components",
]
# The shared DB daemon — one process, no UI, just the engine + wire protocol.
# Starts with `wo db serve` (see docs/plan/ui/06-shared-db-daemon.md when
# that sub-phase lands). Apps connect at runtime via WO_DB.
[database] [database]
listen = "127.0.0.1:5555" # native wire-protocol port
data_dir = "./data" data_dir = "./data"
isolation = "snapshot" # snapshot isolation for the canonical checkout flow isolation = "snapshot" # default for cross-paradigm checkout
[test] [test]
parallel = false # ordering tests touch the same rows; serialize parallel = false # cross-app integration tests hit the same DB

View file

@ -0,0 +1,125 @@
# 09 — Scale-out: thread-per-core for 10k concurrent users
**Context sources:** [`./08-sendfile-static-assets.md`](./08-sendfile-static-assets.md) (last single-threaded phase), [`./assembly/02-writeonce-stance.md`](./assembly/02-writeonce-stance.md) (the "single-threaded" policy we're now refining), [`../runtime/database/02-wo-language.md#concurrency-model`](../runtime/database/02-wo-language.md#concurrency-model) (original concurrency stance), [`docs/examples/ecommerce/`](../examples/ecommerce/) (the target workload), [`./linux/`](./linux/) (kernel primitives), [`reference/go/src/runtime/`](../../reference/go/src/runtime/) (precedent for a runtime that scales across threads).
## Context
Phases 02–08 produce a single-threaded event-loop runtime with zero external Rust dependencies — good enough for the blog sample and the first 500k ops/sec on one core. **The ecommerce workload at `docs/examples/ecommerce/` pushes past that ceiling**: ~10,000 connected websocket subscribers watching `/api/orders/live`, ~1,000 checkouts per second during peak, and every commit fanning out delta frames to a sizeable subset of the connected clients. No single core survives that, regardless of how tight the event loop is.
This phase is the refinement of the Phase-2 concurrency doctrine "shard to scale past one core" into a concrete architecture. The stance stays the same — **no Go-style goroutines, no work-stealing across threads, no shared mutable heap** — but now we have multiple event loops, each owning its core, its share of connections, and its slice of engine state.
This doc is a **master plan**. It outlines sub-phases 10–15 at a high level; each sub-phase lands as its own numbered plan doc when implementation starts. No code changes in this pass.
## Goal
Serve the ecommerce sample at 10,000 concurrent websocket subscribers + 1,000 checkouts/second on a single 8–16 core box, with per-request tail latencies (`p99`) inside 50 ms for reads and 100 ms for commits. After this phase sequence lands, `cargo run --bin wo -- run docs/examples/ecommerce` can handle production-shaped load with only the process count as the horizontal scale knob.
## Design decisions (locked)
1. **Thread-per-core, not M:N.** `N` OS threads pinned to `N` cores via `sched_setaffinity(cpu_set_t)`. Each thread runs its own event loop (the [phase-02 `runtime/` module](./02-event-loop-epoll.md)) plus a local shard of engine state. Pinned for the thread's lifetime; a connection accepted on thread K stays on thread K forever. Precedent: Seastar / ScyllaDB / Redis Cluster.
2. **Shared-nothing state.** No cross-thread mutable access to the catalog, engine rows, or subscription registry. Communication is message-passing over single-producer-single-consumer ring buffers (crossbeam-style, built on `std::sync::atomic`, per [`./assembly/02-writeonce-stance.md`](./assembly/02-writeonce-stance.md) — still no asm). If thread A needs to touch data owned by thread B, it sends a message; B processes it on its own tick.
3. **SO_REUSEPORT for listener-side load balancing.** Every thread binds a socket with `SO_REUSEPORT` on the same `:8080` — the kernel distributes incoming SYNs across the `N` listener sockets with consistent hashing on the connection 4-tuple. No user-space accept-thread bottleneck. Linux ≥ 3.9 is fine; ≥ 4.5 adds `BPF` filters for custom routing if we ever need session-affinity.
4. **Per-thread io_uring ring.** Each thread gets its own `io_uring_setup` ring with `IORING_SETUP_SINGLE_ISSUER` + `IORING_SETUP_SQPOLL` ([per `./linux/07-io_uring.md`](./linux/07-io_uring.md)). No ring sharing across threads — simpler ordering, no contention.
5. **Shard key: customer id (modulo N).** The ecommerce schema is customer-centric — one customer's orders + purchase edges + cart live on the same shard. Cross-customer queries (admin `list orders`) fan out; same-customer operations (checkout) are local. Blog shard key would be `author.id` for the same reason.
6. **Cross-shard transactions via 2PC.** A checkout that updates inventory on shard A and customer balance on shard B uses two-phase commit between the two engine threads. Phase 4's transaction coordinator (from [`../runtime/database/02-wo-language.md`](../runtime/database/02-wo-language.md) § Cross-Paradigm Transaction Coordinator) already handles this pattern for sql+doc+graph inside one process; it generalises cleanly to cross-thread.
7. **No Go-style goroutines.** Connections are not tasks that migrate. Each connection's state machine runs on its owning thread's event loop, just as it does in the single-threaded model — the difference is there are now `N` event loops running concurrently.
## What we copy from Go, what we don't
Read [`reference/go/src/runtime/netpoll_epoll.go`](../../reference/go/src/runtime/netpoll_epoll.go) and [`reference/go/src/runtime/proc.go`](../../reference/go/src/runtime/proc.go) for the shape; copy the **ideas** about fd-to-loop mapping and atomic-counter-based wake-up. Do **not** copy:
| Go feature | Why writeonce skips it |
| --- | --- |
| Goroutines (M:N scheduling, work stealing) | Goroutines pay context-switch + GC-scan costs the thread-per-core model avoids. Scylla benchmarks consistently beat Go-style runtimes at the same hardware. |
| Shared heap + GC | No heap GC — Rust ownership. Data is partitioned across threads, not shared with locks. |
| `gogo` / `mcall` / `systemstack` asm | No scheduler-controlled stack switching. See [`./assembly/02-writeonce-stance.md`](./assembly/02-writeonce-stance.md). |
| `cgo` boundary | Rust is the only language. `libc` is already ABI-compatible via `extern "C"`. |
| `asyncPreempt` preemption | Handlers run to completion on their owning thread. Back-pressure comes from bounded per-thread queues, not preemption. |
And what we **do** copy:
| Go pattern | Writeonce translation |
| --- | --- |
| Per-P netpoller (the `pp.pollDesc` model) | Per-thread `EventLoop` (the phase-02 `runtime::EventLoop`) |
| `netpollBreak` (fd wake-up via sendto) | Per-thread `eventfd` — one fd per thread, write to it to wake a sleeping `epoll_wait`. See [`./linux/02-eventfd.md`](./linux/02-eventfd.md). |
| `findrunnable` (what to do when idle) | Per-thread idle-state: drain in-process message queues, run compaction, run periodic timers (from [`./linux/03-timerfd.md`](./linux/03-timerfd.md)). |
| `runtime.GOMAXPROCS` | `WO_THREADS` env var (defaults to `std::thread::available_parallelism()`). |
## Linux primitives this phase leans on (beyond the phase-02/03/08 set)
Reference cards already exist for most; this phase adds the ones that are cross-thread-specific:
| Primitive | Use | Reference |
| --- | --- | --- |
| `SO_REUSEPORT` | N listener sockets on the same port; kernel load-balances accepts | [`reference/linux/net/core/sock_reuseport.c`](../../reference/linux/net/core/sock_reuseport.c) — worth adding `linux/12-so-reuseport.md` |
| `sched_setaffinity` + `cpu_set_t` | Pin each thread to its core | [`reference/linux/kernel/sched/core.c`](../../reference/linux/kernel/sched/core.c) |
| `futex(2)` | Fallback cross-thread wait if per-thread eventfd wake-up isn't enough | [`reference/linux/kernel/futex/`](../../reference/linux/kernel/futex/) — worth `linux/13-futex.md` |
| `membarrier(2)` | Process-wide memory barrier when a rebalance migrates state between threads | [`reference/linux/kernel/sched/membarrier.c`](../../reference/linux/kernel/sched/membarrier.c) |
| `io_uring` with `IORING_SETUP_SINGLE_ISSUER` | One ring per thread, pinned | [`./linux/07-io_uring.md`](./linux/07-io_uring.md) |
| `eventfd` per thread | Cross-thread wake-up — thread A writes to thread B's eventfd to deliver a message | [`./linux/02-eventfd.md`](./linux/02-eventfd.md) |
| `mmap(MAP_HUGETLB)` | Per-thread arena allocator backed by 2 MB pages for cache locality | [`./linux/08-mmap.md`](./linux/08-mmap.md) |
## Sub-phase sequence
Each one lands as its own numbered plan doc when ready for implementation. Smoke test (`cargo run --bin wo -- run docs/examples/ecommerce` serves correctly) stays green after every sub-phase.
### `10-thread-per-core.md` — N event loops, `SO_REUSEPORT`
Introduce a thread-pool manager at `crates/rt/src/runtime/scheduler.rs` (Go parallel: `proc.go`). Spawn `WO_THREADS` OS threads at boot; each pins itself and runs an `EventLoop`. Replace the single `Listener` with per-thread listeners bound `SO_REUSEPORT` to the same port. State is still global at first (shared `Arc<Mutex<Engine>>`) — one thing at a time. Exit criterion: `wo run` boots N threads visible in `ps -T`, accepts load balanced across them per `ss -tnp`, no regression in the 20-assertion blog smoke.
### `11-sharded-engine.md` — per-thread engine state
Partition the in-memory engine catalog + row BTreeMaps by shard id (= thread id). Shard key is `customer.id` for ecommerce / `author.id` for blog / per-type default for anything else. Add a shard router in front of every REST/WS handler: resolve the shard from the request's identifying field, send an in-process message to that thread's mailbox, await response. Shared `Arc<Mutex<Engine>>` goes away; each thread owns its slice. Cross-shard reads (admin `list orders`) fan out to every thread and merge results.
### `12-per-shard-wal.md` — one WAL file per shard
Each thread has its own `foo.wal` + `foo.data` + per-thread `io_uring` ring (phase 3's durability work, repeated per shard). Recovery is parallel across threads. No shared WAL writer thread. Group commit is per-thread.
### `13-cross-shard-subscriptions.md` — LIVE fanout
A commit on shard K that creates/updates rows of type T needs to wake subscribers on every shard watching T. Via broadcast: K writes the delta to a per-subscriber-thread mailbox — one message per destination thread, not per subscriber. The destination thread then does the fine-grained predicate match against its local subscription table. Avoids N² traffic when N connections watch the same stream.
### `14-cross-shard-txn.md` — 2PC for transactions that span shards
`fn checkout(customer, product, qty)` might touch shards A (customer), B (product), and C (order) if they hash differently. The transaction coordinator (already designed in [`../runtime/database/02-wo-language.md`](../runtime/database/02-wo-language.md) § Cross-Paradigm Transaction Coordinator) generalises to cross-shard: `begin(snapshot_ts)` broadcasts to all participating shards, `prepare()` collects votes, `commit(wal_lsn)` atomically flips markers, `abort()` if any participant refuses. The per-shard WAL entries carry the 2PC state machine.
### `15-observability-and-rebalance.md` — ops
Per-shard metrics (connections, ops/s, p99, WAL lag), Prometheus scrape endpoint on one well-known thread. A `WO_RESHARD` admin command migrates a contiguous customer-id range from shard K to shard K′ via state snapshot → replay → cutover. For a fixed-core deployment this is rare; matters when `WO_THREADS` changes between runs.
## Verification targets (after 15 lands)
Ecommerce sample on an 8-core box with `WO_THREADS=8`:
| Metric | Target | How measured |
| --- | --- | --- |
| Concurrent WS subscribers | **10,000** | `websocat` fan-out against `/api/orders/live` + persistent count |
| Checkout throughput | **1,000/s** sustained | Load driver fires `POST /api/fn/checkout` with per-customer key distribution |
| Read p99 | **< 50 ms** | `GET /api/orders?customer=X` under 10k-subscriber background load |
| Commit p99 | **< 100 ms** | Measured from `POST /api/fn/checkout` acceptance to HTTP ack |
| Memory steady-state | **< 2 GB RSS** | 10k connections × 2 KB/conn + engine working set |
| Dep count | **1** (`libc`) | `crates/rt/Cargo.toml` still has only libc after all this |
| `wo run docs/examples/blog` | still boots and serves | phase 02–08 regression test, unchanged |
## Non-scope
- **No Go-style goroutines, even after this phase.** Adding M:N scheduling is not on the roadmap. When one core runs out, add more cores (more threads) — horizontally, thread-per-core.
- **No distributed (multi-node) sharding.** This phase is single-box only. Redis-Cluster-style network sharding is a separate future phase; the in-process shard bus (phase 10's mailboxes) is not the same thing as a cluster membership protocol.
- **No dynamic thread count at runtime.** `WO_THREADS` is set at boot and pinned. Adding/removing a thread means a rolling restart. Acceptable for a database; fundamental to the zero-contention model.
- **No work-stealing.** A slow handler on thread A does not get rebalanced to thread B. Back-pressure is the thread-local queue filling up. If one thread hot-spots because of a bad shard key, the fix is to reshard — not to steal.
- **No `std::thread::available_parallelism` on exotic hosts.** `WO_THREADS` override covers kubernetes CFS-bound pods, NUMA partitioning, and single-core debug runs.
- **No new external Rust dependencies.** Same stance as phases 02–08 — `libc` only. Message passing, atomics, affinity, futex — all through libc or `std::sync::atomic`.
## Escape hatch
If the "single core per process, shard across processes" argument ([Redis Cluster model](../runtime/database/02-wo-language.md#concurrency-model)) turns out to be more operationally attractive than a single multi-threaded process, **every decision in this plan translates**. Per-thread shards become per-process shards; `SO_REUSEPORT` inside the kernel becomes a reverse proxy in front; in-process mailboxes become Unix domain sockets. The phase-02 event loop is the reusable atom regardless.
## Cross-references
- [`./08-sendfile-static-assets.md`](./08-sendfile-static-assets.md) — last prerequisite phase; feature-complete single-threaded runtime.
- [`./assembly/02-writeonce-stance.md`](./assembly/02-writeonce-stance.md) — updated to reference this phase's thread-per-core model; still no asm.
- [`../runtime/database/02-wo-language.md#concurrency-model`](../runtime/database/02-wo-language.md#concurrency-model) — the stance this plan refines.
- [`reference/go/src/runtime/proc.go`](../../reference/go/src/runtime/proc.go) — Go's scheduler, for contrast.
- [`reference/go/src/runtime/netpoll_epoll.go`](../../reference/go/src/runtime/netpoll_epoll.go) — per-P netpoller, the idea we borrow.
- [`reference/linux/net/core/sock_reuseport.c`](../../reference/linux/net/core/sock_reuseport.c) — kernel load balancer.
- [`reference/linux/kernel/sched/core.c`](../../reference/linux/kernel/sched/core.c) — affinity syscalls.

View file

@ -8,8 +8,8 @@ Each Go asm category from [`01-go-runtime-asm.md`](./01-go-runtime-asm.md) maps
| Go asm need | What writeonce uses | Why it covers the gap | | Go asm need | What writeonce uses | Why it covers the gap |
| --- | --- | --- | | --- | --- | --- |
| Scheduler stack switching (`gogo`, `mcall`, `systemstack`) | — nothing — | Single-threaded event loop (see Phase 2 [Concurrency Model](../runtime/database/02-wo-language.md#concurrency-model)). No goroutines, no stack switching, no `g0`. | | Scheduler stack switching (`gogo`, `mcall`, `systemstack`) | — nothing — | Single-threaded event loop through phases 02–08 (see Phase 2 [Concurrency Model](../runtime/database/02-wo-language.md#concurrency-model)). [Phase 09](../09-concurrency-scaleout.md) introduces **thread-per-core** scaling for the 10k-user ecommerce workload — but still no Go-style stack switching: each thread runs its own event loop, connections are pinned for their lifetime, and cross-thread work is message-passing, not scheduler-stealing. No goroutines, no `g0`, even at scale. |
| Preemption (`asyncPreempt`) | — nothing — | No preemption. Handlers run to completion on the single thread. | | Preemption (`asyncPreempt`) | — nothing — | No preemption through phases 02–08. Phase 09's thread-per-core model keeps this property: handlers run to completion on whichever thread owns their connection. |
| Atomic operations (`Load`, `Store`, `Cas`, `Xadd`, ...) | [`std::sync::atomic`](https://doc.rust-lang.org/std/sync/atomic/) | The compiler emits the right instruction per target — `LOCK CMPXCHG` on x86, `LDXR/STXR` on ARM, `LR.W/SC.W` on RISC-V. Ordering is in the type signature (`Ordering::Acquire`, `Release`, `SeqCst`). | | Atomic operations (`Load`, `Store`, `Cas`, `Xadd`, ...) | [`std::sync::atomic`](https://doc.rust-lang.org/std/sync/atomic/) | The compiler emits the right instruction per target — `LOCK CMPXCHG` on x86, `LDXR/STXR` on ARM, `LR.W/SC.W` on RISC-V. Ordering is in the type signature (`Ordering::Acquire`, `Release`, `SeqCst`). |
| Memory barriers (`MFENCE` etc.) | [`std::sync::atomic::fence(Ordering)`](https://doc.rust-lang.org/std/sync/atomic/fn.fence.html) | One call, one fence, arch-neutral. | | Memory barriers (`MFENCE` etc.) | [`std::sync::atomic::fence(Ordering)`](https://doc.rust-lang.org/std/sync/atomic/fn.fence.html) | One call, one fence, arch-neutral. |
| `memmove` / `memequal` / `memclr` | [`core::ptr::copy`](https://doc.rust-lang.org/core/ptr/fn.copy.html), `<[T]>::copy_from_slice`, `==`, `[T]::fill(0)` | LLVM emits the same vectorised code Go's asm does, often better because it knows alignment statically. | | `memmove` / `memequal` / `memclr` | [`core::ptr::copy`](https://doc.rust-lang.org/core/ptr/fn.copy.html), `<[T]>::copy_from_slice`, `==`, `[T]::fill(0)` | LLVM emits the same vectorised code Go's asm does, often better because it knows alignment statically. |
@ -45,6 +45,7 @@ Phase 3's WAL loop batches commits and fsyncs them in one `io_uring` submission.
- **Rust answer:** [`std::hint::spin_loop()`](https://doc.rust-lang.org/std/hint/fn.spin_loop.html). The compiler emits `PAUSE` / `WFE` per target. Use `core::hint::black_box` to prevent compiler over-optimisation of measurement. - **Rust answer:** [`std::hint::spin_loop()`](https://doc.rust-lang.org/std/hint/fn.spin_loop.html). The compiler emits `PAUSE` / `WFE` per target. Use `core::hint::black_box` to prevent compiler over-optimisation of measurement.
- **When to escalate:** when benchmarks show a specific hotspot the compiler is provably wrong about. Not before. - **When to escalate:** when benchmarks show a specific hotspot the compiler is provably wrong about. Not before.
## The escape hatch ## The escape hatch
If all three defences above are exhausted — benchmarked, documented, reviewed — assembly goes in: If all three defences above are exhausted — benchmarked, documented, reviewed — assembly goes in:

213
docs/plan/ui/00-overview.md Normal file
View file

@ -0,0 +1,213 @@
# UI track — `.htmlx` live templates + Angular-style monorepo
**Context sources:** [`docs/examples/ecommerce/ui/`](../../examples/ecommerce/ui/) (current `##ui` screens — storefront, order_tracker, admin_orders), [`docs/examples/ecommerce/types/`](../../examples/ecommerce/types/) + [`docs/examples/ecommerce/logic/`](../../examples/ecommerce/logic/) (the shared-schema + shared-fn anchor), [`reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/) (v1 template engine — `{{path}}`, `{{#each}}`, `{{> partial}}`, `data-bind` attributes), [`templates/`](../../../templates/) (v1 blog's concrete `.htmlx` usage), [`docs/runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) (Phase 6's `##ui` + `##app` block spec).
## Context
Three threads converge into one plan:
1. **`##ui` needs a concrete output format.** Phase 6's spec says screens "compile to a render tree" served as SSR HTML with a thin client runtime, but the actual template format isn't named. The v1 `.htmlx` engine at [`reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/) already speaks `{{bindings}}`, `{{#each}}`, `{{> partials}}`, and `data-bind` attributes — it's 90% of what the new runtime needs and already has a working parser + renderer. Adopting it (and extending it with live-subscription semantics) is cheaper than inventing a new format.
2. **The samples want a home that matches how real frontends are organised.** The ecommerce sample today is one flat directory with `types/`, `logic/`, and `ui/` beside each other. A real deployment has *multiple apps* against the same data: a customer storefront, an admin dashboard, a fulfillment console, maybe a read-only analytics viewer. Each has its own routes, its own policies, its own ideal binary shape. Angular (via Nx / Angular CLI workspaces) solved this with `apps/*` + `libs/*` on top of a shared root config — writeonce adopts the same shape.
3. **Each app wants to be its own binary but share a database.** Running storefront and admin as one monolith conflates concerns: a CPU spike in admin stalls customer checkout; an admin auth bug opens customer data paths. Splitting into per-app binaries that share a single database backend (via the Phase-4 native wire protocol) gives blast-radius isolation without duplicating data.
Intended outcome: `docs/examples/ecommerce/` refactors into a workspace with `shared/` + `apps/storefront/` + `apps/admin/`. `wo build apps/storefront` produces a `storefront` binary. `wo db serve` runs the shared database. The apps connect over `wo://…` and serve `.htmlx` SSR pages that subscribe to LIVE queries without a page reload.
## Goal
After this track's sub-phases land:
- A **monorepo workspace** at `docs/examples/ecommerce/` with `shared/{types,logic,components}` + `apps/{storefront,admin}` structure.
- A **per-app binary** for each app under `apps/`: `wo build apps/storefront` → `./target/wo/storefront`, `wo build apps/admin` → `./target/wo/admin`. Each binary includes only its own `##ui` / `##app` / app-local types and logic; shared code compiles in by path reference.
- A **shared DB daemon** (`wo db serve`) — one process, no UI, just the engine and wire-protocol server. Each app binary connects as a client via the Phase-4 native protocol.
- **`.htmlx` as the compiled UI output.** Every `##ui` screen compiles into an `.htmlx` template file that the app binary serves; hand-written `.htmlx` files under `apps/X/ui/*.htmlx` are accepted as a first-class authoring alternative.
- **`.htmlx` subscribes.** A `<wo:live source="...">` subtree in the template registers a LIVE query at page load; a ~20 KB client JS runtime patches DOM nodes on delta frames without reloading the page.
- **Per-app users + policies.** Each app declares its role set in `apps/X/app.wo`; row-level policies on shared types stay global, app-scoped policies layer on top per route.
## Design decisions (locked)
1. **`.htmlx` is the template format; `##ui` is the DSL that emits it.** Authors choose per-screen: declare `##ui #home { source: Product, columns: [...] }` in `.wo` and let the compiler produce `home.htmlx`; OR hand-write `home.htmlx` for a custom page. Both flow through the same `wo-htmlx` renderer.
2. **Extend v1 `.htmlx` with two new constructs** — `<wo:live source="..." key="...">...</wo:live>` (subscription subtree) and `wo:bind="field"` (field-level live binding). The rest of the v1 syntax (`{{path}}`, `{{#each}}`, `{{> partial}}`) carries through unchanged.
3. **One binary per app, shared database process.** Not a shared library, not a monolith. Apps connect via the Phase-4 wire protocol (`wo://host:port`) — the same connection a Go/TS client would use. No in-process shared state between apps; their isolation is enforced by the OS process boundary.
4. **Angular-parallel workspace layout.** `apps/*` for deployable binaries, `shared/*` for libs shared across apps (types, logic, UI components), `wo.toml` at the workspace root. Each app also has its own `wo.toml` that names which `shared/` directories it depends on.
5. **File structure mirrors Angular component organisation.** Each UI screen lives in its own directory: `apps/storefront/ui/home/{home.wo, home.htmlx, home.css}`. Tests go in `home.test.wo`. Matches the Angular component pattern (`home.component.ts`, `home.component.html`, `home.component.scss`).
6. **Per-app routes, not per-screen routes.** `apps/storefront/app.wo` declares route table; each route maps to a `ui.<screen>` declared under `apps/storefront/ui/*/`. Cross-app navigation is an external redirect, not an internal route.
7. **Policy composition.** Global policies live in `shared/types/<type>.wo` next to the `type` declaration (today). App-scoped policies live in `apps/X/app.wo` and AND with the global set — an admin app might relax a storefront policy for ops roles but can never relax beyond what the type's own policy permits.
## Angular parallels — what writeonce copies, what it doesn't
| Angular feature | Writeonce translation | Notes |
| --- | --- | --- |
| `nx workspace` / `angular.json` | Root `wo.toml` with `[workspace] apps = [...], shared = [...]` | Path references, not package registry |
| `apps/<app>/` | `apps/<app>/` with `app.wo` + `ui/` + local `types/` + local `logic/` | 1:1 naming |
| `libs/<lib>/` | `shared/<lib>/` | Used `shared/` instead of `libs/` — matches the more common monorepo idiom (Nx defaults to `libs`, but `shared` is clearer for this audience) |
| `<component>.ts` + `.html` + `.scss` | `<screen>.wo` + `.htmlx` + `.css` under `ui/<screen>/` | One-directory-per-screen |
| `ng build <app>` | `wo build apps/<app>` | Per-app binary output |
| Dependency injection | Service-block resolution — `service rest` blocks in shared types are callable from any app by import | No runtime DI container; bindings are resolved at compile time |
| RxJS observables | LIVE subscription frames on a WebSocket | Declarative `live` attribute instead of imperative `.subscribe(...)` |
| Zone.js change detection | Per-row delta dispatch + field-level `wo:bind` | No full-tree change detection — only the rows/fields the delta names get repainted |
| `HttpClient` | Built-in wire-protocol client inside the app binary | No separate library to import; always present |
### What we don't copy
- **No TypeScript.** Authoring is `.wo` (for logic) + `.htmlx` (for templates) + `.css`. If a page needs bespoke JS interactivity beyond what `wo:bind` covers, a `<script>` tag inside `.htmlx` is fine — but the client runtime itself is vanilla JS, not a framework.
- **No component library split.** Angular's `@angular/core`, `@angular/common`, etc. are a package hierarchy. Writeonce's runtime is one binary; "components" are just shared `.htmlx` partials under `shared/components/`.
- **No decorator metadata / reflect-metadata.** Rust macros + compile-time codegen do the same work.
## Reference materials
Read before writing each sub-phase:
| Source | Why |
| --- | --- |
| [`reference/crates/wo-htmlx/src/parser.rs`](../../../reference/crates/wo-htmlx/src/parser.rs) + [`render.rs`](../../../reference/crates/wo-htmlx/src/render.rs) | The v1 template engine's exact surface — what parses, what renders, what the AST looks like. ~500 LOC total. |
| [`templates/article.htmlx`](../../../templates/article.htmlx), [`templates/home.htmlx`](../../../templates/home.htmlx) | Concrete usage of the v1 format — how `{{path}}` and `data-bind` actually read in real templates. |
| [`docs/examples/ecommerce/ui/{storefront,order_tracker,admin_orders}.wo`](../../examples/ecommerce/ui/) | The `##ui` side — what the declarative DSL promises to produce. These screens are the target of the first compiler pass. |
| [`docs/runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) | Phase 6's full-stack block spec — `##ui`, `##app`, `##policy`, `##service`, `##logic` — already designed but not yet compiled. |
| [`docs/runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md) | Phase 4's wire protocol — what the per-app binary speaks to the shared DB over. |
| [Nx monorepo docs](https://nx.dev/concepts/more-concepts/why-monorepos) | Background on the apps/libs split pattern; shape of `nx.json`. |
## Target layout
```
docs/examples/ecommerce/
├── wo.toml # workspace — lists apps, names the shared DB port
├── shared/ # imported by any apps that need it
│ ├── types/
│ │ ├── customer.wo # Customer + role union + policy read/write
│ │ ├── product.wo # Product + inventory + similar_to graph
│ │ ├── order.wo # Order + line_items + lifecycle triggers
│ │ └── purchase.wo # link Customer -> Product
│ ├── logic/
│ │ └── checkout.wo # fn checkout / mark_paid / mark_shipped
│ └── components/ # reusable .htmlx partials
│ ├── layout.htmlx # top-level page chrome
│ ├── header.htmlx
│ ├── money.htmlx # {{> money amount=x}} → $x.xx
│ └── order-row.htmlx # used by both storefront and admin
├── apps/
│ ├── storefront/ # customer-facing; no admin routes
│ │ ├── wo.toml # declares `shared = ["../shared"]`
│ │ ├── app.wo # routes: / → home, /product/:sku → product-detail
│ │ ├── logic/
│ │ │ └── cart.wo # app-local: fn add_to_cart, fn remove_from_cart
│ │ ├── types/
│ │ │ └── cart.wo # type Cart { lines: [CartLine], ... } — not shared
│ │ └── ui/
│ │ ├── home/
│ │ │ ├── home.wo # ##ui #home — declarative spec
│ │ │ ├── home.htmlx # optional hand-written override
│ │ │ └── home.css
│ │ └── product-detail/
│ │ └── product-detail.wo
│ └── admin/
│ ├── wo.toml
│ ├── app.wo # routes: /orders → orders, /inventory → inventory; gated role Admin|Ops
│ ├── logic/
│ │ └── fulfillment.wo # app-local: fn ship_order calls shared.mark_shipped
│ └── ui/
│ ├── orders/
│ │ ├── orders.wo # ##ui #admin-orders, live: true
│ │ └── orders.htmlx # hand-tuned layout overrides the auto-generated
│ └── inventory/
│ └── inventory.wo
└── tests/ # workspace-level integration
└── cross-app.test.wo # a checkout from storefront visible in admin live feed
```
Compile outputs:
```
target/wo/
├── storefront # ~12 MB static binary — app.wo compiled + ui/ templates baked in
├── admin # ~12 MB static binary
└── db # the `wo db` server (shared by all apps)
```
## `.htmlx` with live subscriptions — target format
v1 carries forward unchanged:
```htmlx
<h1>{{article.title}}</h1>
<ul>
{{#each articles}}
<li><a href="/article/{{slug}}">{{title}}</a></li>
{{/each}}
</ul>
{{> layout.footer}}
```
Two new constructs for live:
```htmlx
<!-- Subtree bound to a LIVE query; client subscribes at page load -->
<wo:live source="Order{ status != Cancelled }" sort="placed_at desc" key="id">
<table class="orders">
<thead><tr><th>#</th><th>Status</th><th>Customer</th><th>Total</th></tr></thead>
<tbody>
{{#each rows}}
<tr data-key="{{id}}">
<td>{{id}}</td>
<td wo:bind="status" class="status-{{status}}">{{status}}</td>
<td>{{customer.name}}</td>
<td>{{> money amount=total}}</td>
</tr>
{{/each}}
</tbody>
</table>
</wo:live>
```
Semantics:
- `<wo:live source="...">` emits a `LIVE <source>` query registration at SSR time. The initial result renders the `{{#each rows}}` body.
- The compiler also emits a JSON manifest (in a `<script data-wo-manifest>` tag) telling the client runtime which DOM id maps to which row key and what fields are `wo:bind`-ed.
- On page load, the client runtime opens a WebSocket back to the app, subscribes, and processes delta frames: `Insert` appends a row, `Update` finds `[data-key="<id>"]` and replaces `wo:bind`-ed cells, `Delete` removes the row.
- `wo:bind="field"` on any element tells the runtime "this element's text content reflects `row.field`"; delta Updates patch it in place.
## Sub-phase sequence
Each lands as its own plan doc under `docs/plan/ui/`. No code yet — this master plan outlines the order.
| # | File | Goal |
| --- | --- | --- |
| `01` | `01-htmlx-format-spec.md` | Nail down the exact `.htmlx` grammar — everything v1 has plus `<wo:live>` and `wo:bind`. Includes a manifest-emission spec so the client knows what to subscribe to. |
| `02` | `02-ui-compiler.md` | `##ui` → `.htmlx` compiler. Walks the parsed Phase-6 block and emits the template with the right `<wo:live>` / `{{#each}}` / `wo:bind` skeleton. Falls back gracefully when a hand-written `.htmlx` exists beside the `.wo`. |
| `03` | `03-client-runtime.md` | ~20 KB vanilla-JS runtime bundled with the app binary. Parses `<script data-wo-manifest>`, opens WebSocket, handles `snapshot`/`insert`/`update`/`delete` frames, patches DOM by `data-key` + `wo:bind`. |
| `04` | `04-workspace-layout.md` | Concrete refactor of `docs/examples/ecommerce/` from the current flat shape into `shared/` + `apps/*`. Defines `wo.toml` workspace grammar. |
| `05` | `05-per-app-binaries.md` | `wo build apps/X` produces one static binary per app. Each contains its own types/logic/ui + the shared dirs it imports. Shared DB connection is configured via `WO_DB` env var. |
| `06` | `06-shared-db-daemon.md` | `wo db serve` — headless database daemon. Per-app authn (API key per app), per-app connection scope. Apps see only types their policy allows. |
| `07` | `07-per-app-policies.md` | App-scope policy composition — global `policy read ...` on a type AND app-local `policy` in `app.wo` ⇒ effective policy = AND of both. Admin app's relaxations, storefront's restrictions. |
## Verification
After all seven sub-phases land:
| Target | Measure |
| --- | --- |
| `wo build apps/storefront` succeeds, produces one static binary | `file target/wo/storefront` → ELF, `ldd` shows only libc |
| `wo build apps/admin` succeeds | same |
| `wo db serve` + `storefront --db wo://localhost:5555` + `admin --db wo://localhost:5555` all run simultaneously | three processes, three ports, one data directory |
| Admin live orders table updates within 100 ms of a checkout on the storefront | Open `/admin/orders` in a browser, fire `POST /api/fn/checkout` against storefront's wire port, observe DOM patch |
| Customer's Order visible in their storefront order tracker but not to other customers; admin sees all | Policy round-trip |
| `wo run apps/storefront` serves hand-written `home.htmlx` if present, falls back to `##ui #home` generation if not | File-presence-based dispatch |
| `curl http://localhost:8080/healthz` from each app process | `200 ok` — standard liveness across the tracks |
## Non-scope
- **No TypeScript, no JSX.** `.htmlx` is HTML + Mustache + two `wo:` tags. The client runtime is 500 lines of vanilla JS.
- **No build-time Angular-style bundling.** No Webpack, no esbuild, no tree-shaking. The client JS is a pre-compiled static artifact inside each app binary.
- **No hot module reload in production.** `wo dev` reloads in development (inotify watches `apps/*/ui/`); production binaries don't self-reload.
- **No cross-app shared session state.** Each app authenticates independently. Shared identity is the customer row in the shared DB — both apps see the same user, but each app issues its own session token.
- **No dynamic shared-library linking between apps.** Sharing happens at source level (`shared/` dirs imported by path). Each binary is a fully-static blob.
- **No React/Vue compatibility layer.** If a downstream app wants those, they sit outside the writeonce runtime and talk to the shared DB over the wire protocol — same as any other client.
## Cross-references
- [`../09-concurrency-scaleout.md`](../09-concurrency-scaleout.md) — when the shared DB daemon needs to handle 10k connections across multiple apps, that plan's thread-per-core model applies to the daemon process.
- [`../assembly/02-writeonce-stance.md`](../assembly/02-writeonce-stance.md) — still no asm. The client runtime is vanilla JS, no WASM.
- [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) — Phase 6's full-stack block spec that this track implements.
- [`../../runtime/database/04-client-api.md`](../../runtime/database/04-client-api.md) — the wire protocol per-app binaries speak to the shared DB over.
- [`../../examples/ecommerce/ui/admin_orders.wo`](../../examples/ecommerce/ui/admin_orders.wo) — the motivating workload: a live ops table bound to the order stream.
- [`reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/) — the template engine ~90% of this track will reuse.
- [`templates/`](../../../templates/) — v1 blog's actual `.htmlx` files; the format this track extends.

17
infra/sync.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
# sync.sh — sync content, templates, and static assets without rebuilding or restarting
set -e
SERVER="shoney@192.168.0.217"
REMOTE_DIR="/opt/writeonce"
echo "Syncing content..."
rsync -az --delete content/ "$SERVER:/tmp/writeonce-content/"
echo "Syncing templates..."
rsync -az --delete templates/ "$SERVER:/tmp/writeonce-templates/"
echo "Syncing static assets..."
rsync -az --delete static/ "$SERVER:/tmp/writeonce-static/"
echo "Sync complete. Files staged in /tmp/writeonce-{content,templates,static}/"

181
reference/rest/blog.rest Normal file
View file

@ -0,0 +1,181 @@
###############################################################################
# blog.rest — exercise the `.wo` runtime against docs/examples/blog/
#
# Start the server first:
# cargo run --bin wo -- run docs/examples/blog
#
# Then in VS Code (REST Client extension) or JetBrains (HTTP Client):
# click "Send Request" on each block, top to bottom
#
# Response back-references (# @name foo → {{foo.response.body.id}}) are
# supported by both clients — later blocks pick up ids minted by earlier
# blocks automatically. For `curl` equivalents, see reference/rest/README.md.
###############################################################################
@host = http://127.0.0.1:8080
### Runtime info — expected 200
GET {{host}}/
### Liveness probe — expected 200 "ok"
GET {{host}}/healthz
###############################################################################
# Author — exposes: list, get, me, subscribe
###############################################################################
### List authors (empty on fresh boot) — expected 200 []
GET {{host}}/api/authors
### Author create is NOT exposed in the blog sample (expose list, get, me, subscribe)
# Expected 405 — method not allowed. The sample expects authors seeded by
# `on startup do: seed_admin()` in app.wo. Stage 2 doesn't run startup hooks
# yet, so the list above will be empty.
POST {{host}}/api/authors
Content-Type: application/json
{ "email": "alice@example.com", "handle": "alice", "display": "Alice" }
### /me — Stage 3 session layer; expected 501
GET {{host}}/api/authors/me
### LIVE subscribe — Stage 3; expected 501
GET {{host}}/api/authors/live
###############################################################################
# Article — exposes: list, get, create, update, delete, subscribe
###############################################################################
### Create an article — expected 201
# @name createArticle
POST {{host}}/api/articles
Content-Type: application/json
{
"slug": "hello-writeonce",
"title": "Hello, writeonce",
"author": 1,
"published": true,
"meta": {
"excerpt": "First post on the new runtime.",
"body_md": "# Hi\n\nHello from the `.wo` runtime. The server, the database, and this HTTP API are all one binary.\n"
}
}
### Create a second article (draft) — expected 201
# @name createDraft
POST {{host}}/api/articles
Content-Type: application/json
{
"slug": "second-draft",
"title": "Second Post (draft)",
"author": 1,
"published": false,
"meta": { "excerpt": "", "body_md": "WIP." }
}
### List articles — expected 200 with 2 rows
GET {{host}}/api/articles
### Get one article by id — expected 200
GET {{host}}/api/articles/{{createArticle.response.body.id}}
### Partial update (PATCH) — change just the title — expected 200
PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
Content-Type: application/json
{ "title": "Hi, writeonce!" }
### Partial update of an embedded-document field — expected 200
# Note: Stage 2's PATCH does a shallow merge at the top level.
# To change `meta.excerpt` alone you re-send the whole `meta` object.
PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
Content-Type: application/json
{
"meta": { "excerpt": "Updated excerpt.", "body_md": "# Hi\n\nUpdated body." }
}
### Publish the draft — expected 200 (`on update` trigger that sets
### `published_at` is Stage 3+; Stage 2 just records the bool change).
PATCH {{host}}/api/articles/{{createDraft.response.body.id}}
Content-Type: application/json
{ "published": true }
### Delete the draft — expected 204 No Content
DELETE {{host}}/api/articles/{{createDraft.response.body.id}}
### Get the deleted id — expected 404
GET {{host}}/api/articles/{{createDraft.response.body.id}}
### LIVE subscribe (Article) — Stage 3; expected 501
GET {{host}}/api/articles/live
###############################################################################
# Tag — exposes: list, get, subscribe
###############################################################################
### List tags — expected 200 [] (create is not exposed)
GET {{host}}/api/tags
### Tag create NOT exposed (expose list, get, subscribe) — expected 405
POST {{host}}/api/tags
Content-Type: application/json
{ "slug": "rust", "label": "Rust" }
### LIVE subscribe (Tag) — Stage 3; expected 501
GET {{host}}/api/tags/live
###############################################################################
# Comment — exposes: list, get, create, update, delete, subscribe
###############################################################################
### Create a comment — expected 201
# @name createComment
POST {{host}}/api/comments
Content-Type: application/json
{
"article": {{createArticle.response.body.id}},
"author": 1,
"body": "Nice post. Runs on one binary which is still weird to me."
}
### List comments — expected 200 with 1 row
GET {{host}}/api/comments
### Get one comment — expected 200
GET {{host}}/api/comments/{{createComment.response.body.id}}
### Update comment body — expected 200 (the `on update when old.body != new.body
### do set self.edited_at = now()` trigger is Stage 3+; edited_at stays unset)
PATCH {{host}}/api/comments/{{createComment.response.body.id}}
Content-Type: application/json
{ "body": "Edited: really, one binary? Neat." }
### Delete comment — expected 204
DELETE {{host}}/api/comments/{{createComment.response.body.id}}
### LIVE subscribe (Comment) — Stage 3; expected 501
GET {{host}}/api/comments/live
###############################################################################
# Final state — should show the updated article still there, no drafts,
# no comments (all deleted above).
###############################################################################
### Final article list — expected 200 with 1 row
GET {{host}}/api/articles
### Final comment list — expected 200 []
GET {{host}}/api/comments

View file

@ -0,0 +1,142 @@
###############################################################################
# ecommerce.rest — exercise the `.wo` runtime against docs/examples/ecommerce/
#
# Start the server first:
# cargo run --bin wo -- run docs/examples/ecommerce
#
# The ecommerce sample is heavier on features that land in later stages:
# * orders are minted by `fn checkout(customer, product, qty) in txn snapshot`
# — transactional functions are a Stage 3/4 addition. Stage 2 does not
# register `/api/fn/checkout` yet; the block below documents that.
# * customer/product/order rows are seeded by `on startup do: seed()` in
# app.wo — startup hooks are also Stage 3+. Lists start empty.
# * Order-status lifecycle triggers (`on update when old.status != Paid ...`)
# are Stage 3+.
#
# This file therefore focuses on what Stage 2 *does* serve — route wiring,
# empty-list reads, method-not-allowed for non-exposed operations, and the
# Stage-3 stubs that respond 501. It doubles as a living spec for what the
# ecommerce sample should behave like once Stage 3+ lands.
###############################################################################
@host = http://127.0.0.1:8080
### Runtime info — expected 200
GET {{host}}/
### Liveness probe — expected 200 "ok"
GET {{host}}/healthz
###############################################################################
# Product — exposes: list, get, subscribe
# Stage 2 does NOT expose create — admin console is expected to seed inventory.
###############################################################################
### List products — expected 200 [] (no startup seed yet)
GET {{host}}/api/products
### Product create NOT exposed — expected 405
POST {{host}}/api/products
Content-Type: application/json
{
"sku": "SKU-WIDGET",
"name": "Widget",
"price": 1999,
"meta": { "description": "A widget.", "images": [], "attributes": { "colour": "blue" } },
"inventory": { "on_hand": 50, "reserved": 0, "reorder_at": 10 }
}
### Get by id — expected 404 (nothing exists)
GET {{host}}/api/products/1
### LIVE subscribe — Stage 3; expected 501
GET {{host}}/api/products/live
###############################################################################
# Customer — exposes: get, me, update, subscribe
# Customer create is gated by admin/self-signup flows outside this sample's
# scope. For now the list endpoint is not exposed either.
###############################################################################
### List NOT exposed — expected 404
# Customer's `expose get, me, update, subscribe` has nothing at the collection
# root, so no route is registered at `/api/customers` at all. The server
# returns 404 "no route" rather than 405 "method not allowed".
GET {{host}}/api/customers
### Customer create NOT exposed — expected 404
# Same reason: no method attached to /api/customers.
POST {{host}}/api/customers
Content-Type: application/json
{ "email": "carol@shop.test", "name": "Carol", "role": "Customer" }
### Get by id — expected 404 (nothing exists)
GET {{host}}/api/customers/1
### Update by id — would work if the customer existed; expected 404
PATCH {{host}}/api/customers/1
Content-Type: application/json
{ "name": "Carol Updated" }
### /me — Stage 3 session layer; expected 501
GET {{host}}/api/customers/me
### LIVE subscribe — Stage 3; expected 501
GET {{host}}/api/customers/live
###############################################################################
# Order — exposes: list, get, subscribe
# Orders are created by `fn checkout(...)` (see logic/checkout.wo), not via
# POST. Stage 2 does not register transactional-fn endpoints, so the list is
# empty until Stage 3/4 brings them online.
###############################################################################
### List orders — expected 200 []
GET {{host}}/api/orders
### Order create NOT exposed (use fn checkout) — expected 405
POST {{host}}/api/orders
Content-Type: application/json
{ "customer": 1, "status": "Pending", "line_items": [] }
### LIVE subscribe — same WebSocket the `##ui #admin-orders` board opens in
### Stage 6. For now the endpoint responds 501. This is the single most
### requested Stage 3 endpoint for this sample.
GET {{host}}/api/orders/live
###############################################################################
# fn checkout — deferred to Stage 3/4 (transactional functions)
#
# When transactional-fn endpoints land, this block becomes the canonical
# cross-paradigm ACID test: one call updates the product's inventory doc,
# inserts an Order row, creates a Purchase graph edge, and threads the new
# order id through all three stores inside one BEGIN ... COMMIT.
#
# See docs/examples/ecommerce/logic/checkout.wo and
# docs/runtime/database/05-go-sdk.md "Checkout from Go without codegen".
###############################################################################
### Stage 2 returns 404 — the route isn't registered.
POST {{host}}/api/fn/checkout
Content-Type: application/json
{ "customer": 1, "product": 1, "qty": 2 }
###############################################################################
# Purchase (link type) — no `service rest` block declared
# Purchase edges are created by fn checkout and read via
# `Customer.purchased` traversal (Stage 3+ query language).
###############################################################################
### Purchase list NOT exposed — expected 404 (no route registered)
GET {{host}}/api/purchases

4
static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#222"/>
<text x="16" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#fff" text-anchor="middle">W</text>
</svg>

After

Width:  |  Height:  |  Size: 289 B

5
static/logo.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 32">
<rect width="32" height="32" rx="4" fill="#222"/>
<text x="16" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#fff" text-anchor="middle">W</text>
<text x="42" y="23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="18" font-weight="700" fill="#222" text-anchor="start">writeonce</text>
</svg>

After

Width:  |  Height:  |  Size: 466 B

View file

@ -1,2 +1,19 @@
<h1>about</h1> <div class="about-page">
<p>writeonce is a content platform built as a single Rust binary with embedded storage, no external database, and kernel-level subscriptions.</p> <h1>about me</h1>
<p>Hi, I'm <strong>Shoney John Arickathil</strong> — a full stack developer based in Mannheim, Germany. I write about software engineering, systems programming, DevOps, and things I learn along the way.</p>
<p>I work across the stack with C# .NET, Python, TypeScript, Java Spring Boot, Rust, and Go. My interests lean toward backend systems, infrastructure as code, and building tools from scratch.</p>
<p>Outside of work, I enjoy digging into low-level systems programming, experimenting with machine learning, and writing about what I find.</p>
<h2>about this site</h2>
<p>writeonce is my personal blog. The engine behind it is a single Rust binary with embedded storage — no external database, no framework, just a custom runtime with kernel-level file watching for live content updates.</p>
<p>The source code is on <a href="https://github.com/shoneyj/writeonce">GitHub</a>.</p>
<h2>get in touch</h2>
<ul class="contact-links">
<li><a href="mailto:shoney.john@outlook.com">shoney.john@outlook.com</a></li>
<li><a href="https://github.com/shoneyj">GitHub</a></li>
<li><a href="https://www.linkedin.com/in/shoney-john">LinkedIn</a></li>
</ul>
</div>

View file

@ -1,5 +1,12 @@
<article> <article class="blog-post">
<h1 data-bind="article.title">{{article.title}}</h1> <header class="post-header">
<p class="meta">by {{article.author}} &middot; {{article.tags}}</p> <h1 data-bind="article.title">{{article.title}}</h1>
{{article.content_html}} <p class="meta">{{article.date}} &middot; {{article.tags}}</p>
</header>
<div class="post-content">
{{article.content_html}}
</div>
<footer class="post-footer">
<a href="/">&larr; back to all posts</a>
</footer>
</article> </article>

View file

@ -1,4 +1,5 @@
<div class="article-card"> <div class="article-card">
<h2><a href="/blog/{{article.sys_title}}">{{article.title}}</a></h2> <h2><a href="/blog/{{article.sys_title}}">{{article.title}}</a></h2>
<p class="meta">{{article.date}}</p>
<p class="tags">{{article.tags}}</p> <p class="tags">{{article.tags}}</p>
</div> </div>

View file

@ -1,2 +0,0 @@
<h1>contact</h1>
<p>Reach out via GitHub.</p>

View file

@ -1,3 +1,3 @@
<footer> <footer>
<p>writeonce</p> <p>writeonce &mdash; Shoney Arickathil &middot; <a href="https://github.com/shoneyj">GitHub</a> &middot; <a href="https://www.linkedin.com/in/shoney-john">LinkedIn</a></p>
</footer> </footer>

View file

@ -1,7 +1,9 @@
<header> <header>
<nav> <nav>
<a href="/">writeonce</a> <a href="/" class="site-name"><img src="/static/logo.svg" alt="writeonce" class="site-logo"></a>
<a href="/about">about</a> <div class="nav-links">
<a href="/contact">contact</a> <a href="/">blog</a>
<a href="/about">about</a>
</div>
</nav> </nav>
</header> </header>

View file

@ -1,4 +1,12 @@
<h1>articles</h1> <div class="home-page">
{{#each articles}} <div class="hero">
{{> article-card article=this}} <h1>writeonce</h1>
{{/each}} <p class="tagline">a personal blog on software engineering, systems programming, and everything in between.</p>
</div>
<section class="recent-posts">
<h2>recent posts</h2>
{{#each articles}}
{{> article-card article=this}}
{{/each}}
</section>
</div>

View file

@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{page_title}}</title> <title>{{page_title}}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/styles/main.css"> <link rel="stylesheet" href="/static/styles/main.css">
<link rel="stylesheet" href="/static/styles/code-theme.css"> <link rel="stylesheet" href="/static/styles/code-theme.css">
</head> </head>

View file

@ -1,18 +1,65 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 2rem; color: #333; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.7; max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem; color: #2d2d2d; background: #fafafa; }
header nav { display: flex; gap: 1.5rem; padding: 1rem 0; border-bottom: 1px solid #eee; margin-bottom: 2rem; }
header nav a { text-decoration: none; color: #555; } /* Navigation */
header nav a:first-child { font-weight: bold; color: #111; } header nav { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; border-bottom: 2px solid #222; margin-bottom: 2.5rem; }
h1 { margin-bottom: 1rem; } header nav .site-name { text-decoration: none; display: flex; align-items: center; }
h2 { margin: 1.5rem 0 0.5rem; } .site-logo { height: 28px; width: auto; }
p { margin-bottom: 0.75rem; } header nav .nav-links { display: flex; gap: 1.5rem; }
.meta { color: #888; font-size: 0.9rem; } header nav .nav-links a { text-decoration: none; color: #555; font-size: 0.95rem; }
.tags { color: #666; font-size: 0.85rem; } header nav .nav-links a:hover { color: #111; }
.article-card { padding: 1rem 0; border-bottom: 1px solid #f0f0f0; }
.article-card h2 { margin: 0; font-size: 1.2rem; } /* Hero */
.hero { margin-bottom: 2.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e0e0e0; }
.hero h1 { font-size: 2rem; letter-spacing: -1px; margin-bottom: 0.25rem; }
.tagline { color: #666; font-size: 1.05rem; }
/* Headings */
h1 { font-size: 1.75rem; margin-bottom: 1rem; letter-spacing: -0.5px; }
h2 { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
h3 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
/* Text */
p { margin-bottom: 1rem; }
a { color: #0066cc; }
a:hover { color: #004499; }
/* Article cards */
.recent-posts h2 { margin-bottom: 1rem; font-size: 1.1rem; text-transform: uppercase; letter-spacing: 1px; color: #888; font-weight: 600; }
.article-card { padding: 1.25rem 0; border-bottom: 1px solid #eee; }
.article-card h2 { margin: 0 0 0.25rem; font-size: 1.2rem; }
.article-card a { text-decoration: none; color: #222; } .article-card a { text-decoration: none; color: #222; }
.article-card a:hover { color: #0066cc; } .article-card a:hover { color: #0066cc; }
article section { margin-bottom: 1.5rem; } .article-card .meta { color: #999; font-size: 0.85rem; margin-bottom: 0.25rem; }
code { background: #f5f5f5; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; } .article-card .tags { color: #888; font-size: 0.8rem; }
pre { background: #f5f5f5; padding: 1rem; border-radius: 6px; overflow-x: auto; margin: 1rem 0; }
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #eee; color: #999; font-size: 0.85rem; } /* Blog post */
.blog-post .post-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #eee; }
.blog-post .post-header h1 { margin-bottom: 0.5rem; }
.blog-post .post-content { margin-bottom: 2rem; }
.blog-post .post-content p { margin-bottom: 1rem; }
.blog-post .post-content ul, .blog-post .post-content ol { margin: 1rem 0; padding-left: 1.5rem; }
.blog-post .post-content li { margin-bottom: 0.5rem; }
.blog-post .post-footer { padding-top: 1.5rem; border-top: 1px solid #eee; }
.blog-post .post-footer a { color: #555; text-decoration: none; font-size: 0.9rem; }
.blog-post .post-footer a:hover { color: #111; }
.meta { color: #888; font-size: 0.9rem; }
/* About page */
.about-page h2 { margin-top: 2rem; }
/* Contact links */
.contact-links { list-style: none; padding: 0; }
.contact-links li { padding: 0.4rem 0; }
.contact-links a { color: #0066cc; text-decoration: none; }
.contact-links a:hover { text-decoration: underline; }
/* Code */
code { background: #f0f0f0; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; }
pre { background: #f0f0f0; padding: 1rem; border-radius: 6px; overflow-x: auto; margin: 1rem 0; }
pre code { background: none; padding: 0; }
/* Footer */
footer { margin-top: 3rem; padding-top: 1rem; border-top: 2px solid #222; color: #999; font-size: 0.85rem; }
footer a { color: #999; text-decoration: none; }
footer a:hover { color: #555; }