writeonce/docs/examples/blog
2026-04-21 02:53:15 +02:00
..
tests writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00
types writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00
ui writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00
app.wo writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00
README.md Pivot to the .wo language runtime: design docs, phase plans 2026-04-21 02:48:45 +02:00
wo.toml writeonce language prototype for simple blog and ecommerce 2026-04-21 02:53:15 +02:00

blog — a sample writeonce app

A complete blogging website in ~200 lines of .wo that creates a database, exposes REST + live-subscription endpoints, renders HTML pages, enforces row-level policies, and emits typed client SDKs.

This project is a docs artifact — it illustrates the shape of a real wo init'd project. The wo toolchain referenced here is the one specified in ../../runtime/wo-language.md; the engine is at prototype stage in ../../../prototypes/wo-db/.

What it does

Thing How
Persists articles, authors, tags, comments type declarations compiled to relational rows + embedded documents + graph edges
Serves 24 REST endpoints (CRUD + subscribe × 4 types) service rest blocks on each type
Serves 4 web pages (list, detail, tag, admin) ##ui screens + route table in app.wo
Pushes live updates on every commit live: true on screens + LIVE queries under the hood
Enforces "drafts hidden from anonymous readers" policy read anyone when published == true
Bumps published_at automatically on update trigger inside the transaction
Generates a typed Go client wo gen sdk --lang go

Project layout

blog/
├── wo.toml                   # project manifest (like go.mod)
├── app.wo                    # routes, theme, startup hooks
├── types/
│   ├── author.wo             # Author type + per-type service/policy
│   ├── article.wo            # Article — all three paradigms in one type
│   ├── tag.wo                # Tag taxonomy
│   └── comment.wo            # Reader comments
├── ui/
│   ├── article_list.wo       # home page list view (live)
│   └── article_detail.wo     # per-article page with comments + related
└── tests/
    └── article_test.wo       # `wo test` picks this up

No main.wo is needed — a pure type+service app auto-generates its entry point. Add main.wo if you need CLI args, background workers, or custom startup logic beyond the on startup hook in app.wo.

Run it

$ cd docs/examples/blog
$ wo run
[wo] parsing: 7 files, 4 types, 2 ui screens
[wo] compiling schema: 4 sql tables, 1 doc collection, 3 graph edge types
[wo] starting runtime (engine: in-memory, data_dir: ./data)
[wo] on startup: seed_admin() — inserted admin@example.com
[wo] HTTP listening on :8080

  GET    /api/articles              list
  GET    /api/articles/:id          get
  POST   /api/articles              create
  PATCH  /api/articles/:id          update
  DELETE /api/articles/:id          delete
  WS     /api/articles/live         subscribe
  GET    /api/authors               list
  GET    /api/authors/me            me
  WS     /api/authors/live          subscribe
  GET    /api/tags                  list
  GET    /api/comments              list
  POST   /api/comments              create
  WS     /api/comments/live         subscribe
  ... (and the rest)

  GET    /                          ui.article-list
  GET    /article/:slug             ui.article-detail
  GET    /tag/:slug                 ui.article-list (filtered)
  GET    /admin                     ui.article-list (role: Admin)

Exercise the REST API

# Create an author (requires admin session — see auth docs; stub'd here for brevity)
$ curl -X POST localhost:8080/api/authors \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $ADMIN_TOKEN" \
    -d '{"email":"alice@example.com","handle":"alice","display":"Alice","role":"Author"}'
{"id":2,"email":"alice@example.com","handle":"alice",...}

# Create an article as that author
$ curl -X POST localhost:8080/api/articles \
    -H "Authorization: Bearer $ALICE_TOKEN" \
    -d '{
      "slug": "hello",
      "title": "Hello, writeonce",
      "author": 2,
      "meta": {"excerpt":"First post","body_md":"# Hi\n\nHello."},
      "published": true
    }'
{"id":1,"slug":"hello","title":"Hello, writeonce","published_at":"2026-04-17T12:00:00Z",...}

# List published articles (public — no token)
$ curl localhost:8080/api/articles
[{"id":1,"slug":"hello","title":"Hello, writeonce",...}]

# Filter by tag (via the query layer)
$ curl 'localhost:8080/api/articles?tags.slug=rust'
[...]

Subscribe to live updates

$ websocat ws://localhost:8080/api/articles/live?published=eq.true
{"kind":"snapshot","rows":[{"id":1,"slug":"hello",...}]}

# Now in another terminal, update article 1. The open socket receives:
{"kind":"update","id":1,"old":{"title":"Hello, writeonce"},"new":{"title":"Hello!"}}

No polling. The subscription predicate was registered at connect time; the engine's commit path emits the delta directly.

Generate a Go client

$ wo gen sdk --lang go --out ./client
[wo] reading types from ./types/
[wo] writing ./client/sdk.go (4 types, 16 endpoints, 4 subscriptions)

Use it:

import "github.com/you/blog/client"

c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))

// Typed query
articles, _ := c.Articles.List(ctx, client.Where{Published: ptr(true)})

// Typed subscription — deltas arrive on a channel
sub, _ := c.Articles.Subscribe(ctx, client.Where{Published: ptr(true)})
for d := range sub.C {
    switch d.Kind {
    case client.Insert:
        fmt.Printf("new article: %s\n", d.Row.Title)
    case client.Update:
        fmt.Printf("updated: %s\n", d.Row.Slug)
    }
}

Run the tests

$ wo test
=== tests/article_test.wo ===
  create and fetch by slug                               OK (3ms)
  policy blocks public read of unpublished drafts        OK (4ms)
  graph traversal: related articles                      OK (7ms)
  live subscription receives delta on commit             OK (12ms)

PASS  4/4 tests, 0 failures (26ms)

Each test block runs against an isolated engine snapshot that's rolled back at the end — no setup/teardown code needed.

Build a production binary

$ wo build --target linux-amd64 --out bin/blog
[wo] static binary: bin/blog (14 MB, database + HTTP + subscription engine embedded)
$ ./bin/blog
[wo] HTTP listening on :8080

One binary, no dependencies. Copy it to a server, run it, done. The database file lives in ./data/ relative to the binary; the WAL ensures crash safety (Phase 3).