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:
parent
2af90a5dd1
commit
9279175f26
64 changed files with 1511 additions and 289 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -13,10 +13,12 @@
|
|||
# Legacy blog content and data (v1 writeonce storage)
|
||||
/content
|
||||
|
||||
# Symlink to the Linux kernel source tree for research — user-specific
|
||||
# absolute path; each contributor sets their own via
|
||||
# Symlinks to research source trees — user-specific absolute paths.
|
||||
# Each contributor sets their own via:
|
||||
# ln -s <path-to-linux-src> reference/linux
|
||||
# ln -s <path-to-go-src> reference/go
|
||||
/reference/linux
|
||||
/reference/go
|
||||
|
||||
# Editor / OS noise — left broad on purpose so a contributor doesn't
|
||||
# accidentally commit their IDE scratch or macOS metadata.
|
||||
|
|
@ -27,3 +29,5 @@
|
|||
/.vscode/*
|
||||
!/.vscode/settings.json.example
|
||||
!/.vscode/extensions.json
|
||||
|
||||
/prototypes
|
||||
9
crates/app/Cargo.toml
Normal file
9
crates/app/Cargo.toml
Normal 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
20
crates/app/src/lib.rs
Normal 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
9
crates/db/Cargo.toml
Normal 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
27
crates/db/src/lib.rs
Normal 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
9
crates/engine/Cargo.toml
Normal 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
19
crates/engine/src/lib.rs
Normal 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
9
crates/gen/Cargo.toml
Normal 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
21
crates/gen/src/lib.rs
Normal 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
9
crates/http/Cargo.toml
Normal 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
20
crates/http/src/lib.rs
Normal 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
9
crates/logic/Cargo.toml
Normal 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
19
crates/logic/src/lib.rs
Normal 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
9
crates/policy/Cargo.toml
Normal 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
25
crates/policy/src/lib.rs
Normal 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
9
crates/ql/Cargo.toml
Normal 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
13
crates/ql/src/lib.rs
Normal 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/).
|
||||
9
crates/service/Cargo.toml
Normal file
9
crates/service/Cargo.toml
Normal 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
19
crates/service/src/lib.rs
Normal 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
9
crates/sub/Cargo.toml
Normal 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
21
crates/sub/src/lib.rs
Normal 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
9
crates/txn/Cargo.toml
Normal 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
19
crates/txn/src/lib.rs
Normal 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
9
crates/ui/Cargo.toml
Normal 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
24
crates/ui/src/lib.rs
Normal 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
9
crates/value/Cargo.toml
Normal 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
14
crates/value/src/lib.rs
Normal 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
9
crates/wal/Cargo.toml
Normal 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
17
crates/wal/src/lib.rs
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
| 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
|
||||
## Layout
|
||||
|
||||
```
|
||||
ecommerce/
|
||||
├── wo.toml
|
||||
├── app.wo
|
||||
├── types/
|
||||
│ ├── customer.wo
|
||||
│ ├── product.wo
|
||||
│ ├── order.wo
|
||||
│ └── purchase.wo # link type — graph edge with properties
|
||||
├── logic/
|
||||
│ └── checkout.wo # transactional functions (fn … in txn snapshot)
|
||||
├── ui/
|
||||
│ ├── storefront.wo
|
||||
│ ├── order_tracker.wo
|
||||
│ └── admin_orders.wo # the live ops table
|
||||
└── tests/
|
||||
├── wo.toml # workspace manifest (apps[] + shared[] + database)
|
||||
├── README.md # this file
|
||||
│
|
||||
├── shared/ # code imported by one or more apps
|
||||
│ ├── types/
|
||||
│ │ ├── customer.wo # role union, policy, service rest expose
|
||||
│ │ ├── product.wo # inventory + similar_to graph
|
||||
│ │ ├── order.wo # tagged-union status + line_items array
|
||||
│ │ └── purchase.wo # link Customer -> Product
|
||||
│ ├── logic/
|
||||
│ │ ├── checkout.wo # fn checkout / mark_paid / mark_shipped
|
||||
│ │ └── seed.wo # on-startup demo seed + admin-ops bypass policy
|
||||
│ └── components/ # reusable .htmlx partials (forward-looking — UI track)
|
||||
│ ├── 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
|
||||
```
|
||||
|
||||
## 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
|
||||
$ cd docs/examples/ecommerce
|
||||
$ wo run
|
||||
[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
|
||||
# 1. Start the shared DB daemon — headless, just the engine + wire protocol
|
||||
wo db serve --data-dir ./data # listens on wo://127.0.0.1:5555
|
||||
|
||||
GET /api/products list
|
||||
GET /api/products/:id get
|
||||
WS /api/products/live subscribe
|
||||
GET /api/orders list
|
||||
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)
|
||||
# 2. Start each app, pointing at the daemon
|
||||
WO_DB=wo://127.0.0.1:5555 \
|
||||
STOREFRONT_DB_KEY=$ADMIN_TOKEN \
|
||||
wo run apps/storefront # HTTP on :8080
|
||||
|
||||
GET / ui.storefront
|
||||
GET /product/:sku ui.product-detail
|
||||
GET /orders ui.order-tracker
|
||||
GET /admin/orders ui.admin-orders (Admin | Ops)
|
||||
WO_DB=wo://127.0.0.1:5555 \
|
||||
ADMIN_DB_KEY=$ADMIN_TOKEN \
|
||||
wo run apps/admin # HTTP on :8081
|
||||
```
|
||||
|
||||
> **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
|
||||
|
||||
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.
|
||||
## Building binaries
|
||||
|
||||
```bash
|
||||
# Place an order — one HTTP call runs the whole BEGIN ... COMMIT block
|
||||
$ curl -X POST localhost:8080/api/fn/checkout \
|
||||
-H "Authorization: Bearer $CUSTOMER_TOKEN" \
|
||||
-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":"..."}]
|
||||
wo build apps/storefront # → target/wo/storefront
|
||||
wo build apps/admin # → target/wo/admin
|
||||
wo build --all # everything in apps/
|
||||
```
|
||||
|
||||
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
|
||||
$ open http://localhost:8080/admin/orders # authenticated as Admin or Ops
|
||||
```
|
||||
- `[workspace]` manifests (per-app build — UI sub-phase 05)
|
||||
- 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.
|
||||
|
||||
```
|
||||
WS /api/orders/live ? status!=Cancelled
|
||||
```
|
||||
## Comparison with the blog sample
|
||||
|
||||
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
|
||||
# 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}'
|
||||
## Source pointers
|
||||
|
||||
# 2. Payment webhook flips status Pending → Paid — row updates in place, paid_at fills in
|
||||
$ curl -X POST localhost:8080/api/fn/mark_paid -d '{"order":2}'
|
||||
|
||||
# 3. Ops ships the order — status → Shipped, shipped_at fills in
|
||||
$ curl -X POST localhost:8080/api/fn/mark_shipped -d '{"order":2}'
|
||||
```
|
||||
|
||||
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
|
||||
- **Master plan:** [`../../plan/ui/00-overview.md`](../../plan/ui/00-overview.md)
|
||||
- **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)
|
||||
- **v1 template engine that `.htmlx` compilation will reuse:** [`../../../reference/crates/wo-htmlx/`](../../../reference/crates/wo-htmlx/)
|
||||
- **Checkout transaction that's the canonical cross-paradigm test:** [`shared/logic/checkout.wo`](shared/logic/checkout.wo)
|
||||
|
|
|
|||
19
docs/examples/ecommerce/apps/admin/app.wo
Normal file
19
docs/examples/ecommerce/apps/admin/app.wo
Normal 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
|
||||
|
|
@ -3,13 +3,19 @@
|
|||
-- shown; every commit (checkout, status flip, trigger-updated timestamp) pushes
|
||||
-- a delta down the WebSocket and the client runtime swaps the row in place.
|
||||
-- No polling, no refresh button.
|
||||
--
|
||||
-- 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
|
||||
#admin-orders
|
||||
#orders
|
||||
title: "Orders — Live"
|
||||
source: Order
|
||||
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.
|
||||
filter:
|
||||
|
|
@ -37,10 +43,10 @@
|
|||
sort:
|
||||
default: placed_at desc
|
||||
|
||||
-- Single-row + bulk actions. Each one calls a `fn` from logic/checkout.wo
|
||||
-- (or a stdlib action like `email`). The compiler wires them to endpoints.
|
||||
-- Single-row + bulk actions. Each one calls a `fn` from
|
||||
-- shared/logic/checkout.wo or an app-local fulfillment fn.
|
||||
actions:
|
||||
row-click: /admin/orders/:id
|
||||
row-click: /orders/:id
|
||||
row-mark-paid: mark_paid(self) role: Admin | Ops
|
||||
row-mark-shipped: mark_shipped(self) role: Admin | Ops
|
||||
row-cancel: update self set status = Cancelled role: Admin
|
||||
24
docs/examples/ecommerce/apps/admin/wo.toml
Normal file
24
docs/examples/ecommerce/apps/admin/wo.toml
Normal 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"
|
||||
19
docs/examples/ecommerce/apps/storefront/app.wo
Normal file
19
docs/examples/ecommerce/apps/storefront/app.wo
Normal 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/
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
-- Public product list. Live inventory — when a checkout reserves the last
|
||||
-- unit, the "In stock" badge flips to "Out of stock" on every open browser
|
||||
-- without refresh.
|
||||
-- Storefront home — public product list. Live inventory: when a checkout
|
||||
-- reserves the last unit, the "In stock" badge flips to "Out of stock" on
|
||||
-- every open browser without refresh.
|
||||
--
|
||||
-- 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
|
||||
#storefront
|
||||
#home
|
||||
title: "Shop"
|
||||
source: Product
|
||||
live: true
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
-- to `customer == $session.user`, so a user only ever sees their own orders.
|
||||
|
||||
##ui
|
||||
#order-tracker
|
||||
#orders
|
||||
title: "Your Orders"
|
||||
source: Order{ customer == $session.user }
|
||||
live: true
|
||||
26
docs/examples/ecommerce/apps/storefront/wo.toml
Normal file
26
docs/examples/ecommerce/apps/storefront/wo.toml
Normal 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"
|
||||
42
docs/examples/ecommerce/shared/components/layout.htmlx
Normal file
42
docs/examples/ecommerce/shared/components/layout.htmlx
Normal 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>
|
||||
6
docs/examples/ecommerce/shared/components/money.htmlx
Normal file
6
docs/examples/ecommerce/shared/components/money.htmlx
Normal 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>
|
||||
23
docs/examples/ecommerce/shared/components/order-row.htmlx
Normal file
23
docs/examples/ecommerce/shared/components/order-row.htmlx
Normal 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>
|
||||
|
|
@ -1,24 +1,17 @@
|
|||
##app
|
||||
name: "ecommerce"
|
||||
version: 1
|
||||
theme: "light"
|
||||
i18n: [en]
|
||||
-- Idempotent demo seed + the cross-entity Admin/Ops bypass policy.
|
||||
-- Both belong to the *shared* layer rather than to any one app:
|
||||
-- * The seed primes the shared database so either the storefront or the
|
||||
-- admin app shows something on first boot.
|
||||
-- * 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
|
||||
applies_to: Order, Product, Customer, Purchase
|
||||
when: $session.role == Admin or $session.role == Ops
|
||||
effect: skip-row-filters
|
||||
|
||||
-- Startup seed: idempotent demo data so `wo run` shows something useful.
|
||||
on startup
|
||||
do: seed()
|
||||
|
||||
|
|
@ -1,16 +1,32 @@
|
|||
name = "ecommerce"
|
||||
name = "ecommerce-workspace"
|
||||
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]
|
||||
wo = ">= 0.1"
|
||||
|
||||
[server]
|
||||
listen = ":8080"
|
||||
# Apps = one binary each; shared = libraries imported across apps by path.
|
||||
# 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]
|
||||
listen = "127.0.0.1:5555" # native wire-protocol port
|
||||
data_dir = "./data"
|
||||
isolation = "snapshot" # snapshot isolation for the canonical checkout flow
|
||||
isolation = "snapshot" # default for cross-paradigm checkout
|
||||
|
||||
[test]
|
||||
parallel = false # ordering tests touch the same rows; serialize
|
||||
parallel = false # cross-app integration tests hit the same DB
|
||||
|
|
|
|||
125
docs/plan/09-concurrency-scaleout.md
Normal file
125
docs/plan/09-concurrency-scaleout.md
Normal 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.
|
||||
|
|
@ -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 |
|
||||
| --- | --- | --- |
|
||||
| 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`. |
|
||||
| Preemption (`asyncPreempt`) | — nothing — | No preemption. Handlers run to completion on the single thread. |
|
||||
| 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 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`). |
|
||||
| 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. |
|
||||
|
|
@ -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.
|
||||
- **When to escalate:** when benchmarks show a specific hotspot the compiler is provably wrong about. Not before.
|
||||
|
||||
|
||||
## The escape hatch
|
||||
|
||||
If all three defences above are exhausted — benchmarked, documented, reviewed — assembly goes in:
|
||||
|
|
|
|||
213
docs/plan/ui/00-overview.md
Normal file
213
docs/plan/ui/00-overview.md
Normal 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
17
infra/sync.sh
Executable 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
181
reference/rest/blog.rest
Normal 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
|
||||
142
reference/rest/ecommerce.rest
Normal file
142
reference/rest/ecommerce.rest
Normal 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
4
static/favicon.svg
Normal 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
5
static/logo.svg
Normal 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 |
|
|
@ -1,2 +1,19 @@
|
|||
<h1>about</h1>
|
||||
<p>writeonce is a content platform built as a single Rust binary with embedded storage, no external database, and kernel-level subscriptions.</p>
|
||||
<div class="about-page">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
<article>
|
||||
<article class="blog-post">
|
||||
<header class="post-header">
|
||||
<h1 data-bind="article.title">{{article.title}}</h1>
|
||||
<p class="meta">by {{article.author}} · {{article.tags}}</p>
|
||||
<p class="meta">{{article.date}} · {{article.tags}}</p>
|
||||
</header>
|
||||
<div class="post-content">
|
||||
{{article.content_html}}
|
||||
</div>
|
||||
<footer class="post-footer">
|
||||
<a href="/">← back to all posts</a>
|
||||
</footer>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<div class="article-card">
|
||||
<h2><a href="/blog/{{article.sys_title}}">{{article.title}}</a></h2>
|
||||
<p class="meta">{{article.date}}</p>
|
||||
<p class="tags">{{article.tags}}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<h1>contact</h1>
|
||||
<p>Reach out via GitHub.</p>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<footer>
|
||||
<p>writeonce</p>
|
||||
<p>writeonce — Shoney Arickathil · <a href="https://github.com/shoneyj">GitHub</a> · <a href="https://www.linkedin.com/in/shoney-john">LinkedIn</a></p>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<header>
|
||||
<nav>
|
||||
<a href="/">writeonce</a>
|
||||
<a href="/" class="site-name"><img src="/static/logo.svg" alt="writeonce" class="site-logo"></a>
|
||||
<div class="nav-links">
|
||||
<a href="/">blog</a>
|
||||
<a href="/about">about</a>
|
||||
<a href="/contact">contact</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
<h1>articles</h1>
|
||||
{{#each articles}}
|
||||
<div class="home-page">
|
||||
<div class="hero">
|
||||
<h1>writeonce</h1>
|
||||
<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}}
|
||||
{{/each}}
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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/code-theme.css">
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,65 @@
|
|||
* { 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; }
|
||||
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; }
|
||||
header nav a:first-child { font-weight: bold; color: #111; }
|
||||
h1 { margin-bottom: 1rem; }
|
||||
h2 { margin: 1.5rem 0 0.5rem; }
|
||||
p { margin-bottom: 0.75rem; }
|
||||
.meta { color: #888; font-size: 0.9rem; }
|
||||
.tags { color: #666; font-size: 0.85rem; }
|
||||
.article-card { padding: 1rem 0; border-bottom: 1px solid #f0f0f0; }
|
||||
.article-card h2 { margin: 0; font-size: 1.2rem; }
|
||||
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; }
|
||||
|
||||
/* Navigation */
|
||||
header nav { display: flex; justify-content: space-between; align-items: center; padding: 1rem 0; border-bottom: 2px solid #222; margin-bottom: 2.5rem; }
|
||||
header nav .site-name { text-decoration: none; display: flex; align-items: center; }
|
||||
.site-logo { height: 28px; width: auto; }
|
||||
header nav .nav-links { display: flex; gap: 1.5rem; }
|
||||
header nav .nav-links a { text-decoration: none; color: #555; font-size: 0.95rem; }
|
||||
header nav .nav-links a:hover { color: #111; }
|
||||
|
||||
/* 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:hover { color: #0066cc; }
|
||||
article section { margin-bottom: 1.5rem; }
|
||||
code { background: #f5f5f5; padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.9em; }
|
||||
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; }
|
||||
.article-card .meta { color: #999; font-size: 0.85rem; margin-bottom: 0.25rem; }
|
||||
.article-card .tags { color: #888; font-size: 0.8rem; }
|
||||
|
||||
/* 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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue