23 KiB
Phase 6 — Low-Code Full-Stack: .wo as an Application Language
Expand
.wofrom a query language into a declarative application DSL — schema, services, UI, business logic, and authorization in one language, compiled into a single binary.
Previous: Phase 5 — Go Client SDK | Index: database.md
Up to this point .wo is a query language. The next move is to expand it into an application language — a declarative, low-code/no-code DSL in the shape of SAP Core Data Services (CDS): one language, one file extension, one compilation pipeline that produces database schema, service endpoints, UI screens, and business logic from the same source tree.
The reference precedent is SAP CDS, where a small amount of .cds code declares:
- Entities (tables), types, associations (relationships)
- Services that project entities to OData/REST
- UI annotations (
@UI.LineItem,@UI.Facet) that drive SAP Fiori rendering - Actions, functions, and authorization rules
From those declarations, SAP generates a full running application — data model, REST API, CRUD UI, authorization layer — with the developer writing almost no imperative code. .wo aims at the same target, for the same reason: most enterprise and e-commerce applications are 90% CRUD on structured data with live views; declaring what you want and letting the runtime generate the rest is faster than hand-writing it.
Two Authoring Styles — Type-Attached vs Standalone
Behavior declarations fall into two groups:
| Group | Blocks | Authoring style |
|---|---|---|
| Behavior ON an entity | policy, on <event>, service |
Type-attached — declared inside the type block they govern. One name-resolution rule, zero cross-file coupling for single-entity behavior. |
| Cross-entity composition | ##ui, ##app, ##logic that spans entities |
Standalone — a screen composes multiple entities via source:; an app manifest names routes; a workflow that touches both orders and inventory needs its own block. |
Type-attached is the default — and the preferred authoring style because the schema layer (Phase 2) already names entities, and policy read when author == $session.user is most legible next to the author: ref User declaration it references.
Both forms compile to the same runtime model. A type-attached policy block is de-sugared into the same planner rewrite rule as a standalone ##policy block — splitting is an authoring convenience.
Project Layout
Convention over configuration. A .wo project is a tree of .wo files, each in a role-specific directory. The compiler discovers files by path.
myapp/
├── app/
│ ├── database/ # schema — `type` declarations (with inline
│ │ ├── article.wo # policy, on, service blocks per type)
│ │ ├── user.wo
│ │ └── order.wo
│ ├── ui/ # standalone screens — ##ui
│ │ ├── list.wo
│ │ ├── detail.wo
│ │ ├── form.wo
│ │ └── dashboard.wo
│ ├── logic/ # cross-entity workflows — ##logic
│ │ └── order-workflow.wo
│ ├── auth/ # cross-entity / session-level policies —
│ │ └── policies.wo # ##policy for things that don't fit on a type
│ ├── api/ # service bundles that expose many types —
│ │ └── services.wo # ##service for multi-entity APIs
│ └── app.wo # root: name, routes, theme, i18n
├── migrations/ # generated, versioned schema migrations
├── static/ # hand-written assets (images, custom CSS)
└── wo.toml # project metadata
Most single-entity behavior lives next to the type in database/; logic/ and auth/ and api/ are for the cross-entity cases. Every file contributes to a single compiled model. Splitting is for humans; the runtime sees one graph of declarations.
File Type Examples
app/database/article.wo — one type declaration covers relational fields, embedded document, graph edges, and the per-entity policy/triggers/service:
type Article {
id: Id
sys_title: Slug @unique
title: Text
published: Bool = false
author: ref User -- foreign key
meta: { -- embedded document
tags: [Text]
excerpt: Text
body_md: Markdown
reviews: [{ user: ref User, stars: Int, body: Markdown, at: Timestamp }]
}
created_at: Timestamp = now()
published_at: Timestamp?
related: multi Article @edge(:RELATED_TO)
prerequisites: multi Article @edge(:PREREQUISITE)
-- type-attached policy — replaces a separate ##policy block for
-- the single-entity case
policy read when published == true
policy read for role editor
policy read for role owner when author == $session.user
policy write for role editor
policy write for role owner when author == $session.user
policy delete for role admin
-- type-attached trigger — fires inside the transaction on commit
on update when old.published == false and new.published == true
do set self.published_at = now()
do emit "article.published"(self)
do enqueue "send-subscriber-emails" with { article_id: self.id }
-- type-attached service — exposes CRUD + subscribe on a REST path
service rest "/api/articles" expose list, get, create, update, delete, subscribe
}
type User { ... } -- authors; AUTHORED is derivable from Article.author via backlink
The compiler emits the underlying ##sql relational row, ##doc embedded structure, and ##graph edges from the single type declaration. multi Article @edge(:RELATED_TO) declares a zero-property graph edge whose direction and label match the original graph sketch; ref User emits a foreign-key column in the relational store. Inverses (User.articles: backlink Article.author) generate an AUTHORED edge — or a plain inverse column, at the planner's discretion.
app/ui/list.wo — a live list view; renders to HTML, wires subscriptions automatically:
##ui
#article-list
title: "Articles"
source: article
live: true -- auto-subscribes via LIVE query
filter:
published = true
columns:
- sys_title label: "Slug"
- title label: "Title" searchable
- meta.tags label: "Tags" renderer: tag-chips
- created_at label: "Created" renderer: relative-date
- author.name label: "Author" join: author_id -> user
sort:
default: created_at desc
actions:
row-click: /article/:sys_title
create: /article/new role: editor
row-edit: /article/edit/:id role: editor | owner
row-delete: delete role: editor confirm: true
pagination: 20
app/ui/detail.wo — a detail view composed of nested renderers, including a graph traversal:
##ui
#article-detail
title: $article.title
source: article
key: sys_title
live: true
sections:
- header:
fields: [title, author.name, created_at]
- body:
renderer: markdown
source: meta.body_md
- related:
title: "Related Articles"
renderer: list
source: Article{ sys_title == $key }.related -- schema-layer path
columns: [title, meta.excerpt]
live: true
Screens stay standalone because they compose data from multiple types. The source: expression is a schema-layer path (preferred) or a raw MATCH/SELECT from the query layer — both resolve to the same planner input.
app/logic/order-workflow.wo — ##logic is reserved for cross-entity triggers that don't belong on a single type. The on-article-published trigger lives on type Article (shown above); the on-order-placed trigger touches orders and every product in the line items, so it stays standalone:
##logic
#on-order-placed
when: insert(Order)
do:
- validate: self.total == sum(self.line_items.*.qty * self.line_items.*.unit)
- for-each item in self.line_items:
- update: Product{ id == item.product.id }
set inventory.on_hand -= item.qty
assert inventory.on_hand >= 0
app/auth/policies.wo — reserved for session-level or cross-entity rules that don't fit on a single type. Most RBAC lives type-attached (see the policy read ... block on type Article above). A standalone ##policy is useful for things like "admins bypass all row filters":
##policy
#admin-bypass
applies_to: Article, Order, User
when: role == admin
effect: skip-row-filters
app/api/services.wo — reserved for multi-entity API bundles. Single-entity services live type-attached (see service rest "/api/articles" on type Article above). A bundle endpoint that exposes a curated subset or a custom aggregation goes here:
##service
#storefront
path: /api/storefront
protocols: [rest, graphql]
expose:
- Product as products operations: [list, get, subscribe]
- Article as articles operations: [list, get]
- categories: Product{ featured == true }.category -- custom path
app/app.wo — root manifest:
##app
name: "writeonce"
version: 1
theme: "light"
i18n: [en, de]
routes:
/ -> ui.article-list { filter: { published: true } }
/article/:slug -> ui.article-detail { key: $slug }
/admin/articles -> ui.article-list { role: editor }
Compilation Pipeline
The .wo compiler loads every .wo file in the tree and emits a single runtime bundle:
app/**/*.wo
│
▼
┌─────────────┐
│ Parser │ one grammar, all block types
└─────────────┘
│
▼
┌─────────────┐
│ Analyzer │ name resolution across files, type check, policy check
└─────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Unified Application Model (AST) │
└─────────────────────────────────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌─────────┐ ┌────────┐ ┌──────────┐
│ Schema │ │ Services │ │ UI │ │ Logic │ │ Policies │
│ (DDL) │ │(endpoints)│ │(widgets)│ │(hooks) │ │ (rbac) │
└────────┘ └──────────┘ └─────────┘ └────────┘ └──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Migrations HTTP/GraphQL HTML / JSON Commit- Planner
applied to / native manifest time filters
engine dispatch (SSR or triggers merged into
client) every query
Each leaf maps to a runtime component from the earlier phases:
- Schema → engine: the
.woDDL goes to the in-memory engine from Phase 3. Migrations rebuild the schema; data is preserved where possible. - Services → endpoints: HTTP/GraphQL/native dispatch via the wire-protocol layer from Phase 4.
- UI → widgets: a new component — UI declarations compile to a render tree. Default renderer is server-rendered HTML (SSR) with a thin client runtime for subscription wiring.
live: trueon any UI node issues aLIVE SELECT/LIVE MATCHthrough the subscription engine and the client runtime swaps DOM fragments on each delta. - Logic → hooks: triggers compile to server-side procedures invoked by the transaction coordinator on matching commits. Same transaction as the mutation — ACID across the hook's writes.
- Policies → planner: predicates intersected with every query/subscription at registration time (already covered in Phase 4).
How Subscriptions Wire Themselves
The key low-code payoff: a UI developer never writes subscription code. They write live: true on a list or detail, and:
- The UI compiler inspects the view's
source(table, document query, or graphMATCH). - It generates a
LIVEquery that returns exactly the fields the UI displays. - It emits a subscription handle in the rendered page.
- The client runtime opens a WebSocket, registers the subscription, and binds incoming deltas to DOM fragments by key.
- When a row changes in the engine, the delta flows: engine → subscription registry → client runtime → DOM patch.
Zero hand-written subscription code. Zero polling. Adding a new live column to a list is one line in a .wo file.
Generated Application Stack
For the writeonce schema above, sa build produces:
| Layer | Generated From | Output |
|---|---|---|
| Database schema | app/database/*.wo |
In-memory engine arenas + migrations |
| REST / GraphQL / native endpoints | app/api/*.wo + app/database/*.wo |
HTTP handlers, OpenAPI spec, GraphQL SDL |
| SSR HTML | app/ui/*.wo + routes in app.wo |
Per-route renderers compiled into the server binary |
| Client runtime | app/ui/*.wo |
Small JS bundle: subscription client + DOM patcher + form binding |
| Admin UI | All of the above | Auto-generated CRUD screens for every ##sql/##doc entity (override any with a ##ui block) |
| Typed SDKs | app/database/*.wo |
Go/TypeScript/Rust clients per Phase 5 |
| Migrations | Schema diff vs. current database | Versioned forward/backward migrations in migrations/ |
| Observability | Everything | Structured logs, query metrics, subscription lag dashboards |
Equivalent hand-written stack: schema (SQL), ORM models, REST controllers, GraphQL schema + resolvers, HTML templates, client JS, subscription plumbing, migrations, admin CRUD, SDKs. Likely 10,000–50,000 lines for a small e-commerce site. .wo target: ~500 lines across the app/ tree.
Developer Experience — The CLI
sa init myapp # scaffold with sensible defaults
cd myapp
sa dev # live-reload server — edit .wo, see changes instantly
sa build --target linux-amd64 # single static binary with everything inside
sa migrate --plan # preview schema migrations
sa migrate --apply # apply migrations
sa gen sdk --lang go --out ./sdk # emit typed client
sa deploy # upload to a running runtime node
sa dev is the make-or-break command. It must:
- Detect
.wochanges via inotify (same mechanism aswo-watch) - Recompile incrementally (~50 ms for a single-file change)
- Hot-swap UI renderers without losing client state
- Run schema migrations in a sandbox, surface conflicts before applying
- Keep open subscriptions alive across reloads (re-register on connect)
Comparison With Other Declarative Full-Stack Systems
| System | Schema | UI | Logic | Live queries | Single binary |
|---|---|---|---|---|---|
| SAP CDS | .cds entities |
@UI annotations → Fiori |
Actions, functions | No (request-response) | No (Java/Node runtime) |
| Hasura | Reads from Postgres | No (external UI) | Actions, event triggers | Yes (polling-based internally) | No |
| Supabase | Postgres schema | Auto-admin UI only | Edge functions, triggers | Yes (logical replication) | No |
| Retool / AppSmith / Budibase | External DB | Visual drag-and-drop | JS snippets | Partial | No |
Wasp (wasp-lang.org) |
.wasp + Prisma |
React components | JS functions | No | No (Node + React) |
| RedwoodJS | .sdl + Prisma |
React | JS | No | No |
| Django + Admin | Python models | Auto-admin only | Python views | No | No |
| Phoenix LiveView | Ecto schemas | HEEx templates | Elixir functions | Yes (native) | No (BEAM runtime) |
Anvil (anvil.works) |
Proprietary | Python drag-and-drop | Python | No | No |
.wo (this design) |
type DSL (unified) over ##sql+##doc+##graph substrate |
##ui declarations + type-attached |
type-attached on <event> + ##logic |
Yes (native) | Yes |
The differentiators: cross-paradigm schema (no competitor unifies SQL + document + graph in one DDL), engine-native subscriptions (most bolt on a replication layer or poll), and single static binary as the deployment unit (no separate database process, no separate UI server, no separate message bus).
Closest philosophical precedents:
- SAP CDS for the declaration-first application language — the explicit inspiration.
- Phoenix LiveView for the subscription-wired UI model.
- Wasp for the
.wasp→ full stack compilation pipeline. - Django Admin for the "generate CRUD from the model" reflex.
Scope Addition
This is a compiler + a renderer + a UI toolkit on top of Phases 2–4. Rough new components:
| Component | Work |
|---|---|
type DSL parser + schema compiler |
Entity declarations → ##sql/##doc/##graph physical schema; type-attached policy/on/service → same runtime components as standalone blocks |
##ui grammar + analyzer |
UI widget tree, field bindings, renderer dispatch |
| Trigger compiler | Type-attached on <event> + standalone ##logic; both run inside txn coordinator |
| Policy compiler + planner integration | Type-attached policy + standalone ##policy both intersected with every query at registration (some of this exists in Phase 2 already) |
| Service compiler + dispatch table | Type-attached service + standalone ##service; endpoint registration at startup |
| UI render tree → SSR HTML | Template engine, layout system, component library (table, form, chart, etc.) |
| Client runtime (~50 KB JS) | Subscription client, DOM patcher, form binding, validation |
| Auto-admin UI | Generic CRUD screens per entity, override-able with ##ui |
| Migration engine | Schema diffing, forward/backward migrations, online reshape for the in-memory engine |
CLI (sa binary) |
init, dev, build, migrate, gen, deploy |
| Dev-mode live reload | inotify + incremental compiler + client hot-swap |
| Hosted runtime | Optional — for sa deploy to work without self-hosting |
Rough effort on top of Phases 2–4: 12–24 months with a small team, most of it in the UI compiler and client runtime — that's where the complexity lives, not in the language spec.
Honest Framing
This section is the endgame, not the next step. The sensible build order:
- Ship the engine (Phase 2, in-memory + io_uring durability via Phase 3) — query layer (
##sql/##doc/##graph) only. - Ship the wire protocol + Go SDK (Phase 4 + Phase 5).
- Ship the schema-layer
typeDSL that compiles to the three paradigm blocks. From this point forward, authoring happens against types; the paradigm blocks become an artifact the compiler emits. - Add type-attached
service(and standalone##servicefor bundles) — declarative endpoints. - Add type-attached
policy(and standalone##policyfor cross-entity rules) — declarative authorization. - Add type-attached
on <event>triggers (and standalone##logicfor cross-entity workflows) — declarative triggers. - Add
##ui— declarative rendering. This is where.wobecomes a low-code platform. - Add the CLI, live-reload, admin UI.
Each step is shippable on its own. Every step after (3) converts imperative code developers are writing by hand into declarative code they write once. The value compounds: by step (7), a small e-commerce app is a ~500-line .wo tree instead of a ~50 KLOC TypeScript/Go/SQL repository.
The risk is the same as every low-code platform: the 20% of use cases outside the declarative model have to have an escape hatch. .wo reserves one: any ##ui node can point to a custom server-rendered template, and any ##logic block can call out to a host-language plugin (Go/Rust/Wasm). Without that escape hatch, low-code becomes no-code in the pejorative sense — you can build 80% of the app and the rest is impossible.
Reference Implementations Worth Studying
- SAP CDS — https://cap.cloud.sap/docs/cds/. Read the CDS Language Reference cover to cover before designing
##ui. Thirty years of ERP app-generation is condensed into that spec. - Wasp — https://github.com/wasp-lang/wasp. Open-source
.wasp→ React + Node + Prisma compiler. Closest living sibling to.wo. - Phoenix LiveView — https://github.com/phoenixframework/phoenix_live_view. The rendering-subscription loop done right in Elixir.
- HTMX + Hyperscript — https://htmx.org. Tiny client runtime that consumes server-rendered HTML fragments on events. Good model for
.wo's client bundle. - Retool / Budibase / Appsmith (open source) — https://github.com/Budibase/budibase, https://github.com/appsmithorg/appsmith. Visual low-code; useful to see which UI primitives users actually ask for.
- SurrealDB
definesyntax — SurrealQL includes declarativeDEFINE TABLE,DEFINE FIELD,DEFINE EVENTthat are partway toward an application DSL. Worth studying for how much declaration fits inside a query language. - Django admin source —
django/contrib/admin/. The canonical "CRUD from models" implementation; read how it introspects schema to generate list/detail/edit views. - PocketBase — https://github.com/pocketbase/pocketbase. Single-binary Go app with SQLite, auto-admin UI, realtime subscriptions. Proves the single-binary low-code model is buildable.
.wois what PocketBase would look like if its data model were multi-paradigm and its query engine were custom.
Why This Belongs in This Series
The question that opened Phase 1 — "should writeonce use a document or graph database?" — has now inverted. The answer drove past "no, use flat files" through "build your own query language" and "build your own ACID engine" and "build your own wire protocol" to arrive here: a full-stack declarative application platform where the database, the subscriptions, the UI, and the business logic are one artifact compiled from one language.
That is the actual ambition. Every earlier phase is a subcomponent of this one. Decide honestly whether the project is a blog engine that needed a graph index, or an application platform that happens to start as a blog engine. The answer determines which phases are scope and which are cautionary.