Collection
A Collection<T> is the handle vault.collection<T>(name) returns — a typed set of records inside a vault, conventionally bound to the collection's plural name (invoices, clients). The generic T threads through every read and write, so records come back typed, and an optional schema validates them on the way in and out. It sits at the bottom of the Noydb → Vault → Collection<T> hierarchy — the object your application spends most of its time with.
Every method on this page is on that instance and works when you pass zero strategies to createNoydb() — the strategy-free kernel surface. The full query DSL is covered in depth on Querying and the validation scaffold on Schema; this page documents each method's signature and contract. Anything a collection gains from an opt-in strategy — history, search, blobs, presence, tiers, classified fields — is gathered at the end under Unlocked by services.
The reference is organized into groups — reading and writing, bulk operations, querying and scanning, change events, deterministic lookup, and introspection — each opening with a short note on what unites its methods, then the individual methods.
Reading and writing
The four single-record primitives. All writes run inside the hub's write-queue tracker, so db.writeQueue.pending reflects an in-flight put() or delete(), and a completed write fires a change event.
get()
get(id: string, locale?: LocaleReadOptions): Promise<T | null>
Fetch a single record by id, decrypted (and optionally locale-resolved). Returns null when no record exists for id. Output is schema-validated after decryption when a schema is attached.
id— the record identifier.locale— optional locale options. When provided,i18nTextfields resolve to the requested locale string anddictKeyfields gain a<field>Labelvirtual field; pass{ locale: 'raw' }to return the full{ [locale]: string }map instead.
put()
put(
id: string,
record: T,
options?: {
reason?: string
source?: string
sourceTs?: string
}
): Promise<void>
Create or update a record. When a schema is attached the record is validated (and possibly coerced) before encryption; a rejected record throws SchemaValidationError. Runs inside the write-queue tracker and fires a change event on commit.
id— the record identifier.record— the record body, validated by the collection's schema if one was attached.options.reason— stamped onto the resulting ledger entry so audit consumers can filter on it (e.g.import:*).options.source— an opaque source id ('crm-sync','firm-A') stamped as_source/_sourceTswhen the collection hasprovenance: true; ignored otherwise (zero cost).options.sourceTs— an ISO-8601 origin-timestamp override; supplied withsourceon a provenance collection, it replaces the machine-stampednow()so re-merges preserve the origin refresh time across vaults.
Inputs put() refuses
Since 0.7.0-pre.5 two boundary values throw a TypeError at the seal instead of being
coerced (noy-db#1220). TypeScript rejects both already; they arrive from untyped call sites — plain
JavaScript, a JSON body, a generic helper:
| Input | Was silently | Now |
|---|---|---|
a non-string id | String({}) → the record is sealed at the address "[object Object]", which nothing queries | TypeError |
an undefined record | JSON.stringify(undefined) → undefined, so the envelope is sealed over nothing — _data a bare GCM tag over zero ciphertext | TypeError |
Both produced envelopes that are valid and undecodable: sealed correctly, addressed or filled
with nothing. The two read paths then disagreed about the same bytes, and neither named the cause —
measured against hub 0.7.0-pre.4, reading an empty-sealed record returned undefined from the
cached path and threw SyntaxError: Unexpected end of JSON input out of decryptRecord on the
hydrate path, where a genuinely absent record returns null. So the failure arrived either as a
value get() does not declare — its return type is T | null, and any caller writing if (!rec)
folds it into no such record anyway — or as an error that reads as your store returned corrupt
bytes. The guards are stated as invariants on the values themselves, so they hold for every caller
rather than for the two calls that surfaced them.
NOYDB_ENVELOPE_GENERATION is unaffected, and nothing about your stored data changed —
there is no migration to plan. Only inputs that previously produced an unreadable record are now
refused.And it repairs nothing. Records already sealed over an empty plaintext stay exactly as
unreadable. There is deliberately no recovery path: the hub cannot tell "sealed over nothing by
mistake" from "ciphertext this build cannot open" without opening the bytes, and treating the
second as the first is the downgrade this whole error class exists to refuse. Re-write the affected
records from your source of truth.delete()
delete(id: string): Promise<void>
Delete a record by id. Runs inside the write-queue tracker and fires a change event on commit. Deleting a non-existent id is not an error (idempotent).
id— the record identifier to delete.
list()
list(locale?: LocaleReadOptions): Promise<T[]>
Return every record in the collection, decrypted. Throws in lazy mode — bulk listing defeats lazy hydration; use scan() to iterate page-by-page instead.
locale— optional locale options, applied to each record before it is returned (seeget()).
Bulk operations
One round-trip for many records. Each item still flows through the normal per-record path (validation, ledger append, change event); the saving is that the adapter stays hot across the batch.
putMany()
putMany(
entries: ReadonlyArray<readonly [id: string, record: T, opts?: PutManyItemOptions]>,
options?: PutManyOptions
): Promise<PutManyResult>
Write many records in one call. Best-effort by default — if item 5 of 10 fails, items 1–4 are already persisted and 6–10 are still attempted; the returned PutManyResult lists every success and failure so the caller can roll forward or back. Pass { atomic: true } for a transactional variant that pre-flights every expectedVersion and reverts on mid-batch failure, throwing rather than returning a mixed result. One change event fires per successfully-written record.
Since hub 0.5.0, { atomic: true } delegates the whole batch through one store.tx() call on a store that declares txAtomic (noy-db#921): every leg carries CAS against the pre-flight snapshot, so the batch is genuinely all-or-nothing at the store, and history/ledger/change events fire per record after the batch is durable, in entry order. An ineligible batch — duplicate ids, a store without txAtomic, or an atomic-path refusal such as a live onBeforeWrite hook or a derivation/materialized-view/CRDT/unique-constraint collection — keeps the sequential pre-flight → execute → best-effort-revert loop unchanged. Which stores declare txAtomic is on the store capabilities matrix.
entries— the[id, record, opts?]tuples to write, processed in order.options— batch options; notablyatomicto switch to the transactional executor.
getMany()
getMany(ids: readonly string[]): Promise<Map<string, T | null>>
Read many records in one call, returning a Map in input order. Missing records surface as null entries so the caller can distinguish "not found" from "lookup failed". Reads go through the same per-id path as get(), keeping cache behavior consistent.
ids— the record ids to fetch; the result Map preserves this order.
deleteMany()
deleteMany(ids: readonly string[]): Promise<DeleteManyResult>
Delete many records in one call. Same best-effort contract as putMany(): a mid-batch failure still attempts the rest. Deleting a non-existent id is not a failure.
ids— the record ids to delete.
Querying and scanning
The always-on read path. query() reads the decrypted in-memory cache with a chainable builder; scan() streams from the store page-by-page for collections larger than RAM. This group documents each method's signature — the full DSL (operators, .or() / .and(), .filter(), streaming aggregation) is on Querying. Joins, aggregates, live results, and indexed dispatch are opt-in services.
query()
query(): Query<T>
query(predicate: (record: T) => boolean): T[]
Build a chainable query against the collection's decrypted in-memory cache. Called with no arguments it returns an immutable Query<T> builder (where / orderBy / limit / offset + synchronous toArray / first / count terminals). A backward-compatible overload accepts a predicate and returns the filtered records directly; prefer the chainable form for new code. Throws in lazy mode — use lazyQuery() or scan() there.
predicate— (legacy overload only) a(record) => booleanfilter; returns the matching records array directly.
scan()
scan(opts?: { pageSize?: number }): ScanBuilder<T>
Stream every record page-by-page as an AsyncIterable<T>, decrypting at most pageSize records at a time — the way to process collections larger than RAM. The returned ScanBuilder chains .where() / .filter() (same semantics as query()) and carries a memory-bounded .aggregate(spec) terminal (which requires the aggregate service). Works in both eager and lazy mode.
opts.pageSize— records decrypted per page. Default100.
count()
count(): Promise<number>
Count the records in the collection. Instant in eager mode (the in-memory cache size); in lazy mode it counts live tier-0 envelopes by inspection, loading no record bodies.
listPage()
listPage(opts?: { cursor?: string; limit?: number }): Promise<{ items: T[]; nextCursor: string | null }>
Fetch a single page of records via the adapter's optional listPage extension, returning the decrypted records plus an opaque cursor for the next page. Omit cursor (or pass undefined) to start from the beginning; the final page returns nextCursor: null. Adapters without native listPage fall back to a synthetic list-and-slice, with a one-time console warning.
opts.cursor— the opaque cursor from the previous page; omit to start at the beginning.opts.limit— the page size.
Change events
subscribe()
subscribe(cb: (event: CollectionChangeEvent<T>) => void): () => void
Subscribe to every committed put / delete on this collection; returns an unsubscribe function. This is an event stream, always-on — the callback fires after the store write commits, never for in-flight or rolled-back writes. The event is { type: 'put', id, record } (the current decrypted value) or { type: 'delete', id, record: null }. For a reactive value that recomputes as matching data changes, use query().live() / query().subscribe(cb) — see the live service.
cb— called with eachCollectionChangeEvent<T>as writes commit.
Deterministic lookup
Blind equality search over deterministically-encrypted fields. These work strategy-free but require the collection to declare deterministicFields (with acknowledgeDeterministicRisk: true) — the stored _det[field] ciphertext is compared to a freshly computed deterministic ciphertext, so no record bodies are decrypted during the search. This deliberately leaks equality between records; see the Enclave page for the trade-off.
findByDet()
findByDet(field: string, value: unknown): Promise<T | null>
Return the first record whose deterministic field matches value, or null when none matches. Throws when field is not declared in deterministicFields, so a typo fails loudly at the call site.
field— a field declared in the collection'sdeterministicFields.value— the plaintext value to match by deterministic ciphertext.
queryByDet()
queryByDet(field: string, value: unknown): Promise<T[]>
Return every record whose deterministic field matches value — the same semantics as findByDet() without the short-circuit.
field— a field declared in the collection'sdeterministicFields.value— the plaintext value to match by deterministic ciphertext.
Introspection
Zero-I/O reads of the collection's declared shape and cache state — for devtools, schema-aware exporters, and UI that renders from describe().
describe()
describe(): CollectionDescription
describe(opts: DescribeOptions): Promise<CollectionDescription>
Describe the collection's field schema from in-memory config, with zero store I/O. The synchronous overload merges moneyFields / dictKeyFields / refs / computed / fieldMeta (types inferred from config); the async overload additionally derives validator-exact types and resolves dynamic dict labels.
opts— (async overload only)DescribeOptionstoggling validator-exact type derivation and dynamic-dict label resolution.
getSchema()
getSchema(): StandardSchemaV1<unknown, T> | undefined
Return the Standard Schema validator attached at construction, or undefined if none was provided. Returned by reference — treat it as immutable.
getMeta()
getMeta(): CollectionMeta | undefined
Return the collection's declared descriptive metadata (label / description / icon), or undefined if none was set.
getFieldMeta()
getFieldMeta(): Record<string, FieldMeta> | undefined
Return the declared consumer-neutral per-field metadata channel, or undefined if none was set.
getConfig()
getConfig(): CollectionConfig | undefined
Return a snapshot of every actively-set collection-level configuration option, or undefined when nothing is configured.
toJSONSchema()
toJSONSchema(): Promise<object>
Return a JSON Schema for this collection, with describe() metadata attached as x- extensions.
cacheStats()
cacheStats(): CacheStats
Cache statistics for devtools and monitoring. Eager mode returns size only (every read is a cache hit by construction); lazy mode returns the full LRU stats ({ hits, misses, evictions, size, bytes }).
Unlocked by services
Further methods appear on a Collection<T> once you opt into the matching service — a tree-shakeable with*() capability wired into createNoydb. Each stays gated (throwing a *NotEnabledError) until its strategy is present. Browse the Services catalog; grouped by capability:
- History —
history(id),getVersion(id, v),revert(id, v),diff(id, a, b?),pruneRecordHistory(...),clearHistory(...). - Search & retrieval —
search(field, query),retrieve(query),similarTo(vector),rebuildEmbeddings(),warmIndex(),flushIndex(). - Classified fields —
reveal(id, field),verify(id, field, candidate),verifyGroup(...),findByDigest(field, candidate),scrubEquatableTags(field). - Blobs —
blob(id). - Presence —
presence(opts?). - Tiers —
putAtTier(...),getAtTier(id),listAtTier(),elevate(id, tier),demote(id, tier). - Indexing & lazy mode —
lazyQuery()(see also lazy mode),rebuildIndexes(),reconcileIndex(field),getIndexes(). - CRDT —
getRaw(id).