Skip to content

Introduction

Planck is a document database that hosts your application right inside its own process. You write the handlers, queries, and templates, compile all of that into one WebAssembly module, and the database loads the same module and runs it. Requests come in over HTTP, the runtime dispatches them to your code, your code talks to the store through host functions, and the bytes go back out.

This page walks through what all that means in practice: the unit of deployment, the runtime layout, the patterns you can build on top of it, and the six repos that make up the project.


The unit of deployment

A Planck service is just one .wasm file. We describe it using three words, and each of these words maps to a concrete property of the binary.

Discrete

A service maps to exactly one bounded context. orders.wasm owns order placement, line items, totals, and the lifecycle from pending to paid. It does not own inventory. It does not own delivery routing. Cross-context work happens by emitting and observing events on change streams (more on those below), and not by reaching into another service's tables.

The binary boundary is the bounded-context boundary itself. No shared schema, no cross-domain joins, no "common" library of types that gets pulled in everywhere. Two services that both talk about an "order" might use entirely different in-memory shapes for the same, and that is perfectly fine.

Dense

One process contains the HTTP listener, the WASM runtime, and the storage engine. The .wasm you ship contains the domain types, the handlers, the templates, and the business rules. The artifact you build and the artifact that runs are one and the same.

One file, one version, one checksum.

Sovereign

The service owns its data, its schema, its evolution cadence, and its release schedule. Nothing outside the service reads its store directly. The only way in is through the HTTP routes that the service exposes. In case orders wants to migrate its schema, it does so without coordinating with inventory. And if inventory wants to roll back to last week's binary, orders does not even notice.

Sovereignty is what lets two teams ship without locking each other's calendars together. It also makes the "database per service" rule cheap, because the database is already sitting inside the service.


The runtime, end to end

One process. Three pieces are worth naming here: the HTTP listener, the WASM runtime, and the change streams.

The database hosts the runtime

The Planck engine loads your .wasm module into a pool of WASM instances. When an HTTP request arrives, the listener picks a free instance, hands the request to your handler, and ships out the bytes that the handler produces.

What you ship:

  • One .wasm file.
  • A db.yaml and a service.yaml for the engine and the runtime.

That is the whole deployable.

Direct access to the store

The handler and the store live in the same process. Queries go through a host function that the engine exposes to the WASM module. A handler looks roughly like this:

zig
fn handle(ctx_ptr: ?*anyopaque, allocator: std.mem.Allocator, req: *const Request, res: *Response) !void {
    var q = try planck.Query.initWithAllocator(ctx.client, allocator);
    const orders = try q
        .where("customer_id", .eq, customer_id)
        .where("status", .eq, "pending")
        .limit(20)
        .run();
    // ... build a response ...
}

.run() walks the in-memory index inside the database process and hands back rows that the WASM module reads from host memory, with a typed view layered on top. Reads do not allocate a result-set parser, and they do not open a connection. As such, a second query costs roughly the same as the first one.

Business rules stay in the handler, where they belong. The store stores; the handler decides.

You own the response format

A handler returns bytes. What those bytes actually are is your call, per route.

  • HTML fragments for an HTMX, Datastar, or Alpine swap. The template engine (ZSX, part of schnell) lives in the same process.
  • JSON for an API or a SPA fetch.
  • Server-sent events for live tails and dashboards.
  • Raw bytes for a CSV export or a binary blob.
  • A redirect, or an empty body with a status.

Rendering a ZSX template looks like this:

zig
var out = std.ArrayList(u8).init(allocator);
defer out.deinit();
try Fragment.render(args, &out, allocator);
try res.html(out.items);

The same service can mix all of these. /orders returns an HTML fragment for the dashboard; /api/orders returns JSON for an external integration; /orders/stream opens an SSE channel for live status. Three routes, one binary, one store.

Coordination over change streams

WASM modules are sandboxed. They cannot open sockets. Do note that this is the design itself, and not some workaround.

Every store can publish a change stream: an ordered log of insert, update, and delete events keyed by LSN. A separate native process (typically the sse/ subproject of your app) opens a Watch against the streams it cares about and reacts to them. The producer does not know that the consumer exists. Adding a new consumer needs zero changes on the producer side.

