mindoodb 0.0.53 → 0.0.55

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 (99) hide show
  1. package/README-server.md +125 -69
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +17 -0
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +22 -0
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  6. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +58 -0
  7. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  8. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +156 -3
  9. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  10. package/dist/browser/index.mjs +2 -0
  11. package/dist/core/BaseMindooDB.d.ts +73 -74
  12. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  13. package/dist/core/BaseMindooDB.js +263 -509
  14. package/dist/core/BaseMindooDB.js.map +1 -1
  15. package/dist/core/MindooDBServerAdmin.d.ts +44 -22
  16. package/dist/core/MindooDBServerAdmin.d.ts.map +1 -1
  17. package/dist/core/MindooDBServerAdmin.js +45 -21
  18. package/dist/core/MindooDBServerAdmin.js.map +1 -1
  19. package/dist/core/appendonlystores/syncStores.d.ts +147 -0
  20. package/dist/core/appendonlystores/syncStores.d.ts.map +1 -0
  21. package/dist/core/appendonlystores/syncStores.js +612 -0
  22. package/dist/core/appendonlystores/syncStores.js.map +1 -0
  23. package/dist/core/builtinDbInvariants.d.ts +30 -0
  24. package/dist/core/builtinDbInvariants.d.ts.map +1 -1
  25. package/dist/core/builtinDbInvariants.js +97 -0
  26. package/dist/core/builtinDbInvariants.js.map +1 -1
  27. package/dist/core/index.d.ts +1 -1
  28. package/dist/core/index.d.ts.map +1 -1
  29. package/dist/core/index.js +7 -4
  30. package/dist/core/index.js.map +1 -1
  31. package/dist/core/types.d.ts +42 -0
  32. package/dist/core/types.d.ts.map +1 -1
  33. package/dist/node/server/CapabilityMatcher.d.ts +2 -2
  34. package/dist/node/server/CapabilityMatcher.js +2 -2
  35. package/dist/node/server/MindooDBServer.d.ts +14 -1
  36. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  37. package/dist/node/server/MindooDBServer.js +166 -159
  38. package/dist/node/server/MindooDBServer.js.map +1 -1
  39. package/dist/node/server/SyncEventBus.d.ts +10 -0
  40. package/dist/node/server/SyncEventBus.d.ts.map +1 -1
  41. package/dist/node/server/SyncEventBus.js.map +1 -1
  42. package/dist/node/server/TenantManager.d.ts +58 -6
  43. package/dist/node/server/TenantManager.d.ts.map +1 -1
  44. package/dist/node/server/TenantManager.js +90 -65
  45. package/dist/node/server/TenantManager.js.map +1 -1
  46. package/dist/node/server/add-to-network.d.ts.map +1 -1
  47. package/dist/node/server/add-to-network.js +33 -15
  48. package/dist/node/server/add-to-network.js.map +1 -1
  49. package/dist/node/server/canonicalSetupName.d.ts +22 -0
  50. package/dist/node/server/canonicalSetupName.d.ts.map +1 -0
  51. package/dist/node/server/canonicalSetupName.js +38 -0
  52. package/dist/node/server/canonicalSetupName.js.map +1 -0
  53. package/dist/node/server/config.d.ts.map +1 -1
  54. package/dist/node/server/config.js +33 -1
  55. package/dist/node/server/config.js.map +1 -1
  56. package/dist/node/server/peer/ClusterAuditLog.d.ts +64 -0
  57. package/dist/node/server/peer/ClusterAuditLog.d.ts.map +1 -0
  58. package/dist/node/server/peer/ClusterAuditLog.js +99 -0
  59. package/dist/node/server/peer/ClusterAuditLog.js.map +1 -0
  60. package/dist/node/server/peer/ClusterJobRunner.d.ts +43 -0
  61. package/dist/node/server/peer/ClusterJobRunner.d.ts.map +1 -0
  62. package/dist/node/server/peer/ClusterJobRunner.js +119 -0
  63. package/dist/node/server/peer/ClusterJobRunner.js.map +1 -0
  64. package/dist/node/server/peer/ClusterManager.d.ts +116 -0
  65. package/dist/node/server/peer/ClusterManager.d.ts.map +1 -0
  66. package/dist/node/server/peer/ClusterManager.js +274 -0
  67. package/dist/node/server/peer/ClusterManager.js.map +1 -0
  68. package/dist/node/server/peer/PeerAuthService.d.ts +69 -0
  69. package/dist/node/server/peer/PeerAuthService.d.ts.map +1 -0
  70. package/dist/node/server/peer/PeerAuthService.js +208 -0
  71. package/dist/node/server/peer/PeerAuthService.js.map +1 -0
  72. package/dist/node/server/peer/PeerReplicator.d.ts +232 -0
  73. package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -0
  74. package/dist/node/server/peer/PeerReplicator.js +879 -0
  75. package/dist/node/server/peer/PeerReplicator.js.map +1 -0
  76. package/dist/node/server/peer/routes.d.ts +66 -0
  77. package/dist/node/server/peer/routes.d.ts.map +1 -0
  78. package/dist/node/server/peer/routes.js +322 -0
  79. package/dist/node/server/peer/routes.js.map +1 -0
  80. package/dist/node/server/peer/types.d.ts +244 -0
  81. package/dist/node/server/peer/types.d.ts.map +1 -0
  82. package/dist/node/server/peer/types.js +189 -0
  83. package/dist/node/server/peer/types.js.map +1 -0
  84. package/dist/node/server/server.d.ts +1 -1
  85. package/dist/node/server/server.js +18 -37
  86. package/dist/node/server/server.js.map +1 -1
  87. package/dist/node/server/serverinit.d.ts +2 -1
  88. package/dist/node/server/serverinit.d.ts.map +1 -1
  89. package/dist/node/server/serverinit.js +34 -12
  90. package/dist/node/server/serverinit.js.map +1 -1
  91. package/dist/node/server/types.d.ts +42 -30
  92. package/dist/node/server/types.d.ts.map +1 -1
  93. package/dist/node/server/types.js +1 -7
  94. package/dist/node/server/types.js.map +1 -1
  95. package/dist/node/server/validation.d.ts +0 -6
  96. package/dist/node/server/validation.d.ts.map +1 -1
  97. package/dist/node/server/validation.js +1 -7
  98. package/dist/node/server/validation.js.map +1 -1
  99. package/package.json +1 -1
@@ -9,7 +9,7 @@ const storeEntryTime_1 = require("./storeEntryTime");
9
9
  const uuid_1 = require("uuid");
10
10
  const types_1 = require("./types");
11
11
  const types_2 = require("./appendonlystores/types");
12
- const bloom_1 = require("./appendonlystores/bloom");
12
+ const syncStores_1 = require("./appendonlystores/syncStores");
13
13
  const types_3 = require("./appendonlystores/network/types");
14
14
  const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
15
15
  const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
