@noy-db/core 0.3.0 → 0.4.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
@@ -100,6 +100,32 @@ interface CompartmentBackup {
100
100
  readonly _exported_by: string;
101
101
  readonly keyrings: Record<string, KeyringFile>;
102
102
  readonly collections: CompartmentSnapshot;
103
+ /**
104
+ * Internal collections (`_ledger`, `_ledger_deltas`, `_history`, `_sync`, …)
105
+ * captured alongside the data collections. Optional for backwards
106
+ * compat with v0.3 backups, which only stored data collections —
107
+ * loading a v0.3 backup leaves the ledger empty (and `verifyBackupIntegrity`
108
+ * skips the chain check, surfacing only a console warning).
109
+ */
110
+ readonly _internal?: CompartmentSnapshot;
111
+ /**
112
+ * Verifiable-backup metadata (v0.4 #46). Embeds the ledger head at
113
+ * dump time so `load()` can cross-check that the loaded chain matches
114
+ * exactly what was exported. A backup whose chain has been tampered
115
+ * with — either by modifying ledger entries or by modifying data
116
+ * envelopes that the chain references — fails this check.
117
+ *
118
+ * Optional for backwards compat with v0.3 backups; missing means
119
+ * "legacy backup, load with a warning, no integrity check".
120
+ */
121
+ readonly ledgerHead?: {
122
+ /** Hex sha256 of the canonical JSON of the last ledger entry. */
123
+ readonly hash: string;
124
+ /** Sequential index of the last ledger entry. */
125
+ readonly index: number;
126
+ /** ISO timestamp captured at dump time. */
127
+ readonly ts: string;
128
+ };
103
129
  }
104
130
  interface DirtyEntry {
105
131
  readonly compartment: string;
@@ -279,6 +305,950 @@ declare class NotFoundError extends NoydbError {
279
305
  declare class ValidationError extends NoydbError {
280
306
  constructor(message?: string);
281
307
  }
308
+ /**
309
+ * Thrown when a Standard Schema v1 validator rejects a record on
310
+ * `put()` (input validation) or on read (output validation). Carries
311
+ * the raw issue list so callers can render field-level errors.
312
+ *
313
+ * `direction` distinguishes the two cases:
314
+ * - `'input'`: the user passed bad data into `put()`. This is a
315
+ * normal error case that application code should handle — typically
316
+ * by showing validation messages in the UI.
317
+ * - `'output'`: stored data does not match the current schema. This
318
+ * indicates a schema drift (the schema was changed without
319
+ * migrating the existing records) and should be treated as a bug
320
+ * — the application should not swallow it silently.
321
+ *
322
+ * The `issues` type is deliberately `readonly unknown[]` on this class
323
+ * so that `errors.ts` doesn't need to import from `schema.ts` (and
324
+ * create a dependency cycle). Callers who know they're holding a
325
+ * `SchemaValidationError` can cast to the more precise
326
+ * `readonly StandardSchemaV1Issue[]` from `schema.ts`.
327
+ */
328
+ declare class SchemaValidationError extends NoydbError {
329
+ readonly issues: readonly unknown[];
330
+ readonly direction: 'input' | 'output';
331
+ constructor(message: string, issues: readonly unknown[], direction: 'input' | 'output');
332
+ }
333
+ /**
334
+ * Thrown when `Compartment.load()` finds that a backup's hash chain
335
+ * doesn't verify, or that its embedded `ledgerHead.hash` doesn't
336
+ * match the chain head reconstructed from the loaded entries.
337
+ *
338
+ * Distinct from `BackupCorruptedError` so callers can choose to
339
+ * recover from one but not the other (e.g., a corrupted JSON file is
340
+ * unrecoverable; a chain mismatch might mean the backup is from an
341
+ * incompatible noy-db version).
342
+ */
343
+ declare class BackupLedgerError extends NoydbError {
344
+ /** First-broken-entry index, if known. */
345
+ readonly divergedAt?: number;
346
+ constructor(message: string, divergedAt?: number);
347
+ }
348
+ /**
349
+ * Thrown when `Compartment.load()` finds that the backup's data
350
+ * collection content doesn't match the ledger's recorded
351
+ * `payloadHash`es. This is the "envelope was tampered with after
352
+ * dump" detection — the chain itself can be intact, but if any
353
+ * encrypted record bytes were swapped, this check catches it.
354
+ */
355
+ declare class BackupCorruptedError extends NoydbError {
356
+ /** The (collection, id) pair whose envelope failed the hash check. */
357
+ readonly collection: string;
358
+ readonly id: string;
359
+ constructor(collection: string, id: string, message: string);
360
+ }
361
+
362
+ /**
363
+ * Standard Schema v1 integration.
364
+ *
365
+ * This file is the v0.4 entry point for **schema validation**. Any
366
+ * validator that implements the [Standard Schema v1
367
+ * protocol](https://standardschema.dev) — Zod, Valibot, ArkType, Effect
368
+ * Schema, etc. — can be attached to a `Collection` or `defineNoydbStore`
369
+ * and will:
370
+ *
371
+ * 1. Validate the record BEFORE encryption on `put()` — bad data is
372
+ * rejected at the store boundary with a rich issue list.
373
+ * 2. Validate the record AFTER decryption on `get()`/`list()`/`query()`
374
+ * — stored data that has drifted from the current schema throws
375
+ * loudly instead of silently propagating garbage to the UI.
376
+ *
377
+ * ## Why vendor the types?
378
+ *
379
+ * Standard Schema is a protocol, not a library. The spec is <200 lines of
380
+ * TypeScript and has no runtime. There's an official `@standard-schema/spec`
381
+ * types package on npm, but pulling it in would add a dependency edge
382
+ * purely for type definitions. Vendoring the minimal surface keeps
383
+ * `@noy-db/core` at **zero runtime dependencies** and gives us freedom to
384
+ * evolve the helpers without a version-lock on the spec package.
385
+ *
386
+ * If the spec changes in a breaking way (unlikely — it's frozen at v1),
387
+ * we update this file and bump our minor.
388
+ *
389
+ * ## Why not just run `schema.parse(value)` directly?
390
+ *
391
+ * Because then we'd be locked to whichever validator happens to have
392
+ * `.parse`. Standard Schema's `'~standard'.validate` contract is the same
393
+ * across every implementation and includes a structured issues list,
394
+ * which is much more useful than a thrown error for programmatic error
395
+ * handling (e.g., rendering field-level messages in a Vue component).
396
+ */
397
+ /**
398
+ * The Standard Schema v1 protocol. A schema is any object that exposes a
399
+ * `'~standard'` property with `version: 1` and a `validate` function.
400
+ *
401
+ * The type parameters are:
402
+ * - `Input` — the type accepted by `validate` (what the user passes in)
403
+ * - `Output` — the type produced by `validate` (what we store/return,
404
+ * may differ from Input if the schema transforms or coerces)
405
+ *
406
+ * In most cases `Input === Output`, but validators that transform
407
+ * (Zod's `.transform`, Valibot's `transform`, etc.) can narrow or widen.
408
+ *
409
+ * We intentionally keep the `types` field `readonly` and optional — the
410
+ * spec marks it as optional because it's only used for inference, and
411
+ * not every implementation bothers populating it at runtime.
412
+ */
413
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
414
+ readonly '~standard': {
415
+ readonly version: 1;
416
+ readonly vendor: string;
417
+ readonly validate: (value: unknown) => StandardSchemaV1SyncResult<Output> | Promise<StandardSchemaV1SyncResult<Output>>;
418
+ readonly types?: {
419
+ readonly input: Input;
420
+ readonly output: Output;
421
+ } | undefined;
422
+ };
423
+ }
424
+ /**
425
+ * The result of a single call to `schema['~standard'].validate`. Either
426
+ * `{ value }` on success or `{ issues }` on failure — never both.
427
+ *
428
+ * The spec allows `issues` to be undefined on success (and some
429
+ * validators leave it that way), so consumers should discriminate on
430
+ * `issues?.length` rather than on truthiness of `value`.
431
+ */
432
+ type StandardSchemaV1SyncResult<Output> = {
433
+ readonly value: Output;
434
+ readonly issues?: undefined;
435
+ } | {
436
+ readonly value?: undefined;
437
+ readonly issues: readonly StandardSchemaV1Issue[];
438
+ };
439
+ /**
440
+ * A single validation issue. The `message` is always present; the `path`
441
+ * is optional and points at the offending field when the schema tracks
442
+ * it (virtually every validator does for object types).
443
+ *
444
+ * The path is deliberately permissive — both a plain `PropertyKey` and a
445
+ * `{ key }` wrapper are allowed so validators that wrap path segments in
446
+ * objects (Zod does this in some modes) don't need special handling.
447
+ */
448
+ interface StandardSchemaV1Issue {
449
+ readonly message: string;
450
+ readonly path?: ReadonlyArray<PropertyKey | {
451
+ readonly key: PropertyKey;
452
+ }> | undefined;
453
+ }
454
+ /**
455
+ * Infer the output type of a Standard Schema. Consumers use this to
456
+ * pull the type out of a schema instance when they want to declare a
457
+ * Collection<T> or defineNoydbStore<T> with `T` derived from the schema.
458
+ *
459
+ * Example:
460
+ * ```ts
461
+ * const InvoiceSchema = z.object({ id: z.string(), amount: z.number() })
462
+ * type Invoice = InferOutput<typeof InvoiceSchema>
463
+ * ```
464
+ */
465
+ type InferOutput<T extends StandardSchemaV1> = T extends StandardSchemaV1<unknown, infer O> ? O : never;
466
+ /**
467
+ * Validate an input value against a schema. Throws
468
+ * `SchemaValidationError` if the schema rejects, with the rich issue
469
+ * list attached. Otherwise returns the (possibly transformed) output
470
+ * value.
471
+ *
472
+ * The `context` string is included in the thrown error's message so the
473
+ * caller knows where the failure happened (e.g. `"put(inv-001)"`) without
474
+ * every caller having to wrap the throw in a try/catch.
475
+ *
476
+ * This function is ALWAYS async because some validators (notably Effect
477
+ * Schema and Zod's `.refine` with async predicates) can return a
478
+ * Promise. We `await` the result unconditionally to normalize the
479
+ * contract — the extra microtask is free compared to the cost of an
480
+ * encrypt/decrypt round-trip.
481
+ */
482
+ declare function validateSchemaInput<Output>(schema: StandardSchemaV1<unknown, Output>, value: unknown, context: string): Promise<Output>;
483
+ /**
484
+ * Validate an already-stored value coming OUT of the collection. This
485
+ * is a distinct helper from `validateSchemaInput` because the error
486
+ * semantics differ: an output-validation failure means the data in
487
+ * storage has drifted from the current schema (an unexpected state),
488
+ * whereas an input-validation failure means the user passed bad data
489
+ * (an expected state for a UI that isn't guarding its inputs).
490
+ *
491
+ * We still throw — silently returning bad data would be worse — but
492
+ * the error carries `direction: 'output'` so upstream code (and a
493
+ * potential migrate hook) can distinguish the two cases.
494
+ */
495
+ declare function validateSchemaOutput<Output>(schema: StandardSchemaV1<unknown, Output>, value: unknown, context: string): Promise<Output>;
496
+
497
+ /**
498
+ * Ledger entry shape + canonical JSON + sha256 helpers.
499
+ *
500
+ * This file holds the PURE primitives used by the hash-chained ledger:
501
+ * the entry type, the deterministic (sort-stable) JSON encoder, and
502
+ * the sha256 hasher that produces `prevHash` and `ledger.head()`.
503
+ *
504
+ * Everything here is validator-free and side-effect free — the only
505
+ * runtime dep is Web Crypto's `subtle.digest` for the sha256 call,
506
+ * which we already use for every other hashing operation in the core.
507
+ *
508
+ * The hash chain property works like this:
509
+ *
510
+ * hash(entry[i]) = sha256(canonicalJSON(entry[i]))
511
+ * entry[i+1].prevHash = hash(entry[i])
512
+ *
513
+ * Any modification to `entry[i]` (field values, field order, whitespace)
514
+ * produces a different `hash(entry[i])`, which means `entry[i+1]`'s
515
+ * stored `prevHash` no longer matches the recomputed hash, which means
516
+ * `verify()` returns `{ ok: false, divergedAt: i + 1 }`. The chain is
517
+ * append-only and tamper-evident without external anchoring.
518
+ */
519
+ /**
520
+ * A single ledger entry in its plaintext form — what gets serialized,
521
+ * hashed, and then encrypted with the ledger DEK before being written
522
+ * to the `_ledger/` adapter collection.
523
+ *
524
+ * ## Why hash the ciphertext, not the plaintext?
525
+ *
526
+ * `payloadHash` is the sha256 of the record's ENCRYPTED envelope bytes,
527
+ * not its plaintext. This matters:
528
+ *
529
+ * 1. **Zero-knowledge preserved.** A user (or a third party) can
530
+ * verify the ledger against the stored envelopes without any
531
+ * decryption keys. The adapter layer already holds only
532
+ * ciphertext, so hashing the ciphertext keeps the ledger at the
533
+ * same privacy level as the adapter.
534
+ *
535
+ * 2. **Determinism.** Plaintext → ciphertext is randomized by the
536
+ * fresh per-write IV, so `hash(plaintext)` would need extra
537
+ * normalization. `hash(ciphertext)` is already deterministic and
538
+ * unique per write.
539
+ *
540
+ * 3. **Detection property.** If an attacker modifies even one byte of
541
+ * the stored ciphertext (trying to flip a record), the hash
542
+ * changes, the ledger's recorded `payloadHash` no longer matches,
543
+ * and a data-integrity check fails. We don't do that check in
544
+ * `verify()` today (v0.4 only checks chain consistency), but the
545
+ * hook is there for a future `verifyIntegrity()` follow-up.
546
+ *
547
+ * Fields marked `op`, `collection`, `id`, `version`, `ts`, `actor` are
548
+ * plaintext METADATA about the operation — NOT the record itself. The
549
+ * entry is still encrypted at rest via the ledger DEK, but adapters
550
+ * could theoretically infer operation patterns from the sizes and
551
+ * timestamps. This is an accepted trade-off for the tamper-evidence
552
+ * property; full ORAM-level privacy is out of scope for noy-db.
553
+ */
554
+ interface LedgerEntry {
555
+ /**
556
+ * Zero-based sequential position of this entry in the chain. The
557
+ * canonical adapter key is this number zero-padded to 10 digits
558
+ * (`"0000000001"`) so lexicographic ordering matches numeric order.
559
+ */
560
+ readonly index: number;
561
+ /**
562
+ * Hex-encoded sha256 of the canonical JSON of the PREVIOUS entry.
563
+ * The genesis entry (index 0) has `prevHash === ''` — the first
564
+ * entry in a fresh compartment has nothing to point back to.
565
+ */
566
+ readonly prevHash: string;
567
+ /**
568
+ * Which kind of mutation this entry records. v0.4 only supports
569
+ * data operations (`put`, `delete`). Access-control operations
570
+ * (`grant`, `revoke`, `rotate`) will be added in a follow-up once
571
+ * the keyring write path is instrumented — that's tracked in the
572
+ * v0.4 epic issue.
573
+ */
574
+ readonly op: 'put' | 'delete';
575
+ /** The collection the mutation targeted. */
576
+ readonly collection: string;
577
+ /** The record id the mutation targeted. */
578
+ readonly id: string;
579
+ /**
580
+ * The record version AFTER this mutation. For `put` this is the
581
+ * newly assigned version; for `delete` this is the version that
582
+ * was deleted (the last version visible to reads).
583
+ */
584
+ readonly version: number;
585
+ /** ISO timestamp of the mutation. */
586
+ readonly ts: string;
587
+ /** User id of the actor who performed the mutation. */
588
+ readonly actor: string;
589
+ /**
590
+ * Hex-encoded sha256 of the encrypted envelope's `_data` field.
591
+ * For `put`, this is the hash of the new ciphertext. For `delete`,
592
+ * it's the hash of the last visible ciphertext at deletion time,
593
+ * or the empty string if nothing was there to delete. Hashing the
594
+ * ciphertext (not the plaintext) preserves zero-knowledge — see
595
+ * the file docstring.
596
+ */
597
+ readonly payloadHash: string;
598
+ /**
599
+ * Optional hex-encoded sha256 of the encrypted JSON Patch delta
600
+ * blob stored alongside this entry in `_ledger_deltas/`. Present
601
+ * only for `put` operations that had a previous version — the
602
+ * genesis put of a new record, and every `delete`, leave this
603
+ * field undefined.
604
+ *
605
+ * The delta payload itself lives in a sibling internal collection
606
+ * (`_ledger_deltas/<paddedIndex>`) and is encrypted with the
607
+ * ledger DEK. Callers use `ledger.loadDelta(index)` to decrypt and
608
+ * deserialize it when reconstructing a historical version.
609
+ *
610
+ * Why optional instead of always-present: the first put of a
611
+ * record has no previous version to diff against, so storing an
612
+ * empty patch would be noise. For deletes there's no "next" state
613
+ * to describe with a delta. Both cases set this field to undefined.
614
+ *
615
+ * Note: the canonical-JSON hasher treats `undefined` as invalid
616
+ * (it's one of the guard rails), so on the wire this field is
617
+ * either `{ deltaHash: '<hex>' }` or absent from the JSON
618
+ * entirely — never `{ deltaHash: undefined }`.
619
+ */
620
+ readonly deltaHash?: string;
621
+ }
622
+ /**
623
+ * Canonical (sort-stable) JSON encoder.
624
+ *
625
+ * This function is the load-bearing primitive of the hash chain:
626
+ * `sha256(canonicalJSON(entry))` must produce the same hex string
627
+ * every time, on every machine, for the same logical entry — otherwise
628
+ * `verify()` would return `{ ok: false }` on cross-platform reads.
629
+ *
630
+ * JavaScript's `JSON.stringify` is almost canonical, but NOT quite:
631
+ * it preserves the insertion order of object keys, which means
632
+ * `{a:1,b:2}` and `{b:2,a:1}` serialize differently. We fix this by
633
+ * recursively walking objects and sorting their keys before
634
+ * concatenation.
635
+ *
636
+ * Arrays keep their original order (reordering them would change
637
+ * semantics). Numbers, strings, booleans, and `null` use the default
638
+ * JSON encoding. `undefined` and functions are rejected — ledger
639
+ * entries are plain data, and silently dropping `undefined` would
640
+ * break the "same input → same hash" property if a caller forgot to
641
+ * omit a field.
642
+ *
643
+ * Performance: one pass per nesting level; O(n log n) for key sorting
644
+ * at each object. Entries are small (< 1 KB) so this is negligible
645
+ * compared to the sha256 call.
646
+ */
647
+ declare function canonicalJson(value: unknown): string;
648
+ /**
649
+ * Compute a hex-encoded sha256 of a string via Web Crypto's subtle API.
650
+ *
651
+ * We use hex (not base64) for hashes because hex is case-insensitive,
652
+ * fixed-length (64 chars), and easier to compare visually in debug
653
+ * output. Base64 would save a few bytes in storage but every encrypted
654
+ * ledger entry is already much larger than the hash itself.
655
+ */
656
+ declare function sha256Hex(input: string): Promise<string>;
657
+ /**
658
+ * Compute the canonical hash of a ledger entry. Short wrapper around
659
+ * `canonicalJson` + `sha256Hex`; callers use this instead of composing
660
+ * the two functions every time, so any future change to the hashing
661
+ * pipeline (e.g., adding a domain-separation prefix) lives in one place.
662
+ */
663
+ declare function hashEntry(entry: LedgerEntry): Promise<string>;
664
+ /**
665
+ * Pad an index to the canonical 10-digit form used as the adapter key.
666
+ * Ten digits is enough for ~10 billion ledger entries per compartment
667
+ * — far beyond any realistic use case, but cheap enough that the extra
668
+ * digits don't hurt storage.
669
+ */
670
+ declare function paddedIndex(index: number): string;
671
+ /** Parse a padded adapter key back into a number. Returns NaN on malformed input. */
672
+ declare function parseIndex(key: string): number;
673
+
674
+ /**
675
+ * RFC 6902 JSON Patch — compute + apply.
676
+ *
677
+ * This module is the v0.4 "delta history" primitive: instead of
678
+ * snapshotting the full record on every put (the v0.3 behavior),
679
+ * `Collection.put` computes a JSON Patch from the previous version to
680
+ * the new version and stores only the patch in the ledger. To
681
+ * reconstruct version N, we walk from the genesis snapshot forward
682
+ * applying patches. Storage scales with **edit size**, not record
683
+ * size — a 10 KB record edited 1000 times costs ~10 KB of deltas
684
+ * instead of ~10 MB of snapshots.
685
+ *
686
+ * ## Why hand-roll instead of using a library?
687
+ *
688
+ * RFC 6902 has good libraries (`fast-json-patch`, `rfc6902`) but every
689
+ * single one of them adds a runtime dependency to `@noy-db/core`. The
690
+ * "zero runtime dependencies" promise is one of the core's load-bearing
691
+ * features, and the patch surface we actually need is small enough
692
+ * (~150 LoC) that vendoring is the right call.
693
+ *
694
+ * What we implement:
695
+ * - `add` — insert a value at a path
696
+ * - `remove` — delete the value at a path
697
+ * - `replace` — overwrite the value at a path
698
+ *
699
+ * What we deliberately skip (out of scope for the v0.4 ledger use):
700
+ * - `move` and `copy` — optimizations; the diff algorithm doesn't
701
+ * emit them, so the apply path doesn't need them
702
+ * - `test` — used for transactional patches; we already have
703
+ * optimistic concurrency via `_v` at the envelope layer
704
+ * - Sophisticated array diffing (LCS, edit distance) — we treat
705
+ * arrays as atomic values and emit a single `replace` op when
706
+ * they differ. The accounting domain has small arrays where this
707
+ * is fine; if we ever need patch-level array diffing we can add
708
+ * it without changing the storage format.
709
+ *
710
+ * ## Path encoding (RFC 6902 §3)
711
+ *
712
+ * Paths look like `/foo/bar/0`. Each path segment is either an object
713
+ * key or a numeric array index. Two characters need escaping inside
714
+ * keys: `~` becomes `~0` and `/` becomes `~1`. We implement both.
715
+ *
716
+ * Empty path (`""`) refers to the root document. Only `replace` makes
717
+ * sense at the root, and our diff function emits it as a top-level
718
+ * `replace` when `prev` and `next` differ in shape (object vs array,
719
+ * primitive vs object, etc.).
720
+ */
721
+ /** A single JSON Patch operation. Subset of RFC 6902 — see file docstring. */
722
+ type JsonPatchOp = {
723
+ readonly op: 'add';
724
+ readonly path: string;
725
+ readonly value: unknown;
726
+ } | {
727
+ readonly op: 'remove';
728
+ readonly path: string;
729
+ } | {
730
+ readonly op: 'replace';
731
+ readonly path: string;
732
+ readonly value: unknown;
733
+ };
734
+ /** A complete JSON Patch document — an array of operations. */
735
+ type JsonPatch = readonly JsonPatchOp[];
736
+ /**
737
+ * Compute a JSON Patch that, when applied to `prev`, produces `next`.
738
+ *
739
+ * The algorithm is a straightforward recursive object walk:
740
+ *
741
+ * 1. If both inputs are plain objects (and not arrays/null):
742
+ * - For each key in `prev`, recurse if `next` has it, else emit `remove`
743
+ * - For each key in `next` not in `prev`, emit `add`
744
+ * 2. If both inputs are arrays AND structurally equal, no-op.
745
+ * Otherwise emit a single `replace` for the whole array.
746
+ * 3. If both inputs are deeply equal primitives, no-op.
747
+ * 4. Otherwise emit a `replace` at the current path.
748
+ *
749
+ * We do not minimize patches across move-like rearrangements — every
750
+ * generated patch is straightforward enough to apply by hand if you
751
+ * had to debug it.
752
+ */
753
+ declare function computePatch(prev: unknown, next: unknown): JsonPatch;
754
+ /**
755
+ * Apply a JSON Patch to a base document and return the result.
756
+ *
757
+ * The base document is **not mutated** — every op clones the parent
758
+ * container before writing to it, so the caller's reference to `base`
759
+ * stays untouched. This costs an extra allocation per op but makes
760
+ * the apply pipeline reorderable and safe to interrupt.
761
+ *
762
+ * Throws on:
763
+ * - Removing a path that doesn't exist
764
+ * - Adding to a path whose parent doesn't exist
765
+ * - A path component that doesn't match the document shape (e.g.,
766
+ * trying to step into a primitive)
767
+ *
768
+ * Throwing is the right behavior for the ledger use case: a failed
769
+ * apply means the chain is corrupted, which should be loud rather
770
+ * than silently producing a wrong reconstruction.
771
+ */
772
+ declare function applyPatch<T = unknown>(base: T, patch: JsonPatch): T;
773
+
774
+ /**
775
+ * `LedgerStore` — read/write access to a compartment's hash-chained
776
+ * audit log.
777
+ *
778
+ * The store is a thin wrapper around the adapter's `_ledger/` internal
779
+ * collection. Every append:
780
+ *
781
+ * 1. Loads the current head (or treats an empty ledger as head = -1)
782
+ * 2. Computes `prevHash` = sha256(canonicalJson(head))
783
+ * 3. Builds the new entry with `index = head.index + 1`
784
+ * 4. Encrypts the entry with the compartment's ledger DEK
785
+ * 5. Writes the encrypted envelope to `_ledger/<paddedIndex>`
786
+ *
787
+ * `verify()` walks the chain from genesis forward and returns
788
+ * `{ ok: true, head }` on success or `{ ok: false, divergedAt }` on the
789
+ * first broken link.
790
+ *
791
+ * ## Thread / concurrency model
792
+ *
793
+ * For v0.4 we assume a **single writer per compartment**. Two
794
+ * concurrent `append()` calls would race on the "read head, write
795
+ * head+1" cycle and could produce a broken chain. The v0.3 sync engine
796
+ * is the primary concurrent-writer scenario, and it uses
797
+ * optimistic-concurrency via `expectedVersion` on the adapter — but
798
+ * the ledger path has no such guard today. Multi-writer hardening is a
799
+ * v0.5 follow-up.
800
+ *
801
+ * Single-writer usage IS safe, including across process restarts:
802
+ * `head()` reads the adapter fresh each call, so a crash between the
803
+ * adapter.put of a data record and the ledger append just means the
804
+ * ledger is missing an entry for that record. `verify()` still
805
+ * succeeds; a future `verifyIntegrity()` helper can cross-check the
806
+ * ledger against the data collections to catch the gap.
807
+ *
808
+ * ## Why hide the ledger from `compartment.collection()`?
809
+ *
810
+ * The `_ledger` name starts with `_`, matching the existing prefix
811
+ * convention for internal collections (`_keyring`, `_sync`,
812
+ * `_history`). The Compartment's public `collection()` method already
813
+ * returns entries for any name, but `loadAll()` filters out
814
+ * underscore-prefixed collections so backups and exports don't leak
815
+ * ledger metadata. We keep the ledger accessible ONLY via
816
+ * `compartment.ledger()` to enforce the hash-chain invariants — direct
817
+ * puts via `collection('_ledger')` would bypass the `append()` logic.
818
+ */
819
+
820
+ /** The internal collection name used for ledger entry storage. */
821
+ declare const LEDGER_COLLECTION = "_ledger";
822
+ /**
823
+ * The internal collection name used for delta payload storage.
824
+ *
825
+ * Deltas live in a sibling collection (not inside `_ledger`) for two
826
+ * reasons:
827
+ *
828
+ * 1. **Listing efficiency.** `ledger.loadAllEntries()` calls
829
+ * `adapter.list(_ledger)` which would otherwise return every
830
+ * delta key alongside every entry key. Splitting them keeps the
831
+ * list small (one key per ledger entry) and the delta reads
832
+ * keyed by the entry's index.
833
+ *
834
+ * 2. **Prune-friendliness.** A future `pruneHistory()` will delete
835
+ * old deltas while keeping the ledger chain intact (folding old
836
+ * deltas into a base snapshot). Separating the storage makes
837
+ * that deletion a targeted operation on one collection instead
838
+ * of a filter across a mixed list.
839
+ *
840
+ * Both collections share the same ledger DEK — one DEK, two
841
+ * internal collections, same zero-knowledge guarantees.
842
+ */
843
+ declare const LEDGER_DELTAS_COLLECTION = "_ledger_deltas";
844
+ /**
845
+ * Input shape for `LedgerStore.append()`. The caller supplies the
846
+ * operation metadata; the store fills in `index` and `prevHash`.
847
+ */
848
+ interface AppendInput {
849
+ op: LedgerEntry['op'];
850
+ collection: string;
851
+ id: string;
852
+ version: number;
853
+ actor: string;
854
+ payloadHash: string;
855
+ /**
856
+ * Optional JSON Patch representing the delta from the previous
857
+ * version to the new version. Present only for `put` operations
858
+ * that had a previous version; omitted for genesis puts and for
859
+ * deletes. When present, `LedgerStore.append` persists the patch
860
+ * in `_ledger_deltas/<paddedIndex>` and records its sha256 hash
861
+ * as the entry's `deltaHash` field.
862
+ */
863
+ delta?: JsonPatch;
864
+ }
865
+ /**
866
+ * Result of `LedgerStore.verify()`. On success, `head` is the hash of
867
+ * the last entry — the same value that should be published to any
868
+ * external anchoring service (blockchain, OpenTimestamps, etc.). On
869
+ * failure, `divergedAt` is the 0-based index of the first entry whose
870
+ * recorded `prevHash` does not match the recomputed hash of its
871
+ * predecessor. Entries at `divergedAt` and later are untrustworthy;
872
+ * entries before that index are still valid.
873
+ */
874
+ type VerifyResult = {
875
+ readonly ok: true;
876
+ readonly head: string;
877
+ readonly length: number;
878
+ } | {
879
+ readonly ok: false;
880
+ readonly divergedAt: number;
881
+ readonly expected: string;
882
+ readonly actual: string;
883
+ };
884
+ /**
885
+ * A LedgerStore is bound to a single compartment. Callers obtain one
886
+ * via `compartment.ledger()` — there is no public constructor to keep
887
+ * the hash-chain invariants in one place.
888
+ *
889
+ * The class holds no mutable state beyond its dependencies (adapter,
890
+ * compartment name, DEK resolver, actor id). Every method reads the
891
+ * adapter fresh so multiple instances against the same compartment
892
+ * see each other's writes immediately (at the cost of re-parsing the
893
+ * ledger on every head() / verify() call; acceptable at v0.4 scale).
894
+ */
895
+ declare class LedgerStore {
896
+ private readonly adapter;
897
+ private readonly compartment;
898
+ private readonly encrypted;
899
+ private readonly getDEK;
900
+ private readonly actor;
901
+ /**
902
+ * In-memory cache of the chain head — the most recently appended
903
+ * entry along with its precomputed hash. Without this, every
904
+ * `append()` would re-load every prior entry to recompute the
905
+ * prevHash, making N puts O(N²) — a 1K-record stress test goes from
906
+ * < 100ms to a multi-second timeout.
907
+ *
908
+ * The cache is populated on first read (`append`, `head`, `verify`)
909
+ * and updated in-place on every successful `append`. Single-writer
910
+ * usage (the v0.4 assumption) keeps it consistent. A second
911
+ * LedgerStore instance writing to the same compartment would not
912
+ * see the first instance's appends in its cached state — that's the
913
+ * concurrency caveat documented at the class level.
914
+ *
915
+ * Sentinel `undefined` means "not yet loaded"; an explicit `null`
916
+ * value means "loaded and confirmed empty" — distinguishing these
917
+ * matters because an empty ledger is a valid state (genesis prevHash
918
+ * is the empty string), and we don't want to re-scan the adapter
919
+ * just because the chain is freshly initialized.
920
+ */
921
+ private headCache;
922
+ constructor(opts: {
923
+ adapter: NoydbAdapter;
924
+ compartment: string;
925
+ encrypted: boolean;
926
+ getDEK: (collectionName: string) => Promise<CryptoKey>;
927
+ actor: string;
928
+ });
929
+ /**
930
+ * Lazily load (or return cached) the current chain head. The cache
931
+ * sentinel is `undefined` until first access; after the first call,
932
+ * the cache holds either a `{ entry, hash }` for non-empty ledgers
933
+ * or `null` for empty ones.
934
+ */
935
+ private getCachedHead;
936
+ /**
937
+ * Append a new entry to the ledger. Returns the full entry that was
938
+ * written (with its assigned index and computed prevHash) so the
939
+ * caller can use the hash for downstream purposes (e.g., embedding
940
+ * in a verifiable backup).
941
+ *
942
+ * This is the **only** way to add entries. Direct adapter writes to
943
+ * `_ledger/` would bypass the chain math and would be caught by the
944
+ * next `verify()` call as a divergence.
945
+ */
946
+ append(input: AppendInput): Promise<LedgerEntry>;
947
+ /**
948
+ * Load a delta payload by its entry index. Returns `null` if the
949
+ * entry at that index doesn't reference a delta (genesis puts and
950
+ * deletes leave the slot empty) or if the delta row is missing
951
+ * (possible after a `pruneHistory` fold).
952
+ *
953
+ * The caller is responsible for deciding what to do with a missing
954
+ * delta — `ledger.reconstruct()` uses it as a "stop walking
955
+ * backward" signal and falls back to the on-disk current value.
956
+ */
957
+ loadDelta(index: number): Promise<JsonPatch | null>;
958
+ /** Encrypt a JSON Patch into an envelope for storage. Mirrors encryptEntry. */
959
+ private encryptDelta;
960
+ /**
961
+ * Read all entries in ascending-index order. Used internally by
962
+ * `append()`, `head()`, `verify()`, and `entries()`. Decryption is
963
+ * serial because the entries are tiny and the overhead of a Promise
964
+ * pool would dominate at realistic chain lengths (< 100K entries).
965
+ */
966
+ loadAllEntries(): Promise<LedgerEntry[]>;
967
+ /**
968
+ * Return the current head of the ledger: the last entry, its hash,
969
+ * and the total chain length. `null` on an empty ledger so callers
970
+ * can distinguish "no history yet" from "empty history".
971
+ */
972
+ head(): Promise<{
973
+ readonly entry: LedgerEntry;
974
+ readonly hash: string;
975
+ readonly length: number;
976
+ } | null>;
977
+ /**
978
+ * Return entries in the requested half-open range `[from, to)`.
979
+ * Defaults: `from = 0`, `to = length`. The indices are clipped to
980
+ * the valid range; no error is thrown for out-of-range queries.
981
+ */
982
+ entries(opts?: {
983
+ from?: number;
984
+ to?: number;
985
+ }): Promise<LedgerEntry[]>;
986
+ /**
987
+ * Reconstruct a record's state at a given historical version by
988
+ * walking the ledger's delta chain backward from the current state.
989
+ *
990
+ * ## Algorithm
991
+ *
992
+ * Ledger deltas are stored in **reverse** form — each entry's
993
+ * patch describes how to undo that put, transforming the new
994
+ * record back into the previous one. `reconstruct` exploits this
995
+ * by:
996
+ *
997
+ * 1. Finding every ledger entry for `(collection, id)` in the
998
+ * chain, sorted by index ascending.
999
+ * 2. Starting from `current` (the present value of the record,
1000
+ * as held by the caller — typically fetched via
1001
+ * `Collection.get()`).
1002
+ * 3. Walking entries in **descending** index order and applying
1003
+ * each entry's reverse patch, stopping when we reach the
1004
+ * entry whose version equals `atVersion`.
1005
+ *
1006
+ * The result is the record as it existed immediately AFTER the
1007
+ * put at `atVersion`. To get the state at the genesis put
1008
+ * (version 1), the walk runs all the way back through every put
1009
+ * after the first.
1010
+ *
1011
+ * ## Caveats
1012
+ *
1013
+ * - **Delete entries** break the walk: once we see a delete, the
1014
+ * record didn't exist before that point, so there's nothing to
1015
+ * reconstruct. We return `null` in that case.
1016
+ * - **Missing deltas** (e.g., after `pruneHistory` folds old
1017
+ * entries into a base snapshot) also stop the walk. v0.4 does
1018
+ * not ship pruneHistory, so today this only happens if an entry
1019
+ * was deleted out-of-band.
1020
+ * - The caller MUST pass the correct current value. Passing a
1021
+ * mutated object would corrupt the reconstruction — the patch
1022
+ * chain is only valid against the exact state that was in
1023
+ * effect when the most recent put happened.
1024
+ *
1025
+ * For v0.4, `reconstruct` is the only way to read a historical
1026
+ * version via deltas. The legacy `_history` collection still
1027
+ * holds full snapshots and `Collection.getVersion()` still reads
1028
+ * from there — the two paths coexist until pruneHistory lands in
1029
+ * a follow-up and delta becomes the default.
1030
+ */
1031
+ reconstruct<T>(collection: string, id: string, current: T, atVersion: number): Promise<T | null>;
1032
+ /**
1033
+ * Walk the chain from genesis forward and verify every link.
1034
+ *
1035
+ * Returns `{ ok: true, head, length }` if every entry's `prevHash`
1036
+ * matches the recomputed hash of its predecessor (and the genesis
1037
+ * entry's `prevHash` is the empty string).
1038
+ *
1039
+ * Returns `{ ok: false, divergedAt, expected, actual }` on the first
1040
+ * mismatch. `divergedAt` is the 0-based index of the BROKEN entry
1041
+ * — entries before that index still verify cleanly; entries at and
1042
+ * after `divergedAt` are untrustworthy.
1043
+ *
1044
+ * This method detects:
1045
+ * - Mutated entry content (fields changed)
1046
+ * - Reordered entries (if any adjacent pair swaps, the prevHash
1047
+ * of the second no longer matches)
1048
+ * - Inserted entries (the inserted entry's prevHash likely fails,
1049
+ * and the following entry's prevHash definitely fails)
1050
+ * - Deleted entries (the entry after the deletion sees a wrong
1051
+ * prevHash)
1052
+ *
1053
+ * It does NOT detect:
1054
+ * - Tampering with the DATA collections that bypassed the ledger
1055
+ * entirely (e.g., an attacker who modifies records without
1056
+ * appending matching ledger entries — this is why we also
1057
+ * plan a `verifyIntegrity()` helper in a follow-up)
1058
+ * - Truncation of the chain at the tail (dropping the last N
1059
+ * entries leaves a shorter but still consistent chain). External
1060
+ * anchoring of `head.hash` to a trusted service is the defense
1061
+ * against this.
1062
+ */
1063
+ verify(): Promise<VerifyResult>;
1064
+ /**
1065
+ * Serialize + encrypt a ledger entry into an EncryptedEnvelope. The
1066
+ * envelope's `_v` field is set to `entry.index + 1` so the usual
1067
+ * optimistic-concurrency machinery has a reasonable version number
1068
+ * to compare against (the ledger is append-only, so concurrent
1069
+ * writes should always bump the index).
1070
+ */
1071
+ private encryptEntry;
1072
+ /** Decrypt an envelope into a LedgerEntry. Throws on bad key / tamper. */
1073
+ private decryptEntry;
1074
+ }
1075
+ /**
1076
+ * Compute the `payloadHash` value for an encrypted envelope. Pulled
1077
+ * out as a standalone helper because both `put` (hash the new
1078
+ * envelope's `_data`) and `delete` (hash the previous envelope's
1079
+ * `_data`) need the same calculation, and the logic is small enough
1080
+ * that duplicating it would be noise.
1081
+ */
1082
+ declare function envelopePayloadHash(envelope: EncryptedEnvelope | null): Promise<string>;
1083
+
1084
+ /**
1085
+ * Foreign-key references — the v0.4 soft-FK mechanism.
1086
+ *
1087
+ * A collection declares its references as metadata at construction
1088
+ * time:
1089
+ *
1090
+ * ```ts
1091
+ * import { ref } from '@noy-db/core'
1092
+ *
1093
+ * const invoices = company.collection<Invoice>('invoices', {
1094
+ * refs: {
1095
+ * clientId: ref('clients'), // default: strict
1096
+ * categoryId: ref('categories', 'warn'),
1097
+ * parentId: ref('invoices', 'cascade'), // self-reference OK
1098
+ * },
1099
+ * })
1100
+ * ```
1101
+ *
1102
+ * Three modes:
1103
+ *
1104
+ * - **strict** — the default. `put()` rejects records whose
1105
+ * reference target doesn't exist, and `delete()` of the target
1106
+ * rejects if any strict-referencing records still exist.
1107
+ * Matches SQL's default FK semantics.
1108
+ *
1109
+ * - **warn** — both operations succeed unconditionally. Broken
1110
+ * references surface only through
1111
+ * `compartment.checkIntegrity()`, which walks every collection
1112
+ * and reports orphans. Use when you want soft validation for
1113
+ * imports from messy sources.
1114
+ *
1115
+ * - **cascade** — `put()` is same as warn. `delete()` of the
1116
+ * target deletes every referencing record. Cycles are detected
1117
+ * and broken via an in-progress set, so mutual cascades
1118
+ * terminate instead of recursing forever.
1119
+ *
1120
+ * Cross-compartment refs are explicitly rejected: if the target
1121
+ * name contains a `/`, `ref()` throws `RefScopeError`. Cross-
1122
+ * compartment refs need an auth story (multi-keyring reads) that
1123
+ * v0.4 doesn't ship — tracked for v0.5.
1124
+ */
1125
+
1126
+ /** The three enforcement modes. Default for new refs is `'strict'`. */
1127
+ type RefMode = 'strict' | 'warn' | 'cascade';
1128
+ /**
1129
+ * Descriptor returned by `ref()`. Collections accept a
1130
+ * `Record<string, RefDescriptor>` in their options. The key is the
1131
+ * field name on the record (top-level only — dotted paths are out of
1132
+ * scope for v0.4), the value describes which target collection the
1133
+ * field references and under what mode.
1134
+ *
1135
+ * The descriptor carries only plain data so it can be serialized,
1136
+ * passed around, and introspected without any class machinery.
1137
+ */
1138
+ interface RefDescriptor {
1139
+ readonly target: string;
1140
+ readonly mode: RefMode;
1141
+ }
1142
+ /**
1143
+ * Thrown when a strict reference is violated — either `put()` with a
1144
+ * missing target id, or `delete()` of a target that still has
1145
+ * strict-referencing records.
1146
+ *
1147
+ * Carries structured detail so UI code (and a potential future
1148
+ * devtools panel) can render "client X cannot be deleted because
1149
+ * invoices 1, 2, and 3 reference it" instead of a bare error string.
1150
+ */
1151
+ declare class RefIntegrityError extends NoydbError {
1152
+ readonly collection: string;
1153
+ readonly id: string;
1154
+ readonly field: string;
1155
+ readonly refTo: string;
1156
+ readonly refId: string | null;
1157
+ constructor(opts: {
1158
+ collection: string;
1159
+ id: string;
1160
+ field: string;
1161
+ refTo: string;
1162
+ refId: string | null;
1163
+ message: string;
1164
+ });
1165
+ }
1166
+ /**
1167
+ * Thrown when `ref()` is called with a target name that looks like
1168
+ * a cross-compartment reference (contains a `/`). Separate error
1169
+ * class because the fix is different: RefIntegrityError means "data
1170
+ * is wrong"; RefScopeError means "the ref declaration is wrong".
1171
+ */
1172
+ declare class RefScopeError extends NoydbError {
1173
+ constructor(target: string);
1174
+ }
1175
+ /**
1176
+ * Helper constructor. Thin wrapper around the object literal so user
1177
+ * code reads like `ref('clients')` instead of `{ target: 'clients',
1178
+ * mode: 'strict' }` — this is the only ergonomics reason it exists.
1179
+ *
1180
+ * Validates the target name eagerly so a misconfigured ref declaration
1181
+ * fails at collection construction time, not at the first put.
1182
+ */
1183
+ declare function ref(target: string, mode?: RefMode): RefDescriptor;
1184
+ /**
1185
+ * Per-compartment registry of reference declarations.
1186
+ *
1187
+ * The registry is populated by `Collection` constructors (which pass
1188
+ * their `refs` option through the Compartment) and consulted by the
1189
+ * Compartment on every `put` / `delete` and by `checkIntegrity`. A
1190
+ * single instance lives on the Compartment for its lifetime; there's
1191
+ * no global state.
1192
+ *
1193
+ * The data structure is two parallel maps:
1194
+ *
1195
+ * - `outbound`: `collection → { field → RefDescriptor }` — what
1196
+ * refs does `collection` declare? Used on put to check
1197
+ * strict-target-exists and on checkIntegrity to walk each
1198
+ * collection's outbound refs.
1199
+ *
1200
+ * - `inbound`: `target → Array<{ collection, field, mode }>` —
1201
+ * which collections reference `target`? Used on delete to find
1202
+ * the records that might be affected by cascade / strict.
1203
+ *
1204
+ * The two views are kept in sync by `register()` and never mutated
1205
+ * otherwise — refs can't be unregistered at runtime in v0.4.
1206
+ */
1207
+ declare class RefRegistry {
1208
+ private readonly outbound;
1209
+ private readonly inbound;
1210
+ /**
1211
+ * Register the refs declared by a single collection. Idempotent in
1212
+ * the happy path — calling twice with the same data is a no-op.
1213
+ * Calling twice with DIFFERENT data throws, because silent
1214
+ * overrides would be confusing ("I changed the ref and it doesn't
1215
+ * update" vs "I declared the same collection twice with different
1216
+ * refs and the second call won").
1217
+ */
1218
+ register(collection: string, refs: Record<string, RefDescriptor>): void;
1219
+ /** Get the outbound refs declared by a collection (or `{}` if none). */
1220
+ getOutbound(collection: string): Record<string, RefDescriptor>;
1221
+ /** Get the inbound refs that target a given collection (or `[]`). */
1222
+ getInbound(target: string): ReadonlyArray<{
1223
+ collection: string;
1224
+ field: string;
1225
+ mode: RefMode;
1226
+ }>;
1227
+ /**
1228
+ * Iterate every (collection → refs) pair that has at least one
1229
+ * declared reference. Used by `checkIntegrity` to walk the full
1230
+ * universe of outbound refs without needing to track collection
1231
+ * names elsewhere.
1232
+ */
1233
+ entries(): Array<[string, Record<string, RefDescriptor>]>;
1234
+ /** Clear the registry. Test-only escape hatch; never called from production code. */
1235
+ clear(): void;
1236
+ }
1237
+ /**
1238
+ * Shape of a single violation reported by `compartment.checkIntegrity()`.
1239
+ *
1240
+ * `refId` is the value we saw in the referencing field — it's the
1241
+ * ID we expected to find in `refTo`, but didn't. Left as `unknown`
1242
+ * because records are loosely typed at the integrity-check layer.
1243
+ */
1244
+ interface RefViolation {
1245
+ readonly collection: string;
1246
+ readonly id: string;
1247
+ readonly field: string;
1248
+ readonly refTo: string;
1249
+ readonly refId: unknown;
1250
+ readonly mode: RefMode;
1251
+ }
282
1252
 
283
1253
  /** In-memory representation of an unlocked keyring. */
284
1254
  interface UnlockedKeyring {
@@ -756,6 +1726,56 @@ declare class Collection<T> {
756
1726
  * disappear from the index without notification.
757
1727
  */
758
1728
  private readonly indexes;
1729
+ /**
1730
+ * Optional Standard Schema v1 validator. When set, every `put()` runs
1731
+ * the input through `validateSchemaInput` before encryption, and every
1732
+ * record coming OUT of `decryptRecord` runs through
1733
+ * `validateSchemaOutput`. A rejected input throws
1734
+ * `SchemaValidationError` with `direction: 'input'`; drifted stored
1735
+ * data throws with `direction: 'output'`. Both carry the rich issue
1736
+ * list from the validator so UI code can render field-level messages.
1737
+ *
1738
+ * The schema is stored as `StandardSchemaV1<unknown, T>` because the
1739
+ * collection type parameter `T` is the OUTPUT type — whatever the
1740
+ * validator produces after transforms and coercion. Users who pass a
1741
+ * schema to `defineNoydbStore` (or `Collection.constructor`) get their
1742
+ * `T` inferred automatically via `InferOutput<Schema>`.
1743
+ */
1744
+ private readonly schema;
1745
+ /**
1746
+ * Optional reference to the compartment-level hash-chained audit
1747
+ * log. When present, every successful `put()` and `delete()` appends
1748
+ * an entry to the ledger AFTER the adapter write succeeds (so a
1749
+ * failed adapter write never produces an orphan ledger entry).
1750
+ *
1751
+ * The ledger is always a compartment-wide singleton — all
1752
+ * collections in the same compartment share the same LedgerStore.
1753
+ * Compartment.ledger() does the lazy init; this field just holds
1754
+ * the reference so Collection doesn't need to reach back up to the
1755
+ * compartment on every mutation.
1756
+ *
1757
+ * `undefined` means "no ledger attached" — supported for tests that
1758
+ * construct a Collection directly without a compartment, and for
1759
+ * future backwards-compat scenarios. Production usage always has a
1760
+ * ledger because Compartment.collection() passes one through.
1761
+ */
1762
+ private readonly ledger;
1763
+ /**
1764
+ * Optional back-reference to the owning compartment's ref
1765
+ * enforcer. When present, `Collection.put` calls
1766
+ * `refEnforcer.enforceRefsOnPut(name, record)` before the adapter
1767
+ * write, and `Collection.delete` calls
1768
+ * `refEnforcer.enforceRefsOnDelete(name, id)` before its own
1769
+ * adapter delete. The Compartment handles the actual registry
1770
+ * lookup and cross-collection enforcement — Collection just
1771
+ * notifies it at the right points in the lifecycle.
1772
+ *
1773
+ * Typed as a structural interface rather than `Compartment`
1774
+ * directly to avoid a circular import. Compartment implements
1775
+ * these two methods; any other object with the same shape would
1776
+ * work too (used only in unit tests).
1777
+ */
1778
+ private readonly refEnforcer;
759
1779
  constructor(opts: {
760
1780
  adapter: NoydbAdapter;
761
1781
  compartment: string;
@@ -779,6 +1799,33 @@ declare class Collection<T> {
779
1799
  * unbounded lazy cache defeats the purpose.
780
1800
  */
781
1801
  cache?: CacheOptions | undefined;
1802
+ /**
1803
+ * Optional Standard Schema v1 validator (Zod, Valibot, ArkType,
1804
+ * Effect Schema, etc.). When set, every `put()` is validated before
1805
+ * encryption and every read is validated after decryption. See the
1806
+ * `schema` field docstring for the error semantics.
1807
+ */
1808
+ schema?: StandardSchemaV1<unknown, T> | undefined;
1809
+ /**
1810
+ * Optional reference to the compartment's hash-chained ledger.
1811
+ * When present, successful mutations append a ledger entry via
1812
+ * `LedgerStore.append()`. Constructed at the Compartment level and
1813
+ * threaded through — see the Compartment.collection() source for
1814
+ * the wiring.
1815
+ */
1816
+ ledger?: LedgerStore | undefined;
1817
+ /**
1818
+ * Optional back-reference to the owning compartment's ref
1819
+ * enforcer (v0.4 #45 — foreign-key references via `ref()`).
1820
+ * Collection.put calls `enforceRefsOnPut` before the adapter
1821
+ * write; Collection.delete calls `enforceRefsOnDelete` before
1822
+ * its own adapter delete. See the `refEnforcer` field docstring
1823
+ * for the full protocol.
1824
+ */
1825
+ refEnforcer?: {
1826
+ enforceRefsOnPut(collectionName: string, record: unknown): Promise<void>;
1827
+ enforceRefsOnDelete(collectionName: string, id: string): Promise<void>;
1828
+ } | undefined;
782
1829
  });
783
1830
  /** Get a single record by ID. Returns null if not found. */
784
1831
  get(id: string): Promise<T | null>;
@@ -828,7 +1875,15 @@ declare class Collection<T> {
828
1875
  cacheStats(): CacheStats;
829
1876
  /** Get version history for a record, newest first. */
830
1877
  history(id: string, options?: HistoryOptions): Promise<HistoryEntry<T>[]>;
831
- /** Get a specific past version of a record. */
1878
+ /**
1879
+ * Get a specific past version of a record.
1880
+ *
1881
+ * History reads intentionally **skip schema validation** — historical
1882
+ * records predate the current schema by definition, so validating them
1883
+ * against today's shape would be a false positive on any schema
1884
+ * evolution. If a caller needs validated history, they should filter
1885
+ * and re-put the records through the normal `put()` path.
1886
+ */
832
1887
  getVersion(id: string, version: number): Promise<T | null>;
833
1888
  /** Revert a record to a past version. Creates a new version with the old content. */
834
1889
  revert(id: string, version: number): Promise<void>;
@@ -919,6 +1974,21 @@ declare class Collection<T> {
919
1974
  /** Get all records as encrypted envelopes (for dump). */
920
1975
  dumpEnvelopes(): Promise<Record<string, EncryptedEnvelope>>;
921
1976
  private encryptRecord;
1977
+ /**
1978
+ * Decrypt an envelope into a record of type `T`.
1979
+ *
1980
+ * When a schema is attached, the decrypted value is validated before
1981
+ * being returned. A divergence between the stored bytes and the
1982
+ * current schema throws `SchemaValidationError` with
1983
+ * `direction: 'output'` — silently returning drifted data would
1984
+ * propagate garbage into the UI and break the whole point of having
1985
+ * a schema.
1986
+ *
1987
+ * `skipValidation` exists for history reads: when calling
1988
+ * `getVersion()` the caller is explicitly asking for an old snapshot
1989
+ * that may predate a schema change, so validating it would be a
1990
+ * false positive. Every non-history read leaves this flag `false`.
1991
+ */
922
1992
  private decryptRecord;
923
1993
  }
924
1994
 
@@ -926,13 +1996,62 @@ declare class Collection<T> {
926
1996
  declare class Compartment {
927
1997
  private readonly adapter;
928
1998
  private readonly name;
929
- private readonly keyring;
1999
+ /**
2000
+ * The active in-memory keyring. NOT readonly because `load()`
2001
+ * needs to refresh it after restoring a different keyring file —
2002
+ * otherwise the in-memory DEKs (from the pre-load session) and
2003
+ * the on-disk wrapped DEKs (from the loaded backup) drift apart
2004
+ * and every subsequent decrypt fails with TamperedError.
2005
+ */
2006
+ private keyring;
930
2007
  private readonly encrypted;
931
2008
  private readonly emitter;
932
2009
  private readonly onDirty;
933
2010
  private readonly historyConfig;
934
- private readonly getDEK;
2011
+ private getDEK;
2012
+ /**
2013
+ * Optional callback that re-derives an UnlockedKeyring from the
2014
+ * adapter using the active user's passphrase. Called by `load()`
2015
+ * after the on-disk keyring file has been replaced — refreshes
2016
+ * `this.keyring` so the next DEK access uses the loaded wrapped
2017
+ * DEKs instead of the stale pre-load ones.
2018
+ *
2019
+ * Provided by Noydb at openCompartment() time. Tests that
2020
+ * construct Compartment directly can pass `undefined`; load()
2021
+ * skips the refresh in that case (which is fine for plaintext
2022
+ * compartments — there's nothing to re-unwrap).
2023
+ */
2024
+ private readonly reloadKeyring;
935
2025
  private readonly collectionCache;
2026
+ /**
2027
+ * Per-compartment ledger store. Lazy-initialized on first
2028
+ * `collection()` call (which passes it through to the Collection)
2029
+ * or on first `ledger()` call from user code.
2030
+ *
2031
+ * One LedgerStore is shared across all collections in a compartment
2032
+ * because the hash chain is compartment-scoped: the chain head is a
2033
+ * single "what did this compartment do last" identifier, not a
2034
+ * per-collection one. Two collections appending concurrently is the
2035
+ * single-writer concurrency concern documented in the LedgerStore
2036
+ * docstring.
2037
+ */
2038
+ private ledgerStore;
2039
+ /**
2040
+ * Per-compartment foreign-key reference registry. Collections
2041
+ * register their `refs` option here on construction; the
2042
+ * compartment uses the registry on every put/delete/checkIntegrity
2043
+ * call. One instance lives for the compartment's lifetime.
2044
+ */
2045
+ private readonly refRegistry;
2046
+ /**
2047
+ * Set of collection record-ids currently being deleted as part of
2048
+ * a cascade. Populated on entry to `enforceRefsOnDelete` and
2049
+ * drained on exit. Used to break mutual-cascade cycles: deleting
2050
+ * A → cascade to B → cascade back to A would otherwise recurse
2051
+ * forever, so we short-circuit when we see an already-in-progress
2052
+ * delete on the same (collection, id) pair.
2053
+ */
2054
+ private readonly cascadeInProgress;
936
2055
  constructor(opts: {
937
2056
  adapter: NoydbAdapter;
938
2057
  name: string;
@@ -941,7 +2060,18 @@ declare class Compartment {
941
2060
  emitter: NoydbEventEmitter;
942
2061
  onDirty?: OnDirtyCallback | undefined;
943
2062
  historyConfig?: HistoryConfig | undefined;
2063
+ reloadKeyring?: (() => Promise<UnlockedKeyring>) | undefined;
944
2064
  });
2065
+ /**
2066
+ * Construct (or reconstruct) the lazy DEK resolver. Captures the
2067
+ * CURRENT value of `this.keyring` and `this.adapter` in a closure,
2068
+ * memoizing the inner getDEKFn after first use so subsequent
2069
+ * lookups are O(1).
2070
+ *
2071
+ * `load()` calls this after refreshing `this.keyring` to discard
2072
+ * the prior session's cached DEKs.
2073
+ */
2074
+ private makeGetDEK;
945
2075
  /**
946
2076
  * Open a typed collection within this compartment.
947
2077
  *
@@ -953,6 +2083,10 @@ declare class Compartment {
953
2083
  * loads records on demand and bounds memory via the LRU cache.
954
2084
  * - `options.cache` configures the LRU bounds. Required in lazy mode.
955
2085
  * Accepts `{ maxRecords, maxBytes: '50MB' | 1024 }`.
2086
+ * - `options.schema` attaches a Standard Schema v1 validator (Zod,
2087
+ * Valibot, ArkType, Effect Schema, etc.). Every `put()` is validated
2088
+ * before encryption; every read is validated after decryption.
2089
+ * Failing records throw `SchemaValidationError`.
956
2090
  *
957
2091
  * Lazy mode + indexes is rejected at construction time — see the
958
2092
  * Collection constructor for the rationale.
@@ -961,13 +2095,143 @@ declare class Compartment {
961
2095
  indexes?: IndexDef[];
962
2096
  prefetch?: boolean;
963
2097
  cache?: CacheOptions;
2098
+ schema?: StandardSchemaV1<unknown, T>;
2099
+ refs?: Record<string, RefDescriptor>;
964
2100
  }): Collection<T>;
2101
+ /**
2102
+ * Enforce strict outbound refs on a `put()`. Called by Collection
2103
+ * just before it writes to the adapter. For every strict ref
2104
+ * declared on the collection, check that the target id exists in
2105
+ * the target collection; throw `RefIntegrityError` if not.
2106
+ *
2107
+ * `warn` and `cascade` modes don't affect put semantics — they're
2108
+ * enforced at delete time or via `checkIntegrity()`.
2109
+ */
2110
+ enforceRefsOnPut(collectionName: string, record: unknown): Promise<void>;
2111
+ /**
2112
+ * Enforce inbound ref modes on a `delete()`. Called by Collection
2113
+ * just before it deletes from the adapter. Walks every inbound
2114
+ * ref that targets this (collection, id) and:
2115
+ *
2116
+ * - `strict`: throws if any referencing records exist
2117
+ * - `cascade`: deletes every referencing record
2118
+ * - `warn`: no-op (checkIntegrity picks it up)
2119
+ *
2120
+ * Cascade cycles are broken via `cascadeInProgress` — re-entering
2121
+ * for the same (collection, id) returns immediately so two
2122
+ * mutually-cascading collections don't recurse forever.
2123
+ */
2124
+ enforceRefsOnDelete(collectionName: string, id: string): Promise<void>;
2125
+ /**
2126
+ * Walk every collection that has declared refs, load its records,
2127
+ * and report any reference whose target id is missing. Modes are
2128
+ * reported alongside each violation so the caller can distinguish
2129
+ * "this is a warning the user asked for" from "this should never
2130
+ * have happened" (strict violations produced by out-of-band
2131
+ * writes).
2132
+ *
2133
+ * Returns `{ violations: [...] }` instead of throwing — the whole
2134
+ * point of `checkIntegrity()` is to surface a list for display
2135
+ * or repair, not to fail noisily.
2136
+ */
2137
+ checkIntegrity(): Promise<{
2138
+ violations: RefViolation[];
2139
+ }>;
2140
+ /**
2141
+ * Return this compartment's hash-chained audit log.
2142
+ *
2143
+ * The ledger is lazy-initialized on first access and cached for the
2144
+ * lifetime of the Compartment instance. Every LedgerStore instance
2145
+ * shares the same adapter and DEK resolver, so `compartment.ledger()`
2146
+ * can be called repeatedly without performance cost.
2147
+ *
2148
+ * The LedgerStore itself is the public API: consumers call
2149
+ * `.append()` (via Collection internals), `.head()`, `.verify()`,
2150
+ * and `.entries({ from, to })`. See the LedgerStore docstring for
2151
+ * the full surface and the concurrency caveats.
2152
+ */
2153
+ ledger(): LedgerStore;
965
2154
  /** List all collection names in this compartment. */
966
2155
  collections(): Promise<string[]>;
967
- /** Dump compartment as encrypted JSON backup string. */
2156
+ /**
2157
+ * Dump compartment as a verifiable encrypted JSON backup string.
2158
+ *
2159
+ * v0.4 backups embed the current ledger head and the full
2160
+ * `_ledger` + `_ledger_deltas` internal collections so the
2161
+ * receiver can run `verifyBackupIntegrity()` after `load()` and
2162
+ * detect any tampering between dump and restore. Pre-v0.4 callers
2163
+ * who didn't have a ledger get a backup without these fields, and
2164
+ * the corresponding `load()` skips the integrity check with a
2165
+ * warning — both modes round-trip cleanly.
2166
+ */
968
2167
  dump(): Promise<string>;
969
- /** Restore compartment from an encrypted JSON backup string. */
2168
+ /**
2169
+ * Restore a compartment from a verifiable backup.
2170
+ *
2171
+ * After loading, runs `verifyBackupIntegrity()` to confirm:
2172
+ * 1. The hash chain is intact (no `prevHash` mismatches)
2173
+ * 2. The chain head matches the embedded `ledgerHead.hash`
2174
+ * from the backup
2175
+ * 3. Every data envelope's `payloadHash` matches the
2176
+ * corresponding ledger entry — i.e. nobody swapped
2177
+ * ciphertext between dump and restore
2178
+ *
2179
+ * On any failure, throws `BackupLedgerError` (chain or head
2180
+ * mismatch) or `BackupCorruptedError` (data envelope mismatch).
2181
+ * The compartment state on the adapter has already been written
2182
+ * by the time we throw, so the caller is responsible for either
2183
+ * accepting the suspect state or wiping it and trying a different
2184
+ * backup.
2185
+ *
2186
+ * Pre-v0.4 backups (no `ledgerHead` field, no `_internal`) load
2187
+ * with a console warning and skip the integrity check entirely
2188
+ * — there's no chain to verify against.
2189
+ */
970
2190
  load(backupJson: string): Promise<void>;
2191
+ /**
2192
+ * End-to-end backup integrity check. Runs both:
2193
+ *
2194
+ * 1. `ledger.verify()` — walks the hash chain and confirms
2195
+ * every `prevHash` matches the recomputed hash of its
2196
+ * predecessor.
2197
+ *
2198
+ * 2. **Data envelope cross-check** — for every (collection, id)
2199
+ * that has a current value, find the most recent ledger
2200
+ * entry recording a `put` for that pair, recompute the
2201
+ * sha256 of the stored envelope's `_data`, and compare to
2202
+ * the entry's `payloadHash`. Any mismatch means an
2203
+ * out-of-band write modified the data without updating the
2204
+ * ledger.
2205
+ *
2206
+ * Returns a discriminated union so callers can handle the two
2207
+ * failure modes differently:
2208
+ * - `{ ok: true, head, length }` — chain verified and all
2209
+ * data matches; safe to use.
2210
+ * - `{ ok: false, kind: 'chain', divergedAt, message }` — the
2211
+ * chain itself is broken at the given index.
2212
+ * - `{ ok: false, kind: 'data', collection, id, message }` —
2213
+ * a specific data envelope doesn't match its ledger entry.
2214
+ *
2215
+ * This method is exposed so users can call it any time, not just
2216
+ * during `load()`. A scheduled background check is the simplest
2217
+ * way to detect tampering of an in-place compartment.
2218
+ */
2219
+ verifyBackupIntegrity(): Promise<{
2220
+ readonly ok: true;
2221
+ readonly head: string;
2222
+ readonly length: number;
2223
+ } | {
2224
+ readonly ok: false;
2225
+ readonly kind: 'chain';
2226
+ readonly divergedAt: number;
2227
+ readonly message: string;
2228
+ } | {
2229
+ readonly ok: false;
2230
+ readonly kind: 'data';
2231
+ readonly collection: string;
2232
+ readonly id: string;
2233
+ readonly message: string;
2234
+ }>;
971
2235
  /** Export compartment as decrypted JSON (owner only). */
972
2236
  export(): Promise<string>;
973
2237
  }
@@ -1114,4 +2378,4 @@ declare function validatePassphrase(passphrase: string): void;
1114
2378
  */
1115
2379
  declare function estimateEntropy(passphrase: string): number;
1116
2380
 
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 };
2381
+ export { type AppendInput, BackupCorruptedError, BackupLedgerError, 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, type InferOutput, InvalidKeyError, type JsonPatch, type JsonPatchOp, type KeyringFile, LEDGER_COLLECTION, LEDGER_DELTAS_COLLECTION, type LedgerEntry, LedgerStore, 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 RefDescriptor, RefIntegrityError, type RefMode, RefRegistry, RefScopeError, type RefViolation, type RevokeOptions, type Role, SchemaValidationError, type StandardSchemaV1, type StandardSchemaV1Issue, type StandardSchemaV1SyncResult, SyncEngine, type SyncMetadata, type SyncStatus, TamperedError, type UserInfo, ValidationError, type VerifyResult, applyPatch, canonicalJson, computePatch, createNoydb, defineAdapter, diff, enrollBiometric, envelopePayloadHash, estimateEntropy, estimateRecordBytes, evaluateClause, evaluateFieldClause, executePlan, formatDiff, hashEntry, isBiometricAvailable, loadBiometric, paddedIndex, parseBytes, parseIndex, readPath, ref, removeBiometric, saveBiometric, sha256Hex, unlockBiometric, validatePassphrase, validateSchemaInput, validateSchemaOutput };