Skip to content

Live Queries

Live queries are how plasma pushes state changes into your UI. You write a query the same way you’d write a one-shot select; adding .live() (or useLiveQuery in React) turns it into a subscription that reruns / re-diffs whenever the underlying rows change.

const live = db.select().from(todos).where(eq(todos.done, 0)).live()
const unsub = live.subscribe((rows) => {
console.log("open todos:", rows.length)
})
// later
unsub()
  • .live() returns a LiveQuery<T> handle.
  • .subscribe(cb) registers a snapshot subscriber. cb(rows) fires every time the window shifts.
  • The returned function unsubscribes.

In React:

import { useLiveQuery } from "@sh1n4ps/plasma-react"
function OpenTodos() {
const rows = useLiveQuery(
() => plasma.db.select().from(todos).where(eq(todos.done, 0)),
[],
)
return <ul>{rows.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
}

The hook returns the current snapshot; React re-renders whenever it changes.

.subscribe gives you the full row list every time something changes. For a table with 10,000 rows and one insert, that’s a 10,001-element array your React reducer has to diff.

.subscribeDelta gives you exactly what changed:

const live = db.select().from(todos).live()
const unsub = live.subscribeDelta?.((delta) => {
console.log("added:", delta.added)
console.log("removed:", delta.removed)
console.log("changed:", delta.changed)
})

Shape:

interface RowDelta<T> {
readonly added: readonly T[]
readonly removed: readonly T[]
readonly changed: readonly T[]
}
  • added — rows that entered the window
  • removed — rows that left the window
  • changed — rows that stayed but whose projected values shifted

The ?. guard is because subscribeDelta is LiveQuery<T>['subscribeDelta']?, optional on the interface — a backend that can’t produce diffs without recomputing (e.g. a server-side fallback) can omit it. Client IVM-eligible queries always expose it.

Situation Reach for
Rendering a list under 1000 rows subscribe (React reconciles cheaply enough)
Rendering a large list with per-row heavy rendering subscribeDelta (patch DOM manually / feed a virtualiser)
Feeding an RxJS / Solid signal / Preact signal subscribeDelta (the shape matches those primitives)
Auditing state changes (“what did the user just do?”) subscribeDelta (the shape lists exactly the touched rows)

The IVM engine sets up asynchronously (it reads seed rows from IDB). subscribe(cb) returns before cb has fired the initial snapshot. For code that wants to observe the first snapshot deterministically — tests, transitions triggered by a specific view being ready — whenReady() returns a Promise that resolves when the initial snapshot has been delivered:

const live = db.select().from(todos).live()
live.subscribe((rows) => setRows(rows))
await live.whenReady?.()
// setRows has fired at least once

whenReady is optional on the interface (whenReady?()) — same reason as subscribeDelta.

Not every query gets the IVM treatment. classifyIvm(ast) returns one of three kinds:

  • "select" — WHERE / ORDER BY / LIMIT / OFFSET across single or inner/left-joined sources. Handled by IvmLiveQuery. Per-mutation cost: O(delta).
  • "aggregate" — GROUP BY / HAVING / count/sum/avg/max/min. Handled by IvmAggregateLiveQuery. Join-free variants do targeted per-group refold; join-with-groupBy falls back to a full rebuild.
  • "none" — RIGHT / FULL join, fromSubquery, or any AST shape not yet supported. Falls back to the whole-query re-run path (runAndDeliver in engine.ts) — correct, but O(total).

You can inspect which branch a query took:

import { classifyIvm } from "@sh1n4ps/plasma-client"
const kind = classifyIvm(db.select().from(todos).where(eq(todos.done, 0)).$ast)
// "select"
  • The callback fires synchronously with the current snapshot on subscribe. If you want to await the first delivery deterministically, use whenReady() instead.
  • Fires again whenever the window shifts. Duplicate identical snapshots are not suppressed on the subscribe path — the reactive hub notifies once per mutation batch, and the query re-emits.
  • Multiple subscribers on the same LiveQuery share one internal reactive computation via a refcount — snapshot subscribers and delta subscribers on the same query do not each spin up a separate index.

The engine suppresses empty deltas ({added:[], removed:[], changed:[]}) before delivering them to subscribeDelta subscribers, so a subscriber doesn’t have to filter { added:[], removed:[], changed:[] } noise from the push-then-pull refresh cycle at every subscription.

Aggregate queries via IvmAggregateLiveQuery handle join-free .groupBy(col) shapes with per-group targeted refold:

  • One-row change → only the affected group (up to 2 — the row’s before-group and after-group) is refolded.
  • Delta subscribers receive {added, removed, changed} where identity is keyed by the group tuple.
  • The snapshot delivery still walks every group to rebuild the full rows array (that’s the subscribe cb contract).

Full O(1) per mutation for the snapshot path itself is queued for v1.1 (Phase 2.1 SnapshotPatch — see Roadmap).

const recent = useLiveQuery(
() => plasma.db.select().from(todos).orderBy(desc(todos.updatedAt)).limit(20),
[],
)

The IVM engine maintains only the top-20 window; a new insert outside that window doesn’t cost you a re-render.

const messagesWithAuthor = useLiveQuery(
() => plasma.db
.select()
.from(messages)
.innerJoin(users, eq(messages.userId, users.id))
.where(eq(messages.roomId, "general")),
[],
)

The returned rows are shaped { messages: { ... }, users: { ... } } (joined tuple). Delta identity keys on the full (messages.id, users.id) tuple, so an author-rename fires changed: 1 per message, not a full remount.

const openCount = useLiveQuery(
() => plasma.db
.select({ n: count() })
.from(todos)
.where(eq(todos.done, 0)),
[],
)
// openCount → [{ n: 42 }]