Watch is a native-client capability. From inside a WASM module you do not subscribe directly; instead you run a small native sidecar that does the long-poll for you. That is exactly what the sse/ subproject in every scaffolded template is meant for, and it is also what ssehub is built on.

For browser-side live updates, change streams fan out through ssehub: a small standalone service that turns one Watch into N browser EventSource connections. SSE is the browser fan-out. The cross-service contract is the change stream itself.

In day-to-day terms: think in events between services, and let each service maintain its own projection of the data it cares about. That is standard Self-Contained Systems doctrine.

Operational tasks live in workbench

Background work that keeps the database healthy (backup, gc, WAL truncate, stats, export, import, restore) is scheduled and run from workbench, the control plane, against each connected planck. Do note that replication is not one of these tasks. Primary-to-replica WAL shipping runs continuously between planck nodes, and not on a schedule.


Three architecture styles

The engine does not dictate any single one. The same toolchain produces three different styles; kindly pick the one your team already understands. planctl new scaffolds all three.

One .wasm per bounded context. Data, logic, and the UI, all of it lives in the same binary. This is the Self-Contained System (SCS) pattern.

orders.wasm
├── stores:    orders, line_items, customers (projection)
├── handlers (HTML): /orders, /orders/:id, /checkout
├── handlers (JSON): /api/orders, /api/orders/:id
├── templates: order_card.zsx, checkout.zsx
└── watches:   inventory.reserved, payment.captured

The UI for "the orders area of the app" is served by the orders service itself. A shell app composes navigation across services, but it does not render their content. This is what lets SCS be independently deployable end to end, front end included.

Pick this when:

  • You are starting something new and do not already have a SPA mandate.
  • You want teams to ship features without coordinating across multiple repos.
  • "The page" maps cleanly to a domain.

Monolith

One .wasm that exposes handlers across every domain. Each domain still owns its own store. The handlers are consolidated; the data is partitioned by domain.

app.wasm
├── stores:   orders, customers, inventory, delivery   (per domain)
├── handlers: /orders/*, /inventory/*, /delivery/*
└── templates: shared layout + per-domain templates

You give up independent deployability per domain. In return you get one binary, one log, one config. Pick this when the team is small enough that splitting the binary would not really split the people, or when boundaries are still shifting, or when deploy independence is simply not worth the modeling cost.

Even in monolith style, kindly do not share a single store across domains. Domain-wise stores keep the option open to split into the service-per-domain style later on.

Microservices

One .wasm per service, JSON only, no HTML. The UI is a separate SPA (Vue, React, Svelte, whichever you prefer). Each service is a classical JSON-in JSON-out service that owns its store.

Pick this when you already have an SPA codebase and a team that owns it, or when you integrate with a lot of non-browser consumers, or when the org is structured around back-end and front-end teams and changing that is not on the table.

Mixing

Nothing stops you from running service-per-domain for most of the app and the microservices style for that one slice which needs an SPA. The choice is per service, and not global.


The six repos

Planck ships as three binaries from a single release tarball, but the project itself is six repos:

  • planck is the database engine: storage, wire protocol, optional WASM hosting, change streams, replication.
  • workbench (wb) is the control plane: identity, supervision, query editor, deploy receiver, scheduler, backup and restore.
  • planctl is the CLI for setting up the host, scaffolding projects, deploying them, and tearing them down.
  • planck-zig-client is the Zig SDK.
  • schnell is the Zig web and template framework (ZSX lives here).
  • ssehub is the SSE fan-out service that turns change streams into browser EventSource connections.

Workbench owns identity, orchestration, IDP, and JWT keys, and it is where the day-to-day operations happen.


A few defaults worth knowing

A handful of decisions are baked in.

  • Config is YAML. The engine reads db.yaml; the runtime reads service.yaml. Both come with safe defaults.
  • TLS in mono apps defaults to TLS 1.3 with an auto-generated self-signed certificate. You can bring your own cert, or disable TLS altogether when Planck sits behind a proxy that terminates it for you.
  • A reverse proxy is optional. In case you want one, just drop your own Caddyfile, nginx.conf, or Traefik config into the project; Planck does not pick one for you.
  • Outbound calls from WASM go through an explicit upstream allowlist in service.yaml.

That much is enough to run a service end to end: one .wasm, a db.yaml, a service.yaml, and the binaries from the release tarball.