/** * Secondary indexes for the query DSL. * * ships **in-memory hash indexes**: * - Built during `Collection.ensureHydrated()` from the decrypted cache * - Maintained incrementally on `put` and `delete` * - Consulted by the query executor for `==` and `in` operators on * indexed fields, falling back to a linear scan otherwise * - Live entirely in memory — no adapter writes for the index itself * * Persistent encrypted index blobs (the spec's "store as a separate * AES-256-GCM blob" note) are deferred to a follow-up issue. The reasons * are documented in the PR body — short version: at the target * scale of 1K–50K records, building the index during hydrate is free, * so persistence buys nothing measurable. */ /** * Index declaration accepted by `Collection`'s constructor. * * Accepts: * - `string` — a single-field hash index (`'clientId'`) * - `{ fields: [...] }` or `readonly string[]` — a composite index * over an ordered field tuple. Only lazy-mode * collections consume composite declarations today; eager mode * silently treats a composite as equivalent to declaring each * component field as its own single-field index. * * Additive variants (unique constraints, partial indexes) will land as * further union members without breaking existing declarations. */ export type IndexDef = string | { readonly fields: readonly string[]; readonly unique?: boolean; } | readonly string[]; /** * Internal representation of a built hash index. * * Maps stringified field values to the set of record ids whose value * for that field matches. Stringification keeps the index simple and * works uniformly for primitives (`'open'`, `'42'`, `'true'`). * * Records whose indexed field is `undefined` or `null` are NOT inserted * — `query().where('field', '==', undefined)` falls back to a linear * scan, which is the conservative behavior. */ export interface HashIndex { readonly field: string; readonly buckets: Map>; } /** * Container for all indexes on a single collection. * * Methods are pure with respect to the in-memory `buckets` Map — they * never touch the adapter or the keyring. The Collection class owns * lifecycle (build on hydrate, maintain on put/delete). */ export declare class CollectionIndexes { private readonly indexes; /** * Per-field bucket-key canonicalizer (#672 review C1), registered ONCE * via {@link setCanonicalizer} when the collection wires indexing up * (money-aware via `ViaPipeline.canonicalizeIndexKey`). Consulted by * EVERY bucket-mutation site — `build`/`upsert`/`remove` — so bucket * membership is symmetric by construction: a value can never be added * under a canonical key and later removed under the raw one (or vice * versa), which is what stranded ids on `put`/`delete` before this fix. */ private canonicalize?; /** * Register the bucket-key canonicalizer. Called once, where the * collection constructs its indexing state — safe to call again (the * closure is simply replaced) but there is normally only one caller. */ setCanonicalizer(fn: (field: string, value: unknown) => string | undefined): void; /** * Declare an index. Subsequent record additions are tracked under it. * Calling this twice for the same field is a no-op (idempotent). */ declare(field: string): void; /** True if the given field has a declared index. */ has(field: string): boolean; /** All declared field names, in declaration order. */ fields(): string[]; /** * Build all declared indexes from a snapshot of records. * Called once per hydration. O(N × indexes.size). Buckets through the * registered {@link setCanonicalizer} closure, same as `upsert`/`remove`. */ build(records: ReadonlyArray<{ id: string; record: T; }>): void; /** * Insert or update a single record across all indexes. * Called by `Collection.put()` after the encrypted write succeeds. * * If `previousRecord` is provided, the record is removed from any old * buckets first — this is the update path. Pass `null` for fresh adds. */ upsert(id: string, newRecord: T, previousRecord: T | null): void; /** * Remove a record from all indexes. Called by `Collection.delete()` * (and as the first half of `upsert` for the update path). */ remove(id: string, record: T): void; /** Drop all index data. Called when the collection is invalidated. */ clear(): void; /** * Equality lookup: return the set of record ids whose `field` matches * the given value. Returns `null` if no index covers the field — the * caller should fall back to a linear scan. * * The returned Set is a reference to the index's internal storage — * callers must NOT mutate it. */ lookupEqual(field: string, value: unknown): ReadonlySet | null; /** * Set lookup: return the union of record ids whose `field` matches any * of the given values. Returns `null` if no index covers the field. */ lookupIn(field: string, values: readonly unknown[]): ReadonlySet | null; }