# Phase 4 — Client API: Wire Protocol and Subscriptions > How remote clients connect, query, and subscribe to live changes — no polling anywhere in the chain. **Previous**: [Phase 3 — In-Memory Engine](./03-inmemory-engine.md) | **Next**: [Phase 5 — Go Client SDK](./05-go-sdk.md) | **Index**: [database.md](../database.md) --- Once the engine is complete it is a **traditional database server**: remote clients connect over the network, issue queries, receive results, and (critically for e-commerce UX) **subscribe to changes without polling**. The client API has three problems to solve: 1. **Wire protocol** — how bytes move between client and server. 2. **Query surface** — what queries look like from the client's perspective (raw `.wo`, SQL, GraphQL, REST). 3. **Subscriptions** — how the server pushes change notifications to clients when matching data mutates, with no client-side polling. ## The Polling Problem This Must Avoid Every naive realtime system reaches for polling first. For an e-commerce platform it is disqualifying: | Polling | Subscriptions | | --- | --- | | Client asks "anything new?" every N ms | Server tells client "here's what changed" when it changes | | Wasted RTTs when nothing changed | Zero traffic when nothing changes | | Stale data up to N ms old | Sub-ms latency after commit | | `O(clients × poll_rate)` server load | `O(mutations × matched_subscribers)` — scales with real change, not client count | | Inventory display lies for up to N ms | Inventory display reflects the commit | | "Order shipped" email triggered by cron | Fired by a committed status-change | Every subscription-based design in this section follows the same rule already set by writeonce in [05-datalayer.md](../../05-datalayer.md) and [03-data.md](../../03-data.md): **the client registers a query once, the server pushes deltas on commit, the client never asks again.** ## Protocol Layer — Pick One or Both Two protocol tiers make sense: a **native binary protocol** for app servers and ORMs that want every microsecond, and a **GraphQL-over-WebSocket layer** for browsers, mobile apps, and third parties. They are not alternatives — they share the same planner and subscription registry underneath. | Option | Best for | Trade-off | | --- | --- | --- | | **Custom binary over TCP** | App servers, in-house clients, highest throughput | Need to ship client libs in every language | | **Postgres wire protocol** (libpq) | Reuse the Postgres client ecosystem (psql, pgx, node-postgres, JDBC) | Locked into Postgres's shape — no native graph/live-query verbs | | **gRPC (HTTP/2)** | Cross-language, well-tooled, server-streaming RPC covers subscriptions | Protobuf schema overhead; HTTP/2 stack cost | | **GraphQL over HTTP + WebSocket** | Web/mobile clients, schema-aware tooling, built-in `subscription` operation | Parser/resolver overhead, N+1 risks | | **REST + SSE** | Simplest to integrate (curl, browser `fetch`) | Verb-per-endpoint sprawl, SSE is unidirectional | **Recommended combination:** - **Native binary protocol** for first-party app servers (cart service, checkout, fulfillment). - **GraphQL over WebSocket** for everything else (web, mobile, partner APIs). Both terminate at the same **session layer** inside the server, which delegates to the `.wo` planner. ## Native Binary Protocol — Shape A minimal framing that's compatible with io_uring on both ends: ``` ┌────────┬────────┬──────────┬─────────────────────────────┐ │ opcode │ req_id │ len │ payload │ │ u8 │ u64 │ u32 │ bincode / msgpack │ └────────┴────────┴──────────┴─────────────────────────────┘ ``` Opcode set: | Opcode | Direction | Purpose | | --- | --- | --- | | `HELLO` | C → S | Protocol version + auth credentials | | `WELCOME` | S → C | Session id + server capabilities | | `PREPARE` | C → S | Compile a `.wo` query, cache plan on server | | `EXECUTE` | C → S | Run prepared plan with bound parameters | | `RESULT` | S → C | Full result set for one query | | `BEGIN` / `COMMIT` / `ROLLBACK` | C → S | Explicit transaction control | | `SUBSCRIBE` | C → S | Register a live query, get a subscription id | | `UNSUBSCRIBE` | C → S | Cancel a subscription | | `DELTA` | S → C | Pushed change matching a subscription | | `COMPLETE` | S → C | Subscription terminated server-side (schema change, etc.) | | `ERROR` | S → C | Typed error with query context | | `PING` / `PONG` | bidirectional | Dead connection detection (no polling for data — just keepalive) | Multiplexed: many in-flight `req_id`s per connection, responses interleaved. Matches io_uring's async nature naturally — a connection never blocks on a slow query. ## GraphQL — Schema, Queries, Mutations, Subscriptions GraphQL has the three verbs e-commerce actually uses: | GraphQL operation | `.wo` mapping | | --- | --- | | `query` | `SELECT` / `MATCH` over the engine, single response | | `mutation` | `INSERT` / `UPDATE` / `DELETE` / `CREATE` inside an implicit transaction | | `subscription` | `LIVE SELECT` / `LIVE MATCH` — server pushes on match | **Schema generation.** The `.wo` DDL is the source of truth; the GraphQL SDL is generated from it: ``` ##sql #products (id, sku, price_cents, meta, inventory) ##doc #product-meta (title, description, reviews, ...) ##graph (user)-[:PURCHASED]->(product) │ ▼ generator │ type Product { id: ID! sku: String! priceCents: Int! meta: ProductMeta! inventory: InventoryLevel! similarTo(limit: Int = 10): [Product!]! # graph traversal purchasedBy: [User!]! # graph traversal } type Subscription { productUpdated(id: ID!): Product! inventoryChanged(sku: String!): InventoryLevel! orderStatus(orderId: ID!): Order! } ``` **Subscription example (e-commerce checkout feedback loop):** ```graphql subscription CartInventory($skus: [String!]!) { inventoryChanged(sku_in: $skus) { sku onHand reserved } } ``` A web client opens this WebSocket subscription when the cart renders. The server only pushes when a committed transaction changes any of those SKUs' inventory — the cart's "2 left!" badge is always live, no polling. **Transport: `graphql-ws` protocol over WebSocket.** Standard, well-tooled (Apollo, urql, Relay, Hasura all speak it). Falls back to HTTP POST for plain queries and mutations. ## Subscription Engine — How Push Actually Works This is the mechanism that makes polling unnecessary. It lives inside the transaction coordinator from [Phase 2](./02-wo-language.md): ``` ┌─────────────────────────────────────────────────────┐ │ Transaction Coordinator (MVCC) │ │ │ │ on COMMIT(txn): │ │ delta = collect_changes(txn) │ │ matched = subscription_registry.match(delta) │ │ for (sub, rows) in matched: │ │ sub.writer.push(DELTA { sub.id, rows }) │ └─────────────────────────────────────────────────────┘ │ │ ▼ ▼ ┌─────────────────────┐ ┌──────────────────────────┐ │ Subscription │ │ Session Writer (per conn)│ │ Registry │ │ - native: io_uring send │ │ │ │ - graphql: ws frame │ │ predicate → [subs] │ │ - grpc: server stream │ └─────────────────────┘ └──────────────────────────┘ ``` **Matching strategies**, in order of cost: | Subscription shape | Matching cost | Example | | --- | --- | --- | | Keyed (primary key) | O(1) hash lookup on commit | `productUpdated(id: 42)` | | Tag / secondary index | O(1) index lookup + scan of matched rows | `orderStatusByUser(userId: 17)` | | Range | O(log n) index range + filter | `ordersPlaced(between: [start, end])` | | Graph traversal | O(edges visited) — bound by depth/limit | `recommendationsFor(userId: 17)` | | Arbitrary predicate | O(subs) — evaluate each against the delta | `LIVE SELECT ... WHERE complex` | The engine indexes subscriptions by their shape so the common cases (keyed, tag-based) don't pay the arbitrary-predicate price. This is **incremental view maintenance** — the same idea that SurrealDB live queries, Materialize, Hasura, and Feldera all implement at different levels of generality. ## Connection I/O — io_uring All the Way The same `io_uring` that drives the WAL (per [Phase 3](./03-inmemory-engine.md)) also drives client sockets. One scheduler, not a mix of epoll for networking and io_uring for storage: | Operation | io_uring opcode | | --- | --- | | Accept new client | `IORING_OP_ACCEPT` | | Read request frame | `IORING_OP_RECV` (with registered buffers) | | Write result / delta | `IORING_OP_SEND` (with `IOSQE_IO_LINK` to chain writes) | | TLS handshake | Userland ring integrated with `IORING_OP_RECV`/`SEND` (e.g., rustls or BoringSSL in non-blocking mode) | | Close | `IORING_OP_CLOSE` | | Keepalive | `IORING_OP_TIMEOUT` per connection | A subscription push is one SQE: `SEND(client_fd, delta_frame)`. Thousands of in-flight pushes across thousands of subscribers is just thousands of SQEs — the kernel batches the actual NIC writes. No thread-per-connection, no blocking send. ## Session State Each connected client has server-side state: | State | Lifetime | Notes | | --- | --- | --- | | Identity / principal | Session | JWT or mTLS validated at `HELLO` | | Current transaction | One txn at a time per session | Auto-rollback on disconnect | | Prepared statements | Session | Plan cached, re-parameterized per `EXECUTE` | | Active subscriptions | Session | All torn down on disconnect (free registry slots, stop pushing) | | Role / RBAC context | Session | Feeds row-level policies into the planner | | Back-pressure credits | Per-subscription | Client advertises how many outstanding `DELTA` frames it can buffer | On disconnect (TCP close, keepalive failure, `EPOLLHUP`-equivalent from io_uring completion): all sessions state is freed, all subscriptions unregistered. Same philosophy as `wo-sub`'s `EPOLLHUP` → automatic `unsubscribe(fd)` from [05-datalayer.md](../../05-datalayer.md), scaled up to a real server. ## Back-Pressure A slow client cannot be allowed to stall commits. The push path must never block on a socket write: 1. Each subscription has a **bounded outbound queue** (say, 1024 deltas). 2. Writer thread drains the queue via `io_uring_send`. 3. On queue overflow, the engine has three policies: - **Drop + resync**: mark the subscription as "behind", push a single `RESYNC` marker, client re-requests current state. - **Coalesce**: fold consecutive deltas for the same key into one (last-writer-wins). - **Disconnect**: close the connection; clients with a stale subscription reconnect. 4. The coordinator never waits on a subscription — it hands the delta to the writer and moves on. This is the same trade-off Kafka makes with consumer lag: fast producers, independent consumers, bounded buffer, spillover policy. ## Authentication and Authorization Covered briefly in [Phase 2](./02-wo-language.md); the wire protocol is where it bites: - **Transport**: TLS mandatory for any non-loopback connection. Offload to `rustls` / `boringssl` userland; io_uring handles only the underlying sockets. - **Authentication** at `HELLO`: JWT (stateless), API key (server-validated), or mTLS (cert-based). - **Authorization**: RBAC + row-level policies evaluated inside the planner. A subscription's registered predicate is **intersected with the user's access policy at registration time** — if the policy says user 17 only sees their own orders, the subscription's effective predicate becomes `(original) AND user_id = 17`. Enforced once, not per push. - **Rate limiting**: per-session token bucket enforced before any query work. Cheap to implement in the io_uring accept/recv path. ## Comparison: This Design vs. Existing Products | Aspect | This design | Postgres + Hasura | Supabase Realtime | SurrealDB | Firebase | | --- | --- | --- | --- | --- | --- | | Transport | Custom binary + GraphQL/WS | SQL wire + GraphQL/WS | Postgres WAL → WS | HTTP + WS | Custom WS | | Subscriptions | Native, planner-integrated | Live queries via polling Postgres | Logical replication fan-out | Native live queries | Native | | Storage coupling | In-process | External Postgres | External Postgres | In-process | Proprietary | | Cross-paradigm | Yes (`.wo`: rel + doc + graph) | Partial (JSONB, no graph) | Relational only | Yes (rel + doc + graph) | Doc only | | Polling internally? | No | **Yes** (Hasura polls Postgres) | No (uses WAL) | No | No | | io_uring throughout | Yes | No | No | Partial | No | Hasura is the instructive one — it gives clients push subscriptions, but internally it polls Postgres because Postgres has no commit-time subscription hook. Building the subscription engine *inside* the database (as this design does) is what eliminates polling end-to-end. ## Reference Implementations - **SurrealDB** — the tightest match: custom engine, WebSocket transport, native `LIVE SELECT`. . Also in [surreal-case-study.md](../surreal-case-study.md). - **Hasura GraphQL Engine** — production-quality GraphQL over Postgres with subscriptions. Read their `graphql-engine/server/src-lib/Hasura/GraphQL/Transport/` for subscription multiplexing. - **Supabase Realtime** — Phoenix/Elixir server that tails Postgres logical replication and fans out over WebSocket. Cleanest demo of "subscriptions as a layer over an existing DB." - **PostgREST** — auto-generated REST from Postgres schema. Simpler than GraphQL, same spirit. - **EdgeDB** — custom binary protocol, custom query language (EdgeQL), compiles to Postgres underneath. Good reference for protocol framing. - **Materialize** — incremental view maintenance as a product; every query is implicitly a subscription. - **Phoenix Channels** (Elixir) — mature pub/sub-over-WebSocket with presence, back-pressure, and reconnection baked in. Worth reading even if the server is Rust/C++. - **graphql-ws** protocol — . The WebSocket sub-protocol every modern GraphQL client speaks. - **Apollo Router** — GraphQL gateway with subscription multiplexing, federation. ## Scope Addition to Phase 2 The client API is a sizable addition to the [Phase 2](./02-wo-language.md) component list: | Component | New work | | --- | --- | | Native wire codec | Binary framing, opcode dispatch, session lifecycle | | Postgres-wire compatibility (optional) | libpq protocol v3 parser — reuse clients | | GraphQL layer | SDL generation from `.wo`, resolver dispatch, `graphql-ws` subscriptions | | REST/SSE gateway (optional) | Thin translation to native protocol | | Subscription registry | Indexed by subscription shape; matched on commit | | Push writer pool | io_uring-backed, per-connection outbound queues, back-pressure policy | | TLS / auth | rustls or boringssl, JWT/mTLS at connection open | | Connection manager | Accept, keepalive, graceful shutdown, fd limits | | Observability | Per-session stats, slow query log, subscription lag, push-queue depth | Rough incremental effort on top of Phase 2: **6–12 months** for a production-quality client layer with both native and GraphQL protocols, assuming the engine underneath is working. ## Why This Matters for E-commerce Every hot user-facing screen is a subscription in disguise: | Screen | Subscription | | --- | --- | | Product page | `productUpdated(id)` — price/stock changes reflect instantly | | Cart | `inventoryChanged(sku_in: cartSkus)` — "out of stock!" appears the moment it's true | | Order status | `orderStatus(orderId)` — pending → paid → shipped, no refresh | | Admin dashboard | `LIVE SELECT COUNT(*) FROM orders WHERE placed_at > NOW() - 1h` | | Recommendations sidebar | `LIVE MATCH (me)-[:VIEWED]->-[:SIMILAR_TO]->(p)` | | Seller notifications | `LIVE MATCH (order)-[:CONTAINS]->(p) WHERE p.seller_id = $me` | Each of these is `O(1)` server work per commit — the matching subscription is indexed by the thing that changed. Without subscriptions, every one of those screens would be a polling loop hammering the database. With subscriptions, server load scales with **actual state change**, not with client count × poll rate. That is the whole argument for building the subscription engine into the database rather than bolting a message bus onto the side: **the engine already knows when something committed. Publishing the delta is a function call, not another system.**