Querying
Always-on. Filter, order, paginate, stream. Source of truth:
packages/hub/src/kernel/query/{builder,scan-builder,predicate}.ts
noy-db is an encrypted document store: the storage backend only ever holds ciphertext, so it can never run a query for you. Instead, every query runs inside @noy-db/hub, after decryption, against the collection's in-memory cache. This section covers the always-on read path: the chainable query() builder (where / orderBy / limit / offset) and the streaming scan() builder for collections larger than RAM. Joins, aggregates, live query results, and indexed dispatch are opt-in services; everything below ships with every install.
Eager queries
collection.query() returns an immutable, chainable builder. Because it reads the already-decrypted in-memory cache, the terminals (toArray, first, count) are synchronous — no await needed:
import { createNoydb } from '@noy-db/hub'
type Invoice = { amount: number; status: string; client: string; date: string }
const db = await createNoydb({ user: 'alice', secret: 'a-strong-secret' })
const vault = await db.openVault('acme')
const invoices = vault.collection<Invoice>('invoices')
await invoices.put('inv-1', { amount: 4200, status: 'paid', client: 'Acme GmbH', date: '2026-05-12' })
await invoices.put('inv-2', { amount: 1800, status: 'paid', client: 'Beta AG', date: '2026-06-02' })
await invoices.put('inv-3', { amount: 950, status: 'open', client: 'Acme GmbH', date: '2026-06-20' })
const recentPaid = invoices.query()
.where('status', '==', 'paid')
.where('amount', '>', 1000)
.orderBy('date', 'desc')
.limit(50)
.toArray()
[
{ "amount": 1800, "status": "paid", "client": "Beta AG", "date": "2026-06-02" },
{ "amount": 4200, "status": "paid", "client": "Acme GmbH", "date": "2026-05-12" }
]
Two more terminals:
const firstOpen = invoices.query().where('status', '==', 'open').first()
// → { amount: 950, status: 'open', client: 'Acme GmbH', date: '2026-06-20' }
// (or null when nothing matches)
const bigOnes = invoices.query().where('amount', '>', 1000).count()
// → 2 (counted after where/filter, BEFORE limit/offset)
Multiple .where() calls are AND-combined. .orderBy(field, direction?) defaults to 'asc'; repeated calls act as tie-breakers. .offset(n) skips the first n matches after ordering — combine with .limit(n) for pagination.
Operators
.where(field, op, value) accepts these operators:
| Op | Matches when |
|---|---|
'==' | field is strictly equal to value |
'!=' | field is strictly not equal to value |
'<' '<=' '>' '>=' | field compares against value — numbers and Dates numerically, strings lexicographically |
'in' | field equals one element of the supplied array — .where('status', 'in', ['paid', 'open']) |
'contains' | a string field contains value as a substring, or an array field contains value as an element |
'startsWith' | a string field starts with value — .where('client', 'startsWith', 'Ac') |
'between' | field is inside the inclusive [lo, hi] range — .where('amount', 'between', [1000, 5000]) |
Field names may be nested dot-paths ('address.city'). Type mismatches never throw — a record whose field has the wrong runtime type simply does not match.
OR / AND groups
.or(callback) builds a group whose clauses are OR-combined; the group itself joins the rest of the plan with AND. .and(callback) is the explicit AND counterpart, useful for grouping inside a larger OR:
// status == 'paid' AND (amount > 3000 OR date >= '2026-06-01')
const n = invoices.query()
.where('status', '==', 'paid')
.or(q => q.where('amount', '>', 3000).where('date', '>=', '2026-06-01'))
.count()
// → 2
Escape hatch: .filter()
.filter(fn) accepts a free-form predicate for conditions the operator set cannot express. It runs on every candidate record and can never drive an index, so prefer .where() when either works:
const juneCount = invoices.query()
.filter(inv => inv.date.startsWith('2026-06'))
.count()
// → 2
Streaming with scan()
Eager queries materialize a snapshot of the whole collection. scan() instead streams records page-by-page from the store, decrypting at most pageSize records (default 100) at a time — the way to process collections larger than RAM:
for await (const inv of invoices.scan({ pageSize: 500 })) {
console.log(inv.client, inv.amount)
}
scan() returns a ScanBuilder that implements AsyncIterable<T> and also chains .where() / .filter() with the same semantics as query() — non-matching records are dropped from the stream:
for await (const inv of invoices.scan().where('status', '==', 'paid')) {
console.log(inv.client, inv.amount) // → "Acme GmbH" 4200, "Beta AG" 1800
}
Streaming aggregation
scan() has an async .aggregate(spec) terminal that folds the stream through named reducers — one record at a time, so memory stays proportional to the number of reducers, never the number of records. It requires the aggregate service: wire withReduce() into createNoydb and import the reducer factories from the @noy-db/hub/reduce subpath (without it, .aggregate() throws with a pointer to that subpath):
import { createNoydb } from '@noy-db/hub'
import { withReduce, sum, count } from '@noy-db/hub/reduce'
const db = await createNoydb({
user: 'alice',
secret: 'a-strong-secret',
reduceStrategy: withReduce(),
})
// … open vault + collection, seed the three invoices as above …
const totals = await invoices.scan()
.where('status', '==', 'paid')
.aggregate({ total: sum('amount'), n: count() })
// → { total: 6000, n: 2 }
The eager counterpart — query().aggregate(spec).run() plus groupBy — is documented on the aggregate service page.
Lazy mode
query() is eager-mode only: it reads the fully-hydrated in-memory cache. On a lazy collection (prefetch: false, bounded cache) it throws. Your options there:
collection.lazyQuery()— the same chainable shape, but every read dispatches through persisted indexes and the terminals return Promises. Requires the indexing service (withIndexing()) and every filtered/sorted field declared inindexes. See lazy mode.collection.scan()— works in both modes; stream and filter withfor await.
Subscriptions (change events)
collection.subscribe(cb) is part of the always-on core. It is an event stream — the callback fires after every committed write, never for in-flight or rolled-back ones:
const stop = invoices.subscribe(event => {
// { type: 'put', id, record } — record is the current decrypted value
// { type: 'delete', id, record: null }
})
stop() // unsubscribe
For a reactive query result — a value that recomputes whenever matching data changes — use query().live() / query().subscribe(cb), documented on the live service page.
Limits
- Memory: eager
query()snapshots the whole collection cache. For collections that don't fit comfortably in memory, preferscan()(or lazy mode). - No SQL pushdown: the store holds only ciphertext, so it cannot evaluate a query. All filtering, ordering, and reducing happens inside the hub after decryption.
count()and joins:count()reports matches afterwhere/filterbut beforelimit/offset, and never applies joins (joins attach fields; they never change the match set).- Cross-collection: use
query().join(field, { as })— see joins and cross-join — ordb.queryAcross()for reads spanning vaults.
Discriminated-union collections
When T is a discriminated union, Collection<T> threads it correctly through get / query / scan — but narrowing a result to one union member is your application's job:
type Invoice = { kind: 'invoice'; invoiceNo: string; amount: number }
type Payment = { kind: 'payment'; paymentNo: string; paidAt: string }
type Receipt = Invoice | Payment
const receipts = vault.collection<Receipt>('receipts').query().toArray()
for (const r of receipts) {
if (r.kind === 'invoice') {
console.log(r.invoiceNo) // r: Invoice — no cast needed
}
}
In plain .ts files the inline comparison above narrows fine. In vue-tsc / template contexts it sometimes does not — use the exported isDiscriminant type-guard, which works reliably everywhere:
import { isDiscriminant } from '@noy-db/hub'
// Filter — result type is Invoice[], member-specific fields accessible without cast:
const invs = receipts.filter(r => isDiscriminant(r, 'kind', 'invoice'))
console.log(invs[0].invoiceNo) // string — no cast
// Branch:
for (const r of receipts) {
if (isDiscriminant(r, 'kind', 'invoice')) {
console.log(r.invoiceNo) // r: Invoice here
}
}
The discriminant key is a parameter — any field name works ('kind', 'type', 'tag', …), e.g. isDiscriminant(r, 'type', 'A').
Note on type aliases. Define the union as a type alias (
type Receipt = Invoice | Payment). A plaininterfacemay limit TypeScript's ability to distribute theExtract-based narrowing correctly.