live
| Subpath | (currently always-core; @noy-db/hub/live extraction is a planned follow-up) |
| Factory | withLive()(planned) |
| Cluster | A — 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(...)andquery.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, prefersubscribe()+ 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/orderBypipeline.live()wraps - Proven by showcase
13-with-live.