@noy-db/core 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -21,7 +21,30 @@ interface EncryptedEnvelope {
21
21
  }
22
22
  /** All records across all collections for a compartment. */
23
23
  type CompartmentSnapshot = Record<string, Record<string, EncryptedEnvelope>>;
24
+ /**
25
+ * Result of a single page fetch via the optional `listPage` adapter extension.
26
+ *
27
+ * `items` carries the actual encrypted envelopes (not just ids) so the
28
+ * caller can decrypt and emit a single record without an extra `get()`
29
+ * round-trip per id. `nextCursor` is `null` on the final page.
30
+ */
31
+ interface ListPageResult {
32
+ /** Encrypted envelopes for this page, in adapter-defined order. */
33
+ items: Array<{
34
+ id: string;
35
+ envelope: EncryptedEnvelope;
36
+ }>;
37
+ /** Opaque cursor for the next page, or `null` if this was the last page. */
38
+ nextCursor: string | null;
39
+ }
24
40
  interface NoydbAdapter {
41
+ /**
42
+ * Optional human-readable adapter name (e.g. 'memory', 'file', 'dynamo').
43
+ * Used in diagnostic messages and the listPage fallback warning. Adapters
44
+ * are encouraged to set this so logs are clearer about which backend is
45
+ * involved when something goes wrong.
46
+ */
47
+ name?: string;
25
48
  /** Get a single record. Returns null if not found. */
26
49
  get(compartment: string, collection: string, id: string): Promise<EncryptedEnvelope | null>;
27
50
  /** Put a record. Throws ConflictError if expectedVersion doesn't match. */
@@ -36,6 +59,26 @@ interface NoydbAdapter {
36
59
  saveAll(compartment: string, data: CompartmentSnapshot): Promise<void>;
37
60
  /** Optional connectivity check for sync engine. */
38
61
  ping?(): Promise<boolean>;
62
+ /**
63
+ * Optional pagination extension. Adapters that implement `listPage` get
64
+ * the streaming `Collection.scan()` fast path; adapters that don't are
65
+ * silently fallen back to a full `loadAll()` + slice (with a one-time
66
+ * console.warn).
67
+ *
68
+ * `cursor` is opaque to the core — each adapter encodes its own paging
69
+ * state (DynamoDB: base64 LastEvaluatedKey JSON; S3: ContinuationToken;
70
+ * memory/file/browser: numeric offset of a sorted id list). Pass
71
+ * `undefined` to start from the beginning.
72
+ *
73
+ * `limit` is a soft upper bound on `items.length`. Adapters MAY return
74
+ * fewer items even when more exist (e.g. if the underlying store has
75
+ * its own page size cap), and MUST signal "no more pages" by returning
76
+ * `nextCursor: null`.
77
+ *
78
+ * The 6-method core contract is unchanged — this is an additive
79
+ * extension discovered via `'listPage' in adapter`.
80
+ */
81
+ listPage?(compartment: string, collection: string, cursor?: string, limit?: number): Promise<ListPageResult>;
39
82
  }
40
83
  /** Type-safe helper for creating adapter factories. */
41
84
  declare function defineAdapter<TOptions>(factory: (options: TOptions) => NoydbAdapter): (options: TOptions) => NoydbAdapter;
@@ -282,8 +325,394 @@ declare function diff(oldObj: unknown, newObj: unknown, basePath?: string): Diff
282
325
  /** Format a diff as a human-readable string. */
283
326
  declare function formatDiff(changes: DiffEntry[]): string;
284
327
 
328
+ /**
329
+ * Operator implementations for the query DSL.
330
+ *
331
+ * All predicates run client-side, AFTER decryption — they never see ciphertext.
332
+ * This file is dependency-free and tree-shakeable.
333
+ */
334
+ /** Comparison operators supported by the where() builder. */
335
+ type Operator = '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'contains' | 'startsWith' | 'between';
336
+ /**
337
+ * A single field comparison clause inside a query plan.
338
+ * Plans are JSON-serializable, so this type uses primitives only.
339
+ */
340
+ interface FieldClause {
341
+ readonly type: 'field';
342
+ readonly field: string;
343
+ readonly op: Operator;
344
+ readonly value: unknown;
345
+ }
346
+ /**
347
+ * A user-supplied predicate function escape hatch. Not serializable.
348
+ *
349
+ * The predicate accepts `unknown` at the type level so the surrounding
350
+ * Clause type can stay non-parametric — this keeps Collection<T> covariant
351
+ * in T at the public API surface. Builder methods cast user predicates
352
+ * (typed `(record: T) => boolean`) into this shape on the way in.
353
+ */
354
+ interface FilterClause {
355
+ readonly type: 'filter';
356
+ readonly fn: (record: unknown) => boolean;
357
+ }
358
+ /** A logical group of clauses combined by AND or OR. */
359
+ interface GroupClause {
360
+ readonly type: 'group';
361
+ readonly op: 'and' | 'or';
362
+ readonly clauses: readonly Clause[];
363
+ }
364
+ type Clause = FieldClause | FilterClause | GroupClause;
365
+ /**
366
+ * Read a possibly nested field path like "address.city" from a record.
367
+ * Returns undefined if any segment is missing.
368
+ */
369
+ declare function readPath(record: unknown, path: string): unknown;
370
+ /**
371
+ * Evaluate a single field clause against a record.
372
+ * Returns false on type mismatches rather than throwing — query results
373
+ * exclude non-matching records by definition.
374
+ */
375
+ declare function evaluateFieldClause(record: unknown, clause: FieldClause): boolean;
376
+ /**
377
+ * Evaluate any clause (field / filter / group) against a record.
378
+ * The recursion depth is bounded by the user's query expression — no risk of
379
+ * blowing the stack on a 50K-record collection.
380
+ */
381
+ declare function evaluateClause(record: unknown, clause: Clause): boolean;
382
+
383
+ /**
384
+ * Secondary indexes for the query DSL.
385
+ *
386
+ * v0.3 ships **in-memory hash indexes**:
387
+ * - Built during `Collection.ensureHydrated()` from the decrypted cache
388
+ * - Maintained incrementally on `put` and `delete`
389
+ * - Consulted by the query executor for `==` and `in` operators on
390
+ * indexed fields, falling back to a linear scan otherwise
391
+ * - Live entirely in memory — no adapter writes for the index itself
392
+ *
393
+ * Persistent encrypted index blobs (the spec's "store as a separate
394
+ * AES-256-GCM blob" note) are deferred to a follow-up issue. The reasons
395
+ * are documented in the v0.3 PR body — short version: at the v0.3 target
396
+ * scale of 1K–50K records, building the index during hydrate is free,
397
+ * so persistence buys nothing measurable.
398
+ */
399
+ /**
400
+ * Index declaration accepted by `Collection`'s constructor.
401
+ *
402
+ * Today only single-field hash indexes are supported. Future shapes
403
+ * (composite, sorted, unique constraints) will land as additive variants
404
+ * of this discriminated union without breaking existing declarations.
405
+ */
406
+ type IndexDef = string;
407
+ /**
408
+ * Internal representation of a built hash index.
409
+ *
410
+ * Maps stringified field values to the set of record ids whose value
411
+ * for that field matches. Stringification keeps the index simple and
412
+ * works uniformly for primitives (`'open'`, `'42'`, `'true'`).
413
+ *
414
+ * Records whose indexed field is `undefined` or `null` are NOT inserted
415
+ * — `query().where('field', '==', undefined)` falls back to a linear
416
+ * scan, which is the conservative behavior.
417
+ */
418
+ interface HashIndex {
419
+ readonly field: string;
420
+ readonly buckets: Map<string, Set<string>>;
421
+ }
422
+ /**
423
+ * Container for all indexes on a single collection.
424
+ *
425
+ * Methods are pure with respect to the in-memory `buckets` Map — they
426
+ * never touch the adapter or the keyring. The Collection class owns
427
+ * lifecycle (build on hydrate, maintain on put/delete).
428
+ */
429
+ declare class CollectionIndexes {
430
+ private readonly indexes;
431
+ /**
432
+ * Declare an index. Subsequent record additions are tracked under it.
433
+ * Calling this twice for the same field is a no-op (idempotent).
434
+ */
435
+ declare(field: string): void;
436
+ /** True if the given field has a declared index. */
437
+ has(field: string): boolean;
438
+ /** All declared field names, in declaration order. */
439
+ fields(): string[];
440
+ /**
441
+ * Build all declared indexes from a snapshot of records.
442
+ * Called once per hydration. O(N × indexes.size).
443
+ */
444
+ build<T>(records: ReadonlyArray<{
445
+ id: string;
446
+ record: T;
447
+ }>): void;
448
+ /**
449
+ * Insert or update a single record across all indexes.
450
+ * Called by `Collection.put()` after the encrypted write succeeds.
451
+ *
452
+ * If `previousRecord` is provided, the record is removed from any old
453
+ * buckets first — this is the update path. Pass `null` for fresh adds.
454
+ */
455
+ upsert<T>(id: string, newRecord: T, previousRecord: T | null): void;
456
+ /**
457
+ * Remove a record from all indexes. Called by `Collection.delete()`
458
+ * (and as the first half of `upsert` for the update path).
459
+ */
460
+ remove<T>(id: string, record: T): void;
461
+ /** Drop all index data. Called when the collection is invalidated. */
462
+ clear(): void;
463
+ /**
464
+ * Equality lookup: return the set of record ids whose `field` matches
465
+ * the given value. Returns `null` if no index covers the field — the
466
+ * caller should fall back to a linear scan.
467
+ *
468
+ * The returned Set is a reference to the index's internal storage —
469
+ * callers must NOT mutate it.
470
+ */
471
+ lookupEqual(field: string, value: unknown): ReadonlySet<string> | null;
472
+ /**
473
+ * Set lookup: return the union of record ids whose `field` matches any
474
+ * of the given values. Returns `null` if no index covers the field.
475
+ */
476
+ lookupIn(field: string, values: readonly unknown[]): ReadonlySet<string> | null;
477
+ }
478
+
479
+ /**
480
+ * Chainable, immutable query builder.
481
+ *
482
+ * Each builder operation returns a NEW Query — the underlying plan is never
483
+ * mutated. This makes plans safe to share, cache, and serialize.
484
+ */
485
+
486
+ interface OrderBy {
487
+ readonly field: string;
488
+ readonly direction: 'asc' | 'desc';
489
+ }
490
+ /**
491
+ * A complete query plan: zero-or-more clauses, optional ordering, pagination.
492
+ * Plans are JSON-serializable as long as no FilterClause is present.
493
+ *
494
+ * Plans are intentionally NOT parametric on T — see `predicate.ts` FilterClause
495
+ * for the variance reasoning. The public `Query<T>` API attaches the type tag.
496
+ */
497
+ interface QueryPlan {
498
+ readonly clauses: readonly Clause[];
499
+ readonly orderBy: readonly OrderBy[];
500
+ readonly limit: number | undefined;
501
+ readonly offset: number;
502
+ }
503
+ /**
504
+ * Source of records that a query executes against.
505
+ *
506
+ * The interface is non-parametric to keep variance friendly: callers cast
507
+ * their typed source (e.g. `QuerySource<Invoice>`) into this opaque shape.
508
+ *
509
+ * `getIndexes` and `lookupById` are optional fast-path hooks. When both are
510
+ * present and a where clause matches an indexed field, the executor uses
511
+ * the index to skip a linear scan. Sources without these methods (or with
512
+ * `getIndexes` returning `null`) always fall back to a linear scan.
513
+ */
514
+ interface QuerySource<T> {
515
+ /** Snapshot of all current records. The query never mutates this array. */
516
+ snapshot(): readonly T[];
517
+ /** Subscribe to mutations; returns an unsubscribe function. */
518
+ subscribe?(cb: () => void): () => void;
519
+ /** Index store for the indexed-fast-path. Optional. */
520
+ getIndexes?(): CollectionIndexes | null;
521
+ /** O(1) record lookup by id, used to materialize index hits. */
522
+ lookupById?(id: string): T | undefined;
523
+ }
524
+ /**
525
+ * The chainable builder. All methods return a new Query — the original
526
+ * remains unchanged. Terminal methods (`toArray`, `first`, `count`,
527
+ * `subscribe`) execute the plan against the source.
528
+ *
529
+ * Type parameter T flows through the public API for ergonomics, but the
530
+ * internal storage uses `unknown` so Collection<T> stays covariant.
531
+ */
532
+ declare class Query<T> {
533
+ private readonly source;
534
+ private readonly plan;
535
+ constructor(source: QuerySource<T>, plan?: QueryPlan);
536
+ /** Add a field comparison. Multiple where() calls are AND-combined. */
537
+ where(field: string, op: Operator, value: unknown): Query<T>;
538
+ /**
539
+ * Logical OR group. Pass a callback that builds a sub-query.
540
+ * Each clause inside the callback is OR-combined; the group itself
541
+ * joins the parent plan with AND.
542
+ */
543
+ or(builder: (q: Query<T>) => Query<T>): Query<T>;
544
+ /**
545
+ * Logical AND group. Same shape as `or()` but every clause inside the group
546
+ * must match. Useful for explicit grouping inside a larger OR.
547
+ */
548
+ and(builder: (q: Query<T>) => Query<T>): Query<T>;
549
+ /** Escape hatch: add an arbitrary predicate function. Not serializable. */
550
+ filter(fn: (record: T) => boolean): Query<T>;
551
+ /** Sort by a field. Subsequent calls are tie-breakers. */
552
+ orderBy(field: string, direction?: 'asc' | 'desc'): Query<T>;
553
+ /** Cap the result size. */
554
+ limit(n: number): Query<T>;
555
+ /** Skip the first N matching records (after ordering). */
556
+ offset(n: number): Query<T>;
557
+ /** Execute the plan and return the matching records. */
558
+ toArray(): T[];
559
+ /** Return the first matching record, or null. */
560
+ first(): T | null;
561
+ /** Return the number of matching records (after where/filter, before limit). */
562
+ count(): number;
563
+ /**
564
+ * Re-run the query whenever the source notifies of changes.
565
+ * Returns an unsubscribe function. The callback receives the latest result.
566
+ * Throws if the source does not support subscriptions.
567
+ */
568
+ subscribe(cb: (result: T[]) => void): () => void;
569
+ /**
570
+ * Return the plan as a JSON-friendly object. FilterClause entries are
571
+ * stripped (their `fn` cannot be serialized) and replaced with
572
+ * { type: 'filter', fn: '[function]' } so devtools can still see them.
573
+ */
574
+ toPlan(): unknown;
575
+ }
576
+ /**
577
+ * Execute a plan against a snapshot of records.
578
+ * Pure function — same input, same output, no side effects.
579
+ *
580
+ * Records are typed as `unknown` because plans are non-parametric; callers
581
+ * cast the return type at the API surface (see `Query.toArray()`).
582
+ */
583
+ declare function executePlan(records: readonly unknown[], plan: QueryPlan): unknown[];
584
+
585
+ interface LruOptions {
586
+ /** Maximum number of entries before eviction. Required if `maxBytes` is unset. */
587
+ maxRecords?: number;
588
+ /** Maximum total bytes before eviction. Computed from per-entry `size`. */
589
+ maxBytes?: number;
590
+ }
591
+ interface LruStats {
592
+ /** Total cache hits since construction (or `resetStats()`). */
593
+ hits: number;
594
+ /** Total cache misses since construction (or `resetStats()`). */
595
+ misses: number;
596
+ /** Total entries evicted since construction (or `resetStats()`). */
597
+ evictions: number;
598
+ /** Current number of cached entries. */
599
+ size: number;
600
+ /** Current sum of cached entry sizes (in bytes, approximate). */
601
+ bytes: number;
602
+ }
603
+ /**
604
+ * O(1) LRU cache. Both `get()` and `set()` promote the touched entry to
605
+ * the most-recently-used end. Eviction happens after every insert and
606
+ * walks the front of the Map iterator dropping entries until both
607
+ * budgets are satisfied.
608
+ */
609
+ declare class Lru<K, V> {
610
+ private readonly entries;
611
+ private readonly maxRecords;
612
+ private readonly maxBytes;
613
+ private currentBytes;
614
+ private hits;
615
+ private misses;
616
+ private evictions;
617
+ constructor(options: LruOptions);
618
+ /**
619
+ * Look up a key. Hits promote the entry to most-recently-used; misses
620
+ * return undefined. Both update the running stats counters.
621
+ */
622
+ get(key: K): V | undefined;
623
+ /**
624
+ * Insert or update a key. If the key already exists, its size is
625
+ * accounted for and the entry is promoted to MRU. After insertion,
626
+ * eviction runs to maintain both budgets.
627
+ */
628
+ set(key: K, value: V, size: number): void;
629
+ /**
630
+ * Remove a key without affecting hit/miss stats. Used by `Collection.delete()`.
631
+ * Returns true if the key was present.
632
+ */
633
+ remove(key: K): boolean;
634
+ /** True if the cache currently holds an entry for the given key. */
635
+ has(key: K): boolean;
636
+ /**
637
+ * Drop every entry. Stats counters survive — call `resetStats()` if you
638
+ * want a clean slate. Used by `Collection.invalidate()` on key rotation.
639
+ */
640
+ clear(): void;
641
+ /** Reset hit/miss/eviction counters to zero. Does NOT touch entries. */
642
+ resetStats(): void;
643
+ /** Snapshot of current cache statistics. Cheap — no copying. */
644
+ stats(): LruStats;
645
+ /**
646
+ * Iterate over all currently-cached values. Order is least-recently-used
647
+ * first. Used by tests and devtools — production callers should use
648
+ * `Collection.scan()` instead.
649
+ */
650
+ values(): IterableIterator<V>;
651
+ /**
652
+ * Walk the cache from the LRU end and drop entries until both budgets
653
+ * are satisfied. Called after every `set()`. Single pass — entries are
654
+ * never re-promoted during eviction.
655
+ */
656
+ private evictUntilUnderBudget;
657
+ private overBudget;
658
+ }
659
+
660
+ /**
661
+ * Cache policy helpers — parse human-friendly byte budgets into raw numbers.
662
+ *
663
+ * Accepted shapes (case-insensitive on suffix):
664
+ * number — interpreted as raw bytes
665
+ * '1024' — string of digits, raw bytes
666
+ * '50KB' — kilobytes (×1024)
667
+ * '50MB' — megabytes (×1024²)
668
+ * '1GB' — gigabytes (×1024³)
669
+ *
670
+ * Decimals are accepted (`'1.5GB'` → 1610612736 bytes).
671
+ *
672
+ * Anything else throws — better to fail loud at construction time than
673
+ * to silently treat a typo as 0 bytes (which would evict everything).
674
+ */
675
+ /** Parse a byte budget into a positive integer number of bytes. */
676
+ declare function parseBytes(input: number | string): number;
677
+ /**
678
+ * Estimate the in-memory byte size of a decrypted record.
679
+ *
680
+ * Uses `JSON.stringify().length` as a stand-in for actual heap usage.
681
+ * It's a deliberate approximation: real V8 heap size includes pointer
682
+ * overhead, hidden classes, and string interning that we can't measure
683
+ * from JavaScript. The JSON length is a stable, monotonic proxy that
684
+ * costs O(record size) per insert — fine when records are typically
685
+ * < 1 KB and the cache eviction is the slow path anyway.
686
+ *
687
+ * Returns `0` (and the caller must treat it as 1 for accounting) if
688
+ * stringification throws on circular references; this is documented
689
+ * but in practice records always come from JSON-decoded envelopes.
690
+ */
691
+ declare function estimateRecordBytes(record: unknown): number;
692
+
285
693
  /** Callback for dirty tracking (sync engine integration). */
286
694
  type OnDirtyCallback = (collection: string, id: string, action: 'put' | 'delete', version: number) => Promise<void>;
695
+ /**
696
+ * Per-collection cache configuration. Only meaningful when paired with
697
+ * `prefetch: false` (lazy mode); eager mode keeps the entire decrypted
698
+ * cache in memory and ignores these bounds.
699
+ */
700
+ interface CacheOptions {
701
+ /** Maximum number of records to keep in memory before LRU eviction. */
702
+ maxRecords?: number;
703
+ /**
704
+ * Maximum total decrypted byte size before LRU eviction. Accepts a raw
705
+ * number or a human-friendly string: `'50KB'`, `'50MB'`, `'1GB'`.
706
+ * Eviction picks the least-recently-used entry until both budgets
707
+ * (maxRecords AND maxBytes, if both are set) are satisfied.
708
+ */
709
+ maxBytes?: number | string;
710
+ }
711
+ /** Statistics exposed via `Collection.cacheStats()`. */
712
+ interface CacheStats extends LruStats {
713
+ /** True if this collection is in lazy mode. */
714
+ lazy: boolean;
715
+ }
287
716
  /** A typed collection of records within a compartment. */
288
717
  declare class Collection<T> {
289
718
  private readonly adapter;
@@ -297,6 +726,36 @@ declare class Collection<T> {
297
726
  private readonly historyConfig;
298
727
  private readonly cache;
299
728
  private hydrated;
729
+ /**
730
+ * Lazy mode flag. `true` when constructed with `prefetch: false`.
731
+ * In lazy mode the cache is bounded by an LRU and `list()`/`query()`
732
+ * throw — callers must use `scan()` or per-id `get()` instead.
733
+ */
734
+ private readonly lazy;
735
+ /**
736
+ * LRU cache for lazy mode. Only allocated when `prefetch: false` is set.
737
+ * Stores `{ record, version }` entries the same shape as `this.cache`.
738
+ * Tree-shaking note: importing Collection without setting `prefetch:false`
739
+ * still pulls in the Lru class today; future bundle-size work could
740
+ * lazy-import the cache module.
741
+ */
742
+ private readonly lru;
743
+ /**
744
+ * In-memory secondary indexes for the query DSL.
745
+ *
746
+ * Built during `ensureHydrated()` and maintained on every put/delete.
747
+ * The query executor consults these for `==` and `in` operators on
748
+ * indexed fields, falling back to a linear scan for unindexed fields
749
+ * or unsupported operators.
750
+ *
751
+ * v0.3 ships in-memory only — persistence as encrypted blobs is a
752
+ * follow-up. See `query/indexes.ts` for the design rationale.
753
+ *
754
+ * Indexes are INCOMPATIBLE with lazy mode in v0.3 — the constructor
755
+ * rejects the combination because evicted records would silently
756
+ * disappear from the index without notification.
757
+ */
758
+ private readonly indexes;
300
759
  constructor(opts: {
301
760
  adapter: NoydbAdapter;
302
761
  compartment: string;
@@ -307,6 +766,19 @@ declare class Collection<T> {
307
766
  getDEK: (collectionName: string) => Promise<CryptoKey>;
308
767
  historyConfig?: HistoryConfig | undefined;
309
768
  onDirty?: OnDirtyCallback | undefined;
769
+ indexes?: IndexDef[] | undefined;
770
+ /**
771
+ * Hydration mode. `'eager'` (default) loads everything into memory on
772
+ * first access — matches v0.2 behavior exactly. `'lazy'` defers loads
773
+ * to per-id `get()` calls and bounds memory via the `cache` option.
774
+ */
775
+ prefetch?: boolean;
776
+ /**
777
+ * LRU cache options. Only meaningful when `prefetch: false`. At least
778
+ * one of `maxRecords` or `maxBytes` must be set in lazy mode — an
779
+ * unbounded lazy cache defeats the purpose.
780
+ */
781
+ cache?: CacheOptions | undefined;
310
782
  });
311
783
  /** Get a single record by ID. Returns null if not found. */
312
784
  get(id: string): Promise<T | null>;
@@ -314,10 +786,46 @@ declare class Collection<T> {
314
786
  put(id: string, record: T): Promise<void>;
315
787
  /** Delete a record by ID. */
316
788
  delete(id: string): Promise<void>;
317
- /** List all records in the collection. */
789
+ /**
790
+ * List all records in the collection.
791
+ *
792
+ * Throws in lazy mode — bulk listing defeats the purpose of lazy
793
+ * hydration. Use `scan()` to iterate over the full collection
794
+ * page-by-page without holding more than `pageSize` records in memory.
795
+ */
318
796
  list(): Promise<T[]>;
319
- /** Filter records by a predicate. */
797
+ /**
798
+ * Build a chainable query against the collection. Returns a `Query<T>`
799
+ * builder when called with no arguments.
800
+ *
801
+ * Backward-compatible overload: passing a predicate function returns
802
+ * the filtered records directly (the v0.2 API). Prefer the chainable
803
+ * form for new code.
804
+ *
805
+ * @example
806
+ * ```ts
807
+ * // New chainable API:
808
+ * const overdue = invoices.query()
809
+ * .where('status', '==', 'open')
810
+ * .where('dueDate', '<', new Date())
811
+ * .orderBy('dueDate')
812
+ * .toArray();
813
+ *
814
+ * // Legacy predicate form (still supported):
815
+ * const drafts = invoices.query(i => i.status === 'draft');
816
+ * ```
817
+ */
818
+ query(): Query<T>;
320
819
  query(predicate: (record: T) => boolean): T[];
820
+ /**
821
+ * Cache statistics — useful for devtools, monitoring, and verifying
822
+ * that LRU eviction is happening as expected in lazy mode.
823
+ *
824
+ * In eager mode, returns size only (no hits/misses are tracked because
825
+ * every read is a cache hit by construction). In lazy mode, returns
826
+ * the full LRU stats: `{ hits, misses, evictions, size, bytes }`.
827
+ */
828
+ cacheStats(): CacheStats;
321
829
  /** Get version history for a record, newest first. */
322
830
  history(id: string, options?: HistoryOptions): Promise<HistoryEntry<T>[]>;
323
831
  /** Get a specific past version of a record. */
@@ -337,12 +845,77 @@ declare class Collection<T> {
337
845
  pruneRecordHistory(id: string | undefined, options: PruneOptions): Promise<number>;
338
846
  /** Clear all history for this collection (or a specific record). */
339
847
  clearHistory(id?: string): Promise<number>;
340
- /** Count records in the collection. */
848
+ /**
849
+ * Count records in the collection.
850
+ *
851
+ * In eager mode this returns the in-memory cache size (instant). In
852
+ * lazy mode it asks the adapter via `list()` to enumerate ids — slower
853
+ * but still correct, and avoids loading any record bodies into memory.
854
+ */
341
855
  count(): Promise<number>;
856
+ /**
857
+ * Fetch a single page of records via the adapter's optional `listPage`
858
+ * extension. Returns the decrypted records for this page plus an opaque
859
+ * cursor for the next page.
860
+ *
861
+ * Pass `cursor: undefined` (or omit it) to start from the beginning.
862
+ * The final page returns `nextCursor: null`.
863
+ *
864
+ * If the adapter does NOT implement `listPage`, this falls back to a
865
+ * synthetic implementation: it loads all ids via `list()`, sorts them,
866
+ * and slices a window. The first call emits a one-time console.warn so
867
+ * developers can spot adapters that should opt into the fast path.
868
+ */
869
+ listPage(opts?: {
870
+ cursor?: string;
871
+ limit?: number;
872
+ }): Promise<{
873
+ items: T[];
874
+ nextCursor: string | null;
875
+ }>;
876
+ /**
877
+ * Stream every record in the collection page-by-page, yielding decrypted
878
+ * records as an `AsyncIterable<T>`. The whole point: process collections
879
+ * larger than RAM without ever holding more than `pageSize` records
880
+ * decrypted at once.
881
+ *
882
+ * @example
883
+ * ```ts
884
+ * for await (const record of invoices.scan({ pageSize: 500 })) {
885
+ * await processOne(record)
886
+ * }
887
+ * ```
888
+ *
889
+ * Uses `adapter.listPage` when available; otherwise falls back to the
890
+ * synthetic pagination path with the same one-time warning.
891
+ */
892
+ scan(opts?: {
893
+ pageSize?: number;
894
+ }): AsyncIterableIterator<T>;
895
+ /** Decrypt a page of envelopes returned by `adapter.listPage`. */
896
+ private decryptPage;
342
897
  /** Load all records from adapter into memory cache. */
343
898
  private ensureHydrated;
344
899
  /** Hydrate from a pre-loaded snapshot (used by Compartment). */
345
900
  hydrateFromSnapshot(records: Record<string, EncryptedEnvelope>): Promise<void>;
901
+ /**
902
+ * Rebuild secondary indexes from the current in-memory cache.
903
+ *
904
+ * Called after any bulk hydration. Incremental put/delete updates
905
+ * are handled by `indexes.upsert()` / `indexes.remove()` directly,
906
+ * so this only fires for full reloads.
907
+ *
908
+ * Synchronous and O(N × indexes.size); for the v0.3 target scale of
909
+ * 1K–50K records this completes in single-digit milliseconds.
910
+ */
911
+ private rebuildIndexes;
912
+ /**
913
+ * Get the in-memory index store. Used by `Query` to short-circuit
914
+ * `==` and `in` lookups when an index covers the where clause.
915
+ *
916
+ * Returns `null` if no indexes are declared on this collection.
917
+ */
918
+ getIndexes(): CollectionIndexes | null;
346
919
  /** Get all records as encrypted envelopes (for dump). */
347
920
  dumpEnvelopes(): Promise<Record<string, EncryptedEnvelope>>;
348
921
  private encryptRecord;
@@ -369,8 +942,26 @@ declare class Compartment {
369
942
  onDirty?: OnDirtyCallback | undefined;
370
943
  historyConfig?: HistoryConfig | undefined;
371
944
  });
372
- /** Open a typed collection within this compartment. */
373
- collection<T>(collectionName: string): Collection<T>;
945
+ /**
946
+ * Open a typed collection within this compartment.
947
+ *
948
+ * - `options.indexes` declares secondary indexes for the query DSL.
949
+ * Indexes are computed in memory after decryption; adapters never
950
+ * see plaintext index data.
951
+ * - `options.prefetch` (default `true`) controls hydration. Eager mode
952
+ * loads everything on first access; lazy mode (`prefetch: false`)
953
+ * loads records on demand and bounds memory via the LRU cache.
954
+ * - `options.cache` configures the LRU bounds. Required in lazy mode.
955
+ * Accepts `{ maxRecords, maxBytes: '50MB' | 1024 }`.
956
+ *
957
+ * Lazy mode + indexes is rejected at construction time — see the
958
+ * Collection constructor for the rationale.
959
+ */
960
+ collection<T>(collectionName: string, options?: {
961
+ indexes?: IndexDef[];
962
+ prefetch?: boolean;
963
+ cache?: CacheOptions;
964
+ }): Collection<T>;
374
965
  /** List all collection names in this compartment. */
375
966
  collections(): Promise<string[]>;
376
967
  /** Dump compartment as encrypted JSON backup string. */
@@ -523,4 +1114,4 @@ declare function validatePassphrase(passphrase: string): void;
523
1114
  */
524
1115
  declare function estimateEntropy(passphrase: string): number;
525
1116
 
526
- export { type BiometricCredential, type ChangeEvent, type ChangeType, Collection, Compartment, type CompartmentBackup, type CompartmentSnapshot, type Conflict, ConflictError, type ConflictStrategy, DecryptionError, type DiffEntry, type DirtyEntry, type EncryptedEnvelope, type GrantOptions, type HistoryConfig, type HistoryEntry, type HistoryOptions, InvalidKeyError, type KeyringFile, NOYDB_BACKUP_VERSION, NOYDB_FORMAT_VERSION, NOYDB_KEYRING_VERSION, NOYDB_SYNC_VERSION, NetworkError, NoAccessError, NotFoundError, Noydb, type NoydbAdapter, NoydbError, type NoydbEventMap, type NoydbOptions, type Permission, PermissionDeniedError, type Permissions, type PruneOptions, type PullResult, type PushResult, ReadOnlyError, type RevokeOptions, type Role, SyncEngine, type SyncMetadata, type SyncStatus, TamperedError, type UserInfo, ValidationError, createNoydb, defineAdapter, diff, enrollBiometric, estimateEntropy, formatDiff, isBiometricAvailable, loadBiometric, removeBiometric, saveBiometric, unlockBiometric, validatePassphrase };
1117
+ export { type BiometricCredential, type CacheOptions, type CacheStats, type ChangeEvent, type ChangeType, type Clause, Collection, CollectionIndexes, Compartment, type CompartmentBackup, type CompartmentSnapshot, type Conflict, ConflictError, type ConflictStrategy, DecryptionError, type DiffEntry, type DirtyEntry, type EncryptedEnvelope, type FieldClause, type FilterClause, type GrantOptions, type GroupClause, type HashIndex, type HistoryConfig, type HistoryEntry, type HistoryOptions, type IndexDef, InvalidKeyError, type KeyringFile, type ListPageResult, Lru, type LruOptions, type LruStats, NOYDB_BACKUP_VERSION, NOYDB_FORMAT_VERSION, NOYDB_KEYRING_VERSION, NOYDB_SYNC_VERSION, NetworkError, NoAccessError, NotFoundError, Noydb, type NoydbAdapter, NoydbError, type NoydbEventMap, type NoydbOptions, type Operator, type OrderBy, type Permission, PermissionDeniedError, type Permissions, type PruneOptions, type PullResult, type PushResult, Query, type QueryPlan, type QuerySource, ReadOnlyError, type RevokeOptions, type Role, SyncEngine, type SyncMetadata, type SyncStatus, TamperedError, type UserInfo, ValidationError, createNoydb, defineAdapter, diff, enrollBiometric, estimateEntropy, estimateRecordBytes, evaluateClause, evaluateFieldClause, executePlan, formatDiff, isBiometricAvailable, loadBiometric, parseBytes, readPath, removeBiometric, saveBiometric, unlockBiometric, validatePassphrase };