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.
The basic shape
Section titled “The basic shape”const live = db.select().from(todos).where(eq(todos.done, 0)).live()const unsub = live.subscribe((rows) => { console.log("open todos:", rows.length)})// laterunsub().live()returns aLiveQuery<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.
subscribeDelta — O(delta) diffs
Section titled “subscribeDelta — O(delta) diffs”.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 windowremoved— rows that left the windowchanged— 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.
When to use which
Section titled “When to use which”| 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) |
whenReady — deterministic setup await
Section titled “whenReady — deterministic setup await”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 oncewhenReady is optional on the interface (whenReady?()) — same
reason as subscribeDelta.
The IVM eligibility gate
Section titled “The IVM eligibility gate”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 byIvmLiveQuery. Per-mutation cost: O(delta)."aggregate"— GROUP BY / HAVING / count/sum/avg/max/min. Handled byIvmAggregateLiveQuery. 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 (runAndDeliverinengine.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"subscribe semantics
Section titled “subscribe semantics”- 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
LiveQueryshare one internal reactive computation via a refcount — snapshot subscribers and delta subscribers on the same query do not each spin up a separate index.
Empty-delta suppression
Section titled “Empty-delta suppression”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 query specifics
Section titled “Aggregate query specifics”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).
Common patterns
Section titled “Common patterns”Sort + limit for a “recent” feed
Section titled “Sort + limit for a “recent” feed”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.
Joined view with subscribeDelta
Section titled “Joined view with subscribeDelta”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.
Aggregate count
Section titled “Aggregate count”const openCount = useLiveQuery( () => plasma.db .select({ n: count() }) .from(todos) .where(eq(todos.done, 0)), [],)// openCount → [{ n: 42 }]What to read next
Section titled “What to read next”- Concepts / Push, Pull, Rebase — the sync loop that triggers live-query notifications
- CRDT columns — live queries over automatically-merging state