mindoodb 0.0.91 → 0.0.93

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.
Files changed (77) hide show
  1. package/README-server.md +34 -0
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +12 -3
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  5. package/dist/cloudflare/host.d.ts.map +1 -1
  6. package/dist/cloudflare/host.js +3 -0
  7. package/dist/cloudflare/host.js.map +1 -1
  8. package/dist/cloudflare/types.d.ts +2 -0
  9. package/dist/cloudflare/types.d.ts.map +1 -1
  10. package/dist/core/BaseMindooDB.d.ts +59 -0
  11. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  12. package/dist/core/BaseMindooDB.js +383 -39
  13. package/dist/core/BaseMindooDB.js.map +1 -1
  14. package/dist/core/BaseMindooTenant.d.ts +51 -2
  15. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  16. package/dist/core/BaseMindooTenant.js +131 -39
  17. package/dist/core/BaseMindooTenant.js.map +1 -1
  18. package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -1
  19. package/dist/core/appendonlystores/MaterializationPlanner.js +19 -6
  20. package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -1
  21. package/dist/core/appendonlystores/network/AuthenticationService.d.ts +2 -1
  22. package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
  23. package/dist/core/appendonlystores/network/AuthenticationService.js +2 -1
  24. package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
  25. package/dist/core/crypto/AuthChallenge.d.ts +27 -2
  26. package/dist/core/crypto/AuthChallenge.d.ts.map +1 -1
  27. package/dist/core/crypto/AuthChallenge.js +25 -4
  28. package/dist/core/crypto/AuthChallenge.js.map +1 -1
  29. package/dist/core/crypto/EntrySignature.d.ts +1 -1
  30. package/dist/core/crypto/EntrySignature.js +2 -2
  31. package/dist/core/crypto/EntrySignature.js.map +1 -1
  32. package/dist/core/indexing/fulltext/DocumentFullTextIndex.d.ts.map +1 -1
  33. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js +7 -2
  34. package/dist/core/indexing/fulltext/DocumentFullTextIndex.js.map +1 -1
  35. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +4 -1
  36. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -1
  37. package/dist/core/indexing/summary/DocumentSummaryStore.js +110 -19
  38. package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -1
  39. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  40. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +6 -1
  41. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  42. package/dist/core/types.d.ts +56 -2
  43. package/dist/core/types.d.ts.map +1 -1
  44. package/dist/core/types.js.map +1 -1
  45. package/dist/core/userkeys/recipients.d.ts +12 -0
  46. package/dist/core/userkeys/recipients.d.ts.map +1 -1
  47. package/dist/core/userkeys/recipients.js +42 -9
  48. package/dist/core/userkeys/recipients.js.map +1 -1
  49. package/dist/core/userkeys/sealedCrypto.d.ts +17 -0
  50. package/dist/core/userkeys/sealedCrypto.d.ts.map +1 -1
  51. package/dist/core/userkeys/sealedCrypto.js +31 -8
  52. package/dist/core/userkeys/sealedCrypto.js.map +1 -1
  53. package/dist/core/userkeys/sealedTypes.d.ts +52 -0
  54. package/dist/core/userkeys/sealedTypes.d.ts.map +1 -1
  55. package/dist/core/userkeys/sealedTypes.js +37 -1
  56. package/dist/core/userkeys/sealedTypes.js.map +1 -1
  57. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  58. package/dist/node/server/MindooDBServer.js +4 -0
  59. package/dist/node/server/MindooDBServer.js.map +1 -1
  60. package/dist/server/DeviceDiscoveryService.d.ts.map +1 -1
  61. package/dist/server/DeviceDiscoveryService.js +3 -1
  62. package/dist/server/DeviceDiscoveryService.js.map +1 -1
  63. package/dist/server/SystemAdminAuth.d.ts.map +1 -1
  64. package/dist/server/SystemAdminAuth.js +3 -1
  65. package/dist/server/SystemAdminAuth.js.map +1 -1
  66. package/dist/server/legacyChallengeSignatures.d.ts +11 -0
  67. package/dist/server/legacyChallengeSignatures.d.ts.map +1 -0
  68. package/dist/server/legacyChallengeSignatures.js +50 -0
  69. package/dist/server/legacyChallengeSignatures.js.map +1 -0
  70. package/dist/server/peer/PeerAuthService.d.ts.map +1 -1
  71. package/dist/server/peer/PeerAuthService.js +3 -1
  72. package/dist/server/peer/PeerAuthService.js.map +1 -1
  73. package/dist/server/types.d.ts +8 -0
  74. package/dist/server/types.d.ts.map +1 -1
  75. package/dist/server/types.js +8 -0
  76. package/dist/server/types.js.map +1 -1
  77. package/package.json +2 -1
