stable

live

Reactive subscriptions: `collection.subscribe()` fires after every committed write, and `query.live()` re-runs and updates in place as its collections change.
Subpath(currently always-core; @noy-db/hub/live extraction is a planned follow-up)
FactorywithLive()(planned)
ClusterA — Read & Query
LOC cost~210 (planned — off-bundle when not opted in)

What it does

Lets your UI react to data changes instead of polling for them. A collection is the typed store you read and write records through; collection.subscribe(cb) calls back after every put or delete committed to it. query().live() goes further: it returns a value that holds the current query result and re-runs — updating that value in place — whenever anything the query depends on changes, including every collection reached through a .join(). Both ship as part of the always-on core today; there is nothing to opt into yet.

When you need it

  • Vue / Pinia / React stores that need to re-render on data changes
  • Real-time UIs (dashboards, live feeds, presence indicators)
  • Collaborative features where remote pulls should update local UI

Opt in

There is nothing to opt into today — collection.subscribe() and query.live() ship as part of the always-on core, available on every collection and query with no extra import or wiring. The catalog reserves a @noy-db/hub/live subpath and a withLive() factory (~210 LOC) for a future extraction; until that lands, both are simply there.

Usage

import { createNoydb } from '@noy-db/hub'

interface Invoice {
  id: string
  amount: number
  status: 'open' | 'paid'
}

const db = await createNoydb({ user: 'me', secret: 'a-strong-secret' })
const vault = await db.openVault('billing')
const invoices = vault.collection<Invoice>('invoices')

function render(rows: readonly Invoice[]) {
  // your UI update — set a Vue ref, React state, etc.
}

// Event stream: fires once per committed put/delete
const stopEvents = invoices.subscribe((event) => {
  console.log(event.type, event.id)
})

await invoices.put('inv-1', { id: 'inv-1', amount: 100, status: 'open' })
// → put inv-1

// Reactive value: re-runs the query whenever a relevant write commits.
// Await it — `live()` hydrates the collection first (see the API note below).
const openInvoices = await invoices.query()
  .where('status', '==', 'open')
  .live()

render(openInvoices.value)
// → [{ id: 'inv-1', amount: 100, status: 'open' }] — subscribe() below won't fire on registration, so paint this initial value yourself
openInvoices.subscribe(() => render(openInvoices.value))

// later, e.g. on component unmount
openInvoices.stop()
stopEvents()

API

collection.subscribe(cb)

Registers a callback that fires after every committed put or delete on the collection. Returns an unsubscribe function.

const stop = invoices.subscribe((event) => {
  console.log(event)
})

await invoices.put('inv-1', { id: 'inv-1', amount: 100, status: 'open' })
// → { type: 'put', id: 'inv-1', record: { id: 'inv-1', amount: 100, status: 'open' } }
stop()

event.record is the current decrypted value on put (or null in the rare case another op deleted the record before the lookup finished); it is always null on delete.

query.live()

Returns a LiveQuery<T> — a reactive value that re-runs the query whenever its collection, or any .join() target, changes.

::: warning Await it, or load the collection first live() must hydrate the collection before it can answer. Await the terminal — or call await collection.list() / await collection.get(id) beforehand, which works just as well (both verified against @noy-db/hub 0.8.0-pre.0). Reading any property of an un-awaited live() on an unloaded collection throws CollectionNotHydratedError, because an unloaded collection is not an empty one — its snapshot is empty by absence, not by content, and a UI bound to it would render "no records" for data that simply had not loaded. Note the awaited value is a different object from the one live() returns synchronously; keep the awaited one. :::

const live = await invoices.query().where('status', '==', 'open').live()

live.value              // → readonly Invoice[] — current result, updated in place on every change
live.error              // → Error | null — set if the most recent re-run threw
const unsub = live.subscribe(() => render(live.value))
live.stop()             // tears down every upstream subscription; also makes subscribe() a no-op

.subscribe() does not fire immediately on registration — read .value directly for the initial paint. Always call .stop() when the live query is no longer needed, or its upstream subscriptions keep re-running it forever.

Framework bindings

@noy-db/in-vue, @noy-db/in-react, and similar packages build their reactive hooks on the same change-event stream that subscribe() and .live() expose, so app code usually binds to a Vue Ref or a React hook instead of calling these directly.

Behavior when NOT opted in (post-extraction)

  • collection.subscribe(...) and query.live() will throw with a pointer to @noy-db/hub/live
  • One-shot reads (.toArray(), .first(), .count()) stay in core

Pairs well with

  • joins — reactive joins merge change-streams from every join target
  • crdt — CRDT collection updates surface through live queries
  • sync — pulls fire change events, propagating to live subscribers

Edge cases & limits

  • Subscriptions are in-process; cross-tab / cross-process reactivity needs an external coordinator (BroadcastChannel, sync engine)
  • live() re-runs the query on every change — for hot collections, prefer subscribe() + manual delta application
  • Stop subscriptions on unmount to avoid memory leaks

See also

  • joins — .join() targets are part of what a live query watches
  • crdt — CRDT merges surface to the UI through the same live-query mechanism
  • sync — pulls fire the change events that live queries and subscriptions react to
  • Query basics — the where/filter/orderBy pipeline .live() wraps
  • Proven by showcase 13-with-live.