lazy
| Subpath | @noy-db/hub/lazy |
| Factory | withLazy() |
| Layer | with-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: falseplus acachebudget declared per collection — at least one ofmaxRecords/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.
| Field | Type | Purpose |
|---|---|---|
maxRecords | number | Maximum number of records kept in memory before LRU eviction. |
maxBytes | number | string | Maximum 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().
| Field | Type | Meaning |
|---|---|---|
hits | number | Cache hits since the collection was opened. |
misses | number | Cache misses since the collection was opened. |
evictions | number | Entries evicted since the collection was opened. |
size | number | Current number of cached entries. |
bytes | number | Current 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. |
lazy | boolean | true 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: falsewithoutlazyStrategy: 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.warnper process (suppressed underNODE_ENV=test) pointing atwithLazy()as the forward-stable spelling, and will be removed at 1.0 — passlazyStrategy: withLazy()explicitly to avoid relying on it.
Limits & gotchas
- A cache budget is mandatory. Opening a
prefetch: falsecollection withoutmaxRecordsand/ormaxBytesincachethrows immediately:lazy mode (prefetch: false) requires a cache option with maxRecords and/or maxBytes. list()andquery()throw in lazy mode. Usescan({ pageSize })for a full pass, orlazyQuery()for indexedwhere/orderByreads (requires theindexingservice and the queried fields declared inindexes).- 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. uniqueindexes aren't supported on lazy collections. Declaringunique: trueon an index for aprefetch: falsecollection throwsUnsupportedIndexOptionErrorat 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
- Services overview · Orientation map
- Core 01 — Vault & collections — eager vs. lazy hydration and when to switch
- Core 05 — Stores — what a lazy read goes to on each
get()/scan()page - indexing —
lazyQuery(), the indexed read path for lazy collections - joins — why a lazy collection can't be a join's right side
- Proven by showcase
131-with-lazy.