@@ -568,6 +568,19 @@ class BaseMindooDB {
568
568
  * Maintained only via {@link rememberL2Record} / {@link forgetL2Record}.
569
569
  */
570
570
  this.l2RecordChangeSeqs = new Map();
571
+ /**
572
+ * Whether {@link l2RecordChangeSeqs} lists every L2 record this instance
573
+ * may use: true for a fresh cache and for checkpoints that say so
574
+ * (`l2Tracked`), false for older checkpoints. When true, a document
575
+ * without an entry has no usable L2 record and the read skips the L2
576
+ * lookup instead of asking the cache store (one store round trip per
577
+ * document on a cold start). Records another tab wrote meanwhile are
578
+ * missed until the next restore; the document is then materialized from
579
+ * the store, as on any L2 miss.
580
+ */
581
+ this.l2RecordsTracked = true;
582
+ /** L2 records fetched in one batch for a prefetch window (consumed by tryLoadFromL2). */
583
+ this.l2Prefetched = new Map();
571
584
  /**
572
585
  * Tenant KeyBag fingerprint observed at the time the in-memory index
573
586
  * was last reconciled with the underlying store.
@@ -615,6 +628,16 @@ class BaseMindooDB {
615
628
  // ---------------------------------------------------------------------------
616
629
  /** Serializes concurrent author-trust reconciles (per database). */
617
630
  this.authorTrustReconcileChain = Promise.resolve();
631
+ /**
632
+ * Key series this replica gained or lost during the current pass. Documents of
633
+ * a group document are not re-checked one by one when its wraps change; the
634
+ * next {@link reconcileGroupKeyAccess} re-runs the key visibility reconcile.
635
+ */
636
+ this.groupKeyAccessChanged = new Set();
637
+ /** Per document: whether its origin decrypted with the partial series held then. */
638
+ this.partialSeriesProbes = new Map();
639
+ /** Group key series last probed without success, by key id (ms), see ingestGroupKey. */
640
+ this.groupKeyMisses = new Map();
618
641
  (0, databaseIdValidation_1.validateDatabaseId)(store.getId(), "dbId");
619
642
  (0, databaseIdValidation_1.validateDatabaseId)(attachmentStore.getId(), "dbId");
620
643
  if (store.getId() !== attachmentStore.getId()) {
@@ -1870,6 +1893,10 @@ class BaseMindooDB {
1870
1893
  if (this.l2RecordChangeSeqs.size > 0) {
1871
1894
  checkpoint.l2RecordChangeSeqs = Object.fromEntries(this.l2RecordChangeSeqs);
1872
1895
  }
1896
+ // The map above is complete only when it was complete on restore.
1897
+ if (this.l2RecordsTracked) {
1898
+ checkpoint.l2Tracked = true;
1899
+ }
1873
1900
  // Persist the KeyBag fingerprint observed at the last reconciliation
1874
1901
  // so the next restore can decide whether to skip the visibility
1875
1902
  // metadata scan. `null` is acceptable - older checkpoints simply
@@ -1932,6 +1959,7 @@ class BaseMindooDB {
1932
1959
  ? checkpoint.locallyIndexedEntryIds.filter((id) => typeof id === "string")
1933
1960
  : []);
1934
1961
  this.l2RecordChangeSeqs.clear();
1962
+ this.l2RecordsTracked = checkpoint.l2Tracked === true;
1935
1963
  if (checkpoint.l2RecordChangeSeqs &&
1936
1964
  typeof checkpoint.l2RecordChangeSeqs === "object") {
1937
1965
  for (const [docId, changeSeq] of Object.entries(checkpoint.l2RecordChangeSeqs)) {
@@ -3143,7 +3171,7 @@ class BaseMindooDB {
3143
3171
  const existingIndex = this.getDocIndexPosition(docId);
3144
3172
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
3145
3173
  await this.ingestSealed(visibility.decryptionKeyId, metadataByDoc.get(docId));
3146
- const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
3174
+ const canRead = await this.canReadWithKey(visibility.decryptionKeyId, docId, metadataByDoc.get(docId));
3147
3175
  if (canRead) {
3148
3176
  // This scan is the authority on visibility, so it also heals a tally
3149
3177
  // that a visible->visible short-circuit below would leave untouched.
@@ -3447,6 +3475,8 @@ class BaseMindooDB {
3447
3475
  this.beginChangeNotificationHold();
3448
3476
  try {
3449
3477
  await this.syncStoreChangesInternal();
3478
+ // Joined or left a group document in this batch: its documents follow.
3479
+ await this.reconcileGroupKeyAccess();
3450
3480
  }
3451
3481
  finally {
3452
3482
  this.endChangeNotificationHold();
@@ -3505,6 +3535,26 @@ class BaseMindooDB {
3505
3535
  this.logger.debug(`Dropping cached sealed document ${docId} — newest wrap is not for this user`);
3506
3536
  return;
3507
3537
  }
3538
+ // Full rebuild: drop the cached state and materialize the document
3539
+ // again, through the access-control path when that is enforced.
3540
+ const reloadFully = async () => {
3541
+ this.docCache.delete(docId);
3542
+ updatedDoc = await this.loadDocumentInternal(docId);
3543
+ if (updatedDoc) {
3544
+ // New entries triggered this reload, so force the re-emit.
3545
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
3546
+ }
3547
+ };
3548
+ if (await this.isAclEnforced()) {
3549
+ // The incremental path below applies new entries without the
3550
+ // access-control gate (docs/accesscontrol.md §10). With access
3551
+ // control active, a change by someone the rules do not allow must be
3552
+ // quarantined like on a fresh load, so rebuild the document through
3553
+ // maybeMaterializeWithAccessControl instead.
3554
+ this.logger.debug(`Document ${docId} found in cache, access control enforced: full reload`);
3555
+ await reloadFully();
3556
+ return;
3557
+ }
3508
3558
  // Document is cached - try incremental update
3509
3559
  this.logger.debug(`Document ${docId} found in cache, attempting incremental update`);
3510
3560
  try {
@@ -3525,12 +3575,7 @@ class BaseMindooDB {
3525
3575
  catch (error) {
3526
3576
  // If incremental update fails, fall back to full reload
3527
3577
  this.logger.warn(`Incremental update failed for document ${docId}, falling back to full reload:`, error);
3528
- this.docCache.delete(docId);
3529
- updatedDoc = await this.loadDocumentInternal(docId);
3530
- if (updatedDoc) {
3531
- // New entries triggered this reload, so force the re-emit.
3532
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
3533
- }
3578
+ await reloadFully();
3534
3579
  }
3535
3580
  }
3536
3581
  else {
@@ -3568,7 +3613,7 @@ class BaseMindooDB {
3568
3613
  sealedIngestEntries = fullDocMetadata;
3569
3614
  }
3570
3615
  await this.ingestSealed(representativeEntry.decryptionKeyId, sealedIngestEntries);
3571
- const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
3616
+ const keyAvailable = await this.canReadWithKey(representativeEntry.decryptionKeyId, docId, coldSync ? entryMetadataList : fullDocMetadata);
3572
3617
  if (!keyAvailable) {
3573
3618
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
3574
3619
  // This is where a device that is still waiting for its keys meets
@@ -4497,6 +4542,10 @@ class BaseMindooDB {
4497
4542
  throw new Error("createDocument: recipients is not supported together with a convergent custom id; " +
4498
4543
  "pass assumeUniqueId for random ids.");
4499
4544
  }
4545
+ if (useDeterministicSeed && options.groupKeyDocId !== undefined) {
4546
+ throw new Error("createDocument: groupKeyDocId is not supported together with a convergent custom id; " +
4547
+ "pass assumeUniqueId for random ids.");
4548
+ }
4500
4549
  if (useDeterministicSeed && (options.attachments?.length ?? 0) > 0) {
4501
4550
  throw new Error("createDocument: attachments are not supported together with a convergent custom id; " +
4502
4551
  "pass assumeUniqueId, or add them with changeDoc().");
@@ -4746,6 +4795,9 @@ class BaseMindooDB {
4746
4795
  if (useDeterministicSeed && options.recipients !== undefined) {
4747
4796
  throw new Error("createDocuments: recipients is not supported together with a convergent custom id; pass assumeUniqueId");
4748
4797
  }
4798
+ if (useDeterministicSeed && options.groupKeyDocId !== undefined) {
4799
+ throw new Error("createDocuments: groupKeyDocId is not supported together with a convergent custom id; pass assumeUniqueId");
4800
+ }
4749
4801
  // Sanitize caller-provided initial values: internal/reserved fields are
4750
4802
  // managed by MindooDB and must not be seeded by callers. `undefined`
4751
4803
  // values are treated as "not set" (Automerge cannot represent undefined).
@@ -5084,13 +5136,112 @@ class BaseMindooDB {
5084
5136
  async ingestSealed(keyId, entries) {
5085
5137
  if (!this.tenant.ingestSealedFromEntries)
5086
5138
  return;
5139
+ const hadKey = (0, sealedTypes_1.isSealedKeyId)(keyId) ? await this.tenant.hasDecryptionKey(keyId) : false;
5140
+ const heldBefore = this.tenant.getSealedGenerations?.(keyId).length ?? 0;
5087
5141
  this.sealedIngestDepth++;
5088
5142
  try {
5089
5143
  await this.tenant.ingestSealedFromEntries(keyId, entries);
5144
+ // A document encrypted with a group document's key series (`groupKeyDocId`)
5145
+ // carries no recipient block of its own: the wraps are on the group document.
5146
+ if ((0, sealedTypes_1.isSealedKeyId)(keyId) && !(0, recipients_1.newestRecipientBlock)(entries)) {
5147
+ await this.ingestGroupKey(keyId);
5148
+ }
5149
+ // A series that does not reach back to epoch 1 may be completed from the
5150
+ // key document's older recipient blocks; `entries` is often just a batch.
5151
+ if ((0, sealedTypes_1.isSealedKeyId)(keyId) && this.tenant.needsDeepSealedIngest?.(keyId)) {
5152
+ const keyDocId = (0, sealedTypes_1.sealedKeyDocId)(keyId);
5153
+ if (keyDocId) {
5154
+ await this.tenant.ingestSealedFromEntries(keyId, await this.scanAllMetadata(this.store, { docId: keyDocId }), { complete: true });
5155
+ }
5156
+ }
5090
5157
  }
5091
5158
  finally {
5092
5159
  this.sealedIngestDepth--;
5093
5160
  }
5161
+ if ((0, sealedTypes_1.isSealedKeyId)(keyId) &&
5162
+ (hadKey !== (await this.tenant.hasDecryptionKey(keyId)) ||
5163
+ (hadKey &&
5164
+ heldBefore !== (this.tenant.getSealedGenerations?.(keyId).length ?? 0)))) {
5165
+ this.partialSeriesProbes.clear();
5166
+ // Joined or left the key series: documents of a group document that were
5167
+ // hidden (or readable) until now have to follow (see reconcileGroupKeyAccess).
5168
+ this.groupKeyAccessChanged.add(keyId);
5169
+ }
5170
+ }
5171
+ /**
5172
+ * Whether this replica can read a document of key `keyId`. Usually the key id
5173
+ * alone decides; a reader added to a group without its history
5174
+ * (`history: "none"`) knows the key id but holds only the newer generations,
5175
+ * so the document's origin entry is test-decrypted (once per document).
5176
+ */
5177
+ async canReadWithKey(keyId, docId, metas) {
5178
+ if (!(await this.tenant.hasDecryptionKey(keyId)))
5179
+ return false;
5180
+ if (!(0, sealedTypes_1.isSealedKeyId)(keyId) || !this.tenant.isPartialSealedSeries?.(keyId))
5181
+ return true;
5182
+ const known = this.partialSeriesProbes.get(docId);
5183
+ if (known !== undefined)
5184
+ return known;
5185
+ const all = metas ?? (await this.scanAllMetadata(this.store, { docId }));
5186
+ // Loading starts from the newest snapshot when there is one (written under
5187
+ // the new key when readers were added without history), else from the create.
5188
+ const origin = all
5189
+ .filter((meta) => meta.entryType === "doc_snapshot")
5190
+ .sort((a, b) => b.createdAt - a.createdAt)[0] ??
5191
+ all.find((meta) => meta.entryType === "doc_create");
5192
+ if (!origin)
5193
+ return true;
5194
+ let ok = false;
5195
+ try {
5196
+ const [entry] = await this.store.getEntries([origin.id]);
5197
+ if (entry) {
5198
+ await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
5199
+ ok = true;
5200
+ }
5201
+ }
5202
+ catch {
5203
+ ok = false;
5204
+ }
5205
+ this.partialSeriesProbes.set(docId, ok);
5206
+ return ok;
5207
+ }
5208
+ /**
5209
+ * Makes the key series `$sealed:<groupDocId>` available from the group
5210
+ * document's own entries when the session does not have it yet. A miss is
5211
+ * remembered briefly, so a few hundred documents of a group this replica is not
5212
+ * a member of cost one scan of the group document, not one each.
5213
+ */
5214
+ async ingestGroupKey(keyId) {
5215
+ const groupDocId = (0, sealedTypes_1.sealedKeyDocId)(keyId);
5216
+ if (!groupDocId || (await this.tenant.hasDecryptionKey(keyId)))
5217
+ return;
5218
+ const lastMiss = this.groupKeyMisses.get(keyId);
5219
+ if (lastMiss !== undefined && Date.now() - lastMiss < BaseMindooDB.GROUP_KEY_MISS_TTL_MS)
5220
+ return;
5221
+ const groupEntries = await this.scanAllMetadata(this.store, { docId: groupDocId });
5222
+ if (groupEntries.length > 0) {
5223
+ await this.tenant.ingestSealedFromEntries(keyId, groupEntries);
5224
+ }
5225
+ if (await this.tenant.hasDecryptionKey(keyId)) {
5226
+ this.groupKeyMisses.delete(keyId);
5227
+ }
5228
+ else {
5229
+ this.groupKeyMisses.set(keyId, Date.now());
5230
+ }
5231
+ }
5232
+ /**
5233
+ * After a pass that changed access to a key series (added to or removed from a
5234
+ * group document), reveal or hide the documents that use it. Runs the full key
5235
+ * visibility reconcile, which is rare enough: it only happens when this
5236
+ * replica's membership in a group changed.
5237
+ */
5238
+ async reconcileGroupKeyAccess() {
5239
+ if (this.groupKeyAccessChanged.size === 0 || this.isTimeTravelMode())
5240
+ return;
5241
+ this.groupKeyAccessChanged.clear();
5242
+ this.groupKeyMisses.clear();
5243
+ await this.reconcileKeyVisibility();
5244
+ this.groupKeyAccessChanged.clear();
5094
5245
  }
5095
5246
  /**
5096
5247
  * Unwrap a `$sealed:` DEK from entry recipient wraps into the tenant session
@@ -8509,7 +8660,9 @@ class BaseMindooDB {
8509
8660
  isAccessible: () => true,
8510
8661
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
8511
8662
  isWitnessed: () => internalDoc.witnessed ?? false,
8512
- isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
8663
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
8664
+ (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id) === undefined,
8665
+ getGroupKeyDocId: () => (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id),
8513
8666
  getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
8514
8667
  getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
8515
8668
  isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
@@ -9670,6 +9823,7 @@ class BaseMindooDB {
9670
9823
  attachmentIds,
9671
9824
  bypassPrecheck,
9672
9825
  recipients: options.recipients,
9826
+ forceSnapshot: options.forceSnapshot,
9673
9827
  });
9674
9828
  }
9675
9829
  catch (error) {
@@ -9772,8 +9926,9 @@ class BaseMindooDB {
9772
9926
  signingKeyPair,
9773
9927
  signingKeyPassword,
9774
9928
  createdByPublicKey: createdByPublicKey ?? "",
9929
+ force: options.forceSnapshot,
9775
9930
  }
9776
- : { createdByPublicKey: createdByPublicKey ?? "" });
9931
+ : { createdByPublicKey: createdByPublicKey ?? "", force: options.forceSnapshot });
9777
9932
  }
9778
9933
  async buildDocChangeStoreEntry(options) {
9779
9934
  const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds, attachmentRefs, recipients, } = options;
@@ -9840,7 +9995,7 @@ class BaseMindooDB {
9840
9995
  // An absent counter (first write this session) forces one scan to learn
9841
9996
  // the real backlog, after which the counter tracks it incrementally.
9842
9997
  const knownWrites = this.writesSinceSnapshotCheck.get(docId);
9843
- if (knownWrites !== undefined) {
9998
+ if (knownWrites !== undefined && !options.force) {
9844
9999
  const writes = knownWrites + 1;
9845
10000
  if (writes < this.snapshotMinChanges) {
9846
10001
  this.writesSinceSnapshotCheck.set(docId, writes);
@@ -9856,10 +10011,11 @@ class BaseMindooDB {
9856
10011
  const latestSnapshotAt = latestSnapshot?.createdAt ?? 0;
9857
10012
  const changesSinceSnapshot = replayEntries.filter((em) => em.createdAt > latestSnapshotAt).length;
9858
10013
  this.writesSinceSnapshotCheck.set(docId, changesSinceSnapshot);
9859
- if (changesSinceSnapshot < this.snapshotMinChanges) {
10014
+ if (changesSinceSnapshot < this.snapshotMinChanges && !options.force) {
9860
10015
  return;
9861
10016
  }
9862
- if (latestSnapshot &&
10017
+ if (!options.force &&
10018
+ latestSnapshot &&
9863
10019
  Date.now() - latestSnapshot.createdAt < this.snapshotCooldownMs) {
9864
10020
  return;
9865
10021
  }
@@ -10022,6 +10178,34 @@ class BaseMindooDB {
10022
10178
  await flushPage();
10023
10179
  return buffer;
10024
10180
  }
10181
+ /**
10182
+ * Fetch the L2 records of a prefetch window in one batched read instead
10183
+ * of one cache-store round trip per document. Only documents that may
10184
+ * have a record are asked for (see {@link l2RecordsTracked}); the results
10185
+ * are consumed by {@link tryLoadFromL2}. Best effort: on failure the
10186
+ * loads fall back to their own reads.
10187
+ */
10188
+ async prefetchL2Records(docIds) {
10189
+ const store = this.cacheManager?.getStore() ?? null;
10190
+ if (!store) {
10191
+ return [];
10192
+ }
10193
+ const wanted = docIds.filter((docId) => !this.l2Prefetched.has(docId) &&
10194
+ (!this.l2RecordsTracked || this.l2RecordChangeSeqs.has(docId)));
10195
+ if (wanted.length === 0) {
10196
+ return [];
10197
+ }
10198
+ const prefix = this.getCachePrefix();
10199
+ try {
10200
+ const records = await store.getMany("doc", wanted.map((docId) => `${prefix}/${docId}`));
10201
+ wanted.forEach((docId, i) => this.l2Prefetched.set(docId, records[i] ?? null));
10202
+ return wanted;
10203
+ }
10204
+ catch (err) {
10205
+ this.logger.warn(`Batched L2 read for a prefetch window failed:`, err);
10206
+ return [];
10207
+ }
10208
+ }
10025
10209
  /**
10026
10210
  * Materialize a window of upcoming documents so iteration reads them from
10027
10211
  * the cache instead of the store.
@@ -10037,7 +10221,7 @@ class BaseMindooDB {
10037
10221
  *
10038
10222
  * Returns the position prefetched through, which the caller threads back in.
10039
10223
  */
10040
- async prefetchIterationWindow(indexSnapshot, startIndex, prefetchedThrough, idPrefix) {
10224
+ async prefetchIterationWindow(indexSnapshot, startIndex, prefetchedThrough, idPrefix, persist = true) {
10041
10225
  const windowSize = this.iteratePrefetchWindowDocs;
10042
10226
  if (windowSize <= 0) {
10043
10227
  return { prefetchedThrough: startIndex, prefetchedDocuments: 0 };
@@ -10097,14 +10281,20 @@ class BaseMindooDB {
10097
10281
  this.logger.warn(`Failed to batch-fetch entries for prefetch window:`, err);
10098
10282
  this.materializationEntryBuffer = null;
10099
10283
  }
10284
+ const prefetchedL2 = await this.prefetchL2Records(docIds);
10100
10285
  try {
10101
- await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId).catch((err) => {
10286
+ await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId, persist).catch((err) => {
10102
10287
  this.logger.warn(`Failed to prefetch document ${docId}:`, err);
10103
10288
  return null;
10104
10289
  })));
10105
10290
  }
10106
10291
  finally {
10107
10292
  this.materializationEntryBuffer = null;
10293
+ // Records a load did not consume (shared or failed load) must not be
10294
+ // used by a later read: they may be outdated by then.
10295
+ for (const docId of prefetchedL2) {
10296
+ this.l2Prefetched.delete(docId);
10297
+ }
10108
10298
  }
10109
10299
  return {
10110
10300
  prefetchedThrough: scannedThrough,
@@ -10524,6 +10714,7 @@ class BaseMindooDB {
10524
10714
  docId: "",
10525
10715
  };
10526
10716
  const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
10717
+ const persistLoaded = options?.persistLoadedDocuments !== false;
10527
10718
  this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
10528
10719
  // Use a stable snapshot of the index for this generator run so concurrent
10529
10720
  // updates do not reorder/skip entries while iterating.
@@ -10536,7 +10727,7 @@ class BaseMindooDB {
10536
10727
  // so concurrent iterations do not share a runway.
10537
10728
  let prefetchedThrough = startIndex;
10538
10729
  const prefetchFrom = async (from) => {
10539
- const result = await this.prefetchIterationWindow(indexSnapshot, from, prefetchedThrough, idPrefix);
10730
+ const result = await this.prefetchIterationWindow(indexSnapshot, from, prefetchedThrough, idPrefix, persistLoaded);
10540
10731
  prefetchedThrough = result.prefetchedThrough;
10541
10732
  prefetchedDocuments += result.prefetchedDocuments;
10542
10733
  };
@@ -10593,7 +10784,7 @@ class BaseMindooDB {
10593
10784
  let internalDoc = this.getCachedDocument(entry.docId);
10594
10785
  if (!internalDoc) {
10595
10786
  this.logger.debug(`Document ${entry.docId} not in cache, loading from store`);
10596
- internalDoc = await this.loadDocumentInternal(entry.docId);
10787
+ internalDoc = await this.loadDocumentInternal(entry.docId, persistLoaded);
10597
10788
  if (internalDoc) {
10598
10789
  loadedDocuments++;
10599
10790
  }
@@ -10820,13 +11011,23 @@ class BaseMindooDB {
10820
11011
  const prefix = this.getCachePrefix();
10821
11012
  const key = `${prefix}/${docId}`;
10822
11013
  let bytes;
10823
- try {
10824
- bytes = await store.get("doc", key);
11014
+ if (this.l2Prefetched.has(docId)) {
11015
+ bytes = this.l2Prefetched.get(docId) ?? null;
11016
+ this.l2Prefetched.delete(docId);
10825
11017
  }
10826
- catch (e) {
10827
- this.logger.warn(`L2 read failed for ${docId}; falling back to full materialization: ${e}`);
11018
+ else if (this.l2RecordsTracked && !this.l2RecordChangeSeqs.has(docId)) {
11019
+ // Known to have no L2 record: skip the store round trip.
10828
11020
  return null;
10829
11021
  }
11022
+ else {
11023
+ try {
11024
+ bytes = await store.get("doc", key);
11025
+ }
11026
+ catch (e) {
11027
+ this.logger.warn(`L2 read failed for ${docId}; falling back to full materialization: ${e}`);
11028
+ return null;
11029
+ }
11030
+ }
10830
11031
  if (!bytes)
10831
11032
  return null;
10832
11033
  let deserialized;
@@ -11347,6 +11548,12 @@ class BaseMindooDB {
11347
11548
  options.decryptionKeyId !== undefined) {
11348
11549
  throw new Error("createDocument: recipients and decryptionKeyId are mutually exclusive");
11349
11550
  }
11551
+ if (options.groupKeyDocId !== undefined) {
11552
+ if (options.recipients !== undefined || options.decryptionKeyId !== undefined) {
11553
+ throw new Error("createDocument: groupKeyDocId is mutually exclusive with recipients and decryptionKeyId");
11554
+ }
11555
+ return await this.requireGroupKey(options.groupKeyDocId);
11556
+ }
11350
11557
  if (options.recipients !== undefined) {
11351
11558
  return (0, sealedTypes_1.sealedKeyId)(docId ?? options.id ?? "pending");
11352
11559
  }
@@ -11366,6 +11573,27 @@ class BaseMindooDB {
11366
11573
  }
11367
11574
  return "default";
11368
11575
  }
11576
+ /**
11577
+ * The key id of a group document's key series, after checking that the group
11578
+ * document is a sealed document of this database and that the caller is a member.
11579
+ */
11580
+ async requireGroupKey(groupKeyDocId) {
11581
+ if (!types_1.CUSTOM_DOC_ID_REGEX.test(groupKeyDocId) && !/^[a-z0-9_]+$/.test(groupKeyDocId)) {
11582
+ throw new Error(`createDocument: invalid groupKeyDocId "${groupKeyDocId}"`);
11583
+ }
11584
+ const keyId = (0, sealedTypes_1.sealedKeyId)(groupKeyDocId);
11585
+ const groupEntries = await this.scanAllMetadata(this.store, { docId: groupKeyDocId });
11586
+ const groupCreate = groupEntries.find((entry) => entry.entryType === "doc_create");
11587
+ if (!groupCreate || groupCreate.decryptionKeyId !== keyId) {
11588
+ throw new Error(`createDocument: group document "${groupKeyDocId}" does not exist in this database or was not created with recipients`);
11589
+ }
11590
+ this.groupKeyMisses.delete(keyId);
11591
+ await this.ingestSealed(keyId, groupEntries);
11592
+ if (!(await this.tenant.hasDecryptionKey(keyId))) {
11593
+ throw new Error(`createDocument: you are not a member of group document "${groupKeyDocId}"`);
11594
+ }
11595
+ return keyId;
11596
+ }
11369
11597
  async prepareSealedCreate(options, docId, keyId, now, signerPublicKey) {
11370
11598
  if (options.recipients === undefined)
11371
11599
  return undefined;
@@ -11510,6 +11738,9 @@ class BaseMindooDB {
11510
11738
  async setRecipients(doc, recipients, options) {
11511
11739
  return this.mutateRecipients(doc, "set", recipients, options);
11512
11740
  }
11741
+ async revealHistory(doc, recipients, options) {
11742
+ return this.mutateRecipients(doc, "reveal", recipients, options);
11743
+ }
11513
11744
  async canChangeRecipients(doc, next) {
11514
11745
  const after = { ...doc.getData() };
11515
11746
  after[sealedTypes_1.ENCRYPT_FOR_FIELD] = {
@@ -11523,6 +11754,10 @@ class BaseMindooDB {
11523
11754
  if (!(0, sealedTypes_1.isSealedKeyId)(doc.getDecryptionKeyId())) {
11524
11755
  throw new Error("Recipient mutators require a document created with `recipients`");
11525
11756
  }
11757
+ const groupDocId = (0, sealedTypes_1.groupKeyDocIdOf)(doc.getDecryptionKeyId(), doc.getId());
11758
+ if (groupDocId !== undefined) {
11759
+ throw new Error(`Document ${doc.getId()} uses the key series of group document ${groupDocId}; change the recipients there`);
11760
+ }
11526
11761
  const tenant = this.tenant;
11527
11762
  const docId = doc.getId();
11528
11763
  let internalDoc = this.getCachedDocument(docId) ?? (await this.loadDocumentInternal(docId));
@@ -11562,6 +11797,16 @@ class BaseMindooDB {
11562
11797
  addedTargets.push(target);
11563
11798
  }
11564
11799
  }
11800
+ const revealedIds = [];
11801
+ if (mode === "reveal") {
11802
+ for (const [id, entry] of Object.entries(currentMap)) {
11803
+ if (entry.removedAt || !entry.historyFrom)
11804
+ continue;
11805
+ if (resolved.targets.some((target) => matchesTarget(id, entry, target))) {
11806
+ revealedIds.push(id);
11807
+ }
11808
+ }
11809
+ }
11565
11810
  if (mode === "remove") {
11566
11811
  for (const [id, entry] of Object.entries(currentMap)) {
11567
11812
  if (entry.removedAt)
@@ -11582,19 +11827,69 @@ class BaseMindooDB {
11582
11827
  removedStableIds.push(base);
11583
11828
  }
11584
11829
  }
11585
- const rotate = removedStableIds.length > 0;
11586
- const currentGens = tenant.getSealedGenerations(internalDoc.decryptionKeyId);
11587
- if (currentGens.length === 0) {
11830
+ const withoutHistory = (mode === "add" || mode === "set") &&
11831
+ addedTargets.length > 0 &&
11832
+ (options?.history ?? sealedTypes_1.DEFAULT_RECIPIENT_HISTORY) === "none";
11833
+ const rotate = removedStableIds.length > 0 || withoutHistory;
11834
+ const heldSeries = tenant.getSealedSeries
11835
+ ? tenant.getSealedSeries(internalDoc.decryptionKeyId)
11836
+ : tenant
11837
+ .getSealedGenerations(internalDoc.decryptionKeyId)
11838
+ .map((key, index, all) => ({ epoch: all.length - index, key }));
11839
+ if (heldSeries.length === 0) {
11588
11840
  throw new Error("Cannot change recipients: the current document key is not in this session");
11589
11841
  }
11590
- let generations = currentGens;
11591
- let epoch = internalDoc.sealedRecipients?.epoch ?? currentGens.length;
11842
+ // The epoch comes from the store, not from the cached document.
11843
+ const newestBlock = (0, recipients_1.newestRecipientBlock)(await this.scanAllMetadata(this.store, { docId })) ??
11844
+ internalDoc.sealedRecipients;
11845
+ let epoch = Math.max(newestBlock?.epoch ?? 0, heldSeries[0].epoch);
11846
+ if (heldSeries[0].epoch < epoch) {
11847
+ throw new Error("Cannot change recipients: this replica does not hold the current document key yet; sync first");
11848
+ }
11849
+ // What this replica can seal: the contiguous run of generations that ends at
11850
+ // the current epoch. A member who joined without the earlier history holds a
11851
+ // shorter run; readers entitled to more keep taking the older generations
11852
+ // from the earlier recipient blocks (see ingestSealedFromEntries).
11853
+ let series = heldSeries;
11854
+ let run = [];
11855
+ for (const entry of heldSeries) {
11856
+ if (run.length > 0 && entry.epoch !== run[run.length - 1].epoch - 1)
11857
+ break;
11858
+ run.push(entry);
11859
+ }
11592
11860
  if (rotate) {
11593
11861
  const nextDek = tenant
11594
11862
  .getCryptoAdapter()
11595
11863
  .getRandomValues(new Uint8Array(32));
11596
- generations = [nextDek, ...currentGens];
11597
11864
  epoch += 1;
11865
+ run = [{ epoch, key: nextDek }, ...run];
11866
+ series = [{ epoch, key: nextDek }, ...heldSeries];
11867
+ }
11868
+ /** Oldest epoch this replica can hand to anyone. */
11869
+ const sealFrom = run[run.length - 1].epoch;
11870
+ const generations = run.map((entry) => entry.key);
11871
+ const addedHistoryFrom = withoutHistory
11872
+ ? epoch
11873
+ : sealFrom > 1
11874
+ ? sealFrom
11875
+ : undefined;
11876
+ // Revealing reaches as far back as the caller's own history.
11877
+ const revealTo = sealFrom > 1 ? sealFrom : undefined;
11878
+ for (const id of [...revealedIds]) {
11879
+ if ((currentMap[id]?.historyFrom ?? 1) <= sealFrom) {
11880
+ revealedIds.splice(revealedIds.indexOf(id), 1);
11881
+ }
11882
+ }
11883
+ const historyFromByStable = new Map();
11884
+ for (const [id, entry] of Object.entries(currentMap)) {
11885
+ if (entry.removedAt || !entry.historyFrom)
11886
+ continue;
11887
+ const from = revealedIds.includes(id) ? revealTo : entry.historyFrom;
11888
+ if (!from)
11889
+ continue;
11890
+ historyFromByStable.set(id.split("#")[0], from);
11891
+ if (entry.keyFingerprint)
11892
+ historyFromByStable.set(entry.keyFingerprint, from);
11598
11893
  }
11599
11894
  const remainingByStable = new Map();
11600
11895
  const existingSpecs = Object.entries(currentMap)
@@ -11613,8 +11908,14 @@ class BaseMindooDB {
11613
11908
  for (const target of existingResolved.targets)
11614
11909
  remainingByStable.set(target.stableId, target);
11615
11910
  }
11911
+ for (const [stableId, target] of remainingByStable) {
11912
+ const from = historyFromByStable.get(stableId) ??
11913
+ historyFromByStable.get(target.keyFingerprint);
11914
+ if (from)
11915
+ remainingByStable.set(stableId, { ...target, historyFrom: from });
11916
+ }
11616
11917
  for (const target of addedTargets)
11617
- remainingByStable.set(target.stableId, target);
11918
+ remainingByStable.set(target.stableId, addedHistoryFrom ? { ...target, historyFrom: addedHistoryFrom } : target);
11618
11919
  for (const id of removedStableIds) {
11619
11920
  remainingByStable.delete(id);
11620
11921
  for (const [stableId, target] of [...remainingByStable.entries()]) {
@@ -11635,7 +11936,7 @@ class BaseMindooDB {
11635
11936
  targets: remaining,
11636
11937
  epoch,
11637
11938
  });
11638
- tenant.rememberSealedGenerations(internalDoc.decryptionKeyId, generations);
11939
+ tenant.rememberSealedGenerations(internalDoc.decryptionKeyId, series.map((entry) => entry.key), recipientsBlock, series.map((entry) => entry.epoch));
11639
11940
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
11640
11941
  const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, { message: changeMessage }, (draft) => {
11641
11942
  if (addedTargets.length > 0) {
@@ -11647,10 +11948,22 @@ class BaseMindooDB {
11647
11948
  addedBy: signer,
11648
11949
  keyFingerprint: target.keyFingerprint,
11649
11950
  ...(target.label ? { label: target.label } : {}),
11951
+ ...(addedHistoryFrom ? { historyFrom: addedHistoryFrom } : {}),
11650
11952
  };
11651
11953
  }
11652
11954
  (0, recipients_1.applyEncryptForAdds)(draft, addMap, epoch);
11653
11955
  }
11956
+ if (revealedIds.length > 0) {
11957
+ const map = draft[sealedTypes_1.ENCRYPT_FOR_FIELD];
11958
+ for (const id of revealedIds) {
11959
+ if (!map[id])
11960
+ continue;
11961
+ if (revealTo)
11962
+ map[id].historyFrom = revealTo;
11963
+ else
11964
+ delete map[id].historyFrom;
11965
+ }
11966
+ }
11654
11967
  if (removedStableIds.length > 0) {
11655
11968
  (0, recipients_1.applyEncryptForRemoves)(draft, removedStableIds, now, signer);
11656
11969
  }
@@ -11671,6 +11984,9 @@ class BaseMindooDB {
11671
11984
  successMessage: "recipients updated",
11672
11985
  bypassPrecheck: options?.bypassAccessControlPrecheck,
11673
11986
  recipients: recipientsBlock,
11987
+ // Readers added without history cannot decrypt the older entries of this
11988
+ // document itself: a snapshot under the new key gives them its current state.
11989
+ forceSnapshot: withoutHistory,
11674
11990
  });
11675
11991
  if (options?.change) {
11676
11992
  await this.changeDoc(this.wrapDocument(internalDoc), options.change, {
@@ -12136,21 +12452,36 @@ class BaseMindooDB {
12136
12452
  * document were indexed since it started, so no caller gets a state older
12137
12453
  * than its call.
12138
12454
  */
12139
- loadDocumentInternal(docId) {
12455
+ loadDocumentInternal(docId, persist = true) {
12140
12456
  const generation = this.docEntryGeneration.get(docId) ?? 0;
12141
12457
  const inFlight = this.documentLoadsInFlight.get(docId);
12458
+ let promise;
12142
12459
  if (inFlight && inFlight.generation === generation) {
12143
- return inFlight.promise;
12460
+ promise = inFlight.promise;
12461
+ }
12462
+ else {
12463
+ const started = this.loadDocumentUncoalesced(docId, persist).finally(() => {
12464
+ if (this.documentLoadsInFlight.get(docId)?.promise === started) {
12465
+ this.documentLoadsInFlight.delete(docId);
12466
+ }
12467
+ });
12468
+ this.documentLoadsInFlight.set(docId, { generation, promise: started });
12469
+ promise = started;
12144
12470
  }
12145
- const promise = this.loadDocumentUncoalesced(docId).finally(() => {
12146
- if (this.documentLoadsInFlight.get(docId)?.promise === promise) {
12147
- this.documentLoadsInFlight.delete(docId);
12471
+ if (!persist) {
12472
+ return promise;
12473
+ }
12474
+ // A regular read of a document a scan materialized (shared load or L1
12475
+ // hit) wants it in the persistent cache as usual.
12476
+ return promise.then((loaded) => {
12477
+ if (loaded?.scanOnly && this.docCache.get(docId) === loaded) {
12478
+ delete loaded.scanOnly;
12479
+ this.markDocDirty(docId);
12148
12480
  }
12481
+ return loaded;
12149
12482
  });
12150
- this.documentLoadsInFlight.set(docId, { generation, promise });
12151
- return promise;
12152
12483
  }
12153
- async loadDocumentUncoalesced(docId) {
12484
+ async loadDocumentUncoalesced(docId, persist = true) {
12154
12485
  const startedAt = Date.now();
12155
12486
  const cacheCheckStartedAt = Date.now();
12156
12487
  // Short-circuit on the visibility layer: a doc marked
@@ -12559,7 +12890,17 @@ class BaseMindooDB {
12559
12890
  // result is already outdated and the next read materializes again)
12560
12891
  if (this.isEntryGenerationCurrent(docId, entryGeneration)) {
12561
12892
  await this.storeCachedDocument(internalDoc);
12562
- this.markDocDirty(docId);
12893
+ // Scan loads (index builds) keep the document in L1 only: marking it
12894
+ // dirty would write every document of a full pass to the persistent
12895
+ // L2 cache on eviction. While the background warmer is running, the
12896
+ // L2 cache is being filled anyway: persist then, so the warmer does
12897
+ // not materialize the same document a second time.
12898
+ if (persist || this.warmerPromise !== null) {
12899
+ this.markDocDirty(docId);
12900
+ }
12901
+ else {
12902
+ internalDoc.scanOnly = true;
12903
+ }
12563
12904
  this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
12564
12905
  }
12565
12906
  else {
@@ -12702,7 +13043,9 @@ class BaseMindooDB {
12702
13043
  isAccessible: () => internalDoc.accessible ?? true,
12703
13044
  isAwaitingWitness: () => internalDoc.awaitingWitness ?? false,
12704
13045
  isWitnessed: () => internalDoc.witnessed ?? false,
12705
- isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId),
13046
+ isSealed: () => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
13047
+ (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id) === undefined,
13048
+ getGroupKeyDocId: () => (0, sealedTypes_1.groupKeyDocIdOf)(internalDoc.decryptionKeyId, internalDoc.id),
12706
13049
  getRecipients: () => (0, recipients_1.resolveRecipientsFromPayload)(internalDoc.doc, internalDoc.sealedRecipients),
12707
13050
  getRecipientEpoch: () => internalDoc.sealedRecipients?.epoch ?? 0,
12708
13051
  isEncryptedFor: (users) => (0, sealedTypes_1.isSealedKeyId)(internalDoc.decryptionKeyId) &&
@@ -13764,6 +14107,7 @@ class BaseMindooDB {
13764
14107
  }
13765
14108
  }
13766
14109
  exports.BaseMindooDB = BaseMindooDB;
14110
+ BaseMindooDB.GROUP_KEY_MISS_TTL_MS = 2000;
13767
14111
  /** The "access control not enforced" allow decision returned by the
13768
14112
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
13769
14113
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {