Khadim Fall

← Index

No. 01

sp00ky

A local-first sync engine for SurrealDB. Reads are instant, writes sync themselves. The engine is a Rust stream processor built on DBSP.

Year
2025, ongoing
Role
Solo. Engine, protocol, SDK, cloud platform.
Stack
Rust, WebAssembly, TypeScript, SurrealDB, DBSP, SolidJS, Go, Firecracker
Status
in production

This is a solo project and the most ambitious thing I have built so far. I did everything from reading the underlying research paper, implementing the stream processing engine in Rust, designing the sync protocol and building the TypeScript client SDK, up to a managed cloud platform written in Go.

How it started

When I joined Productlane, one of my first projects was to replace the entire backbone of the application: migrating it from Replicache to the Zero Sync Engine. That project changed how I think about building apps. You write a query, and optimistic updates, realtime collaboration and caching just exist. There is nothing extra you have to do, and everything stays efficient, because only changes move through the system. I wanted to understand this deeply enough to build it myself. So I did, for a database I admire: SurrealDB.

What is a sync engine?

Most web apps are built on request and response: the client asks the server for data, waits, renders, and shows spinners in between. Realtime updates get bolted on with websockets, caches get invalidated by hand, and optimistic updates are hand-written for every single mutation. It works, but every feature pays the same tax again and again.

A sync engine flips this model. The client keeps a local replica of the data it cares about. Reads are instant, because they never leave the device. Writes are applied to the local replica immediately and synced to the server in the background. The server streams every relevant change back to all connected clients. The result is software that feels instant and collaborative by default. This architecture is what makes Linear feel the way it does, and tools like Replicache and Zero brought it within reach for the rest of us.

Request/response compared to the sync engine model
Request/response vs. a local replica that syncs in the background.

The point is that none of this is extra work for the app developer anymore. Optimistic updates, realtime, offline support: they stop being features you build and become properties of the platform you build on.

The hard part: keeping queries alive

Once I dug in, I realized the hard part of a sync engine is not moving bytes around. It is keeping query results up to date. A client does not subscribe to tables, it subscribes to queries: “the last 10 threads with their authors, sorted by date”. Every time any record changes, the server has to know which of the thousands of active query results are affected, and what exactly changed in them.

One write fanning out to thousands of live query results
One write, thousands of live query results that might be affected.

The naive approach is to re-run every registered query on every write. That is correct, but it scales with the size of your data instead of the size of your change. A one-row insert should not cost a full table scan times a thousand subscribers. The proper solution is a database stream processor: a system that treats the database as a stream of changes and maintains query results incrementally. I first ran into this idea in Designing Data-Intensive Applications, the book from my distributed systems lectures at TUM, and it stuck with me ever since. Systems like Materialize and Feldera pioneered this space, and the theory behind them is what sp00ky is built on.

SSP: a database stream processor from scratch

The heart of sp00ky is the SSP, the sp00ky stream processor: a Rust engine that implements incremental view maintenance based on DBSP, a mathematical framework published at VLDB 2023 by Mihai Budiu and Frank McSherry. DBSP proves that any relational query can be turned into an incremental one, and gives you the recipe. I implemented that recipe from first principles.

Z-sets: inserts and deletes become algebra

The foundational primitive is the Z-set: a collection that maps every row to an integer weight. A weight of +1 means the row is present, -1 means it was deleted, 0 means it does not exist. This sounds trivial, but it is a profound trick: inserts and deletes stop being different operations and become the same algebra. Z-sets form an abelian group under addition, which means changes can be combined, reordered and processed in parallel, and the math guarantees the result stays correct. A batch of changes, a delta, is just another Z-set.

Z-set weights and how deltas add
Z-sets map rows to integer weights, so changes combine like numbers.

Circuits: queries as dataflow graphs

A query is compiled into a circuit: a directed graph of operators (Scan, Filter, Map, Join, TopK, Aggregate, Distinct) connected by streams of Z-sets. Every operator can be evaluated in two modes: a snapshot mode that computes the full result from all data, and a delta mode that takes only a change as input and produces the corresponding change as output. Stateful operators get their memory from the integration operator (z⁻¹), which accumulates deltas over time: the state at any moment is simply the sum of everything that ever flowed through.

A query circuit transforming a delta into a view delta
A query compiled to a circuit: a delta enters, a view delta falls out.

What happens on a write

When a record changes, the SSP turns it into a tiny Z-set delta. For a new user that is literally {user:alice → +1}. That delta flows through the circuit of every affected view. Each operator transforms the change and passes it on, and what falls out at the end is a view delta: exactly which rows entered, left or changed in each query result. Clients get patched with just that. The cost is proportional to the size of the change, not the size of the database.

Being honest about the hard 20%

Implementing DBSP also taught me where the real difficulty lives. Filters and projections incrementalize beautifully: their delta rule is the operator itself. The stateful operators are the hard part. The delta rule for a join has three terms (ΔA⋈B, A⋈ΔB, ΔA⋈ΔB), which means every join node has to keep accumulated z⁻¹ state for both of its inputs. Join, TopK, Aggregate and Distinct each carry that state and update it on every step. Deriving why those three terms are exactly right, and what breaks without the state, was worth more to me than any tutorial could have been.

One engine, two worlds

The same Rust engine compiles to two targets: a native server sidecar, and an 810KB WebAssembly module that runs the identical circuit logic inside the browser. The client does not just cache data: it can compute view deltas locally, which is what makes true offline-first behavior possible.

Architecture

On the server side, sp00ky uses a hub-and-spoke topology. A single Scheduler owns change data capture: generated table events on SurrealDB post every mutation to its ingest endpoint, where it is appended to a write-ahead log and broadcast over HTTP to any number of stateless SSP sidecars. Each sidecar maintains its share of the registered views and writes the materialized results back into SurrealDB as graph edges, which means view state survives restarts and sidecars can be scaled horizontally without coordination.

sp00ky server architecture, hub and spokesp00ky server architecture, hub and spoke
The Scheduler broadcasts every change over HTTP to stateless SSP sidecars.

Every view result carries a blake3 hash of its content. Clients compare hashes to detect drift and self-heal by re-fetching a snapshot, so even if a delta is ever lost, the system converges back to correctness on its own. For collaborative text, sp00ky integrates CRDTs, so concurrent edits merge without conflicts.

The developer experience

All of this machinery exists to make the surface simple. This is a complete live query in a sp00ky app, with end-to-end types generated from the database schema:

const threads = useQuery(() =>
  db.query('thread')
    .related('author')
    .orderBy('created_at', 'desc')
    .limit(10)
    .build()
)

That is everything. This query is realtime: the UI updates the moment anyone changes a thread. It is optimistic: local mutations show up instantly. It works offline and syncs back when the connection returns. There is no cache to invalidate, no websocket handler to write, no loading state to orchestrate. That was the bar Zero set for me at Productlane, and reaching it with my own engine was the whole point of this project.

Seeing inside the engine

A sync engine earns its simple surface by hiding machinery, and hidden machinery is miserable to debug. So sp00ky ships its own Chrome DevTools extension: a panel that attaches to any page running a sp00ky app and shows the engine from the inside. Every live query appears with its status, result data and update count. Both databases are browsable, the WASM replica in the browser and the remote SurrealDB, with inline edits for poking at state. The panel also watches auth state and checks version alignment across frontend, Scheduler and SSPs, so drift between deployed components shows up as a labeled warning instead of a confusing bug.

The sp00ky panel inside Chrome DevTools, showing the Queries tab: a timeline of query updates and a table of live SurrealQL queries with status, update count, size and last update time
The sp00ky panel in Chrome DevTools. Every live query on the page, with its updates on a timeline.

The part I use most is timing. Every query keeps rolling p50/p90/p99 percentiles for each phase of an update: the WASM ingest split into store apply, circuit step and transform, then local fetch, remote fetch and frontend reconcile. The timing tab sorts all queries slowest first, so “why does this list lag” is answered by reading a table instead of sprinkling timers through the code.

The extension also bridges into an MCP server, because agents debug apps now too. An AI agent gets the same view I get: tools to run queries, read the schema with sp00ky’s annotations, pull per-phase timings and lint queries, live through the browser tab when one is connected, or directly against the database when not.

sp00ky cloud

A sync engine nobody can run helps nobody. Operating sp00ky means running SurrealDB, a Scheduler and a fleet of SSP sidecars, and I did not want that to be the price of entry. So I built sp00ky cloud: a managed platform written in Go, where a single CLI command takes you from a local project to a running, billed, backed-up cluster. Building it turned this project from an engine into a product, and it taught me a whole second discipline on top of stream processing: running infrastructure for other people.

Control plane and data plane

The platform splits cleanly in two. The control plane is a Go REST API, a Postgres database and a background worker that continuously reconciles what exists against what should exist: provisioning, black-box uptime checks of every public endpoint once a minute, and cascading cleanup of everything a deleted project leaves behind. The data plane is a fleet of hosts joined over a Tailscale mesh, each running a small Go agent that manages the actual workloads over gRPC. The control plane never talks to a tenant workload directly, and no host port is ever exposed to the public internet.

Isolation you can bill for

Containers share a kernel, and for paying customers that was not good enough. Every tenant gets their own Firecracker microVMs: separate kernels, booting in around 200 milliseconds, created and watched by a small host agent over gRPC. Each tenant lives on its own private network bridge with no route to any other tenant, and secrets are handed to the VM at boot instead of baked into images. The runtime itself is a pluggable interface with Docker, Firecracker and Nomad backends, so the deployment logic is written once and does not care what executes it.

Deployments as a state machine

A deployment walks through an explicit state machine: pending, provisioning, migrating, deploying apps, running. Every transition is persisted and streamed live to the CLI over server-sent events, so you watch your cluster come up VM by VM. The stateful infrastructure (SurrealDB, Scheduler, SSPs) survives across deployments and only the app VMs are recreated, which means redeploys keep your data and your live views, and a bad release can be rolled back.

Secrets, backups and billing

Environment secrets live in a dual-key encrypted vault: every value is encrypted with AES-256-GCM, the vault key is wrapped per member with a key derived from their passphrase, and the server never sees a passphrase at all. For CI deploys the vault key is re-wrapped under a server transit key with explicit member approval, so pipelines can decrypt without a human present. Backups snapshot SurrealDB to S3-compatible object storage on a per-project schedule and restore through their own tracked lifecycle. Billing runs on Stripe: fixed plans through checkout, the subscription lifecycle driven by webhooks, and usage tracked for storage, bandwidth and extra SSP instances.

Built for AI operators too

The entire platform is also exposed as an MCP server with over 30 tools, from deploy and scale to backup restore and vault management, guarded by scoped tokens that mask secrets by default. An AI agent can operate a sp00ky cluster end to end with exactly the same guarantees a human gets through the CLI.

What I learned

This project took me from user of a sync engine to person who has implemented the research it is built on. I learned to read a database paper and turn its algebra into production Rust, that the same code can serve a server fleet and a browser tab, and that the best developer experiences are the ones where enormous complexity is spent so the user of your tool has to spend none. Most of all, it gave me deep respect for the teams who run this architecture at real scale, because I now know exactly which problems they had to solve.

Next: 02 WhitePawn Web →