/** * Persistent, encrypted secondary indexes for lazy-mode collections. * * Parallel to the in-memory `CollectionIndexes` used by eager mode (see * `packages/hub/src/query/indexes.ts`): same logical surface, but entries * are materialised as encrypted side-car records (`_idx//`) * and bulk-loaded into an in-memory mirror on first query. * * This module only owns the id-namespace convention, the in-memory mirror, * and the typed errors. Write-path integration (PR 2 / ), query-planner * dispatch (PR 3 / , PR 4 / ), and the rebuild/reconcile utilities * (PR 5 / ) live in other files. * * See the design spec for the full architecture + threat model. */ /** * Reserved id prefix for encrypted index side-car records. * Matches the existing `_keyring`, `_ledger_deltas/…`, `_meta/handle` * conventions inside a collection's id namespace. */ export declare const IDX_PREFIX: "_idx/"; /** * Encode the side-car record id for a (field, recordId) pair. * * Format: `_idx//` — no escaping. Field names may contain * dots (for dotted-path access consistent with eager-mode `readPath`); * record ids may contain slashes. The first two slash-separated segments * are `_idx` and the field; everything after the *second* slash is the * record id verbatim. */ export declare function encodeIdxId(field: string, recordId: string): string; /** * Decode a side-car id back into `{ field, recordId }`, or `null` if the * input is not a well-formed idx id. A well-formed id is: * - prefixed with `_idx/` * - contains a field segment (non-empty, no slashes) * - contains a record-id segment (non-empty, may contain slashes) */ export declare function decodeIdxId(id: string): { field: string; recordId: string; } | null; /** * Fast-path predicate for discriminating side-car ids from regular record * ids and other reserved namespaces. Used by the hub to filter `list()` * results during bulk-load of the in-memory mirror. */ export declare function isIdxId(id: string): boolean; /** * Sorted-value entry returned by `orderedBy()`. Mirrors the body shape * used by the write path — but `orderedBy` emits them already sorted by * `value` in the requested direction. Consumers (PR 4 / ) treat the * array as immutable and paginate via a numeric offset. * * **Note on `value`:** as of, this is the ORIGINAL TYPED * value (number, Date, boolean, etc.), not the stringified bucket key. * That's what lets range predicates and `orderedBy` compare numerically * instead of stumbling into `'10' < '2'` on `String(n)`. */ export interface OrderedEntry { readonly recordId: string; readonly value: unknown; } /** * Bulk-load row shape accepted by `ingest()`. The `value` field is the * decrypted index body's `value` field verbatim. */ export interface IngestRow { readonly recordId: string; readonly value: unknown; } /** * Structured index definition. Single-field indexes carry just a field * name; composite indexes carry the ordered list of fields and * the synthetic `key` (= fields joined by `COMPOSITE_DELIMITER`) used * as the bucket-map key and side-car envelope id segment. */ export type PersistedIndexDef = { readonly kind: 'single'; readonly field: string; readonly key: string; } | { readonly kind: 'composite'; readonly fields: readonly string[]; readonly key: string; }; /** * Delimiter used to synthesize a composite-index key from an ordered * field list. Intentionally a character that is extremely unusual in * JavaScript object keys (`|`) so collision with a literal field name * is vanishingly rare in practice. Composite declarations whose field * names contain `|` are rejected at declare-time with an explicit * error. */ export declare const COMPOSITE_DELIMITER = "|"; export declare function compositeKey(fields: readonly string[]): string; export declare class PersistedCollectionIndex { private readonly indexes; private readonly defs; /** * Per-field bucket-key canonicalizer (#677 — lazy twin of #672's * `CollectionIndexes.setCanonicalizer`), registered ONCE via * {@link setCanonicalizer} when the collection wires indexing up * (money-aware via `ViaPipeline.canonicalizeIndexKey`). Consulted by * EVERY bucket-mutation site — `ingest`/`upsert`/`remove` — so bucket * membership is symmetric: a legacy non-canonical stored value (e.g. * `'0100'`) and a canonical one (`'100'`) land in the SAME bucket, * mirroring `eager-indexes.ts`'s `addToIndex`/`removeFromIndex`. */ 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 a single-field index. Subsequent `upsert` / `ingest` calls * populate the in-memory mirror; calls before `declare` are no-ops * (tolerant bulk-load ordering). Idempotent. */ declare(field: string): void; /** * Declare a composite (multi-field) index. The synthetic * key is `fields.join('|')`; it doubles as the in-memory map key and * the `_idx//` side-car field segment. Callers upsert * and lookup via the same `key` as single-field indexes, just with a * tuple value (JSON-stringified for bucketing). */ declareComposite(fields: readonly string[]): void; /** * Every declared index's structured definition. Collection walks this * when materialising side-cars on put/delete so it can extract a * single-field value or a composite tuple appropriately. */ definitions(): PersistedIndexDef[]; /** True if `field` has been declared as indexable on this mirror. */ has(field: string): boolean; /** All declared field names, in declaration order. */ fields(): string[]; /** * Bulk-load the mirror from decrypted index bodies. Intended to be * called once per field after reading the collection's `_idx//*` * side-cars. Safe to call twice with the same rows — bucket Sets * deduplicate recordIds. If `field` is not declared, this is a no-op * (tolerates the case where bulk-load runs before `declare()` lands). */ ingest(field: string, rows: readonly IngestRow[]): void; /** * Incrementally update a record's index entry for one field. Called by * `Collection.put()` after the main write succeeds. If * `previousValue` is non-null, the record is removed from the old * bucket first — this is the update path. Pass `null` for fresh adds. * No-op if the field is not declared. */ upsert(recordId: string, field: string, newValue: unknown, previousValue: unknown): void; /** * Remove a record from the index for one field. Called by * `Collection.delete()`. No-op if the field is not declared or * the record isn't in the bucket. Empty buckets are dropped to keep * the Map clean. */ remove(recordId: string, field: string, value: unknown): void; /** * Drop all bucket data while preserving field declarations. Called on * invalidation (incoming sync changes, keyring rotation) — the next * query re-populates via `ingest`. */ clear(): void; /** * Equality lookup — return the set of record ids whose `field` matches * `value`. Returns `null` if the field is not declared (caller falls * back to scan or throws `IndexRequiredError`). Returns a shared empty * set if the field is declared but no record matches — that set MUST * NOT be mutated by the caller. * * `value` is bucketed via the plain {@link stringifyKey} — it is NOT * canonicalized here. Callers on a Via-covered field (money) canonicalize * `value` themselves BEFORE calling (see `lazy-builder.ts`'s * `resolveCandidateIds()`, #677) — the SAME caller-canonicalizes split * `eager-indexes.ts`'s `lookupEqual`/`lookupIn` use (see `kernel/query/ * builder.ts`'s `candidateRecords()`), so probing here twice never * double-canonicalizes. */ lookupEqual(field: string, value: unknown): ReadonlySet | null; /** * Set lookup — return the union of record ids whose `field` matches any * of `values`. Returns `null` if the field is not declared. Returns a * fresh (non-shared) Set — safe for the caller to mutate. Same * caller-canonicalizes contract as {@link lookupEqual} (#677). */ lookupIn(field: string, values: readonly unknown[]): ReadonlySet | null; /** * Range lookup. Return record ids whose indexed value * satisfies the predicate. Comparison happens on the ORIGINAL TYPED * value carried in `state.values` — so numeric `<` sorts numerically, * not lexicographically on `String(n)`. Returns `null` if the field * is not declared. * * Supported ops: `'<'`, `'<='`, `'>'`, `'>='`, `'between'`. For * `'between'`, `value` is `[lo, hi]` and both bounds are inclusive * (matches the eager-mode operator contract in `predicate.ts`). * * NOT canonicalized (#677): this method compares `value` against the * ORIGINAL TYPED `state.values`, not a stringified bucket key, so a * `canonicalizeIndexKey`-shaped (`string | undefined`) result would not * even type-check as a bound here. This does NOT mirror eager mode the * way `lookupEqual`/`lookupIn` do: `moneyIndexProbe` (`via/money/ * where.ts`) never emits a range probe, so EAGER mode's `candidateRecords` * never calls an index for a range clause and always scans. * * LAZY mode's `resolveCandidateIds()` (`lazy-builder.ts`) no longer * dispatches a Via-covered (e.g. money) range clause through THIS method * at all (#684) — it enumerates the field's indexed ids via `orderedBy()` * instead and leaves the raw/typed comparison here for the (now * Via-aware) post-filter to redo correctly in scaled-int space. A * NON-via range clause still dispatches through this method exactly as * before — the raw/typed comparison here is correct and sufficient for * fields with no decode-transforming Via binding. */ lookupRange(field: string, op: '<' | '<=' | '>' | '>=' | 'between', value: unknown): ReadonlySet | null; /** * Sorted iteration — return every entry on `field` as an * `OrderedEntry[]`, sorted by the ORIGINAL TYPED value (no more * `'10' < '2'` surprises on numeric fields). Consumers paginate with * a numeric offset. `OrderedEntry.value` is the typed value. */ orderedBy(field: string, dir: 'asc' | 'desc'): readonly OrderedEntry[] | null; }