@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/README.md +1 -1
- package/dist/index.cjs +952 -16
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +597 -6
- package/dist/index.d.ts +597 -6
- package/dist/index.js +943 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
373
|
-
|
|
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 };
|