import type { EncryptedEnvelope, HistoryOptions, HistoryEntry, PruneOptions, LocaleReadOptions, PutManyItemOptions, PutManyOptions, PutManyResult, DeleteManyResult, SealedView, ClassifiedVerdict } from './types.js'; import type { FieldMeta } from '../with-shape/introspection/field-meta.js'; import type { CollectionMeta } from '../with-shape/introspection/meta.js'; import { type ClassifiedEntry } from '../port/with/classified-strategy.js'; import type { CrdtState } from '../with-commit/crdt/crdt.js'; import type { I18nTextDescriptor, DictKeyDescriptor, StaticDictDescriptor, DictionaryHandle } from '../port/with/i18n-strategy.js'; import type { LookupDescriptor } from '../port/with/lookup-strategy.js'; import { ViaPipeline } from './via/pipeline.js'; import { type ViaDescriptor, type ViaEraseReport } from './via/index.js'; import type { MutationOrigin } from './mutation.js'; import { type WaveContext, type RollupOutcome, type RollupDeleteIntent } from './via/dispatch.js'; import type { ComputedFields } from '../with-formula/computed/index.js'; import { type SealedShredSlot } from './enclave/index.js'; import { type TierMoveResult } from '../with-audit/tiers/index.js'; import type { GhostRecord } from './types.js'; import type { StandardSchemaV1 } from './schema.js'; import type { DiffEntry } from '../with-commit/history/diff.js'; import { Query, ScanBuilder } from './query/index.js'; import type { JoinableSource } from './query/index.js'; import type { CollectionIndexes } from '../with-lookup/indexing/eager-indexes.js'; import { LazyQuery } from '../with-lookup/indexing/lazy-builder.js'; import type { SearchOptions, SearchResult } from '../with-lookup/search/index.js'; import type { RetrieveOptions, RetrieveHit } from '../with-lookup/search/retrieve-types.js'; import { type CollectionDescription, type DescribeOptions } from '../with-shape/introspection/describe.js'; import type { CollectionConfig } from '../with-shape/introspection/types.js'; import { type LruStats } from './cache/index.js'; import type { PresenceHandle } from '../with-party/team/presence.js'; import type { BlobSet } from '../with-shape/blobs/blob-set.js'; import type { TxContext } from '../with-commit/tx/transaction.js'; import { type CollectionOpts } from './collection-config.js'; /** * Callback for dirty tracking (sync engine integration). `action: 'revert'` * (spec #591) means "un-dirty" — the fan-out wiring in `noydb.ts` routes it * to `SyncEngine.removeDirty` instead of `trackChange`; `version` is unused * for that action. */ export type OnDirtyCallback = (collection: string, id: string, action: 'put' | 'delete' | 'revert', version: number) => Promise; /** * Event delivered to a `collection.subscribe()` callback. Distinct * from the hub-level `ChangeEvent` — this one is bound to a single * collection's type `T` and hydrates the record from cache on put. * * - `type: 'put'` — `record` is the current decrypted value, or * `null` in the rare case where another op deleted the record * between the emit and the handler firing. * - `type: 'delete'` — `record` is always `null`; the deletion is * the only information. */ export interface CollectionChangeEvent { readonly type: 'put' | 'delete'; readonly id: string; readonly record: T | null; } /** * Per-collection cache configuration. Only meaningful when paired with * `prefetch: false` (lazy mode); eager mode keeps the entire decrypted * cache in memory and ignores these bounds. */ export interface CacheOptions { /** Maximum number of records to keep in memory before LRU eviction. */ maxRecords?: number; /** * Maximum total decrypted byte size before LRU eviction. Accepts a raw * number or a human-friendly string: `'50KB'`, `'50MB'`, `'1GB'`. * Eviction picks the least-recently-used entry until both budgets * (maxRecords AND maxBytes, if both are set) are satisfied. */ maxBytes?: number | string; } /** Statistics exposed via `Collection.cacheStats()`. */ export interface CacheStats extends LruStats { /** True if this collection is in lazy mode. */ lazy: boolean; } /** A typed collection of records within a vault. */ export declare class Collection { #private; private readonly adapter; private readonly vault; private readonly name; private readonly keyring; private readonly storeCiphertext; private readonly ramCiphertext; private readonly emitter; private readonly writeQueue; private readonly schemaUpdateGate; private readonly schemaFence; private readonly writeHooks; private readonly subsystemBus; private readonly activeTxId; private readonly getDEK; private readonly onDirty; /** #693: live check — true when multi-tab write-propagation is active; gates the #606 marker-id-set fallback read. */ private readonly tabCoordinated; private readonly historyConfig; /** True when the caller explicitly provided a `historyConfig` option (vs. inheriting the vault default). */ private readonly historyConfigExplicit; /** * tree-shake seam — the strategy that backs `collection.blob(id)`. * Defaults to `NO_BLOBS`, a ~10-line stub that throws with an actionable * message. Consumers opt into real blob storage by importing * `{ blobs }` from `@noy-db/hub/blobs` and passing the returned * strategy to `createNoydb({ blobStrategy: blobs() })`. With the * default stub, none of the BlobSet / chunk / MIME-magic machinery * reaches the bundle. */ private readonly blobStrategy; private readonly objectStore; private readonly blobFields; private readonly blobTierPolicy; private readonly aggregateStrategy; private readonly crdtStrategy; private readonly tiersStrategy; private readonly searchStrategy; private readonly historyStrategy; private readonly syncStrategy; private readonly cache; private hydrated; /** * #606: ids known to carry a delete marker in the store — lets the #589 * re-create version-continuity gate in `_putInternal` read the store ONLY * for a known marker prior, instead of unconditionally on every insert. * Populated on hydration (`ensureHydrated`/`hydrateFromSnapshot`), local * delete (`_doDelete`), and the sync/tab/cutover choke point * (`_invalidateCacheEntry`). Accepted drift: `vault._purgeDeleteMarkers`/ * `_purgeMarkersOn` remove markers directly on the raw store, bypassing * Collection, so this set can hold stale ids after a purge on an * already-loaded collection — perf-only and self-healing (a stale id just * costs one `adapter.get` that returns non-marker/null, and version * resolution falls back to 1 correctly). Do not try to wire purge into it. */ private readonly markerIds; /** * Lazy mode flag. `true` when constructed with `prefetch: false`. * In lazy mode the cache is bounded by an LRU and `list()`/`query()` * throw — callers must use `scan()` or per-id `get()` instead. */ private readonly lazy; /** * LRU cache for lazy mode. Only allocated when `prefetch: false` is set. * Stores `{ record, version }` entries the same shape as `this.cache`. * Tree-shaking note: importing Collection without setting `prefetch:false` * still pulls in the Lru class today; future bundle-size work could * lazy-import the cache module. */ private readonly lru; /** * tree-shake seam — per-Collection indexing state. Owned by the * `IndexStrategy` passed through from `createNoydb({ indexStrategy })`. * Defaults to a disabled state (both accessors return null) so the * `CollectionIndexes` / `PersistedCollectionIndex` / `LazyQuery` * classes never reach the bundle when indexing is unused. * * Accessor helpers below (`get indexes()`, `get persistedIndexes()`) * preserve the field-access ergonomics without changing every * caller site. */ private readonly indexState; /** * In-memory unique-constraint enforcement for eager mode. * `null` when no `unique:true` indexes are declared on this collection, or when the collection is in lazy mode (which throws at registration). */ private readonly uniqueConstraints; /** * True once `_idx/*` side-cars have been bulk-loaded into * `persistedIndexes`. Flipped by `ensurePersistedIndexesLoaded()` on * first lazy-mode query so subsequent queries skip the adapter round * trip. Invalidation (remote sync, rotation) resets it alongside * `persistedIndexes.clear()`. */ private persistedIndexesLoaded; /** * Accessor for the in-memory eager-mode index mirror. Returns `null` * when indexing is disabled on this Noydb instance (the * `NO_INDEXING` default) or when the collection is in lazy mode * (which uses the persisted mirror instead). */ private get indexes(); /** * Accessor for the persisted-mirror (lazy-mode) index. Returns `null` when indexing is disabled or the collection is in eager mode. */ private get persistedIndexes(); /** * per-collection reconcile-on-open policy. Read once * from `CollectionOptions.reconcileOnOpen` and applied by * `ensurePersistedIndexesLoaded()` on the first lazy-mode query. */ private readonly reconcileOnOpen; /** * Re-entrancy guard for the auto-reconcile path. `reconcileIndex` * reloads the mirror after applying fixes, which re-enters * `ensurePersistedIndexesLoaded`; without this flag we'd trigger a * second auto-reconcile pass and potentially infinite recursion. */ private autoReconciling; /** * Optional Standard Schema v1 validator. When set, every `put()` runs * the input through `validateSchemaInput` before encryption, and every * record coming OUT of `decryptRecord` runs through * `validateSchemaOutput`. A rejected input throws * `SchemaValidationError` with `direction: 'input'`; drifted stored * data throws with `direction: 'output'`. Both carry the rich issue * list from the validator so UI code can render field-level messages. * * The schema is stored as `StandardSchemaV1` because the * collection type parameter `T` is the OUTPUT type — whatever the * validator produces after transforms and coercion. Users who pass a * schema to `defineNoydbStore` (or `Collection.constructor`) get their * `T` inferred automatically via `InferOutput`. */ private readonly schema; /** * Vault-default locale. Used as the fallback when no per-call * locale option is passed to `get()`/`list()`. Provided by Vault * at collection construction time via the `collection({ locale })` or * `openVault(name, { locale })` path. * * `undefined` means "no default locale set" — i18nText fields will * throw `LocaleNotSpecifiedError` unless a per-call locale is passed. */ private readonly defaultLocale; /** Field name → `I18nTextDescriptor` for `i18nText()` fields (`i18nFields` option); write/read runs through the compiled `via` i18n binding — this remains for `describe()` and the search-index build path. Mutable — see {@link _reconcileReadState} (#671 item 2). */ private i18nFields; /** The configured string fields exposed to `retrieve()`; `undefined` for ordinary collections (zero-cost). */ private readonly textIndexes; /** Session-scoped lexical index store; `undefined` (zero-cost) unless `textIndexes` is non-empty. */ private readonly searchIndexStore; /** Embedding config for write-time vector derivation; `undefined` (zero-cost) for ordinary * collections. When set, `put()` encodes the source field(s) and stores an encrypted `_vec` sidecar. */ private readonly embeddings; /** In-memory vector set, populated lazily from `_vec` sidecars; `undefined` when no embedding config is declared. */ private vectorSet; /** Field name → `DictKeyDescriptor` for `dictKey()` fields; used by `get()`/`list()` to add `Label` virtual fields when a locale is requested. Mutable — see {@link _reconcileReadState} (#671 item 2). */ private dictKeyFields; /** Field name → `LookupDescriptor` for native `lookup()`/`enumOf()`/`dict()` fields (#650 Task 2) — describe()-only in this task. Mutable — see {@link _reconcileReadState} (#671 item 2). */ private lookupFields; /** Sync join-dressing hook (#650 Task 6, #626 retirement) — `querySourceForJoin()`'s `presentForJoin`. Mutable — see {@link _reconcileReadState} (#671 item 3). */ private presentForJoin; /** Consumer-neutral per-field descriptors declared via `fieldMeta`; read by `getFieldMeta()`, merged by `describe()`. */ private fieldMeta; /** Collection-level descriptive metadata declared via `meta`; read by `getMeta()`, surfaced in `describe()`. */ private meta; /** Outbound ref declarations (snapshot from vault refRegistry at construction time); used by `describe()`. */ private readonly _refs; /** Money field descriptors keyed by field path, typed as the opaque {@link ViaDescriptor} marker * (the kernel never inspects the concrete shape); `put()` quantizes to a scaled-int string, * `get()`/`list()` decode back. Mutable so {@link _applyMoneyFields} can attach. */ private moneyFields; private via; /** * Computed scalar fields, evaluated first on every `put()`. Mutable for * the same MV-pre-creation reconcile as {@link moneyFields}. */ private computed; /** * Resolved classified() sensitive-field descriptors, declared via the * `classifiedFields` collection option. Mutable so {@link _applyClassifiedFields} * can attach a declaration to a collection MV-analysis pre-created. */ private classified; /** * Frozen construction-time facts the refusal matrix (R1-R5) checks against. * Stored so {@link _applyClassifiedFields} — door 2, the reconcile seam — * can re-run the SAME guard the config resolver ran at door 1 (C5's lesson: * crdt/conflictPolicy/perRecordKeys are construction-only but * classifiedFields can attach later). */ private readonly classifiedGuardCtx; /** * Digest-only classified fields (`storage: 'digest-only'`), keyed by field * name — the enclave-consumable policy map the codec's write path carries * `_vdig` forward under (C6). `null` when the collection declares none. */ private readonly vdigFields; /** C-A/R10 memoization: marker declared-set lookup (undefined=unresolved) + classified-handle persist-once flag. */ private _markerDigestOnlyCache; private _markerPersisted; /** * Tree-shake seam for `reveal()` — defaults to `NO_CLASSIFIED`, which * throws `ClassifiedNotEnabledError`. Set via the `classifiedStrategy` * `createNoydb()` option (opt in with `withClassified()`). */ private readonly classifiedStrategy; /** Async callback provided by the Vault to open a dynamic dictionary handle (for label-map pre-computation in the search index). Only used in `resolveDictLabelMaps()`; static dicts bypass this entirely. Mutable, assign-once — see {@link _reconcileReadState} (#671 item 1). */ private getDictionary; /** * declared deterministic fields. `null` when the feature * is inactive for this collection; a frozen `Set` otherwise. */ private readonly deterministicFields; /** * Declared structural-group-encryption fields (`sensitive`). Each is * sealed into its own `_sealed[field]` slot under a per-field key and kept * out of the open `_data` blob. Empty set ⇒ feature off (byte-identical * output). See {@link encryptRecord} / {@link decryptRecord}. */ private readonly sensitiveFields; /** * Per-record CEK opt-in (`perRecordKeys: true`). When set, writes mint / * reuse a per-record content-encryption key and stamp `_cek` on the * envelope (see {@link EncryptedEnvelope._cek}). OFF by default — a * non-adopting collection takes the byte-identical legacy path. The READ * path does not consult this flag: `_cek` presence on the envelope is the * format discriminant, so a mixed vault (and a recipient that never set the * flag) still decrypts CEK records. */ private readonly perRecordCek; /** * Per-record provenance opt-in (`provenance: true`). When set, `put()` calls * that supply a `source` option stamp `_source`/`_sourceTs` onto the * unencrypted envelope metadata. Off by default — zero cost for collections * that don't need lineage tracking. */ private readonly provenance; /** * Session-scoped `(id) → CEK` cache for this collection. Lets updates * reuse a record's stable CEK and lets repeated reads skip the AES-KW * unwrap. Bounded by LRU; never persisted. Dropped when the owning * collection instance is discarded — `vault.load()` clears the * collectionCache, so a keyring refresh drops every CEK alongside the * DEK cache. `null` unless `perRecordCek` is set. */ private readonly cekCache; /** * The per-record envelope build + encrypt/decrypt + per-record-CEK + * sealed-field crypto, extracted off this god-object. Holds no mutable * state of its own — it shares this collection's `cekCache` reference (so * tier methods and `vault.invalidateRecordCaches` evictions stay visible to * it) and reads the rest of its dependencies as a frozen context snapshot. */ private readonly codec; /** * declared tiers for this collection. `null` when * tier-aware methods are disabled. Tier 0 is implicit and never * stored here. */ private readonly tiers; private readonly tierMode; private readonly onCrossTierAccess; private readonly addSubjectRef; /** * Optional reference to the vault-level hash-chained audit log. When present, every successful `put()` and `delete()` appends an entry to the ledger AFTER the adapter write succeeds (so a failed adapter write never produces an orphan ledger entry). * * The ledger is always a vault-wide singleton — all * collections in the same vault share the same LedgerStore. * Vault.ledger() does the lazy init; this field just holds * the reference so Collection doesn't need to reach back up to the * vault on every mutation. * * `undefined` means "no ledger attached" — supported for tests that * construct a Collection directly without a vault, and for * future backwards-compat scenarios. Production usage always has a * ledger because Vault.collection() passes one through. */ private readonly ledger; /** — per-collection CRDT mode, or undefined for normal LWW-at-record-level. */ private readonly crdtMode; /** — optional remote/sync adapter for presence broadcasting. */ private readonly syncAdapter; /** — consent-audit hook, no-op when no scope is active. */ private readonly onAccess; /** * Vault-internal hook for derivation dispatch. When set, * `Collection.put` consults the registry after the source-write * commits and writes derived outputs through `getCollection(name).put`. */ private readonly derivationSource; /** * Vault-internal hook for materialized-view dispatch. * Parallel to `derivationSource` — when set, `Collection.put` fires * `MaterializedViewRegistry.onSourceWrite` after the source-write * commits + after `dispatchDerivations` has run. */ private readonly materializedViewSource; /** #638 Task 4 — `Vault._collectGraphTouch`; a no-op absent an open batch. `collectDelete` (#640) is the sync-apply delete socket. */ private readonly graphDispatch; /** * Optional back-reference to the owning compartment's ref * enforcer. When present, `Collection.put` calls * `refEnforcer.enforceRefsOnPut(name, record)` before the adapter * write, and `Collection.delete` calls * `refEnforcer.enforceRefsOnDelete(name, id)` before its own * adapter delete. The Vault handles the actual registry * lookup and cross-collection enforcement — Collection just * notifies it at the right points in the lifecycle. * * Typed as a structural interface rather than `Vault` * directly to avoid a circular import. Vault implements * these two methods; any other object with the same shape would * work too (used only in unit tests). */ private readonly refEnforcer; /** * Optional back-reference to the owning compartment's join resolver *`). When present, * `Collection.query()` builds a `JoinContext` that lets the Query * resolve `.join(field)` calls into target collections via this * resolver. * * Two methods: * - `resolveSource(name)` — fetch a `JoinableSource` for the * right-side collection by name. Returning `null` means "no * such collection in this compartment" — the executor then * throws an actionable error naming the missing target. * - `resolveRef(leftCollection, field)` — look up the ref * descriptor the left collection declared for this field. * `null` when the field has no ref, which makes `.join()` * throw at plan time before any records are touched. * * Typed structurally rather than as `Vault` to avoid a * circular import. Vault implements these two methods; any * other object with the same shape works too (used only in unit * tests against a plain object). */ private readonly joinResolver; constructor(opts: CollectionOpts); /** * Return the Standard Schema validator attached to this collection, * or `undefined` if none was provided at construction time. * * Exposed (read-only) for the Vault-level export primitive, * which surfaces each collection's schema in the per-chunk metadata * so downstream serializers (`@noy-db/as-*` packages, custom * exporters) can produce schema-aware output without poking at * collection internals. The validator object is returned by * reference — callers must treat it as immutable. */ getSchema(): StandardSchemaV1 | undefined; /** The declared consumer-neutral field metadata channel (canonical). */ getFieldMeta(): Record | undefined; /** The collection's declared descriptive metadata. */ getMeta(): CollectionMeta | undefined; /** * Aggregate all collection-level configuration options that are actively set * into a {@link CollectionConfig} snapshot. Returns `undefined` when no options * are configured (omitting the `config` block from `dumpSchema()` output). * Consumed by `walk.ts` to populate `CollectionDescriptor.config`. */ getConfig(): CollectionConfig | undefined; /** * Describe the collection's field schema from in-memory config — zero store I/O. * Sync overload (no args): merges moneyFields/dictKeyFields/refs/computed/fieldMeta/ * taint into a {@link CollectionDescription} (types inferred from config; the async * overload also derives validator-exact types and resolves dynamic dict labels). */ describe(): CollectionDescription; describe(opts: DescribeOptions): Promise; /** * Async describe implementation. Derives validator-exact types via deriveZodFields * (lazy, no static zod import), optionally resolves dynamic-dict labels from vault.dictionary(name).list(), * then delegates to buildDescription (which also runs fieldMeta key-validation). */ private describeAsync; /** JSON Schema for this collection with describe() metadata as x- extensions. */ toJSONSchema(): Promise; /** Single-point audited reveal of one classified field. Requires withClassified(). */ reveal(id: string, field: string): Promise; /** Verify-without-reveal: verdict-only oracle for one classified field. Requires withClassified(). */ verify(id: string, field: string, candidate: string): Promise; /** k-of-n challenge over the collection's secretAnswer members. Requires withClassified(). */ verifyGroup(id: string, answers: Record, opts: { readonly min: number; }): Promise<{ readonly passed: boolean; }>; private _classifiedVerifyCtx; /** * Equatable blind-index lookup: the ids whose classified digest-only * `equatable` field carries a `_bidx` tag matching `candidate` AND whose * `_vdig` payload confirms it. Requires the field to be declared * `classified.password({ equatable: true })` (or `secretAnswer`) and the * collection to have opened its `acknowledgeEquatableRisk` door. * * The algorithm order is load-bearing (spec §3): * 1. Caller-bug refusals thrown at ~0 elapsed, BEFORE any PBKDF2 (R9 / * Oracle #6): the three field mis-declarations share ONE constant, * field-name-free message so the refusal text cannot enumerate which * fields are classified / digest-only / equatable. * 2. Derive the ONE blind-index target UNCONDITIONALLY (I-1 / F1): an empty * collection still pays exactly one 600K PBKDF2, so wall-time can never * distinguish "no records" from "no match". No early return may precede * this line. * 3. Scan `list + one get` per id, string-comparing the stored `_bidx` tag — * decrypting NOTHING — and retain each hit's already-fetched envelope. * 4. Emit the single sweep consent op (`onAccess('find', '*')`) now — after * the scan, before confirm (Oracle #5) — fixing its store-write timestamp * independent of hit count. * 5. Confirm-by-verify against the ALREADY-FETCHED envelope (C-B): run the * enclave `verifyDigestField` on an in-memory `getEnvelope` closure so the * confirm reads ZERO additional envelopes. A tag-hit whose `_vdig` fails * to confirm is dropped silently (a splice is indistinguishable from a * stale tag). Store-shape law: the only store calls are `list + N get`. */ findByDigest(field: string, candidate: string): Promise; /** * Retire a field's equatable blind-index (`_bidx`) coverage across every * live record — the SOLE lazy-write-independent drop-path for a still-live * record's tag (besides clear / `forget()` / DEK-rotation). For each envelope * carrying `_bidx[field]`, rewrite it WITHOUT that slot (dropping the whole * `_bidx` map when it becomes empty), leaving `_vdig[field]` and everything * else INTACT — the field stays `digest-only`, only the index coverage is * retired. NO crypto, NO re-mint, NO re-encrypt: a targeted envelope rewrite. * Returns the count of records scrubbed. * * This is a maintenance write, not a read-egress: it emits NO `'find'` op and * no consent. The field is validated to a declared equatable digest-only * classified field (only such a field ever carries `_bidx`). * * **Ledger consistency**: dropping `_bidx[field]` changes the envelope's * payload hash (`_bidx` is bound into `envelopeBodyForHash`), so a raw * `adapter.put` of the scrubbed envelope WITHOUT a matching ledger entry would * desync the chain and make a future integrity cross-check flag false * tampering. After each rewrite we therefore append an `op:'migration'` entry * recording the NEW payloadHash at the SAME version — this is index retirement, * not a new record version, so `_v` is NOT bumped; the migration op is * reverse-delta-inert (`ledger.reconstruct` skips non-put/delete ops) yet * keeps the hash chain and the record's latest recorded payloadHash correct. */ scrubEquatableTags(field: string): Promise; /** * @internal — attach money descriptors post-construction. MV dependency * analysis auto-creates a source collection (without options) during * `openVault`, before the user's `collection(name, { moneyFields })` * declaration; this reconciles that ordering. First-wins. Not public. * * PREPENDS money rather than appending: {@link compileViaBindings} always * compiles money before i18n (money-first pipeline order — see its * docstring), and by the time this reconcile runs an i18n binding may * already be sitting in `this.via.bindings` (declared at construction). * Appending here would yield `[i18n, money]` on this path vs `[money, * i18n]` from compile — prepending keeps both paths money-first. */ _applyMoneyFields(moneyFields: Record): void; /** @internal — attach computed fields post-construction. See {@link _applyMoneyFields}. */ _applyComputed(computed: ComputedFields): void; /** @internal — attach fieldMeta post-construction. See {@link _applyMoneyFields}. First-wins. */ _applyFieldMeta(fieldMeta: Record): void; /** @internal — attach collection-level meta post-construction. See {@link _applyMoneyFields}. First-wins. */ _applyMeta(meta: CollectionMeta): void; /** * @internal — attach classified fields post-construction. See {@link _applyMoneyFields}. * First-wins. Note: unlike money/computed/meta, this cannot retro-seal a * collection that was already auto-created — `sensitiveFields` is frozen at * construction time. A reconciled declaration is only accepted when none of * its members are `storage: 'recoverable'` (those require sealing at first * open); otherwise this throws rather than silently persisting the value as * inline plaintext while `describe()` advertises protection. */ _applyClassifiedFields(classifiedFields: Record): void; get _ramCiphertext(): boolean; /** * Get a single record by ID. * * @param id Record identifier. * @param locale Optional locale options. When provided, * `i18nText` fields are resolved to the requested locale * string, and `dictKey` fields get a `Label` * virtual field added. Pass `{ locale: 'raw' }` to * return the full `{ [locale]: string }` map instead. * * @returns The decrypted (and optionally locale-resolved) record, or * `null` if not found. */ get(id: string, locale?: LocaleReadOptions): Promise | null>; /** * Return the raw CRDT state for a record. * Only available on collections configured with `crdt: 'lww-map' | 'rga' | 'yjs'`. * Use this for merge operations or to pass to `@noy-db/yjs`. * Throws if the collection is not in CRDT mode. */ getRaw(id: string): Promise; /** * Read a record's unencrypted envelope metadata (version, timestamps, * provenance) without decrypting the body. * * Returns `null` when no envelope exists for `id` (record absent or never * written). Only `_source`/`_sourceTs` fields are populated when the * collection was opened with `provenance: true` AND the record was written * with a `source` option — but this method works on any collection because * it reads the raw envelope directly. * * @returns `{ version, timestamp, by?, source?, sourceTs? }` or `null`. * * @example * const meta = await clients.getMetadata('c1') * if (meta) console.log(meta.source, meta.timestamp) */ getMetadata(id: string): Promise<{ readonly version: number; readonly timestamp: string; readonly by?: string; readonly source?: string; readonly sourceTs?: string; } | null>; /** * Return a presence handle for this collection. * * The handle manages an encrypted ephemeral presence channel keyed by an * HKDF derivation of this collection's DEK. Presence payloads are invisible * to the adapter. * * @param opts.staleMs Milliseconds before a peer is considered inactive. * Default: 30 000. * @param opts.pollIntervalMs Milliseconds between storage polls (fallback mode). * Default: 5 000. */ presence

(opts?: { staleMs?: number; pollIntervalMs?: number; }): PresenceHandle

; /** * Create or update a record. Runs inside the hub's write-queue tracker * so `hub.writeQueue.pending` reflects this write. * * @param id Record identifier. * @param record The record body (validated by the collection's schema * if one was attached at `vault.collection(...)` time). * @param options Optional metadata for audit + import workflows. * `reason` is stamped onto the resulting ledger entry * so audit consumers can filter via * `entries.filter(e => e.reason?.startsWith('import:'))`. * `source` is an opaque source id (e.g. `'crm-sync'`, `'firm-A'`) * stamped onto the envelope as `_source`/`_sourceTs` when * the collection has `provenance: true`. Ignored otherwise * (zero cost). * `sourceTs` is an optional ISO-8601 origin timestamp override; * when supplied together with `source` on a provenance collection, * replaces the machine-stamped `now()` so re-merges preserve the * ORIGIN refresh time across vaults. (FR-4) */ put(id: string, record: T, options?: { readonly reason?: string; readonly source?: string; readonly sourceTs?: string; }): Promise; /** * Resolve the prior stored record (with its `_i18nFilled` marker) for * densify. Eager: in-memory cache; lazy: LRU then adapter. undefined if absent. */ private resolveDensifyPrior; /** * Densify provenance for a record: which i18n slots were auto-filled, * e.g. `{ name: ['en'] }`. undefined when nothing was filled. The marker is * stripped from ordinary reads; this is the sanctioned audit accessor. */ i18nProvenance(id: string): Promise | undefined>; /** * Validate a record against this collection's schema WITHOUT writing it. * Returns the (possibly coerced) record on success; throws * {@link SchemaValidationError} (direction: `'input'`) on violation. * A no-op pass-through when no schema is declared. * * Used by FR-8 migrate-then-merge to pre-validate all staged records * before `mergeDecryptedRecords` writes anything — so a failed upgrade * never half-writes the receiver. */ validateInput(record: T): Promise; /** * Resolve the prior record as REAL VALUES for the eager write/delete paths * (history snapshot, ledger patch, index upkeep). The eager cache holds * {@link Sealed} handles for sensitive fields (non-residency), so when the * collection seals anything we re-decrypt the stored envelope to materialise * real values — re-encrypting a handle would otherwise persist the marker * `'[sealed]'` in place of the value. `via?.hasAtRestHooks` (#642) catches * hook-only sealing (taint/classified, no local `sensitiveFields`). */ private resolvePriorValues; /** Resolves the prior envelope/record for a gate event; elides the read (#267) when no handler at `point` needs it (`elided: true`, `env`/`record` null). */ private resolveGatePrior; /** * Wraps {@link RecordCodec.toCacheRecord} with an additional strip for * digest-only classified fields: the codec already omits them from `_data` * on write, but the write path caches the pre-encrypt `record` object * directly (to skip a redundant decrypt) — without this, a vdig field's * plaintext would sit in the working-set cache and `get()` would leak it * right back out, defeating C6. */ private _toCacheableRecord; /** @internal Untracked put body — call {@link put}, not this. */ private _putInternal; /** * Fire registered MV strategies whose dependency set includes this * collection. Eager-mode MVs re-materialize inline via * `MaterializedViewExecutor.refresh`; lazy / manual modes are * no-ops in the foundation; wired in the lazy-mode implementation. * * Skips entirely when the record being written is itself an * MV-emitted row (carries `_materializedFrom`) — defensive guard * against missed cycle detection. * * @internal `wave` (#638 Task 4): when present (the sync/cutover/restore dispatch wave), * an eager MV already refreshed this wave is skipped (per-target dedup, keyed on spec name). */ dispatchMaterializedViews(id: string, record: T, wave?: WaveContext): Promise; /** * Fire registered derivation strategies for this source collection. * Eager mode runs `derive` inline and writes each output via the * sibling `Collection.put`; lazy mode marks dependent outputs stale * (D11 stub today). Errors in non-strict mode are logged and * skipped; strict mode propagates the first failing output's error. * * Skips entirely when the record being written is itself a derived * output (carries `_derivedFrom`) — defensive guard against missed * cycle detection. */ /** * @internal The RAW stored record (canonical-money form, i18n maps * intact), WITHOUT the locale resolution `get()` applies. Used as the * patch base for self-write reverse-denorm so writing back never clobbers * an i18n map or re-quantizes money incorrectly. Returns null for * missing / tombstoned records. */ _getStoredRecord(id: string): Promise; /** @internal #638 Task 4 — decrypted STORED-form record + envelope version for the sync/cutover/ * restore dispatch wave (id threaded into decrypt, matching `_invalidateCacheEntry`'s contract). */ _getStoredRecordForDispatch(id: string): Promise<{ record: T; version: number; } | null>; /** * @internal Ids of records whose top-level `field` equals `value`. * Uses the FK index when the field is indexed (O(matches)); otherwise a * linear scan (O(N) — fine for small child sets; index the FK to scale). */ _findMatchingIds(field: string, value: unknown): Promise; /** @internal Recompute a rollup aggregate onto the parent from `parentId`'s current children (value-equality guarded; no-op absent parent). * `wave` (#638 T4, #640): per-target dedup. Returns the `putDerivedOutput` outcome, or `'noop'` (no parent/no-op/deduped) — `dispatchRollupsOnDelete`'s forget-fanout caller + #640's `_recomputeDeletedRollups` need this to fill their reports. */ private recomputeRollup; /** @internal #640 — this deleted child's rollup PARENT intents (see via/dispatch.ts#resolveRollupDeleteIntents). */ _rollupDeleteIntents(deleted: T): RollupDeleteIntent[]; /** * @internal Fire any rollups for which THIS collection is the child `from`, recomputing the * affected parent after a child delete/forget. Called from the delete path (return discarded) * and from `forgetDerivedFanout` (#638 Task 6), which needs the per-target outcome to fill * `ForgetResult.derivedAggregatesRecomputed`/`derivedResidueFrozen`). `wave` (#640): per-target dedup for the sync-apply path; `undefined` on local-delete (byte-identical). */ dispatchRollupsOnDelete(id: string, deleted: T, wave?: WaveContext): Promise>; /** @internal #640 — the wave's per-id driver: recompute each sync-apply delete's rollup parent. */ _recomputeDeletedRollups(intents: readonly RollupDeleteIntent[], wave: WaveContext): Promise; /** @internal `wave` (#638 Task 4) — threaded to `recomputeRollup` for the sync/cutover/restore * dispatch wave's per-target dedup; `undefined` on the local-write path (byte-identical). */ dispatchDerivations(id: string, record: T, version: number, wave?: WaveContext): Promise; /** * Delete a record by ID. Runs inside the hub's write-queue tracker * so `hub.writeQueue.pending` reflects this write. */ delete(id: string): Promise; /** * @internal — bulk-rewrite every record through a cutover transform. Raw adapter path (bypasses the write gate + guards — the transform is trusted and runs only during the `migrating` phase). Bumps each record's `_v` and appends a ledger `op:'migration'` entry. #708: refuses BEFORE any rewrite if a live record is elevated (assertCutoverTierSafe) — demote it first. */ _applyCutoverTransform(transform: (doc: Record) => Record): Promise; /** @internal Untracked delete body — call {@link delete}, not this. */ private _deleteInternal; /** * @internal — system-internal delete that bypasses user-facing * delete hooks (`onDelete`, FK ref enforcer). Used by derivation tombstones and MV refresh * (Dim 14 v2) — system housekeeping shouldn't trip user invariants * registered against the output collection. The ledger entry and * history snapshot still fire so backup integrity and time-travel * reconstruction stay consistent. * * Returns `true` when it erased a live record, `false` for delete-of-absent (idempotent * contract — both txCtx-aware and txCtx===null callers honour this, short-circuiting before any side-effect). * * When a `txCtx` is supplied, the prior envelope is captured and * pushed onto `txCtx._executed` BEFORE the delete fires — mirrors * the rollback hardening for puts. Callers outside a * multi-record transaction pass `null` and skip the tracking. * * Amendment composition: if `_internalDelete` runs while a vault's * `GuardRegistry` has an amendment window open, the `{before, after: * null}` change pair is pushed onto the amendment change-set the * same way a user-initiated delete would. The `onDelete` user-hook * is still skipped (housekeeping must not trip user invariants in * normal mode), but the amendment's invariant DOES see the change * — so a `RCT-CANCEL-001`-style invariant pairing can reject a * derivation-driven tombstone fired during an admin amendment. * * Constraint to surface to consumers: output collections of * derivations with `optional: true` outputs should not be the * targets of `strict` or `cascade` inbound foreign-key refs — * `_internalDelete` bypasses the ref enforcer by design (the * `onDelete` bypass primitive). Treat the housekeeping path as * "system can tombstone its own emissions regardless of FK shape." * * Permission handling is unchanged: the caller must still hold * write permission on the collection (derivations run under the * user's keyring). */ _internalDelete(id: string, txCtx?: TxContext | null): Promise; private _doDelete; /** * @internal — GDPR crypto-shred a LIVE record to a tombstone. * * Rewrites the on-disk envelope to `{ _noydb, _v, _ts, _by, _iv:'', _data:'' }`, * dropping `_iv`/`_data`/`_cek`/`_det`. The wrapped per-record CEK is gone, so * the body — and (via {@link tombstoneHistory}) every history version under * the same CEK — is permanently undecryptable; the collection DEK and every * other record are untouched. `_det` is stripped too, so `findByDet` no * longer matches the shredded record (avoiding a post-shred TamperedError). * * Unlike `delete()`/`_internalDelete`, this: * - does NOT fire onDelete guards / MV / derivation dispatch (a shred is an * erasure, not a domain delete — re-running those would be wrong), * - does NOT append a per-record ledger entry (`vault.forget()` appends a * single `op:'forget'` summary for the whole subject), * - keeps the record KEY present (it's an overwrite, not an adapter delete) * so the version counter + "record existed" survive for audit. * * Idempotent: returns `null` when the record is absent or already a tombstone. * Otherwise returns `{ previousVersion }`. Invalidates the eager cache, the * lazy LRU, and the per-record CEK cache for this id. */ /** * @internal — decrypt an envelope to a plain record for subject-index * rebuild. Returns `null` for a tombstone or unreadable envelope. * Skips schema validation — the rebuild only reads the subject field. */ _decodeEnvelope(envelope: EncryptedEnvelope, id: string): Promise | null>; _writeTombstone(id: string, actor: string): Promise<{ previousVersion: number; } | null>; /** * Cascade deletes of array-shape derived rows when a source row is deleted. Reads each * registered strategy's fanout sidecar for this source id, deletes every listed derived * row, then deletes the sidecar itself. Returns the REAL erased count (#622 review Finding 1). * * Record-shape derivations are skipped on the ordinary delete path (see _doDelete's comment). * `eraseRecordShapeToo` (#638 T6, default `false`) opts a same-id record-shape copy into * erasure too — forget()'s fanout, GDPR residue. A delete-of-absent contributes 0 either way. * @internal */ dispatchArrayDerivationsOnDelete(id: string, eraseRecordShapeToo?: boolean): Promise; /** * Mirror of {@link dispatchMaterializedViews} for the delete/forget path — no `_materializedFrom` * skip (record's gone); the `internal` gate at `_doDelete` is the recursion guard. Returns the * row count TOMBSTONED across EVERY MV sourced here (#638 T6 — `forgetDerivedFanout`'s * `derivedRecordsErased`) — eager AND lazy/manual `invalidateMVAtRest` purges both contribute now * (#761 item 1, previously eager-only); lazy persists a stale mark for cold-session recompute; * manual serves empty until `refreshView()`. `residueUndecodable`/`residueDeclined` (#776/#785) carry `outputCollection:id` entries whose ownership stamp `invalidateMVAtRest` could not decode, resp. decoded+stamp-matched but declined erasure — surfaced, not erased. * @internal */ dispatchMaterializedViewsOnDelete(id: string): Promise<{ deleted: number; residueUndecodable: string[]; residueDeclined: string[]; }>; /** * List all records in the collection. * * Throws in lazy mode — bulk listing defeats the purpose of lazy * hydration. Use `scan()` to iterate over the full collection * page-by-page without holding more than `pageSize` records in memory. * * @param locale Optional locale options. When provided, * each record is locale-resolved before being returned. */ list(locale?: LocaleReadOptions): Promise; /** * Scan-mode full-text search over a plain-text `field`. Decrypts the * collection in memory and ranks records by BM25 against the tokenized query. * **Zero added store leakage** — pure client-side scan; nothing searchable is * written to the store. (A store-usable blind index for at-scale search is a * separate, gated opt-in.) Eager mode only. * * `opts.match` (`'any'` default | `'all'`), `opts.prefix` (last query term as * a prefix → typeahead), `opts.limit` (top-N). Returns `{ id, score, record }` * ranked by descending score. The default tokenizer is word-boundary based — * see `src/search/tokenize.ts` for the Thai/CJK caveat. */ search(field: string, query: string, opts?: SearchOptions): Promise[]>; /** Force-persist the lexical index now — gated behind `searchStrategy: withSearch()`. */ flushIndex(): Promise; /** Pre-build the lexical index — gated behind `searchStrategy: withSearch()`. */ warmIndex(): Promise; /** Retrieval (lexical | semantic | hybrid) — gated behind `searchStrategy: withSearch()`. */ retrieve(query: string, opts?: RetrieveOptions): Promise[]>; /** Raw-vector kNN — gated behind `searchStrategy: withSearch()`. */ similarTo(vector: Float32Array, opts?: { k?: number; minScore?: number; includeRecord?: boolean; }): Promise[]>; /** Opt-in bulk `_vec` re-derive (#788) — a plain collection has nothing to rebuild; gated behind `searchStrategy: withSearch()` otherwise. */ rebuildEmbeddings(): Promise<{ rebuilt: number; skipped: number; }>; /** * Bind the {@link SearchContext} the search/retrieval surface needs. The * `cache` is the SAME `Map` reference the eager read/write path owns (passed * by reference, never copied) so the index always builds over the live set. */ private searchContext; /** * Put many records in one call. Each item is processed sequentially * through the normal `put()` path — meaning per-item validation, * history snapshots, ledger appends, and change events all still * fire. The round-trip saving comes from the adapter staying hot * across the batch (no connection re-open, no keyring re-unlock). * * ## Semantics * * **Best-effort with per-item results.** If item 5 of 10 fails, items * 1–4 are already persisted and items 6–10 are still attempted. * The returned {@link PutManyResult} lists every success and failure * individually so the caller can decide whether to roll forward * (retry the failures) or roll back (manually delete the successes). * * **True tx-atomic putMany** — pass `{ atomic: true }` to switch * to the transaction executor: pre-flight CAS against every * item's `expectedVersion`, then commit all ops with best-effort * revert on mid-batch failure. Atomic mode throws on failure rather * than returning a mixed-results object. * * ## Change events * * One `change` event per successfully-written record, same as N * single-record puts. Subscribers don't need to special-case bulk. */ putMany(entries: ReadonlyArray, options?: PutManyOptions): Promise; /** * Atomic-mode implementation of {@link putMany}. Pre-flights every * `expectedVersion`, executes all puts in declaration order, and * reverts executed ops via the raw adapter on mid-batch failure. * See `runTransaction` for the shared semantics + crash-window caveat. * * @internal */ private putManyAtomic; /** * Get many records in one call. Returns a `Map` — * missing records surface as `null` entries so the caller can * distinguish "not found" from "lookup failed". Order-stable * iteration (Map preserves insertion order = input `ids` order). * * Reads go through the per-id `get()` path, which means the cache * / hydration logic stays consistent with single-record reads. */ getMany(ids: readonly string[]): Promise>; /** * Delete many records in one call. Same best-effort contract as * {@link putMany}: if item 5 fails, items 1–4 are already deleted * and items 6–10 are still attempted. * * Deleting a non-existent id is not a failure — matches the * idempotent semantics of single-record `delete()`. */ deleteMany(ids: readonly string[]): Promise; /** * Build a chainable query against the collection. Returns a `Query` * builder when called with no arguments. * * Backward-compatible overload: passing a predicate function returns * the filtered records directly (the API). Prefer the chainable * form for new code. * * **Lazy-MV gap:** `query()` is synchronous and does NOT * trigger lazy materialized-view resolve-on-read. If this * collection is a lazy MV's output and the MV is currently stale, * `query().toArray()` returns the pre-stale snapshot. To force a * fresh read on a lazy MV, either call `list()` (which DOES * trigger resolve) or `vault.refreshView(mvName)` before querying. * The proper fix — extending `QuerySource` with an async prepare * hook — is a separate PR. * * @example * ```ts * // New chainable API: * const overdue = invoices.query() * .where('status', '==', 'open') * .where('dueDate', '<', new Date()) * .orderBy('dueDate') * .toArray(); * * // Legacy predicate form (still supported): * const drafts = invoices.query(i => i.status === 'draft'); * ``` */ query(): Query; query(predicate: (record: T) => boolean): T[]; /** * Subscribe to every put/delete on this collection. Returns an * unsubscribe function. * * Fires **after** the store write has committed — subscribers see * only materialised state, never in-flight or rolled-back writes. * * This is an event stream, not a reactive value. For reactive * "current array state" semantics use `query().live()`. Typical * use cases for `subscribe()`: * - audit-trail / activity-feed UI that lists events as they happen * - Pinia-per-collection wiring where each store subscribes once * - outbox-style workers that process every new record * * The callback receives a `CollectionChangeEvent`: * - `{ type: 'put', id, record }` — record is the current * decrypted value. May be `null` if another op deleted the * record between the emit and the handler firing (rare race). * - `{ type: 'delete', id, record: null }` — deletion event; * the record content is gone by the time the handler runs. * * The callback is invoked synchronously *with respect to the emit * moment*, but the record lookup is async (cache hit for eager * collections; one `get()` for lazy collections). If your handler * does not need the record, cast it away and ignore — the lookup * is still performed, but it's cheap on the hydrated path. * * ergonomic wrapper over `db.on('change', …)` that * filters to this collection and hydrates the record. */ subscribe(cb: (event: CollectionChangeEvent) => void): () => void; /** * Return a minimal JoinableSource view of this collection's * in-memory cache. Used by the Vault's `resolveSource` * method when another collection's `.join()` needs to probe this * one as the right side. * * The returned object captures the cache reference through a * closure, so subsequent mutations to the cache are visible to * the joined query. That's intentional: a join that fires after * the right-side collection has been updated should see the * fresh data. * * Throws in lazy mode because the cache is bounded and could * silently miss records — consistent with the `query()` / * `list()` lazy-mode policy. If this becomes a blocker for a * real consumer, the fix is to add an async `scan()`-backed * variant of this method, which is exactly what streaming joins will need anyway. */ querySourceForJoin(): JoinableSource; /** * Cache statistics — useful for devtools, monitoring, and verifying * that LRU eviction is happening as expected in lazy mode. * * In eager mode, returns size only (no hits/misses are tracked because * every read is a cache hit by construction). In lazy mode, returns * the full LRU stats: `{ hits, misses, evictions, size, bytes }`. */ cacheStats(): CacheStats; /** Get version history for a record, newest first. */ history(id: string, options?: HistoryOptions): Promise[]>; /** * Get a specific past version of a record. * * History reads intentionally **skip schema validation** — historical * records predate the current schema by definition, so validating them * against today's shape would be a false positive on any schema * evolution. If a caller needs validated history, they should filter * and re-put the records through the normal `put()` path. */ getVersion(id: string, version: number): Promise; /** Revert a record to a past version. Creates a new version with the old content. */ revert(id: string, version: number): Promise; /** * Compare two versions of a record and return the differences. * Use version 0 to represent "before creation" (empty). * Omit versionB to compare against the current version. */ diff(id: string, versionA: number, versionB?: number): Promise; /** Resolve a version: try history first, then check if it's the current version. */ private resolveVersion; private resolveCurrentOrVersion; /** Prune history entries for a record (or all records if id is undefined). */ pruneRecordHistory(id: string | undefined, options: PruneOptions): Promise; /** Clear all history for this collection (or a specific record). */ clearHistory(id?: string): Promise; /** * Count records in the collection. * * In eager mode this returns the in-memory cache size (instant). In lazy * mode it counts only LIVE tier-0 envelopes via envelope inspection — no * record bodies loaded — matching eager's tombstone/tier exclusion (#706). */ count(): Promise; /** * Fetch a single page of records via the adapter's optional `listPage` * extension. Returns the decrypted records for this page plus an opaque * cursor for the next page. * * Pass `cursor: undefined` (or omit it) to start from the beginning. * The final page returns `nextCursor: null`. * * If the adapter does NOT implement `listPage`, this falls back to a * synthetic implementation: it loads all ids via `list()`, sorts them, * and slices a window. The first call emits a one-time console.warn so * developers can spot adapters that should opt into the fast path. */ listPage(opts?: { cursor?: string; limit?: number; }): Promise<{ items: T[]; nextCursor: string | null; }>; /** * Stream every record in the collection page-by-page as an async * iterable, with chainable `.where()` / `.filter()` clauses and a * memory-bounded `.aggregate(spec)` terminal. * * The whole point: process collections larger than RAM without * ever holding more than `pageSize` records decrypted at once. * * @example * ```ts * // Backward-compatible iteration — unchanged from the previous * // async-generator shape. `ScanBuilder` implements AsyncIterable. * for await (const record of invoices.scan({ pageSize: 500 })) { * await processOne(record) * } * * // — streaming aggregation with O(reducers) memory. * const { total, n } = await invoices.scan() * .where('year', '==', 2025) * .aggregate({ total: sum('amount'), n: count() }) * ``` * * **Lazy-MV gap:** `scan()` is synchronous-build and does * NOT trigger lazy materialized-view resolve-on-read. For lazy * MVs, call `list()` (which DOES resolve) or `vault.refreshView(name)` * before scanning. Same shape as the `query()` limitation. * * Returns a `ScanBuilder` instead of the raw async iterator * that previous versions used. The builder implements * `AsyncIterable`, so every existing `for await … of` call * continues to work unchanged. Direct `.next()` calls on the * iterator — not idiomatic, not used in the codebase — are no * longer supported; upgrade to `for await` or call the new * `.aggregate()` terminal. * * Uses `adapter.listPage` when available; otherwise falls back * to the synthetic pagination path with the same one-time * warning (`listPage()` routes through that fallback internally). */ scan(opts?: { pageSize?: number; }): ScanBuilder; /** Decrypt a page of envelopes returned by `adapter.listPage`. */ private decryptPage; /** Load all records from adapter into memory cache. */ /** * @internal — refresh the in-memory cache entry for a single id by * re-reading from the adapter. Used by the transaction executor's * Phase-3 revert path: that path writes the prior envelope directly * via the raw store (to avoid re-firing Collection-level side * effects), which would otherwise leave this Collection's eager * cache holding the rolled-back value. After revert, the executor * calls this hook so subsequent `get` / `query` reads see the * actual on-disk state. * * Lazy mode: drops the LRU entry; the next `get` repopulates from * the adapter. Eager mode: re-reads the envelope and either sets * the cache entry (record still present) or deletes it (record was * gone before the tx and the revert deleted it again). */ /** * @internal — evict ONLY the per-record CEK cache entry for `id`. Used by * `vault.rotateRecordCek()`: after a hard CEK rotation the cached unwrapped * CEK is stale (it would decrypt the pre-rotation body and fail GCM auth on * the post-rotation body). Eviction must be synchronous with the live-envelope * rewrite so no concurrent read observes the old CEK. Paired with * {@link _invalidateCacheEntry} (which refreshes the decrypted-record cache). * No-op when the collection is not `perRecordKeys`. */ _invalidateCekCacheEntry(id: string): void; /** @internal Satellite fan-out revert cleanup (spec #591): drop the sync dirty entry ('revert' is * onDirty-channel-only) and re-announce the RESTORED state as a plain put/delete — `subscribe()` * only understands those two actions, and the restored record may exist again. */ _compensateRevertedWrite(id: string): Promise; _invalidateCacheEntry(id: string): Promise; /** * Apply a peer tab's committed write to THIS tab's in-memory view: * re-read the (already-persisted) envelope from the shared store + refresh * cache/indexes, then emit a `change` event so reactive consumers re-render. * Never writes to the store and never fires write hooks, so it cannot loop. */ _applyRemoteChange(id: string, action: 'put' | 'delete'): Promise; /** * Origin-tagged mutation choke point (#623 task 10) — the single dispatch * point every put/delete path funnels through, AFTER its own store write, * to fire the side-effects that path performs today. Each case below * performs EXACTLY the side-effect set the seam-map Part-3 table * (`.superpowers/sdd/seam-map-i18n-pipeline.md`) records for `origin` — * this is a pure parity extraction of existing tails, not a behavior * change. Phase C (#621, #622) plugs the dependency-graph dispatch into * this socket, keyed off `origin`, without every call site having to * learn the graph. * * `restore` is never reached: `Vault.load` / `backup.ts#loadVault` drops * the whole `collectionCache` instead of dispatching per record — the * origin is reserved for phase C. * * @internal */ _onRecordMutated(id: string, action: 'put' | 'delete', origin: MutationOrigin, ctx?: { readonly record?: T; readonly version?: number; }): Promise; /** @internal — the current in-memory record without a store read (for conflict capture); also the #640 FK-recovery read on a sync-applied delete — misses on a cold or evicted child (lazy LRU eviction OR an un-hydrated eager collection whose first sync op is a delete), not "lazy-mode" only. */ _peekCached(id: string): T | null; private ensureHydrated; /** Hydrate from a pre-loaded snapshot (used by Vault). */ hydrateFromSnapshot(records: Record): Promise; /** * Rebuild secondary indexes from the current in-memory cache. * * Called after any bulk hydration. Incremental put/delete updates * are handled by `indexes.upsert()` / `indexes.remove()` directly, * so this only fires for full reloads. * * Synchronous and O(N × indexes.size); for the target scale of * 1K–50K records this completes in single-digit milliseconds. */ private rebuildEagerIndexesFromCache; /** * Rebuild unique-constraint maps from the current in-memory cache. * Called after any bulk hydration alongside `rebuildEagerIndexesFromCache`. */ private rebuildUniqueConstraintsFromCache; /** * Rebuild every declared index from scratch. * * Eager mode: refreshes the in-memory `CollectionIndexes` from the * current cache — O(records × declaredFields). * * Lazy mode: tears down every `_idx//` * side-car, walks the canonical record namespace, and materialises * fresh side-cars for every declared field. The in-memory mirror is * cleared and re-ingested. Intended for two scenarios: * 1. Adding a new indexed field to a collection that already holds * records — after the schema change, call `rebuildIndexes()` to * backfill the side-cars. * 2. Recovery from a catastrophic drift (audit noticed many * `index:write-partial` events, operator wants a clean slate). * * The rebuild is NOT incremental — it's a full bulk-replace. For * per-field drift repair, use `reconcileIndex(field)` instead. */ rebuildIndexes(): Promise; /** * Compare the persisted `_idx//*` side-cars against the * canonical records for a single field, reporting the drift (and * optionally repairing it). * * Lazy mode only. Eager mode throws — the in-memory index cannot * drift. * * `missing` — record ids whose value is indexable but no side-car * exists. Happens when a `put()` succeeded but the side-car put * failed (surfaced as `index:write-partial`). * `stale` — side-car ids pointing to a record that no longer exists * or whose current value no longer matches the side-car body. * `applied` — number of writes that were actually applied (always 0 * when `dryRun: true`). * * Design reference: acceptance criteria. */ reconcileIndex(field: string, opts?: { dryRun?: boolean; }): Promise<{ field: string; missing: string[]; stale: string[]; applied: number; }>; /** * Get the in-memory index store. Used by `Query` to short-circuit * `==` and `in` lookups when an index covers the where clause. * * Returns `null` if no indexes are declared on this collection. */ getIndexes(): CollectionIndexes | null; /** * Return a `BlobSet` for the given record id. * * No I/O is performed until you call a method on the handle. * * ```ts * const blobs = invoices.blob('inv-001') * * // Upload a PDF (deduplicates automatically, MIME auto-detected) * await blobs.put('receipt.pdf', pdfBytes) * * // List slots * const files = await blobs.list() // SlotInfo[] * * // Serve as HTTP response (Content-Type, ETag, streaming body) * const res = await blobs.response('receipt.pdf', { inline: true }) * * // Publish a named version (amendment versioning) * await blobs.publish('receipt.pdf', 'issued-2025-01') * * // Raw bytes * const bytes = await blobs.get('receipt.pdf') * ``` * * Blobs are stored in internal collections (`_blob_slots_*`, `_blob_index`, * `_blob_chunks`, `_blob_versions_*`) that are excluded from queries and * `list()`. Slot metadata uses this collection's DEK; chunk data uses a * vault-shared `_blob` DEK (enabling cross-collection deduplication). */ blob(id: string): BlobSet; /** Get all records as encrypted envelopes (for dump). */ dumpEnvelopes(): Promise>; /** * Apply locale resolution to a record. * * Called from `get()` and `list()` when locale options are present. * Uses the effective locale: per-call `locale` takes precedence over * `this.defaultLocale`. * * - i18nText fields: replaced with the resolved string (or the full * map when `locale === 'raw'`). * - dictKey fields: `Label` virtual fields added. * * Returns the record unchanged when no Via pipeline is compiled (#638 Task 7: * was money/i18n/dictKey-flag-gated — missed any OTHER binding's `present` * hook, e.g. the `computed` via-binding's virtual-mode read-time compute; * `this.via` truthiness is the general condition every binding's presence * already implies). */ private applyLocaleToRecord; /** * Low-level: encrypt a pre-serialised JSON string into an EncryptedEnvelope. * Used by both the normal record path and the CRDT path (which serialises * a CrdtState rather than a T). */ /** * Write / update / delete the `_idx//` side-cars for * every declared persistence-index field on this collection after a * successful main-record `put()`. * * Timing: called AFTER `adapter.put()` of the main record succeeds, so * a failed main write never leaves a stale index entry. Side-car write * failures do NOT fail the overall `put()` — the main record is already * durably committed. Per-field failures surface as * `IndexWriteFailureError` on the emitter's `index:write-partial` * channel and the operator runs a reconcile pass later. * * Null/undefined field values are not indexed — matches the * `PersistedCollectionIndex.stringifyKey` contract. If the prior value * was non-null and the new value is null, the side-car is deleted. */ private maintainPersistedIndexesOnPut; /** * Tear down `_idx//` side-cars for a deleted record. * Mirror state updates regardless of adapter outcome; adapter failures * surface on `index:write-partial` the same way put does. */ private maintainPersistedIndexesOnDelete; /** * @internal — hard-delete this record's persisted `_idx//` * side-cars for the erasure path. `forget()` crypto-shreds the body but * keeps the collection DEK, under which these side-cars are encrypted — so * without this they leave the indexed field VALUES readable after a "forget". * * Content-free: the side-car id is `encodeIdxId(def.key, id)`, so it needs no * body decode (the body is being shredded). Eager mode has no durable side-car * → no-op. The in-memory mirror is left as-is: it is ephemeral (rebuilt from * the now-deleted side-cars on reopen) and live reads skip the tombstone, so a * stale mirror hit cannot surface the erased record. Returns the count deleted * + the `def.key`s whose delete FAILED (residue that still leaks the value). */ _purgePersistedIndexes(id: string): Promise<{ purged: number; residue: string[]; }>; /** Drop a record's encrypted _vec sidecar on erasure (a vector is text-invertible). * Called by vault.ts forget() inside a resilient try/catch; residue is reported in ForgetResult. */ _purgeVector(id: string): Promise; /** Drop the persisted lexical-index blob (forget/erasure): an opaque * all-records index must not survive crypto-shred. Idempotent; no-op without persist. */ _purgeSearchIndex(): Promise; /** * Bulk-load the persisted-index mirror from `_idx//*` side-cars * on first lazy-mode query. Idempotent — subsequent calls short-circuit * on the `persistedIndexesLoaded` flag. * * Listing the whole id namespace is acceptable here because the caller * has already decided to pay a first-query cost (this is the indexed * equivalent of lazy-mode hydration, not a per-query scan). */ private ensurePersistedIndexesLoaded; /** * Walk every declared persisted-index field, run `reconcileIndex` * per the configured policy, and emit `index:reconciled` for each. * Called internally by `ensurePersistedIndexesLoaded()` — exposed as * a private helper for readability, not as a public API (the public * entry points are `reconcileIndex` and `rebuildIndexes`). */ private autoReconcile; /** * Construct a `LazyQuery` bound to this collection. Used by the * lazy-mode branch of `query()` and kept private because callers should * always go through `query()` to pick up the eager/lazy dispatch. */ /** * Build a chainable indexed-read query against a lazy-mode collection. * * Companion to `query()`, which is eager-mode only and materialises a * snapshot. `lazyQuery()` dispatches every read through the persisted * index side-cars — no bulk decrypt, no snapshot. Every field touched by * `.where(...)` or `.orderBy(...)` MUST be declared in `indexes`; * otherwise `.toArray()` throws `IndexRequiredError`. * * The returned builder is always Promise-returning on its terminals * (`toArray`, `first`, `count`) because candidate records are decrypted * from the adapter on demand. * * @example * ```ts * const disbursements = vault.collection('disbursements', { * prefetch: false, * cache: { maxRecords: 1000 }, * indexes: ['clientId', 'period'], * }) * const rows = await disbursements.lazyQuery() * .where('clientId', '==', 'c-42') * .orderBy('period', 'desc') * .limit(50) * .toArray() * ``` * * Throws at call time when the collection is in eager mode — use * `query()` there. Throws if no index is declared, because a lazy * query with no index would need to enumerate the whole collection. */ lazyQuery(): LazyQuery; /** * Resolve the stable CEK for a record on the WRITE path — see * {@link resolveStableCek}. Thin delegate that supplies the collection's * CEK cache, live-envelope reader, and DEK resolver. */ private resolveRecordCek; /** C-A/R10: persist the classified marker once per classified handle (store I/O in config-drift.ts). */ private _ensureClassifiedMarker; /** C-A/R10 drift signal — the marker's declared digest-only set, memoized to one store read per handle (see config-drift.ts). */ private _classifiedMarkerDigestOnly; /** * find the first record whose deterministic field matches * the given plaintext. Returns `null` when no match exists. * * Reads every envelope via the adapter and compares the stored * `_det[field]` to a freshly computed deterministic ciphertext — no * record bodies are decrypted during the search, which is the whole * point of a deterministic index. * * Throws when the field is not declared in `deterministicFields`, so a * typo fails loudly at the call site rather than silently returning * null forever. */ findByDet(field: string, value: unknown): Promise; /** * return every record whose deterministic field matches. * Same semantics as {@link findByDet} but without the short-circuit. */ queryByDet(field: string, value: unknown): Promise; /** Bind the {@link DeterministicContext} this collection's det lookups need. */ private detContext; /** tier-aware put — gated behind `tiersStrategy: withTiers()`. `searchResidue: true` = stuck search compensation (#774, mirroring #764's elevate/demote posture); the write itself always completes. */ putAtTier(id: string, record: T, tier: number, opts?: { elevation?: { reason: string; fromTier: number; }; source?: string; sourceTs?: string; }): Promise; /** tier-aware get — gated behind `tiersStrategy: withTiers()`. */ getAtTier(id: string): Promise; /** list ids grouped by the caller's readability — gated behind `withTiers()`. */ listAtTier(): Promise>; /** elevate a record to a higher tier — gated behind `withTiers()`. `searchResidue: true` = stuck search compensation (#764); the move itself always completes. */ elevate(id: string, toTier: number): Promise; /** demote a record to a lower tier — gated behind `withTiers()`. Same `searchResidue` posture as {@link elevate} (#764). */ demote(id: string, toTier: number): Promise; /** * Emit a cross-tier access event. The subscriber sink stays collection-resident (it captures `onCrossTierAccess`); the tiers module reaches it via the {@link TiersContext.emitCrossTierEvent} callback. */ private emitCrossTierEvent; /** * Classify a live envelope's `_sealed` slots for crypto-shred completeness * (#M-1, 2026-06-30 security review). Kept as a `_`-prefixed method on * Collection because `vault.ts` forget() reaches in via this name. */ _classifySealedShred(live: EncryptedEnvelope): Promise<{ readonly slots: readonly SealedShredSlot[]; }>; _onViaErase(id: string, live: EncryptedEnvelope): Promise; get _via(): ViaPipeline | undefined; _setVia(pipeline: ViaPipeline | undefined): void; get _viaFieldsSnapshot(): { i18nFields: Record | undefined; lookupFields: Record | undefined; }; _reconcileReadState(patch: { dictKeyFields?: Record; i18nFields?: Record; lookupFields?: Record; getDictionary?: (name: string) => Promise; presentForJoin?: (record: unknown, locale: string) => unknown; }): void; /** * Bind the {@link TiersContext} the tier ops need — `cekCache` by reference (same `Lru` the read/write path owns) for a synchronous re-wrap; `syncDerived` closes over `this` for {@link syncDerivedOutputs} (#722). */ private tiersContext; /** * Bind the {@link IndexingContext} the index-maintenance surface needs. The * eager `cache` Map and the index / unique-constraint / persisted mirrors are * passed by reference (the SAME instances the query path reads, never * copied); the `persistedIndexesLoaded` flag and `ensure*` hydration stay collection-resident, reached via callbacks. */ private indexingContext; }