mindoodb 0.0.53 → 0.0.54

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 +63 -70
  12. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  13. package/dist/core/BaseMindooDB.js +213 -506
  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 +18 -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,39 @@ 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
+ }
2260
2403
  /**
2261
2404
  * Derive the per-doc visibility summary needed to reconcile the index
2262
2405
  * against the current KeyBag state.
@@ -2392,6 +2535,9 @@ class BaseMindooDB {
2392
2535
  await this.tenant.ingestSealedFromEntries?.(visibility.decryptionKeyId, metadataByDoc.get(docId));
2393
2536
  const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
2394
2537
  if (canRead) {
2538
+ // This scan is the authority on visibility, so it also heals a tally
2539
+ // that a visible->visible short-circuit below would leave untouched.
2540
+ this.forgetInaccessibleDocument(docId);
2395
2541
  // Reveal-on-add only triggers when the previous state was
2396
2542
  // missing or inaccessible; visible->visible transitions stay
2397
2543
  // idempotent via the existing `updateIndex` short-circuit.
@@ -2411,8 +2557,11 @@ class BaseMindooDB {
2411
2557
  changed = true;
2412
2558
  }
2413
2559
  else {
2414
- // Already inaccessible (or never seen). Scrub defensively in
2415
- // case a previous session left an L2 record behind.
2560
+ // Already inaccessible (or never seen, which is the common case on a
2561
+ // device whose keys have not arrived). No index entry is written for
2562
+ // it, so the tally is the only record that it is here at all.
2563
+ this.noteInaccessibleDocument(docId);
2564
+ // Scrub defensively in case a previous session left an L2 record behind.
2416
2565
  await this.purgeMaterializedDocument(docId);
2417
2566
  }
2418
2567
  }
@@ -2786,6 +2935,10 @@ class BaseMindooDB {
2786
2935
  const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
2787
2936
  if (!keyAvailable) {
2788
2937
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
2938
+ // This is where a device that is still waiting for its keys meets
2939
+ // most of the tenant: the doc is skipped before it ever reaches
2940
+ // the index, so record it for `getInaccessibleDocumentCount`.
2941
+ this.noteInaccessibleDocument(docId);
2789
2942
  const existingIndex = this.getDocIndexPosition(docId);
2790
2943
  const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
2791
2944
  if (existing?.accessState === "visible") {
@@ -2840,6 +2993,7 @@ class BaseMindooDB {
2840
2993
  // doc.
2841
2994
  if (error instanceof errors_1.SymmetricKeyNotFoundError) {
2842
2995
  this.logger.debug(`Skipping document ${docId} - missing key: ${error.keyId}`);
2996
+ this.noteInaccessibleDocument(docId);
2843
2997
  // Avoid a per-doc scanAllMetadata round-trip here: everything
2844
2998
  // we need is already in this batch's entryMetadataList plus the
2845
2999
  // existing index entry (if any). We do NOT need a second pass
@@ -3150,54 +3304,8 @@ class BaseMindooDB {
3150
3304
  nextCursor: cursor,
3151
3305
  };
3152
3306
  }
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)}`;
3307
+ getStoreBloomSummary(store) {
3308
+ return (0, syncStores_1.getStoreBloomSummary)(store, this.logger);
3201
3309
  }
3202
3310
  /** Persist a sync scan cursor and schedule a checkpoint flush. */
3203
3311
  saveSyncScanCursor(key, record) {
@@ -3213,171 +3321,8 @@ class BaseMindooDB {
3213
3321
  this.cacheMetaDirty = true;
3214
3322
  this.cacheManager?.markDirty();
3215
3323
  }
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 };
3324
+ filterMissingIds(targetStore, candidateIds, bloom) {
3325
+ return (0, syncStores_1.filterMissingIds)(targetStore, candidateIds, bloom);
3381
3326
  }
