stable

lazy

Bounded-memory reads for collections too large for RAM: `prefetch: false` plus a cache budget, then per-record `get()`/`scan()` over a bounded LRU set.
Subpath@noy-db/hub/lazy
FactorywithLazy()
Layerwith-store

What it does

By default, a noy-db collection is eager: opening it decrypts every record into an in-process cache, and reads/writes run off that cache afterward. That works well up to roughly 1K–50K records per collection. Beyond that, keeping the whole collection in memory wastes RAM you'll never use — most sessions only ever touch a handful of records.

Lazy mode (prefetch: false) skips the bulk load. Every read goes per-record to the store and lands in a small, size-bounded LRU (least-recently-used) cache, so a lazy collection's memory footprint never grows past the budget you declare — old entries are evicted to make room for new ones. The lazy service supplies that bounded cache: opt in with withLazy() and the collection's working set is capped by maxRecords and/or maxBytes, whichever you set.

The tradeoff is that bulk reads are unavailable — list() and query() throw in lazy mode, because materializing the whole collection defeats the point of a bounded cache. Use per-id get(), the paged scan(), or (with indexing enabled) lazyQuery() instead.

Prerequisites

  • prefetch: false plus a cache budget declared per collection — at least one of maxRecords / maxBytes; the factory alone changes nothing until a collection opts in.
  • For indexed reads over a lazy collection: indexingStrategy: withIndexing() with the queried fields declared — see indexing.

Opt in

import { createNoydb } from '@noy-db/hub'
import { withLazy } from '@noy-db/hub/lazy'
import { toMemory } from '@noy-db/to-memory'

const db = await createNoydb({
  store: toMemory(),
  user: 'owner',
  secret: 'correct horse battery staple',
  lazyStrategy: withLazy(),
})

const vault = await db.openVault('books')

interface Disbursement {
  id: string
  clientId: string
  amount: number
}

const disbursements = vault.collection<Disbursement>('disbursements', {
  prefetch: false,                    // opt this collection into lazy mode
  cache: { maxRecords: 1000 },        // required: bound the LRU working set
})

lazyStrategy: withLazy() is set once on createNoydb; each collection then opts in individually with prefetch: false plus a cache budget. Collections left at the default prefetch: true are unaffected.

Usage

Per-record reads

get() and put() work exactly as in eager mode — they just read/write through the store instead of an in-memory copy, populating the LRU as they go:

await disbursements.put('d-1', { id: 'd-1', clientId: 'c-42', amount: 1200 })
await disbursements.put('d-2', { id: 'd-2', clientId: 'c-7', amount: 300 })

await disbursements.get('d-1')
// → { id: 'd-1', clientId: 'c-42', amount: 1200 }

Iterating without a bulk load

scan({ pageSize }) walks the collection page-by-page, never holding more than pageSize decrypted records at once — the right tool for a full pass over a lazy collection:

for await (const record of disbursements.scan({ pageSize: 500 })) {
  console.log(record.id, record.amount)
  // → 'd-1' 1200
  // → 'd-2' 300
}

Cache stats

cacheStats() reports the LRU's hit rate and current occupancy, useful for tuning the cache budget:

const stats = disbursements.cacheStats()
{
  "hits": 1,
  "misses": 0,
  "evictions": 0,
  "size": 2,
  "bytes": 86,
  "lazy": true
}

Both put() calls above populated the LRU directly, so the later get('d-1') is a cache hit — put() never registers as a hit or miss itself. The scan() loop from the previous section doesn't touch these numbers either: lazy scan() streams pages straight from the store and deliberately skips the LRU, so it can't be used to warm the cache.

bytes is tracked unconditionally — every cached record's approximate size (JSON.stringify(record).length) is added to the running total on insert, whether or not maxBytes is configured. Declaring maxBytes doesn't turn tracking on; it only makes that running total something the cache acts on — once the total would exceed it, eviction kicks in. A maxRecords-only budget (like disbursements above) still reports real, non-zero byte usage in cacheStats(), it just never evicts because of it.

list() and query() throw

Both bulk-read methods refuse to run on a lazy collection instead of silently materializing the whole store into memory:

await disbursements.list()
// → throws: list() is not available in lazy mode (prefetch: false).
//   Use collection.scan({ pageSize }) to iterate over the full collection.

disbursements.query()
// → throws: query() is not available in lazy mode (prefetch: false).
//   Use collection.lazyQuery() for indexed reads, or collection.scan({ pageSize })
//   and filter the streamed records with a regular for-await loop.

API reference

withLazy()

function withLazy(): LazyStrategy

Builds the strategy that supplies a lazy collection's bounded LRU cache. Pass the result as lazyStrategy to createNoydb.

CacheOptions

Declared per collection: vault.collection<T>(name, { prefetch: false, cache: { … } }). At least one of maxRecords or maxBytes is required — an unbounded lazy cache defeats the purpose and construction throws otherwise.

FieldTypePurpose
maxRecordsnumberMaximum number of records kept in memory before LRU eviction.
maxBytesnumber | stringMaximum total decrypted byte size before LRU eviction. Accepts a raw number or a human-friendly string ('50KB', '50MB', '1GB'). With both budgets set, eviction runs until both are satisfied.

CacheStats

Returned by collection.cacheStats().

FieldTypeMeaning
hitsnumberCache hits since the collection was opened.
missesnumberCache misses since the collection was opened.
evictionsnumberEntries evicted since the collection was opened.
sizenumberCurrent number of cached entries.
bytesnumberCurrent approximate cached byte size, summed from each cached record's JSON.stringify(record).length at insert time. Always tracked, regardless of budget; maxBytes (if set) turns this total into an eviction trigger — it doesn't gate whether bytes are counted.
lazybooleantrue for a lazy-mode collection; false for eager (where the other fields read as zeroes / the eager cache's plain size).

LazyStrategy

interface LazyStrategy {
  createCache<V>(collection: string, cache: CacheOptions | undefined): Lru<string, V>
}

The interface withLazy() implements. Most consumers never touch this directly — it exists so a custom strategy can be substituted for withLazy()'s default budget validation and LRU construction.

Behavior when NOT opted in

  • prefetch: false without lazyStrategy: withLazy() still works — a deprecated implicit path runs the same budget validation and LRU construction, so behavior is identical.
  • The implicit path logs one console.warn per process (suppressed under NODE_ENV=test) pointing at withLazy() as the forward-stable spelling, and will be removed at 1.0 — pass lazyStrategy: withLazy() explicitly to avoid relying on it.

Limits & gotchas

  • A cache budget is mandatory. Opening a prefetch: false collection without maxRecords and/or maxBytes in cache throws immediately: lazy mode (prefetch: false) requires a cache option with maxRecords and/or maxBytes.
  • list() and query() throw in lazy mode. Use scan({ pageSize }) for a full pass, or lazyQuery() for indexed where/orderBy reads (requires the indexing service and the queried fields declared in indexes).
  • A lazy collection can't be the right side of a .join(). Joining against it throws — open the collection eager (prefetch: true, the default) if it needs to participate in a join.
  • unique indexes aren't supported on lazy collections. Declaring unique: true on an index for a prefetch: false collection throws UnsupportedIndexOptionError at registration, because the write paths that would enforce it don't run in lazy mode.
  • The LRU is per-session, in memory. It is not persisted between restarts; a fresh session starts with an empty cache and repopulates it from the store as records are read.

See also