@@ -352,6 +352,35 @@ class BaseMindooDB {
352
352
  * from the cached materialization and seeds the pending map from them.
353
353
  */
354
354
  this.legacyCheckpointNeedsAuthorTrustScan = false;
355
+ /**
356
+ * Ids of documents that exist in the local store but cannot be read with
357
+ * the current KeyBag, so they are absent from the index and from every
358
+ * document listing.
359
+ *
360
+ * The index alone cannot answer "how much is hidden from me": a document
361
+ * that was never readable on this device gets no index entry at all (see
362
+ * {@link reconcileKeyVisibility}), and one that lost its key becomes an
363
+ * `"inaccessible"` tombstone that listings filter out. This set is the
364
+ * only place that remembers both cases, which is what
365
+ * {@link getInaccessibleDocumentCount} reports so a UI can tell an empty
366
+ * database apart from one whose keys have not arrived yet.
367
+ *
368
+ * Counts documents regardless of their deletion state - the question it
369
+ * answers is "is it here and unreadable", which is knowable without the
370
+ * key, unlike a reliable lifecycle state for a doc we cannot decrypt.
371
+ *
372
+ * Persisted in the metadata checkpoint: a warm start with an unchanged
373
+ * KeyBag skips the visibility scan, so an in-memory-only tally would
374
+ * silently reset to zero on every restart.
375
+ */
376
+ this.inaccessibleDocIds = new Set();
377
+ /**
378
+ * Set while restoring a metadata checkpoint that predates
379
+ * {@link inaccessibleDocIds}. Forces one visibility scan at open so the
380
+ * tally starts out complete instead of counting only what happens to
381
+ * arrive in this session.
382
+ */
383
+ this.legacyCheckpointNeedsVisibilityTally = false;
355
384
  // Local cache support
356
385
  this.cacheManager = null;
357
386
  this.cachePrefix = null;
@@ -462,6 +491,8 @@ class BaseMindooDB {
462
491
  this.userdirectoryHashByDocId = new Map();
463
492
  /** Grant `username_hash` for a signing public key at "now". */
464
493
  this.signerUsernameHashByKey = new Map();
494
+ /** Owning person per personal userdirectory document (its create signer). */
495
+ this.personalDocCreatorHashByDocId = new Map();
465
496
  // ---------------------------------------------------------------------------
466
497
  // Author-trust reconciliation: heal documents whose entries were skipped
467
498
  // because their author was not yet known to the tenant directory.
@@ -578,15 +609,22 @@ class BaseMindooDB {
578
609
  * Hard-wired write gate for `directory` / `userdirectory` (and other
579
610
  * admin-only databases). Independent of the ACL master switch.
580
611
  */
581
- async assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash) {
612
+ async assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash, docId) {
582
613
  const dbId = this.effectiveBuiltinDbId();
583
614
  if (!(0, builtinDbInvariants_1.hasBuiltinWriteInvariant)(dbId) && !this._isAdminOnlyDb) {
584
615
  return;
585
616
  }
586
617
  let signerUsernameHash = null;
618
+ let creatorUsernameHash = null;
587
619
  if (dbId === types_1.USER_DIRECTORY_DB_ID) {
588
620
  signerUsernameHash = await this.resolveSignerUsernameHash(signerPublicKey);
589
621
  this.signerUsernameHashByKey.set(signerPublicKey, signerUsernameHash);
622
+ if ((0, builtinDbInvariants_1.isPersonalUserdirectoryDocId)(docId)) {
623
+ creatorUsernameHash =
624
+ op === "doc_create"
625
+ ? signerUsernameHash
626
+ : await this.ensurePersonalDocCreatorHash(docId);
627
+ }
590
628
  }
591
629
  const decision = (0, builtinDbInvariants_1.evaluateBuiltinWrite)({
592
630
  dbId,
@@ -595,6 +633,8 @@ class BaseMindooDB {
595
633
  adminPublicKey: this.getAdminPublicKey(),
596
634
  documentUsernameHash,
597
635
  signerUsernameHash,
636
+ docId,
637
+ creatorUsernameHash,
598
638
  });
599
639
  if (!decision.allowed) {
600
640
  throw new Error(decision.reason);
@@ -611,7 +651,14 @@ class BaseMindooDB {
611
651
  const entryType = entry.entryType ?? "doc_change";
612
652
  let documentUsernameHash = null;
613
653
  let signerUsernameHash = null;
614
- if (dbId === types_1.USER_DIRECTORY_DB_ID &&
654
+ let creatorUsernameHash = null;
655
+ if (dbId === types_1.USER_DIRECTORY_DB_ID && (0, builtinDbInvariants_1.isPersonalUserdirectoryDocId)(entry.docId)) {
656
+ if (entryType !== "doc_create") {
657
+ creatorUsernameHash = await this.ensurePersonalDocCreatorHash(entry.docId);
658
+ signerUsernameHash = await this.ensureSignerUsernameHash(entry.createdByPublicKey);
659
+ }
660
+ }
661
+ else if (dbId === types_1.USER_DIRECTORY_DB_ID &&
615
662
  (entryType === "doc_change" || entryType === "doc_snapshot") &&
616
663
  entry.docId) {
617
664
  documentUsernameHash = await this.ensureUserdirectoryDocHash(entry.docId);
@@ -624,6 +671,8 @@ class BaseMindooDB {
624
671
  adminPublicKey: this.getAdminPublicKey(),
625
672
  documentUsernameHash,
626
673
  signerUsernameHash,
674
+ docId: entry.docId,
675
+ creatorUsernameHash,
627
676
  });
628
677
  }
629
678
  async ensureSignerUsernameHash(signerKey) {
@@ -634,6 +683,34 @@ class BaseMindooDB {
634
683
  this.signerUsernameHashByKey.set(signerKey, hash);
635
684
  return hash;
636
685
  }
686
+ /**
687
+ * Owner of a personal `userdirectory` document: the person who signed its
688
+ * `doc_create`, resolved through grants rather than the payload. A sealed
689
+ * document is opaque to everyone but its owner, so the payload is not an
690
+ * option — and grants map every device of a person to one hash, which is
691
+ * exactly the granularity ownership needs.
692
+ */
693
+ async ensurePersonalDocCreatorHash(docId) {
694
+ const cached = this.personalDocCreatorHashByDocId.get(docId);
695
+ if (cached !== undefined)
696
+ return cached;
697
+ let hash = null;
698
+ try {
699
+ const metas = await this.scanAllMetadata(this.store, { docId });
700
+ const create = metas.find((meta) => meta.entryType === "doc_create");
701
+ if (create) {
702
+ hash = await this.ensureSignerUsernameHash(create.createdByPublicKey);
703
+ }
704
+ }
705
+ catch {
706
+ hash = null;
707
+ }
708
+ // Only a resolved owner is worth caching: a miss usually means the create
709
+ // entry has not arrived yet, and the next write must ask again.
710
+ if (hash)
711
+ this.personalDocCreatorHashByDocId.set(docId, hash);
712
+ return hash;
713
+ }
637
714
  async ensureUserdirectoryDocHash(docId) {
638
715
  const cached = this.userdirectoryHashByDocId.get(docId);
639
716
  if (cached)
@@ -1472,6 +1549,11 @@ class BaseMindooDB {
1472
1549
  }
1473
1550
  }
1474
1551
  checkpoint.pendingUntrustedAuthors = pendingUntrusted;
1552
+ // Persist the unreadable-document tally (additive field). Written even
1553
+ // when empty, for the same reason as above: the field's PRESENCE marks
1554
+ // the checkpoint as tally-aware, so restore can tell "nothing hidden"
1555
+ // apart from "written before this existed, count unknown".
1556
+ checkpoint.inaccessibleDocIds = Array.from(this.inaccessibleDocIds);
1475
1557
  return new TextEncoder().encode(JSON.stringify(checkpoint));
1476
1558
  }
1477
1559
  /**
@@ -1518,6 +1600,22 @@ class BaseMindooDB {
1518
1600
  else {
1519
1601
  this.legacyCheckpointNeedsAuthorTrustScan = true;
1520
1602
  }
1603
+ // Unreadable-document tally. Missing field means the checkpoint predates
1604
+ // it: the count would otherwise stay at zero for as long as the KeyBag
1605
+ // is unchanged, because that is exactly when the visibility scan is
1606
+ // skipped. Force one scan instead.
1607
+ this.inaccessibleDocIds.clear();
1608
+ if (Array.isArray(checkpoint.inaccessibleDocIds)) {
1609
+ this.legacyCheckpointNeedsVisibilityTally = false;
1610
+ for (const docId of checkpoint.inaccessibleDocIds) {
1611
+ if (typeof docId === "string") {
1612
+ this.inaccessibleDocIds.add(docId);
1613
+ }
1614
+ }
1615
+ }
1616
+ else {
1617
+ this.legacyCheckpointNeedsVisibilityTally = true;
1618
+ }
1521
1619
  this.syncScanCursors.clear();
1522
1620
  if (checkpoint.syncScanCursors && typeof checkpoint.syncScanCursors === "object") {
1523
1621
  for (const [key, value] of Object.entries(checkpoint.syncScanCursors)) {
@@ -1871,6 +1969,17 @@ class BaseMindooDB {
1871
1969
  // Every path that writes entries for a document lands here, so this is
1872
1970
  // where retained metadata for it goes stale (see scannedEntryMetadata).
1873
1971
  this.forgetScannedEntryMetadata(docId);
1972
+ // Same reasoning for the unreadable-document tally: every reveal and every
1973
+ // inaccessibility tombstone passes through here, so keeping it in step
1974
+ // needs no bookkeeping at the individual call sites. The one case this
1975
+ // misses is a document that was never readable, which produces no index
1976
+ // entry at all - those call `noteInaccessibleDocument` directly.
1977
+ if (accessState === "visible") {
1978
+ this.forgetInaccessibleDocument(docId);
1979
+ }
1980
+ else {
1981
+ this.noteInaccessibleDocument(docId);
1982
+ }
1874
1983
  const assignedSeq = this.nextChangeSeq;
1875
1984
  const newEntry = {
1876
1985
  docId,
@@ -2138,7 +2247,8 @@ class BaseMindooDB {
2138
2247
  // transition.
2139
2248
  const currentFingerprint = await this.computeCurrentKeyBagFingerprint();
2140
2249
  const fingerprintMatches = currentFingerprint !== null && currentFingerprint === this.lastReconciledKeyBagFingerprint;
2141
- if (!fingerprintMatches) {
2250
+ if (!fingerprintMatches || this.legacyCheckpointNeedsVisibilityTally) {
2251
+ this.legacyCheckpointNeedsVisibilityTally = false;
2142
2252
  await this.reconcileKeyVisibility();
2143
2253
  }
2144
2254
  this.markKeyBagFingerprintReconciled(currentFingerprint);
@@ -2257,6 +2367,73 @@ class BaseMindooDB {
2257
2367
  this.cacheMetaDirty = true;
2258
2368
  this.cacheManager?.markDirty();
2259
2369
  }
2370
+ /**
2371
+ * Remember that `docId` is present in the store but unreadable. Idempotent:
2372
+ * the same document is re-observed on every sync batch that carries another
2373
+ * entry for it.
2374
+ */
2375
+ noteInaccessibleDocument(docId) {
2376
+ if (this.inaccessibleDocIds.has(docId)) {
2377
+ return;
2378
+ }
2379
+ this.inaccessibleDocIds.add(docId);
2380
+ this.cacheMetaDirty = true;
2381
+ this.cacheManager?.markDirty();
2382
+ }
2383
+ /** Counterpart of {@link noteInaccessibleDocument} for a doc that became readable. */
2384
+ forgetInaccessibleDocument(docId) {
2385
+ if (!this.inaccessibleDocIds.delete(docId)) {
2386
+ return;
2387
+ }
2388
+ this.cacheMetaDirty = true;
2389
+ this.cacheManager?.markDirty();
2390
+ }
2391
+ /**
2392
+ * How many documents this database holds locally that the current KeyBag
2393
+ * cannot open. They appear in no listing and in no view, so this count is
2394
+ * the only signal that a database looks emptier than it is - typically on a
2395
+ * device whose user key is still waiting for approval.
2396
+ *
2397
+ * Includes deleted documents, and counts what has arrived on this device -
2398
+ * not what the tenant holds.
2399
+ */
2400
+ getInaccessibleDocumentCount() {
2401
+ return this.inaccessibleDocIds.size;
2402
+ }
2403
+ /**
2404
+ * Documents in the local store that this KeyBag cannot open, described
2405
+ * from unsigned origin metadata. See {@link MindooDB.listInaccessibleDocuments}.
2406
+ */
2407
+ async listInaccessibleDocuments(options) {
2408
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
2409
+ const docIds = [...this.inaccessibleDocIds].filter((docId) => !idPrefix || (0, idGeneration_1.matchesDocIdPrefix)(docId, idPrefix));
2410
+ if (docIds.length === 0) {
2411
+ return [];
2412
+ }
2413
+ const results = [];
2414
+ for (const docId of docIds) {
2415
+ const originEntries = await this.scanAllMetadata(this.store, {
2416
+ docId,
2417
+ entryTypes: ["doc_create", "doc_snapshot"],
2418
+ });
2419
+ let origin;
2420
+ for (const entry of originEntries) {
2421
+ if (!origin || entry.createdAt < origin.createdAt) {
2422
+ origin = entry;
2423
+ }
2424
+ }
2425
+ const indexPos = this.getDocIndexPosition(docId);
2426
+ const indexEntry = indexPos === undefined ? undefined : this.index[indexPos];
2427
+ results.push({
2428
+ docId,
2429
+ createdAt: origin?.createdAt ?? indexEntry?.lastModified ?? 0,
2430
+ decryptionKeyId: origin?.decryptionKeyId ?? indexEntry?.decryptionKeyId ?? "",
2431
+ createdByPublicKey: origin?.createdByPublicKey ?? "",
2432
+ });
2433
+ }
2434
+ results.sort((left, right) => left.docId.localeCompare(right.docId));
2435
+ return results;
2436
+ }
2260
2437
  /**
2261
2438
  * Derive the per-doc visibility summary needed to reconcile the index
2262
2439
  * against the current KeyBag state.
@@ -2392,6 +2569,9 @@ class BaseMindooDB {
2392
2569
  await this.tenant.ingestSealedFromEntries?.(visibility.decryptionKeyId, metadataByDoc.get(docId));
2393
2570
  const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
2394
2571
  if (canRead) {
2572
+ // This scan is the authority on visibility, so it also heals a tally
2573
+ // that a visible->visible short-circuit below would leave untouched.
2574
+ this.forgetInaccessibleDocument(docId);
2395
2575
  // Reveal-on-add only triggers when the previous state was
2396
2576
  // missing or inaccessible; visible->visible transitions stay
2397
2577
  // idempotent via the existing `updateIndex` short-circuit.
@@ -2411,8 +2591,11 @@ class BaseMindooDB {
2411
2591
  changed = true;
2412
2592
  }
2413
2593
  else {
2414
- // Already inaccessible (or never seen). Scrub defensively in
2415
- // case a previous session left an L2 record behind.
2594
+ // Already inaccessible (or never seen, which is the common case on a
2595
+ // device whose keys have not arrived). No index entry is written for
2596
+ // it, so the tally is the only record that it is here at all.
2597
+ this.noteInaccessibleDocument(docId);
2598
+ // Scrub defensively in case a previous session left an L2 record behind.
2416
2599
  await this.purgeMaterializedDocument(docId);
2417
2600
  }
2418
2601
  }
@@ -2786,6 +2969,10 @@ class BaseMindooDB {
2786
2969
  const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
2787
2970
  if (!keyAvailable) {
2788
2971
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
2972
+ // This is where a device that is still waiting for its keys meets
2973
+ // most of the tenant: the doc is skipped before it ever reaches
2974
+ // the index, so record it for `getInaccessibleDocumentCount`.
2975
+ this.noteInaccessibleDocument(docId);
2789
2976
  const existingIndex = this.getDocIndexPosition(docId);
2790
2977
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
2791
2978
  if (existing?.accessState === "visible") {
@@ -2840,6 +3027,7 @@ class BaseMindooDB {
2840
3027
  // doc.
2841
3028
  if (error instanceof errors_1.SymmetricKeyNotFoundError) {
2842
3029
  this.logger.debug(`Skipping document ${docId} - missing key: ${error.keyId}`);
3030
+ this.noteInaccessibleDocument(docId);
2843
3031
  // Avoid a per-doc scanAllMetadata round-trip here: everything
2844
3032
  // we need is already in this batch's entryMetadataList plus the
2845
3033
  // existing index entry (if any). We do NOT need a second pass
@@ -3150,54 +3338,8 @@ class BaseMindooDB {
3150
3338
  nextCursor: cursor,
3151
3339
  };
3152
3340
  }
3153
- /**
3154
- * Fetch a store's bloom filter summary, returning null if unsupported or on
3155
- * error. Used on the target for missing-id pre-screening (callers fall back
3156
- * to exact checks) and on the source for the total-entry-count progress
3157
- * denominator.
3158
- */
3159
- async getStoreBloomSummary(store) {
3160
- if (typeof store.getIdBloomSummary !== "function") {
3161
- return null;
3162
- }
3163
- try {
3164
- return await store.getIdBloomSummary();
3165
- }
3166
- catch (error) {
3167
- this.logger.warn("Failed to get bloom summary from store, falling back to exact checks", error);
3168
- return null;
3169
- }
3170
- }
3171
- static setSyncAbortSignalOnStore(store, signal) {
3172
- if ('setSyncAbortSignal' in store && typeof store.setSyncAbortSignal === 'function') {
3173
- store.setSyncAbortSignal(signal);
3174
- }
3175
- }
3176
- /**
3177
- * Fetch a store's head descriptor (`{ epoch, maxReceiptOrder }`), returning
3178
- * null when the store (or the remote server behind it) does not support it
3179
- * or the request fails. A null head simply disables the persisted-cursor
3180
- * fast path for this sync.
3181
- */
3182
- async getStoreHeadSafe(store) {
3183
- if (typeof store.getStoreHead !== "function") {
3184
- return null;
3185
- }
3186
- try {
3187
- return await store.getStoreHead();
3188
- }
3189
- catch (error) {
3190
- this.logger.debug("Store head unavailable, falling back to full scan", error);
3191
- return null;
3192
- }
3193
- }
3194
- /** Stable identity for a store in the persisted sync-cursor map. */
3195
- static syncStoreIdentity(store) {
3196
- return store.getCacheIdentity?.() ?? `${store.getId()}/${store.getStoreKind()}`;
3197
- }
3198
- /** Key for {@link syncScanCursors}: one record per (source, target) pair. */
3199
- syncScanCursorKey(sourceStore, targetStore) {
3200
- return `${BaseMindooDB.syncStoreIdentity(sourceStore)}->${BaseMindooDB.syncStoreIdentity(targetStore)}`;
3341
+ getStoreBloomSummary(store) {
3342
+ return (0, syncStores_1.getStoreBloomSummary)(store, this.logger);
3201
3343
  }
3202
3344
  /** Persist a sync scan cursor and schedule a checkpoint flush. */
3203
3345
  saveSyncScanCursor(key, record) {
@@ -3213,171 +3355,8 @@ class BaseMindooDB {
3213
3355
  this.cacheMetaDirty = true;
3214
3356
  this.cacheManager?.markDirty();
3215
3357
  }
3216
- /**
3217
- * From a list of candidate IDs, return only those the target store is
3218
- * missing. Uses bloom-filter pre-screening when available, then falls
3219
- * back to exact `hasEntries` for the uncertain set.
3220
- */
3221
- async filterMissingIds(targetStore, candidateIds, bloom) {
3222
- let definitelyMissing = [];
3223
- let maybeExisting = candidateIds;
3224
- if (bloom) {
3225
- definitelyMissing = [];
3226
- maybeExisting = [];
3227
- for (const id of candidateIds) {
3228
- if ((0, bloom_1.bloomMightContainId)(bloom, id)) {
3229
- maybeExisting.push(id);
3230
- }
3231
- else {
3232
- definitelyMissing.push(id);
3233
- }
3234
- }
3235
- }
3236
- let missingIds = definitelyMissing;
3237
- if (maybeExisting.length > 0) {
3238
- const existing = await targetStore.hasEntries(maybeExisting);
3239
- const existingSet = new Set(existing);
3240
- missingIds = missingIds.concat(maybeExisting.filter((id) => !existingSet.has(id)));
3241
- }
3242
- return missingIds;
3243
- }
3244
- resolveTransferBatchSize(options) {
3245
- if (options?.transferBatchSize && options.transferBatchSize > 0) {
3246
- return options.transferBatchSize;
3247
- }
3248
- if (options?.storeKind === types_2.StoreKind.attachments) {
3249
- return 100;
3250
- }
3251
- return Math.min(options?.pageSize ?? 1000, BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE);
3252
- }
3253
- /**
3254
- * Transfer a set of entry IDs from source to target in fixed-size batches,
3255
- * emitting progress and checking for cancellation between each batch.
3256
- *
3257
- * Callers (cursor-scan path and legacy path) collect the IDs that need
3258
- * transferring, then delegate to this method instead of issuing one
3259
- * monolithic `getEntries`. This gives three benefits:
3260
- *
3261
- * 1. The UI receives frequent progress updates with batch metadata so long
3262
- * transfers no longer look frozen.
3263
- * 2. Cancellation can interrupt work between batches rather than waiting
3264
- * for one large HTTP response to complete.
3265
- * 3. Each server-side `getEntries` + RSA encryption unit is smaller,
3266
- * reducing the risk of socket timeouts on large payloads.
3267
- *
3268
- * Returns partial progress on cancellation so callers can report how much
3269
- * was actually transferred before the abort.
3270
- */
3271
- async transferEntriesInBatches(sourceStore, targetStore, entryIds, options, state) {
3272
- if (entryIds.length === 0) {
3273
- return { transferred: state.transferred, transferredBytes: state.transferredBytes, cancelled: false, rejected: [] };
3274
- }
3275
- const onProgress = options?.onProgress;
3276
- const signal = options?.signal;
3277
- const transferBatchSize = this.resolveTransferBatchSize(options);
3278
- const totalTransferBatches = Math.max(1, Math.ceil(entryIds.length / transferBatchSize));
3279
- // Parallel transfer window (sync-v5, phase 4): each batch is one
3280
- // getEntries + putEntries round trip; a small window of concurrent
3281
- // batches overlaps network latency and server-side crypto. Witness
3282
- // receipts are applied per batch and are order-independent.
3283
- const maxConcurrent = Math.min(Math.max(1, Math.floor(options?.maxConcurrentBatches ?? BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES)), totalTransferBatches);
3284
- let transferred = state.transferred;
3285
- let transferredBytes = state.transferredBytes;
3286
- let cancelled = false;
3287
- let firstError = null;
3288
- let nextBatchIndex = 0;
3289
- // Per-entry rejections reported by a witnessing target (sync-v5): the
3290
- // push continues, the rejected entries are surfaced to the caller.
3291
- const rejected = [];
3292
- const runWorker = async () => {
3293
- while (true) {
3294
- if (firstError || cancelled || signal?.aborted) {
3295
- if (signal?.aborted)
3296
- cancelled = true;
3297
- return;
3298
- }
3299
- const batchIndex = nextBatchIndex++;
3300
- const offset = batchIndex * transferBatchSize;
3301
- if (offset >= entryIds.length) {
3302
- return;
3303
- }
3304
- const currentTransferBatch = batchIndex + 1;
3305
- const batchIds = entryIds.slice(offset, offset + transferBatchSize);
3306
- const pageSummary = state.currentPage ? `page ${state.currentPage}, ` : "";
3307
- onProgress?.({
3308
- phase: "transferring",
3309
- message: `Transferring batch ${currentTransferBatch}/${totalTransferBatches} (${batchIds.length} entries, ${pageSummary}scanned ${state.scanned})...`,
3310
- transferredEntries: transferred,
3311
- transferredBytes,
3312
- scannedEntries: state.scanned,
3313
- totalSourceEntries: state.totalSourceEntries,
3314
- currentPage: state.currentPage,
3315
- currentTransferBatch,
3316
- totalTransferBatches,
3317
- transferBatchSize,
3318
- });
3319
- try {
3320
- const batchEntries = await sourceStore.getEntries(batchIds);
3321
- if (signal?.aborted) {
3322
- cancelled = true;
3323
- return;
3324
- }
3325
- const putResult = await targetStore.putEntries(batchEntries);
3326
- // On push, the remote (witnessing) target returns receipts for accepted
3327
- // entries; persist them back onto the local source so the revision feed
3328
- // re-anchors them from the provisional head to their committed
3329
- // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
3330
- // sync-v5 targets return a structured ack that additionally carries
3331
- // per-entry rejections (signature-class failures the remote skipped).
3332
- const { receipts, batchRejected } = BaseMindooDB.normalizePutResult(putResult);
3333
- if (receipts.length > 0 && sourceStore.applyWitnessReceipts) {
3334
- await sourceStore.applyWitnessReceipts(receipts);
3335
- }
3336
- const rejectedIds = batchRejected.length > 0 ? new Set(batchRejected.map((entry) => entry.id)) : null;
3337
- if (rejectedIds) {
3338
- rejected.push(...batchRejected);
3339
- this.logger.warn(`Target rejected ${batchRejected.length} of ${batchEntries.length} entries in transfer batch ${currentTransferBatch}/${totalTransferBatches}`);
3340
- }
3341
- for (const entry of batchEntries) {
3342
- if (rejectedIds?.has(entry.id)) {
3343
- continue;
3344
- }
3345
- transferredBytes += entry.encryptedSize ?? entry.encryptedData.length;
3346
- }
3347
- transferred += batchEntries.length - batchRejected.length;
3348
- }
3349
- catch (error) {
3350
- if (signal?.aborted) {
3351
- cancelled = true;
3352
- return;
3353
- }
3354
- if (!firstError) {
3355
- firstError = error;
3356
- }
3357
- return;
3358
- }
3359
- onProgress?.({
3360
- phase: "transferring",
3361
- message: `Transferred ${transferred} entries after batch ${currentTransferBatch}/${totalTransferBatches}`,
3362
- transferredEntries: transferred,
3363
- transferredBytes,
3364
- scannedEntries: state.scanned,
3365
- totalSourceEntries: state.totalSourceEntries,
3366
- currentPage: state.currentPage,
3367
- currentTransferBatch,
3368
- totalTransferBatches,
3369
- transferBatchSize,
3370
- });
3371
- }
3372
- };
3373
- await Promise.all(Array.from({ length: maxConcurrent }, () => runWorker()));
3374
- if (signal?.aborted || cancelled) {
3375
- return { transferred, transferredBytes, cancelled: true, rejected };
3376
- }
3377
- if (firstError) {
3378
- throw firstError;
3379
- }
3380
- return { transferred, transferredBytes, cancelled: false, rejected };
3358
+ filterMissingIds(targetStore, candidateIds, bloom) {
3359
+ return (0, syncStores_1.filterMissingIds)(targetStore, candidateIds, bloom);
3381
3360
  }
3382
3361
  /**
3383
3362
  * Normalize the polymorphic putEntries result (`void`, receipts array, or
@@ -3395,15 +3374,39 @@ class BaseMindooDB {
3395
3374
  }
3396
3375
  return { receipts: [], batchRejected: [] };
3397
3376
  }
3377
+ /**
3378
+ * Expose this database's persisted {@link syncScanCursors} map to the shared
3379
+ * store-transfer helper. The map lives in the metadata checkpoint, so saving
3380
+ * a cursor also marks the cache dirty.
3381
+ */
3382
+ syncCursorStore() {
3383
+ return {
3384
+ get: (key) => this.syncScanCursors.get(key) ?? null,
3385
+ save: (key, record) => this.saveSyncScanCursor(key, record),
3386
+ delete: (key) => {
3387
+ this.syncScanCursors.delete(key);
3388
+ },
3389
+ };
3390
+ }
3398
3391
  async syncEntriesFromStore(sourceStore, targetStore, options) {
3399
3392
  const signal = options?.signal;
3400
- BaseMindooDB.setSyncAbortSignalOnStore(sourceStore, signal);
3401
- BaseMindooDB.setSyncAbortSignalOnStore(targetStore, signal);
3393
+ (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, signal);
3394
+ (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, signal);
3402
3395
  try {
3403
3396
  if (options?.mode === "dense") {
3404
3397
  return await this.syncEntriesFromStoreDense(sourceStore, targetStore, options);
3405
3398
  }
3406
- return await this.syncEntriesFromStoreImpl(sourceStore, targetStore, options);
3399
+ // Client rejection semantics: the scan cursor advances past a rejected
3400
+ // entry so a single corrupt or forged entry cannot block this database's
3401
+ // push sync forever (docs/network-sync-protocol.md §5.7.6). The
3402
+ // server-to-server replicator passes `"hold"` instead, because there a
3403
+ // rejection usually means "the peer has not replicated the directory
3404
+ // yet" and skipping would drop the entry permanently.
3405
+ return await (0, syncStores_1.syncEntriesBetweenStores)(sourceStore, targetStore, options, {
3406
+ logger: this.logger,
3407
+ cursors: this.syncCursorStore(),
3408
+ rejectionPolicy: "advance",
3409
+ });
3407
3410
  }
3408
3411
  catch (error) {
3409
3412
  if (signal?.aborted) {
@@ -3413,258 +3416,10 @@ class BaseMindooDB {
3413
3416
  throw error;
3414
3417
  }
3415
3418
  finally {
3416
- BaseMindooDB.setSyncAbortSignalOnStore(sourceStore, undefined);
3417
- BaseMindooDB.setSyncAbortSignalOnStore(targetStore, undefined);
3419
+ (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, undefined);
3420
+ (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, undefined);
3418
3421
  }
3419
3422
  }
3420
- async syncEntriesFromStoreImpl(sourceStore, targetStore, options) {
3421
- let transferred = 0;
3422
- let transferredBytes = 0;
3423
- let scanned = 0;
3424
- // Per-entry rejections reported by a witnessing target (sync-v5): the
3425
- // sync completes; rejected entries are surfaced to the caller as warnings.
3426
- const rejected = [];
3427
- const onProgress = options?.onProgress;
3428
- const pageSize = options?.pageSize ?? 1000;
3429
- const signal = options?.signal;
3430
- if (this.supportsCursorScan(sourceStore)) {
3431
- // Persisted-cursor fast path (sync-v5, phase 1): resume the metadata
3432
- // scan where the previous sync between this (source, target) pair left
3433
- // off — or skip the sync entirely when the source head shows nothing
3434
- // new. The heads are fetched up front (cheap; one request per side for
3435
- // network stores) and also anchor the epochs persisted with the cursor.
3436
- const cursorKey = this.syncScanCursorKey(sourceStore, targetStore);
3437
- const persisted = options?.forceFullScan
3438
- ? null
3439
- : this.syncScanCursors.get(cursorKey) ?? null;
3440
- const [sourceHead, targetHead] = await Promise.all([
3441
- this.getStoreHeadSafe(sourceStore),
3442
- this.getStoreHeadSafe(targetStore),
3443
- ]);
3444
- let cursor = null;
3445
- if (persisted && sourceHead && targetHead) {
3446
- if (persisted.sourceEpoch === sourceHead.epoch &&
3447
- persisted.targetEpoch === targetHead.epoch) {
3448
- if (sourceHead.maxReceiptOrder <= persisted.cursor.receiptOrder) {
3449
- this.logger.debug(`Sync skip: source head ${sourceHead.maxReceiptOrder} already covered by persisted cursor (${cursorKey})`);
3450
- onProgress?.({
3451
- phase: 'preparing',
3452
- message: 'Source unchanged since last sync, nothing to scan',
3453
- transferredEntries: 0,
3454
- scannedEntries: 0,
3455
- });
3456
- return { transferred: 0, scanned: 0, cancelled: false };
3457
- }
3458
- cursor = persisted.cursor;
3459
- this.logger.debug(`Resuming sync scan from persisted cursor receiptOrder=${cursor.receiptOrder} (${cursorKey})`);
3460
- }
3461
- else {
3462
- // Epoch change on either side: the cursor lineage is broken
3463
- // (store reset / receipt-order migration) — full rescan.
3464
- this.logger.info(`Sync cursor epoch changed for ${cursorKey}, discarding persisted cursor and re-scanning`);
3465
- this.syncScanCursors.delete(cursorKey);
3466
- }
3467
- }
3468
- const persistScanCursor = (finalCursor) => {
3469
- if (finalCursor && sourceHead && targetHead) {
3470
- this.saveSyncScanCursor(cursorKey, {
3471
- sourceEpoch: sourceHead.epoch,
3472
- targetEpoch: targetHead.epoch,
3473
- cursor: finalCursor,
3474
- });
3475
- }
3476
- };
3477
- const targetBloom = await this.getStoreBloomSummary(targetStore);
3478
- // Fixed progress denominator: the SOURCE's total entry count. The cursor
3479
- // scan below examines every source entry, so `scannedEntries/totalSourceEntries`
3480
- // is a real completion ratio. (Local stores serve this from a cached bloom
3481
- // summary; network stores answer with one extra request.) The target bloom
3482
- // stays dedicated to missing-id pre-screening.
3483
- const sourceBloom = await this.getStoreBloomSummary(sourceStore);
3484
- const totalSourceEstimate = sourceBloom?.totalIds;
3485
- onProgress?.({
3486
- phase: 'preparing',
3487
- message: 'Preparing to sync entries...',
3488
- transferredEntries: 0,
3489
- scannedEntries: 0,
3490
- totalSourceEntries: totalSourceEstimate,
3491
- });
3492
- // Upper scan bound: the source head captured BEFORE the scan started.
3493
- // Pushing to a witnessing target re-anchors the just-transferred
3494
- // entries to a fresh receiptOrder on the source (applyWitnessReceipts),
3495
- // i.e. past the running cursor — without this bound the scan would
3496
- // re-discover its own transfers and never terminate (first-sync loop).
3497
- // Entries beyond the bound (re-anchored or written concurrently) are
3498
- // picked up by the next sync.
3499
- const scanUpperBound = sourceHead?.maxReceiptOrder;
3500
- // IDs already transferred in this sync session. Guards against
3501
- // re-pushing entries the scan re-discovers when the target bloom
3502
- // snapshot is stale (fetched once up front; empty on a first sync, so
3503
- // it would classify every re-discovered id as "definitely missing"
3504
- // and skip the exact hasEntries check).
3505
- const transferredThisSync = new Set();
3506
- let currentPage = 0;
3507
- // Scan-page pipelining (sync-v5, phase 4): while page N is being
3508
- // filtered and transferred, page N+1 is already being fetched.
3509
- let nextPagePromise = null;
3510
- const discardPrefetch = () => {
3511
- // Swallow errors of an in-flight prefetch we will never consume
3512
- // (abort/cancel paths) to avoid unhandled rejections.
3513
- nextPagePromise?.catch(() => { });
3514
- nextPagePromise = null;
3515
- };
3516
- while (true) {
3517
- if (signal?.aborted) {
3518
- discardPrefetch();
3519
- persistScanCursor(cursor);
3520
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3521
- }
3522
- const page = nextPagePromise
3523
- ? await nextPagePromise
3524
- : await sourceStore.scanEntriesSince(cursor, pageSize);
3525
- nextPagePromise = page.hasMore
3526
- ? sourceStore.scanEntriesSince(page.nextCursor, pageSize)
3527
- : null;
3528
- currentPage++;
3529
- // Clamp the page to the scan bound. Pages are ordered by
3530
- // (receiptOrder, id), so everything past the first out-of-bound
3531
- // entry is out of bound as well.
3532
- let pageEntries = page.entries;
3533
- let reachedScanBound = false;
3534
- if (scanUpperBound !== undefined && pageEntries.length > 0) {
3535
- const inBound = pageEntries.filter((m) => (m.receiptOrder ?? 0) <= scanUpperBound);
3536
- if (inBound.length < pageEntries.length) {
3537
- reachedScanBound = true;
3538
- pageEntries = inBound;
3539
- }
3540
- }
3541
- scanned += pageEntries.length;
3542
- if (signal?.aborted) {
3543
- discardPrefetch();
3544
- persistScanCursor(cursor);
3545
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3546
- }
3547
- if (pageEntries.length > 0) {
3548
- onProgress?.({
3549
- phase: 'transferring',
3550
- message: `Scanned ${scanned} entries, checking for changes (page ${currentPage})...`,
3551
- transferredEntries: transferred,
3552
- scannedEntries: scanned,
3553
- totalSourceEntries: totalSourceEstimate,
3554
- currentPage,
3555
- });
3556
- const ids = pageEntries
3557
- .map((m) => m.id)
3558
- .filter((id) => !transferredThisSync.has(id));
3559
- const missingIds = ids.length > 0
3560
- ? await this.filterMissingIds(targetStore, ids, targetBloom)
3561
- : [];
3562
- if (signal?.aborted) {
3563
- discardPrefetch();
3564
- persistScanCursor(cursor);
3565
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3566
- }
3567
- if (missingIds.length > 0) {
3568
- const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, missingIds, options, {
3569
- transferred,
3570
- transferredBytes,
3571
- scanned,
3572
- totalSourceEntries: totalSourceEstimate,
3573
- currentPage,
3574
- });
3575
- transferred = transferResult.transferred;
3576
- transferredBytes = transferResult.transferredBytes;
3577
- rejected.push(...transferResult.rejected);
3578
- if (transferResult.cancelled) {
3579
- // The current page's transfer did not complete: persist the
3580
- // boundary of the last fully transferred page instead.
3581
- discardPrefetch();
3582
- persistScanCursor(cursor);
3583
- return { transferred, transferredBytes, scanned, cancelled: true, rejected };
3584
- }
3585
- for (const id of missingIds) {
3586
- transferredThisSync.add(id);
3587
- }
3588
- }
3589
- }
3590
- onProgress?.({
3591
- phase: 'transferring',
3592
- message: `Transferred ${transferred} entries (page ${currentPage}, scanned ${scanned})`,
3593
- transferredEntries: transferred,
3594
- transferredBytes,
3595
- scannedEntries: scanned,
3596
- totalSourceEntries: totalSourceEstimate,
3597
- currentPage,
3598
- });
3599
- if (reachedScanBound) {
3600
- // Persist the last in-bound position (NOT page.nextCursor, which
3601
- // already points into the out-of-bound tail): a concurrent write
3602
- // that landed between the bound and the re-anchored tail must be
3603
- // scanned by the next sync.
3604
- const lastInBound = pageEntries.length > 0 ? pageEntries[pageEntries.length - 1] : null;
3605
- if (lastInBound) {
3606
- cursor = { receiptOrder: lastInBound.receiptOrder ?? 0, id: lastInBound.id };
3607
- }
3608
- discardPrefetch();
3609
- break;
3610
- }
3611
- cursor = page.nextCursor;
3612
- if (!page.hasMore) {
3613
- break;
3614
- }
3615
- }
3616
- persistScanCursor(cursor);
3617
- return { transferred, transferredBytes, scanned, cancelled: false, rejected };
3618
- }
3619
- onProgress?.({
3620
- phase: 'preparing',
3621
- message: 'Finding new entries...',
3622
- transferredEntries: 0,
3623
- scannedEntries: 0,
3624
- });
3625
- const targetIds = await targetStore.getAllIds();
3626
- const sourceNewMetadata = await sourceStore.findNewEntries(targetIds);
3627
- if (sourceNewMetadata.length === 0) {
3628
- return { transferred: 0, scanned: 0, cancelled: false };
3629
- }
3630
- if (options?.signal?.aborted) {
3631
- return { transferred: 0, scanned: 0, cancelled: true };
3632
- }
3633
- onProgress?.({
3634
- phase: 'transferring',
3635
- message: `Transferring ${sourceNewMetadata.length} entries...`,
3636
- transferredEntries: transferred,
3637
- scannedEntries: sourceNewMetadata.length,
3638
- totalSourceEntries: sourceNewMetadata.length,
3639
- });
3640
- const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, sourceNewMetadata.map((m) => m.id), options, {
3641
- transferred,
3642
- transferredBytes,
3643
- scanned: sourceNewMetadata.length,
3644
- totalSourceEntries: sourceNewMetadata.length,
3645
- });
3646
- transferred = transferResult.transferred;
3647
- transferredBytes = transferResult.transferredBytes;
3648
- rejected.push(...transferResult.rejected);
3649
- if (transferResult.cancelled) {
3650
- return { transferred, transferredBytes, scanned: sourceNewMetadata.length, cancelled: true, rejected };
3651
- }
3652
- onProgress?.({
3653
- phase: 'transferring',
3654
- message: `Transferred ${transferred} entries`,
3655
- transferredEntries: transferred,
3656
- transferredBytes,
3657
- scannedEntries: sourceNewMetadata.length,
3658
- totalSourceEntries: sourceNewMetadata.length,
3659
- });
3660
- return {
3661
- transferred,
3662
- transferredBytes,
3663
- scanned: sourceNewMetadata.length,
3664
- cancelled: false,
3665
- rejected,
3666
- };
3667
- }
3668
3423
  /**
3669
3424
  * Dense sync: transfer only the entries required to reconstruct the latest
3670
3425
  * state of each document, using the batch materialization planner to skip
@@ -4082,8 +3837,10 @@ class BaseMindooDB {
4082
3837
  }
4083
3838
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4084
3839
  const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4085
- await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash);
3840
+ // The id is settled before the write gate because it decides which rule
3841
+ // applies in `userdirectory`: a personal document is recognized by its id.
4086
3842
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3843
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash, docId);
4087
3844
  // Idempotent create: when a caller-provided id already exists locally,
4088
3845
  // return the existing document instead of producing a duplicate doc_create.
4089
3846
  if (useCustomDocId) {
@@ -4318,8 +4075,8 @@ class BaseMindooDB {
4318
4075
  : [];
4319
4076
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4320
4077
  const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4321
- await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash);
4322
4078
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
4079
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash, docId);
4323
4080
  const now = (0, timeSource_1.semanticNow)();
4324
4081
  const keyId = await this.resolveCreateKeyId(options, docId);
4325
4082
  const sealedCreate = await this.prepareSealedCreate(options, docId, keyId, now, signerPublicKey);
@@ -4631,9 +4388,10 @@ class BaseMindooDB {
4631
4388
  }
4632
4389
  /**
4633
4390
  * Unwrap a `$sealed:` DEK from entry recipient wraps into the tenant session
4634
- * cache. `loadDocumentInternal` already does this; history / timestamp reads
4635
- * must as well — otherwise Haven's revision list throws SymmetricKeyNotFoundError
4636
- * on a cold tenant even when this device is in the wrap list.
4391
+ * cache. `loadDocumentInternal` already does this; history, timestamp, and
4392
+ * DAG reads must as well — otherwise Haven's revision list and DAG explorer
4393
+ * throw SymmetricKeyNotFoundError on a cold tenant even when this device is
4394
+ * in the wrap list.
4637
4395
  */
4638
4396
  async ingestSealedKeyFromEntries(entries) {
4639
4397
  if (entries.length === 0)
@@ -5298,6 +5056,9 @@ class BaseMindooDB {
5298
5056
  const startedAt = Date.now();
5299
5057
  const resolvedTimestamp = this.resolveDagTimestamp(timestamp);
5300
5058
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
5059
+ if (allEntryMetadata.length > 0) {
5060
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
5061
+ }
5301
5062
  const relevantEntries = allEntryMetadata
5302
5063
  .filter((entry) => entry.createdAt <= resolvedTimestamp && (0, DocumentDagAnalysis_1.isDagEntry)(entry));
5303
5064
  const result = (0, DocumentDagAnalysis_1.computeDocumentDagAnalysis)(docId, relevantEntries, resolvedTimestamp);
@@ -5496,6 +5257,9 @@ class BaseMindooDB {
5496
5257
  let decodedChange = null;
5497
5258
  if (metadata.entryType !== "doc_snapshot") {
5498
5259
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
5260
+ if (allEntryMetadata.length > 0) {
5261
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
5262
+ }
5499
5263
  const entries = await this.store.getEntries([entryId]);
5500
5264
  const entry = entries[0];
5501
5265
  if (entry) {
@@ -6186,6 +5950,9 @@ class BaseMindooDB {
6186
5950
  * decoded Automerge hash/dependency metadata for private replay.
6187
5951
  */
6188
5952
  async loadVerifiedReplayChanges(replayEntries) {
5953
+ if (replayEntries.length > 0) {
5954
+ await this.ingestSealedKeyFromEntries(replayEntries);
5955
+ }
6189
5956
  const loadedEntries = replayEntries.length > 0
6190
5957
  ? await this.store.getEntries(replayEntries.map((entry) => entry.id))
6191
5958
  : [];
@@ -6747,7 +6514,17 @@ class BaseMindooDB {
6747
6514
  if (docIds.length === 0) {
6748
6515
  return;
6749
6516
  }
6750
- await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword);
6517
+ // One gate call per document id: inside `userdirectory` the applicable rule
6518
+ // depends on the id (personal documents are owned by their creator), so a
6519
+ // single batch-wide check would deny an owner deleting their own data.
6520
+ if ((0, builtinDbInvariants_1.hasBuiltinWriteInvariant)(this.effectiveBuiltinDbId()) || this._isAdminOnlyDb) {
6521
+ for (const docId of docIds) {
6522
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword, undefined, docId);
6523
+ }
6524
+ }
6525
+ else {
6526
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword);
6527
+ }
6751
6528
  this.logger.info(`Bulk-deleting ${docIds.length} documents`);
6752
6529
  // Unlike creation, a delete must first materialize the CURRENT document
6753
6530
  // (the doc_delete entry is an Automerge change on its heads), which costs
@@ -6794,14 +6571,14 @@ class BaseMindooDB {
6794
6571
  async undeleteDocument(docId, options = {}) {
6795
6572
  return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
6796
6573
  }
6797
- async assertLifecycleMutationAllowed(op, signingKeyPair, signingKeyPassword, documentUsernameHash) {
6574
+ async assertLifecycleMutationAllowed(op, signingKeyPair, signingKeyPassword, documentUsernameHash, docId) {
6798
6575
  this.assertWritable("document lifecycle mutation");
6799
6576
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6800
6577
  if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
6801
6578
  throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
6802
6579
  }
6803
6580
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
6804
- await this.assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash);
6581
+ await this.assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash, docId);
6805
6582
  }
6806
6583
  /**
6807
6584
  * Write a delete or undelete lifecycle entry for an existing document.
@@ -6975,7 +6752,7 @@ class BaseMindooDB {
6975
6752
  async deleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6976
6753
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6977
6754
  this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
6978
- await this.assertLifecycleMutationAllowed("doc_delete", signingKeyPair, signingKeyPassword);
6755
+ await this.assertLifecycleMutationAllowed("doc_delete", signingKeyPair, signingKeyPassword, undefined, docId);
6979
6756
  // Get current document
6980
6757
  const internalDoc = await this.loadDocumentInternal(docId);
6981
6758
  if (!internalDoc || internalDoc.isDeleted) {
@@ -6991,7 +6768,7 @@ class BaseMindooDB {
6991
6768
  if (!internalDoc) {
6992
6769
  throw new errors_1.DocumentNotFoundError(docId);
6993
6770
  }
6994
- await this.assertLifecycleMutationAllowed("doc_undelete", signingKeyPair, signingKeyPassword, this.usernameHashFromInternalDoc(internalDoc));
6771
+ await this.assertLifecycleMutationAllowed("doc_undelete", signingKeyPair, signingKeyPassword, this.usernameHashFromInternalDoc(internalDoc), docId);
6995
6772
  if (!internalDoc.isDeleted) {
6996
6773
  this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
6997
6774
  return;
@@ -8293,7 +8070,7 @@ class BaseMindooDB {
8293
8070
  throw new Error("No Automerge changes to persist");
8294
8071
  }
8295
8072
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomKey ? signingKeyPair : undefined);
8296
- await this.assertBuiltinWriteAllowed("doc_change", signerPublicKey, this.usernameHashFromInternalDoc(internalDoc));
8073
+ await this.assertBuiltinWriteAllowed("doc_change", signerPublicKey, this.usernameHashFromInternalDoc(internalDoc), docId);
8297
8074
  // Signed snapshot of the document's attachments after this change, computed
8298
8075
  // once from the resulting document state so EVERY change path (changeDoc,
8299
8076
  // text patches, merges, ...) carries it consistently on each produced entry.
@@ -8904,6 +8681,9 @@ class BaseMindooDB {
8904
8681
  if (replayEntriesForState.length === 0 && !snapshotEntryId) {
8905
8682
  return null;
8906
8683
  }
8684
+ if (allEntryMetadata.length > 0) {
8685
+ await this.ingestSealedKeyFromEntries(allEntryMetadata);
8686
+ }
8907
8687
  const metadataById = new Map(allEntryMetadata.map((entry) => [entry.id, entry]));
8908
8688
  let startFromSnapshot = snapshotEntryId !== null;
8909
8689
  const snapshotMeta = snapshotEntryId ? (metadataById.get(snapshotEntryId) || null) : null;
@@ -11936,32 +11716,6 @@ class BaseMindooDB {
11936
11716
  }
11937
11717
  }
11938
11718
  exports.BaseMindooDB = BaseMindooDB;
11939
- /**
11940
- * Determine how many entry IDs to fetch per `getEntries` call during sync.
11941
- *
11942
- * This is intentionally separate from the metadata scan `pageSize` so that
11943
- * scanning can page through large ID lists quickly while the heavier
11944
- * payload downloads use a smaller batch to keep progress responsive and
11945
- * cancellation timely.
11946
- *
11947
- * Priority: explicit option > attachment default (100) > docs default (250,
11948
- * capped by pageSize).
11949
- *
11950
- * The docs default is intentionally smaller than the metadata scan pageSize
11951
- * (1000): a full scan page transferred as one `putEntries` call serializes
11952
- * to a multi-megabyte JSON body whose upload cannot report progress, risks
11953
- * the HTTP timeout on slow uplinks (each retry restarts the whole body),
11954
- * and can exceed the remote JSON body limit. Smaller batches keep progress
11955
- * events frequent and make each POST cheap to retry.
11956
- */
11957
- BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE = 250;
11958
- /**
11959
- * Default window of transfer batches running in parallel (sync-v5,
11960
- * phase 4). Kept small: each in-flight batch holds one decrypted page of
11961
- * entries in memory, and HTTP/2 multiplexes the requests over a single
11962
- * connection anyway. Overridable via `SyncOptions.maxConcurrentBatches`.
11963
- */
11964
- BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES = 3;
11965
11719
  /** The "access control not enforced" allow decision returned by the
11966
11720
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
11967
11721
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {