helipod
Get Started

How it works

The reactive model: how helipod knows which queries to refresh when data changes.

Reactivity in helipod is not magic, and it is not polling. It comes from one idea: the engine records what each query reads, records what each mutation writes, and refreshes a query only when the two overlap.

Everything else on this page (the data model, the commit protocol, the matcher that finds affected subscriptions without scanning every live one, and the determinism rule) follows from getting that one idea right.

Read sets: what a query actually records

When a query runs, the engine watches every read it makes and remembers it as a read set. Not "the messages table," but the exact keyspace (table and index) and key range the scan covered.

helipod/messages.ts
export const list = query({
  args: { conversationId: v.id("conversations") },
  handler: (ctx, args) =>
    ctx.db
      .query("messages", "by_conversation")
      .eq("conversationId", args.conversationId)
      .collect(),
});

This handler's read set is not "reads messages." It's the single narrow span of the by_conversation index belonging to this conversationId. .gt/.gte/.lt/.lte narrow it to a bound instead of a single key. ctx.db.get(id) records a range that is a single point. ctx.db.query(table, index).collect() with no .eq/bound at all records the whole index interval: a read set as wide as the index itself.

Even an unbounded scan records a range, just a maximally wide one. A separate table-level fallback exists for the rare read that records no range at all: a subscription with zero recorded ranges is matched by table instead, so any write to that table invalidates it. Either way the rule of thumb holds: the narrower your query, the narrower its read set, and the fewer writes can ever touch it.

Write sets: mutations are the only writers

Every write goes through a mutation, and a mutation's entire handler runs as one serializable transaction: every read inside it sees a consistent snapshot, every write it makes is invisible to everyone else until the handler returns, and then it all lands together or none of it does. On commit, the engine computes the mutation's write set: exactly which rows (as key ranges) it inserted, replaced, or deleted.

helipod/messages.ts
export const send = mutation({
  args: { conversationId: v.id("conversations"), author: v.string(), body: v.string() },
  handler: (ctx, args) => ctx.db.insert("messages", args),
});

This mutation's write set is the one row it inserted, in the by_conversation range for that conversationId. That's the same keyspace shape a read set is expressed in, and the symmetry is what makes intersection possible: a read set and a write set are the same kind of object (keyspace and range), so comparing them is a range-overlap check, not a semantic guess about what a query "means."

Commits are serialized through a single logical writer per shard (see The single-writer ceiling below) under optimistic concurrency control: validate first, then land everything in one atomic step.

Validate

Has any commit landed since this mutation's snapshot that wrote something this mutation read? The check runs against the mutation's own validated read set (built from every ctx.db.get/.collect()/.paginate() call the handler made), not a lock taken up front. The handler runs optimistically, without blocking other commits.

Apply and allocate, atomically

If validation passes, the store allocates the commit timestamp and lands every staged row at that timestamp in one atomic step, inside its own atomicity domain. There's never a window where a timestamp is allocated but its writes aren't yet landed. The write set is then emitted for the reactive fan-out.

If validation fails (a conflicting write landed first), the engine throws a conflict error and the caller replays the mutation's handler from scratch against the new snapshot. This is only safe because mutations are deterministic (see The determinism contract below): replaying the same deterministic code against fresh data is guaranteed to reproduce a correct result, not a guess.

The data model: an append-only MVCC log

Underneath both read sets and write sets is one data structure: an append-only, timestamp-ordered log of document revisions. Every write is a new log entry, never an in-place mutation of an old one:

{ ts, id, value, prev_ts }
  • ts: the commit timestamp this revision was written at.
  • id: the document's internal id.
  • value: the document's full contents at this revision, or null for a tombstone (delete).
  • prev_ts: the timestamp of this document's previous revision, forming a backward chain per document.

A snapshot read at timestamp T is "the newest revision of this document with ts <= T." This is what makes a mutation's snapshot isolation cheap: reading "as of T" is a property of the log, not a lock. The OCC validation phase above walks exactly this prev_ts chain to detect a conflicting write.

This log-shaped model is why helipod is physically schemaless on both of its storage adapters. schema.ts describes what your app expects to find in a table. It is not a CREATE TABLE statement the engine executes. App tables and fields are data rows in this one log, not DDL objects in the underlying database, so there is no migration step as schema.ts evolves, whether you're running on SQLite or on Postgres.

One SQLite database (a small fixed set of physical tables backing the document log, the index log, and a globals store) or one Postgres database (the same shape) can hold every logical table your schema declares, discriminated by table/id columns and versioned by ts. The engine never generates or runs DDL for your tables, and it never leaks which store it's talking to. That's the point of the storage adapter seam: anything that can do an ordered, point-in-time range scan qualifies as a backend.

Reactivity is the intersection

This is the heart of the system. A subscription is a query plus the read set it recorded when it last ran. When a mutation commits with write set W, the engine finds every live subscription whose read set intersects W, re-runs (or diffs, see Going deeper below) exactly those, and pushes the new result.

Every other subscription (including ten thousand others on the same table but a different key range) is untouched: not re-run, not even inspected individually.

There is no polling loop, no manual cache invalidation, and no pub/sub topic to wire up by hand. The intersection is the notification.

emits commitTs, writeRanges, writeTables yes Subscribe: query runs, records readRanges Subscription registry (keyed by keyspace + range) Write Single-writer transactor (OCC commit, assigns ts) Overlaps a subscription's ranges? Re-run or diff, then push WebSocket Client

Send a message to conversation A: the subscription watching conversation A refreshes. The thousand subscriptions watching other conversations never run. Their read sets don't overlap the write.

The determinism contract

This model only works if re-running a query (or replaying a mutation) is trustworthy. If a handler could return a different answer for the same data (because it read the clock, generated a random value, or made a network call), the engine couldn't reason about whether its result really changed. A subscription could show stale or wrong data. A replayed mutation could double-charge a card it already charged once.

So queries and mutations run under a declared capability profile: ctx never grants them a clock, randomness, or the network. Today, functions run in-process, so the JavaScript globals (Date.now, Math.random, fetch) are still physically reachable. Calling one isn't caught at runtime, it just silently breaks the reactivity and replay guarantees, so treat the profile as a hard rule. A planned V8-isolate executor will enforce it at the engine level (see The syscall boundary under Going deeper below).

The profile itself:

CapabilityQuery / MutationAction
ctx.db readsyesno (ctx.runQuery only)
ctx.db writesmutation onlyno (ctx.runMutation only)
Randomnessseeded: ctx.random(), a deterministic PRNG fixed for the transaction's lifetimenative Math.random()/crypto
Clockforbidden: no Date.now(); use ctx.now(), fixed for the transaction's lifetimenative Date.now()
Networkforbidden: no fetchnative fetch

ctx.now() and ctx.random() exist precisely so a handler that genuinely needs "the current time" or "a random value" can have one. It's just fixed for the life of that transaction (and any OCC replay of it), so the same inputs always produce the same read set, write set, and result. Anything that can't be made deterministic this way (a real network call, a webhook, sending an email) belongs in an action, which runs outside the transaction with native capabilities, has no read set or write set of its own, and is never part of a subscription.

Going deeper

Three internals worth knowing about, none required to use the system. See Reactivity for the full subscription-matching story.

The single-writer ceiling and how it scales

Every commit passes through one logical writer per shard: the OCC commit pipeline above is strictly serialized, one commit at a time, because serializing writes is precisely what makes conflict validation cheap and the commit log's timestamp ordering meaningful. That means write throughput on a single shard has a ceiling. You cannot get more total write throughput by adding threads or CPU cores to one writer, because the writer's whole job is to be the one place writes are totally ordered.

The scaling lever is therefore sharding, not concurrency: the platform can shard writes by application/namespace so each shard has its own single-writer transactor and its own commit log, and total write throughput scales by adding shards. This is also why the reactive fan-out path itself turned out to be I/O-bound, not CPU-bound, once measured against a real network-attached store (Postgres) rather than in-memory SQLite: the core mostly waits on round-trips rather than saturating a single thread, which is why the fix for fan-out cost above was an indexed matcher, not parallel worker threads. See Scaling for how sharding is configured in a real deployment.

The whole loop, restated

  1. A client subscribes to a query. The engine runs it, records its read set (as precise keyspace/range pairs, or a table-level fallback), and returns the result.
  2. Later, a mutation commits. Its handler ran as one serializable transaction, was validated against its own read set (replaying on conflict), and landed atomically with its commit timestamp. Its write set is computed on commit.
  3. The engine looks up which subscriptions the write set touches, via the interval-indexed matcher, not a scan of every live subscription.
  4. Each affected subscription is re-run or diffed and pushed over the WebSocket connection; every other subscription is untouched.

That is the entire reactive core. Schema & tables, Queries, Mutations, and Reactivity build the day-to-day API surface on top of it. But if you understand this loop, you understand helipod.

On this page