stable

Querying

The always-on read path: the chainable query() builder (where / orderBy / limit / offset), the streaming scan() builder, subscriptions, and the operator set.

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:

OpMatches 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 in indexes. See lazy mode.
  • collection.scan() — works in both modes; stream and filter with for 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, prefer scan() (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 after where/filter but before limit/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 — or db.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 plain interface may limit TypeScript's ability to distribute the Extract-based narrowing correctly.

See also

  • Indexing — fast-path equality / orderBy dispatch
  • Joins — ref-declared foreign-key joins
  • Aggregate — sum / count / avg / min / max + groupBy
  • Live — reactive query results
  • Lazy mode — bounded-cache collections and lazyQuery()