3382
3327
  /**
3383
3328
  * Normalize the polymorphic putEntries result (`void`, receipts array, or
@@ -3395,15 +3340,39 @@ class BaseMindooDB {
3395
3340
  }
3396
3341
  return { receipts: [], batchRejected: [] };
3397
3342
  }
3343
+ /**
3344
+ * Expose this database's persisted {@link syncScanCursors} map to the shared
3345
+ * store-transfer helper. The map lives in the metadata checkpoint, so saving
3346
+ * a cursor also marks the cache dirty.
3347
+ */
3348
+ syncCursorStore() {
3349
+ return {
3350
+ get: (key) => this.syncScanCursors.get(key) ?? null,
3351
+ save: (key, record) => this.saveSyncScanCursor(key, record),
3352
+ delete: (key) => {
3353
+ this.syncScanCursors.delete(key);
3354
+ },
3355
+ };
3356
+ }
3398
3357
  async syncEntriesFromStore(sourceStore, targetStore, options) {
3399
3358
  const signal = options?.signal;
3400
- BaseMindooDB.setSyncAbortSignalOnStore(sourceStore, signal);
3401
- BaseMindooDB.setSyncAbortSignalOnStore(targetStore, signal);
3359
+ (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, signal);
3360
+ (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, signal);
3402
3361
  try {
3403
3362
  if (options?.mode === "dense") {
3404
3363
  return await this.syncEntriesFromStoreDense(sourceStore, targetStore, options);
3405
3364
  }
3406
- return await this.syncEntriesFromStoreImpl(sourceStore, targetStore, options);
3365
+ // Client rejection semantics: the scan cursor advances past a rejected
3366
+ // entry so a single corrupt or forged entry cannot block this database's
3367
+ // push sync forever (docs/network-sync-protocol.md §5.7.6). The
3368
+ // server-to-server replicator passes `"hold"` instead, because there a
3369
+ // rejection usually means "the peer has not replicated the directory
3370
+ // yet" and skipping would drop the entry permanently.
3371
+ return await (0, syncStores_1.syncEntriesBetweenStores)(sourceStore, targetStore, options, {
3372
+ logger: this.logger,
3373
+ cursors: this.syncCursorStore(),
3374
+ rejectionPolicy: "advance",
3375
+ });
3407
3376
  }
3408
3377
  catch (error) {
3409
3378
  if (signal?.aborted) {
@@ -3413,258 +3382,10 @@ class BaseMindooDB {
3413
3382
  throw error;
3414
3383
  }
3415
3384
  finally {
3416
- BaseMindooDB.setSyncAbortSignalOnStore(sourceStore, undefined);
3417
- BaseMindooDB.setSyncAbortSignalOnStore(targetStore, undefined);
3385
+ (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, undefined);
3386
+ (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, undefined);
3418
3387
  }
3419
3388
  }
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
3389
  /**
3669
3390
  * Dense sync: transfer only the entries required to reconstruct the latest
3670
3391
  * state of each document, using the batch materialization planner to skip
@@ -4082,8 +3803,10 @@ class BaseMindooDB {
4082
3803
  }
4083
3804
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4084
3805
  const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4085
- await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash);
3806
+ // The id is settled before the write gate because it decides which rule
3807
+ // applies in `userdirectory`: a personal document is recognized by its id.
4086
3808
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3809
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash, docId);
4087
3810
  // Idempotent create: when a caller-provided id already exists locally,
4088
3811
  // return the existing document instead of producing a duplicate doc_create.
4089
3812
  if (useCustomDocId) {
@@ -4318,8 +4041,8 @@ class BaseMindooDB {
4318
4041
  : [];
4319
4042
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4320
4043
  const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
4321
- await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash);
4322
4044
  const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
4045
+ await this.assertBuiltinWriteAllowed("doc_create", signerPublicKey, createUsernameHash, docId);
4323
4046
  const now = (0, timeSource_1.semanticNow)();
4324
4047
  const keyId = await this.resolveCreateKeyId(options, docId);
4325
4048
  const sealedCreate = await this.prepareSealedCreate(options, docId, keyId, now, signerPublicKey);
@@ -6747,7 +6470,17 @@ class BaseMindooDB {
6747
6470
  if (docIds.length === 0) {
6748
6471
  return;
6749
6472
  }
6750
- await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword);
6473
+ // One gate call per document id: inside `userdirectory` the applicable rule
6474
+ // depends on the id (personal documents are owned by their creator), so a
6475
+ // single batch-wide check would deny an owner deleting their own data.
6476
+ if ((0, builtinDbInvariants_1.hasBuiltinWriteInvariant)(this.effectiveBuiltinDbId()) || this._isAdminOnlyDb) {
6477
+ for (const docId of docIds) {
6478
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword, undefined, docId);
6479
+ }
6480
+ }
6481
+ else {
6482
+ await this.assertLifecycleMutationAllowed("doc_delete", options.signingKeyPair, options.signingKeyPassword);
6483
+ }
6751
6484
  this.logger.info(`Bulk-deleting ${docIds.length} documents`);
6752
6485
  // Unlike creation, a delete must first materialize the CURRENT document
6753
6486
  // (the doc_delete entry is an Automerge change on its heads), which costs
@@ -6794,14 +6527,14 @@ class BaseMindooDB {
6794
6527
  async undeleteDocument(docId, options = {}) {
6795
6528
  return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword, options.bypassAccessControlPrecheck);
6796
6529
  }
6797
- async assertLifecycleMutationAllowed(op, signingKeyPair, signingKeyPassword, documentUsernameHash) {
6530
+ async assertLifecycleMutationAllowed(op, signingKeyPair, signingKeyPassword, documentUsernameHash, docId) {
6798
6531
  this.assertWritable("document lifecycle mutation");
6799
6532
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6800
6533
  if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
6801
6534
  throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
6802
6535
  }
6803
6536
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
6804
- await this.assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash);
6537
+ await this.assertBuiltinWriteAllowed(op, signerPublicKey, documentUsernameHash, docId);
6805
6538
  }
6806
6539
  /**
6807
6540
  * Write a delete or undelete lifecycle entry for an existing document.
@@ -6975,7 +6708,7 @@ class BaseMindooDB {
6975
6708
  async deleteDocInternal(docId, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6976
6709
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
6977
6710
  this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
6978
- await this.assertLifecycleMutationAllowed("doc_delete", signingKeyPair, signingKeyPassword);
6711
+ await this.assertLifecycleMutationAllowed("doc_delete", signingKeyPair, signingKeyPassword, undefined, docId);
6979
6712
  // Get current document
6980
6713
  const internalDoc = await this.loadDocumentInternal(docId);
6981
6714
  if (!internalDoc || internalDoc.isDeleted) {
@@ -6991,7 +6724,7 @@ class BaseMindooDB {
6991
6724
  if (!internalDoc) {
6992
6725
  throw new errors_1.DocumentNotFoundError(docId);
6993
6726
  }
6994
- await this.assertLifecycleMutationAllowed("doc_undelete", signingKeyPair, signingKeyPassword, this.usernameHashFromInternalDoc(internalDoc));
6727
+ await this.assertLifecycleMutationAllowed("doc_undelete", signingKeyPair, signingKeyPassword, this.usernameHashFromInternalDoc(internalDoc), docId);
6995
6728
  if (!internalDoc.isDeleted) {
6996
6729
  this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
6997
6730
  return;
@@ -8293,7 +8026,7 @@ class BaseMindooDB {
8293
8026
  throw new Error("No Automerge changes to persist");
8294
8027
  }
8295
8028
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomKey ? signingKeyPair : undefined);
8296
- await this.assertBuiltinWriteAllowed("doc_change", signerPublicKey, this.usernameHashFromInternalDoc(internalDoc));
8029
+ await this.assertBuiltinWriteAllowed("doc_change", signerPublicKey, this.usernameHashFromInternalDoc(internalDoc), docId);
8297
8030
  // Signed snapshot of the document's attachments after this change, computed
8298
8031
  // once from the resulting document state so EVERY change path (changeDoc,
8299
8032
  // text patches, merges, ...) carries it consistently on each produced entry.
@@ -11936,32 +11669,6 @@ class BaseMindooDB {
11936
11669
  }
11937
11670
  }
11938
11671
  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
11672
  /** The "access control not enforced" allow decision returned by the
11966
11673
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
11967
11674
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {