@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.cjs +1428 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1270 -6
- package/dist/index.d.ts +1270 -6
- package/dist/index.js +1407 -24
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 };
|