mindoodb 0.0.34 → 0.0.36

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 (93) hide show
  1. package/README.md +2 -0
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +37 -3
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +158 -4
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  6. package/dist/appendonlystores/network/HttpTransport.d.ts +54 -3
  7. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  8. package/dist/appendonlystores/network/HttpTransport.js +344 -17
  9. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  10. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +39 -2
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +93 -11
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  14. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +9 -1
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +27 -0
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  18. package/dist/browser/index.mjs +7 -0
  19. package/dist/core/BaseMindooDB.d.ts +112 -9
  20. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  21. package/dist/core/BaseMindooDB.js +678 -153
  22. package/dist/core/BaseMindooDB.js.map +1 -1
  23. package/dist/core/BaseMindooTenant.d.ts +31 -0
  24. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  25. package/dist/core/BaseMindooTenant.js +132 -3
  26. package/dist/core/BaseMindooTenant.js.map +1 -1
  27. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  28. package/dist/core/BaseMindooTenantDirectory.js +4 -3
  29. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  30. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  31. package/dist/core/BaseMindooTenantFactory.js +2 -1
  32. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  33. package/dist/core/DocumentDagAnalysis.d.ts +39 -0
  34. package/dist/core/DocumentDagAnalysis.d.ts.map +1 -1
  35. package/dist/core/DocumentDagAnalysis.js +147 -3
  36. package/dist/core/DocumentDagAnalysis.js.map +1 -1
  37. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +7 -1
  38. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  39. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +15 -0
  40. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  41. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +55 -6
  42. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  43. package/dist/core/appendonlystores/network/binaryEntryFraming.d.ts +58 -0
  44. package/dist/core/appendonlystores/network/binaryEntryFraming.d.ts.map +1 -0
  45. package/dist/core/appendonlystores/network/binaryEntryFraming.js +121 -0
  46. package/dist/core/appendonlystores/network/binaryEntryFraming.js.map +1 -0
  47. package/dist/core/appendonlystores/network/types.d.ts +68 -0
  48. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  49. package/dist/core/appendonlystores/network/types.js.map +1 -1
  50. package/dist/core/appendonlystores/types.d.ts +58 -1
  51. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  52. package/dist/core/appendonlystores/types.js.map +1 -1
  53. package/dist/core/crypto/RSAEncryption.d.ts +11 -0
  54. package/dist/core/crypto/RSAEncryption.d.ts.map +1 -1
  55. package/dist/core/crypto/RSAEncryption.js +25 -0
  56. package/dist/core/crypto/RSAEncryption.js.map +1 -1
  57. package/dist/core/index.d.ts +2 -2
  58. package/dist/core/index.d.ts.map +1 -1
  59. package/dist/core/index.js +8 -2
  60. package/dist/core/index.js.map +1 -1
  61. package/dist/core/types.d.ts +157 -8
  62. package/dist/core/types.d.ts.map +1 -1
  63. package/dist/core/types.js +9 -1
  64. package/dist/core/types.js.map +1 -1
  65. package/dist/core/utils/idGeneration.d.ts +52 -0
  66. package/dist/core/utils/idGeneration.d.ts.map +1 -1
  67. package/dist/core/utils/idGeneration.js +78 -1
  68. package/dist/core/utils/idGeneration.js.map +1 -1
  69. package/dist/core/utils/index.d.ts +2 -1
  70. package/dist/core/utils/index.d.ts.map +1 -1
  71. package/dist/core/utils/index.js +8 -1
  72. package/dist/core/utils/index.js.map +1 -1
  73. package/dist/core/utils/timeSource.d.ts +34 -0
  74. package/dist/core/utils/timeSource.d.ts.map +1 -0
  75. package/dist/core/utils/timeSource.js +49 -0
  76. package/dist/core/utils/timeSource.js.map +1 -0
  77. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +17 -1
  78. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  79. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +43 -0
  80. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  81. package/dist/node/server/MindooDBServer.d.ts +38 -0
  82. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  83. package/dist/node/server/MindooDBServer.js +262 -6
  84. package/dist/node/server/MindooDBServer.js.map +1 -1
  85. package/dist/node/server/SyncEventBus.d.ts +29 -0
  86. package/dist/node/server/SyncEventBus.d.ts.map +1 -0
  87. package/dist/node/server/SyncEventBus.js +41 -0
  88. package/dist/node/server/SyncEventBus.js.map +1 -0
  89. package/dist/node/server/compressionMiddleware.d.ts +25 -0
  90. package/dist/node/server/compressionMiddleware.d.ts.map +1 -0
  91. package/dist/node/server/compressionMiddleware.js +140 -0
  92. package/dist/node/server/compressionMiddleware.js.map +1 -0
  93. package/package.json +2 -3
@@ -15,6 +15,7 @@ const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
15
15
  const DocumentConflictAnalysis_1 = require("./DocumentConflictAnalysis");
16
16
  const AttachmentReadPlanner_1 = require("./appendonlystores/AttachmentReadPlanner");
17
17
  const idGeneration_1 = require("./utils/idGeneration");
18
+ const timeSource_1 = require("./utils/timeSource");
18
19
  const errors_1 = require("./errors");
19
20
  const EntrySignature_1 = require("./crypto/EntrySignature");
20
21
  const materializationGuard_1 = require("./accesscontrol/materializationGuard");
@@ -190,6 +191,19 @@ class BaseMindooDB {
190
191
  // Track which entry IDs we've already processed
191
192
  this.processedEntryIds = [];
192
193
  this.processedEntryCursor = null;
194
+ /**
195
+ * Persisted sync scan cursors (sync-v5, phase 1), keyed by
196
+ * `"<sourceIdentity>-><targetIdentity>/<storeKind>"` (see
197
+ * {@link syncScanCursorKey}). Each record remembers how far a previous
198
+ * pull/push already scanned the source store, together with the epochs of
199
+ * both stores at that time. On the next sync with the same pair the scan
200
+ * resumes from the cursor — or is skipped entirely when the source head
201
+ * shows nothing new. An epoch change on either side invalidates the record.
202
+ *
203
+ * Persisted in the metadata checkpoint when a cache store is configured;
204
+ * otherwise this only accelerates repeat syncs within the same session.
205
+ */
206
+ this.syncScanCursors = new Map();
193
207
  /**
194
208
  * In-memory LRU of per-document merged state used by the revision-grain
195
209
  * changefeed ({@link iterateRevisionsInternal}). Each entry holds a document's
@@ -412,19 +426,55 @@ class BaseMindooDB {
412
426
  // ---------------------------------------------------------------------------
413
427
  // Local cache support (ICacheable)
414
428
  // ---------------------------------------------------------------------------
429
+ /**
430
+ * Compute the cache prefix for this database. Time-travel instances get a
431
+ * cutoff-scoped prefix (`.../tt/<timeTravelDate>`) so their persisted
432
+ * state (doc cache, metadata checkpoint, summary buckets) is isolated per
433
+ * cutoff and can never collide with — or leak into — the live cache.
434
+ */
435
+ computeCachePrefix() {
436
+ const cacheIdentity = this.store.getCacheIdentity?.() ?? this.store.getId();
437
+ const base = `${this.tenant.getId()}/${cacheIdentity}`;
438
+ return this.timeTravelDate == null ? base : `${base}/tt/${this.timeTravelDate}`;
439
+ }
415
440
  /**
416
441
  * Attach a CacheManager so this DB participates in periodic cache flushing.
417
442
  */
418
443
  setCacheManager(cacheManager) {
419
444
  this.cacheManager = cacheManager;
420
- const cacheIdentity = this.store.getCacheIdentity?.() ?? this.store.getId();
421
- this.cachePrefix = `${this.tenant.getId()}/${cacheIdentity}`;
445
+ this.cachePrefix = this.computeCachePrefix();
422
446
  cacheManager.register(this);
423
447
  // A summary store created before the cache manager was attached picks
424
448
  // up persistence now.
425
449
  this.summaryStore?.attachCache(cacheManager, `${this.getCachePrefix()}/summary`);
426
450
  this.fulltextIndex?.attachCache(cacheManager, `${this.getCachePrefix()}/fulltext`);
427
451
  }
452
+ /**
453
+ * Detach this DB (and its summary/full-text cacheables) from the
454
+ * CacheManager WITHOUT flushing pending state. Used by the tenant's
455
+ * time-travel cache purge: the persisted records are about to be
456
+ * deleted, so a deregister-triggered flush would only resurrect them.
457
+ */
458
+ detachCacheManagerForPurge() {
459
+ const cacheManager = this.cacheManager;
460
+ if (!cacheManager) {
461
+ return;
462
+ }
463
+ // Drop dirty markers first so CacheManager.deregister() skips its
464
+ // flush-before-remove step.
465
+ this.dirtyDocIds.clear();
466
+ this.cacheMetaDirty = false;
467
+ this.summaryStore?.clearDirty();
468
+ this.fulltextIndex?.clearDirty();
469
+ void cacheManager.deregister(this);
470
+ if (this.summaryStore) {
471
+ void cacheManager.deregister(this.summaryStore);
472
+ }
473
+ if (this.fulltextIndex) {
474
+ void cacheManager.deregister(this.fulltextIndex);
475
+ }
476
+ this.cacheManager = null;
477
+ }
428
478
  /**
429
479
  * Get (lazily creating) the document summary buffer for this database.
430
480
  *
@@ -919,7 +969,7 @@ class BaseMindooDB {
919
969
  return (0, queryLive_1.executeQueryLive)(this, this.getSummaryStore(), query, onResult, options);
920
970
  }
921
971
  getCachePrefix() {
922
- return this.cachePrefix ?? `${this.tenant.getId()}/${this.store.getId()}`;
972
+ return this.cachePrefix ?? this.computeCachePrefix();
923
973
  }
924
974
  hasDirtyState() {
925
975
  return this.dirtyDocIds.size > 0 || this.cacheMetaDirty;
@@ -1110,6 +1160,11 @@ class BaseMindooDB {
1110
1160
  hashMap[docId] = Object.fromEntries(inner);
1111
1161
  }
1112
1162
  checkpoint.automergeHashToEntryId = hashMap;
1163
+ // Persisted sync scan cursors (sync-v5, phase 1). Additive field: older
1164
+ // readers ignore it, older checkpoints simply restore an empty map.
1165
+ if (this.syncScanCursors.size > 0) {
1166
+ checkpoint.syncScanCursors = Object.fromEntries(this.syncScanCursors);
1167
+ }
1113
1168
  // For stores without cursor scan, include processedEntryIds
1114
1169
  if (!this.supportsCursorScan(this.store)) {
1115
1170
  checkpoint.processedEntryIds = this.processedEntryIds;
@@ -1149,6 +1204,21 @@ class BaseMindooDB {
1149
1204
  }
1150
1205
  this.lastReconciledKeyBagFingerprint =
1151
1206
  typeof checkpoint.keyBagFingerprint === "string" ? checkpoint.keyBagFingerprint : null;
1207
+ this.syncScanCursors.clear();
1208
+ if (checkpoint.syncScanCursors && typeof checkpoint.syncScanCursors === "object") {
1209
+ for (const [key, value] of Object.entries(checkpoint.syncScanCursors)) {
1210
+ if (typeof value?.sourceEpoch === "string" &&
1211
+ typeof value?.targetEpoch === "string" &&
1212
+ typeof value?.cursor?.receiptOrder === "number" &&
1213
+ typeof value?.cursor?.id === "string") {
1214
+ this.syncScanCursors.set(key, {
1215
+ sourceEpoch: value.sourceEpoch,
1216
+ targetEpoch: value.targetEpoch,
1217
+ cursor: { receiptOrder: value.cursor.receiptOrder, id: value.cursor.id },
1218
+ });
1219
+ }
1220
+ }
1221
+ }
1152
1222
  // Rebuild indexLookup from index
1153
1223
  this.indexLookup.clear();
1154
1224
  this.indexLookupStaleFrom = null;
@@ -1405,7 +1475,7 @@ class BaseMindooDB {
1405
1475
  if (cutoff === undefined) {
1406
1476
  return false;
1407
1477
  }
1408
- return (0, storeEntryTime_1.entryTrustedTime)(entry, Date.now()) >= cutoff;
1478
+ return (0, storeEntryTime_1.entryTrustedTime)(entry, (0, timeSource_1.semanticNow)()) >= cutoff;
1409
1479
  }
1410
1480
  catch (error) {
1411
1481
  this.logger.warn(`Failed to resolve metadata-signature floor: ${String(error)}`);
@@ -1437,14 +1507,23 @@ class BaseMindooDB {
1437
1507
  * keeps repeated visibility reconciliations from inflating the
1438
1508
  * changefeed.
1439
1509
  *
1510
+ * `lastModified` has millisecond resolution, so two genuine content
1511
+ * changes landing in the same millisecond are indistinguishable by the
1512
+ * tracked fields alone. Call sites that KNOW new content entries were
1513
+ * just written/applied (changeDoc, delete/undelete, sync ingest) must
1514
+ * pass `forceChangeSeqBump: true` so the change is re-emitted on the
1515
+ * changefeed instead of being swallowed by the idempotency check.
1516
+ *
1440
1517
  * @param docId The document ID
1441
1518
  * @param lastModified The new last modified timestamp
1442
1519
  * @param isDeleted Whether the document is a deletion/inaccessibility tombstone
1443
1520
  * @param decryptionKeyId Encryption key id derived from the doc's store metadata
1444
1521
  * @param accessState `"visible"` for readable docs, `"inaccessible"` for
1445
1522
  * tombstones produced by the key visibility layer
1523
+ * @param forceChangeSeqBump Bypass the no-op identity check because the
1524
+ * caller knows the document content changed (new store entries)
1446
1525
  */
1447
- updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false) {
1526
+ updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false, forceChangeSeqBump = false) {
1448
1527
  const startedAt = Date.now();
1449
1528
  const assignedSeq = this.nextChangeSeq;
1450
1529
  const newEntry = {
@@ -1464,8 +1543,12 @@ class BaseMindooDB {
1464
1543
  // If only metadata flags stayed identical and caller replays same state, skip.
1465
1544
  // `awaitingWitness` is part of this identity so a witness receipt (which
1466
1545
  // changes only `receivedAt`, not Automerge content) still bumps changeSeq
1467
- // and re-emits the doc on the metadata feed.
1468
- if (existingEntry.lastModified === lastModified
1546
+ // and re-emits the doc on the metadata feed. Content-change call sites
1547
+ // bypass this via forceChangeSeqBump: two changes within the same
1548
+ // millisecond would otherwise be indistinguishable here and the second
1549
+ // one would silently vanish from the changefeed.
1550
+ if (!forceChangeSeqBump
1551
+ && existingEntry.lastModified === lastModified
1469
1552
  && existingEntry.isDeleted === isDeleted
1470
1553
  && existingEntry.decryptionKeyId === decryptionKeyId
1471
1554
  && existingEntry.accessState === accessState
@@ -1721,6 +1804,7 @@ class BaseMindooDB {
1721
1804
  await this.reconcileRestoredIndexWithStore();
1722
1805
  }
1723
1806
  this.probeFulltextSetupAtOpen();
1807
+ this.probeSummarySetupAtOpen();
1724
1808
  return;
1725
1809
  }
1726
1810
  }
@@ -1730,6 +1814,50 @@ class BaseMindooDB {
1730
1814
  // record the current fingerprint for the next warm start.
1731
1815
  this.markKeyBagFingerprintReconciled(await this.computeCurrentKeyBagFingerprint());
1732
1816
  this.probeFulltextSetupAtOpen();
1817
+ this.probeSummarySetupAtOpen();
1818
+ }
1819
+ /**
1820
+ * Open-time probe for summary auto-activation on time-travel snapshots.
1821
+ *
1822
+ * Live databases activate their summary buffer through coalesced change
1823
+ * events ({@link handleChangesForSummaryAutoUpdate}) or an explicit
1824
+ * {@link getSummaryStore} call. A time-travel snapshot is read-only and
1825
+ * never receives change events, so without an open-time probe a snapshot
1826
+ * whose setup document (as of the cutoff) configures a summary would
1827
+ * never build one — every summary-first query/view would pay the full
1828
+ * catch-up on first use. Fired once (fire-and-forget) at the end of
1829
+ * {@link initialize}; the single `update()` run materializes the
1830
+ * documents at the cutoff and fills the buffer (restoring a persisted
1831
+ * state first when a CacheManager is attached, see
1832
+ * `persistTimeTravelCache`).
1833
+ */
1834
+ probeSummarySetupAtOpen() {
1835
+ if (!this.isTimeTravelMode()) {
1836
+ return;
1837
+ }
1838
+ if (!this.summaryAutoUpdateEnabled || this.summarySetupProbed || this.summaryStore) {
1839
+ return;
1840
+ }
1841
+ if (this._isAdminOnlyDb || this.store.getId() === "directory") {
1842
+ return;
1843
+ }
1844
+ this.summarySetupProbed = true;
1845
+ void (async () => {
1846
+ try {
1847
+ const config = await this.getSummarySetup();
1848
+ if (config === null || this.summaryStore) {
1849
+ return;
1850
+ }
1851
+ // No explicit config: the store adopts the setup-document config on
1852
+ // its first update run (seedConfigFromSetupDoc), mirroring
1853
+ // activateSummaryFromSetupDoc for live databases.
1854
+ const summary = this.getSummaryStore();
1855
+ await summary.update();
1856
+ }
1857
+ catch (error) {
1858
+ this.logger.warn(`Time-travel summary activation failed: ${error}`);
1859
+ }
1860
+ })();
1733
1861
  }
1734
1862
  /**
1735
1863
  * Open-time probe for full-text auto-activation. Without it, a
@@ -2048,8 +2176,11 @@ class BaseMindooDB {
2048
2176
  updatedDoc = await this.applyNewEntriesToCachedDocument(cachedDoc, entryMetadataList);
2049
2177
  if (updatedDoc) {
2050
2178
  this.logger.debug(`Successfully updated cached document ${docId} incrementally`);
2051
- // Only update index if document actually changed
2052
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false);
2179
+ // Only update index if document actually changed. A non-null
2180
+ // result means something observable changed (heads or witness
2181
+ // state), so force the changeSeq bump — same-millisecond
2182
+ // lastModified collisions must not swallow the re-emit.
2183
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
2053
2184
  this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted}, awaitingWitness: ${updatedDoc.awaitingWitness ?? false})`);
2054
2185
  }
2055
2186
  else {
@@ -2062,7 +2193,8 @@ class BaseMindooDB {
2062
2193
  this.docCache.delete(docId);
2063
2194
  updatedDoc = await this.loadDocumentInternal(docId);
2064
2195
  if (updatedDoc) {
2065
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false);
2196
+ // New entries triggered this reload, so force the re-emit.
2197
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
2066
2198
  }
2067
2199
  }
2068
2200
  }
@@ -2096,7 +2228,11 @@ class BaseMindooDB {
2096
2228
  const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
2097
2229
  const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
2098
2230
  const { awaitingWitness, witnessed } = (0, storeEntryTime_1.metadataWitnessState)(allDocMetadata);
2099
- this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed);
2231
+ // Force the re-emit only when the batch contains actual replay
2232
+ // entries (create/change/delete/undelete): a snapshot-only batch
2233
+ // changes nothing observable and may keep the idempotent skip.
2234
+ const batchHasReplayEntries = docLifecycleEntries.some((e) => this.isDocumentReplayEntry(e));
2235
+ this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed, batchHasReplayEntries);
2100
2236
  this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted}, awaitingWitness: ${awaitingWitness})`);
2101
2237
  }
2102
2238
  }
@@ -2169,6 +2305,16 @@ class BaseMindooDB {
2169
2305
  };
2170
2306
  const documentEntries = Array.from(entriesByDoc.entries())
2171
2307
  .sort((a, b) => {
2308
+ // Prefer the store's receiptOrder (unique, reflects the actual write
2309
+ // order) so a cold rebuild assigns changeSeqs in the same order the
2310
+ // live session did — `createdAt` has millisecond resolution and ties
2311
+ // for same-ms writes, which would otherwise flip the feed order to
2312
+ // the docId tie-break after a restart.
2313
+ const aMinReceipt = Math.min(...a[1].map((e) => e.receiptOrder ?? Number.MAX_SAFE_INTEGER));
2314
+ const bMinReceipt = Math.min(...b[1].map((e) => e.receiptOrder ?? Number.MAX_SAFE_INTEGER));
2315
+ if (aMinReceipt !== bMinReceipt) {
2316
+ return aMinReceipt - bMinReceipt;
2317
+ }
2172
2318
  const aMinCreatedAt = Math.min(...a[1].map((e) => e.createdAt));
2173
2319
  const bMinCreatedAt = Math.min(...b[1].map((e) => e.createdAt));
2174
2320
  if (aMinCreatedAt !== bMinCreatedAt) {
@@ -2303,6 +2449,46 @@ class BaseMindooDB {
2303
2449
  store.setSyncAbortSignal(signal);
2304
2450
  }
2305
2451
  }
2452
+ /**
2453
+ * Fetch a store's head descriptor (`{ epoch, maxReceiptOrder }`), returning
2454
+ * null when the store (or the remote server behind it) does not support it
2455
+ * or the request fails. A null head simply disables the persisted-cursor
2456
+ * fast path for this sync.
2457
+ */
2458
+ async getStoreHeadSafe(store) {
2459
+ if (typeof store.getStoreHead !== "function") {
2460
+ return null;
2461
+ }
2462
+ try {
2463
+ return await store.getStoreHead();
2464
+ }
2465
+ catch (error) {
2466
+ this.logger.debug("Store head unavailable, falling back to full scan", error);
2467
+ return null;
2468
+ }
2469
+ }
2470
+ /** Stable identity for a store in the persisted sync-cursor map. */
2471
+ static syncStoreIdentity(store) {
2472
+ return store.getCacheIdentity?.() ?? `${store.getId()}/${store.getStoreKind()}`;
2473
+ }
2474
+ /** Key for {@link syncScanCursors}: one record per (source, target) pair. */
2475
+ syncScanCursorKey(sourceStore, targetStore) {
2476
+ return `${BaseMindooDB.syncStoreIdentity(sourceStore)}->${BaseMindooDB.syncStoreIdentity(targetStore)}`;
2477
+ }
2478
+ /** Persist a sync scan cursor and schedule a checkpoint flush. */
2479
+ saveSyncScanCursor(key, record) {
2480
+ const existing = this.syncScanCursors.get(key);
2481
+ if (existing &&
2482
+ existing.sourceEpoch === record.sourceEpoch &&
2483
+ existing.targetEpoch === record.targetEpoch &&
2484
+ existing.cursor.receiptOrder === record.cursor.receiptOrder &&
2485
+ existing.cursor.id === record.cursor.id) {
2486
+ return;
2487
+ }
2488
+ this.syncScanCursors.set(key, record);
2489
+ this.cacheMetaDirty = true;
2490
+ this.cacheManager?.markDirty();
2491
+ }
2306
2492
  /**
2307
2493
  * From a list of candidate IDs, return only those the target store is
2308
2494
  * missing. Uses bloom-filter pre-screening when available, then falls
@@ -2360,65 +2546,130 @@ class BaseMindooDB {
2360
2546
  */
2361
2547
  async transferEntriesInBatches(sourceStore, targetStore, entryIds, options, state) {
2362
2548
  if (entryIds.length === 0) {
2363
- return { transferred: state.transferred, cancelled: false };
2549
+ return { transferred: state.transferred, transferredBytes: state.transferredBytes, cancelled: false, rejected: [] };
2364
2550
  }
2365
2551
  const onProgress = options?.onProgress;
2366
2552
  const signal = options?.signal;
2367
2553
  const transferBatchSize = this.resolveTransferBatchSize(options);
2368
2554
  const totalTransferBatches = Math.max(1, Math.ceil(entryIds.length / transferBatchSize));
2555
+ // Parallel transfer window (sync-v5, phase 4): each batch is one
2556
+ // getEntries + putEntries round trip; a small window of concurrent
2557
+ // batches overlaps network latency and server-side crypto. Witness
2558
+ // receipts are applied per batch and are order-independent.
2559
+ const maxConcurrent = Math.min(Math.max(1, Math.floor(options?.maxConcurrentBatches ?? BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES)), totalTransferBatches);
2369
2560
  let transferred = state.transferred;
2370
- for (let offset = 0; offset < entryIds.length; offset += transferBatchSize) {
2371
- if (signal?.aborted) {
2372
- return { transferred, cancelled: true };
2373
- }
2374
- const currentTransferBatch = Math.floor(offset / transferBatchSize) + 1;
2375
- const batchIds = entryIds.slice(offset, offset + transferBatchSize);
2376
- const pageSummary = state.currentPage ? `page ${state.currentPage}, ` : "";
2377
- onProgress?.({
2378
- phase: "transferring",
2379
- message: `Transferring batch ${currentTransferBatch}/${totalTransferBatches} (${batchIds.length} entries, ${pageSummary}scanned ${state.scanned})...`,
2380
- transferredEntries: transferred,
2381
- scannedEntries: state.scanned,
2382
- totalSourceEntries: state.totalSourceEntries,
2383
- currentPage: state.currentPage,
2384
- currentTransferBatch,
2385
- totalTransferBatches,
2386
- transferBatchSize,
2387
- });
2388
- try {
2389
- const batchEntries = await sourceStore.getEntries(batchIds);
2390
- if (signal?.aborted) {
2391
- return { transferred, cancelled: true };
2561
+ let transferredBytes = state.transferredBytes;
2562
+ let cancelled = false;
2563
+ let firstError = null;
2564
+ let nextBatchIndex = 0;
2565
+ // Per-entry rejections reported by a witnessing target (sync-v5): the
2566
+ // push continues, the rejected entries are surfaced to the caller.
2567
+ const rejected = [];
2568
+ const runWorker = async () => {
2569
+ while (true) {
2570
+ if (firstError || cancelled || signal?.aborted) {
2571
+ if (signal?.aborted)
2572
+ cancelled = true;
2573
+ return;
2392
2574
  }
2393
- const putResult = await targetStore.putEntries(batchEntries);
2394
- // On push, the remote (witnessing) target returns receipts for accepted
2395
- // entries; persist them back onto the local source so the revision feed
2396
- // re-anchors them from the provisional head to their committed
2397
- // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
2398
- if (Array.isArray(putResult) && putResult.length > 0 && sourceStore.applyWitnessReceipts) {
2399
- await sourceStore.applyWitnessReceipts(putResult);
2575
+ const batchIndex = nextBatchIndex++;
2576
+ const offset = batchIndex * transferBatchSize;
2577
+ if (offset >= entryIds.length) {
2578
+ return;
2400
2579
  }
2401
- transferred += batchEntries.length;
2402
- }
2403
- catch (error) {
2404
- if (signal?.aborted) {
2405
- return { transferred, cancelled: true };
2580
+ const currentTransferBatch = batchIndex + 1;
2581
+ const batchIds = entryIds.slice(offset, offset + transferBatchSize);
2582
+ const pageSummary = state.currentPage ? `page ${state.currentPage}, ` : "";
2583
+ onProgress?.({
2584
+ phase: "transferring",
2585
+ message: `Transferring batch ${currentTransferBatch}/${totalTransferBatches} (${batchIds.length} entries, ${pageSummary}scanned ${state.scanned})...`,
2586
+ transferredEntries: transferred,
2587
+ transferredBytes,
2588
+ scannedEntries: state.scanned,
2589
+ totalSourceEntries: state.totalSourceEntries,
2590
+ currentPage: state.currentPage,
2591
+ currentTransferBatch,
2592
+ totalTransferBatches,
2593
+ transferBatchSize,
2594
+ });
2595
+ try {
2596
+ const batchEntries = await sourceStore.getEntries(batchIds);
2597
+ if (signal?.aborted) {
2598
+ cancelled = true;
2599
+ return;
2600
+ }
2601
+ const putResult = await targetStore.putEntries(batchEntries);
2602
+ // On push, the remote (witnessing) target returns receipts for accepted
2603
+ // entries; persist them back onto the local source so the revision feed
2604
+ // re-anchors them from the provisional head to their committed
2605
+ // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
2606
+ // sync-v5 targets return a structured ack that additionally carries
2607
+ // per-entry rejections (signature-class failures the remote skipped).
2608
+ const { receipts, batchRejected } = BaseMindooDB.normalizePutResult(putResult);
2609
+ if (receipts.length > 0 && sourceStore.applyWitnessReceipts) {
2610
+ await sourceStore.applyWitnessReceipts(receipts);
2611
+ }
2612
+ const rejectedIds = batchRejected.length > 0 ? new Set(batchRejected.map((entry) => entry.id)) : null;
2613
+ if (rejectedIds) {
2614
+ rejected.push(...batchRejected);
2615
+ this.logger.warn(`Target rejected ${batchRejected.length} of ${batchEntries.length} entries in transfer batch ${currentTransferBatch}/${totalTransferBatches}`);
2616
+ }
2617
+ for (const entry of batchEntries) {
2618
+ if (rejectedIds?.has(entry.id)) {
2619
+ continue;
2620
+ }
2621
+ transferredBytes += entry.encryptedSize ?? entry.encryptedData.length;
2622
+ }
2623
+ transferred += batchEntries.length - batchRejected.length;
2406
2624
  }
2407
- throw error;
2625
+ catch (error) {
2626
+ if (signal?.aborted) {
2627
+ cancelled = true;
2628
+ return;
2629
+ }
2630
+ if (!firstError) {
2631
+ firstError = error;
2632
+ }
2633
+ return;
2634
+ }
2635
+ onProgress?.({
2636
+ phase: "transferring",
2637
+ message: `Transferred ${transferred} entries after batch ${currentTransferBatch}/${totalTransferBatches}`,
2638
+ transferredEntries: transferred,
2639
+ transferredBytes,
2640
+ scannedEntries: state.scanned,
2641
+ totalSourceEntries: state.totalSourceEntries,
2642
+ currentPage: state.currentPage,
2643
+ currentTransferBatch,
2644
+ totalTransferBatches,
2645
+ transferBatchSize,
2646
+ });
2408
2647
  }
2409
- onProgress?.({
2410
- phase: "transferring",
2411
- message: `Transferred ${transferred} entries after batch ${currentTransferBatch}/${totalTransferBatches}`,
2412
- transferredEntries: transferred,
2413
- scannedEntries: state.scanned,
2414
- totalSourceEntries: state.totalSourceEntries,
2415
- currentPage: state.currentPage,
2416
- currentTransferBatch,
2417
- totalTransferBatches,
2418
- transferBatchSize,
2419
- });
2648
+ };
2649
+ await Promise.all(Array.from({ length: maxConcurrent }, () => runWorker()));
2650
+ if (signal?.aborted || cancelled) {
2651
+ return { transferred, transferredBytes, cancelled: true, rejected };
2652
+ }
2653
+ if (firstError) {
2654
+ throw firstError;
2655
+ }
2656
+ return { transferred, transferredBytes, cancelled: false, rejected };
2657
+ }
2658
+ /**
2659
+ * Normalize the polymorphic putEntries result (`void`, receipts array, or
2660
+ * structured {@link PutEntriesAck}) into receipts + per-entry rejections.
2661
+ */
2662
+ static normalizePutResult(putResult) {
2663
+ if (Array.isArray(putResult)) {
2664
+ return { receipts: putResult, batchRejected: [] };
2665
+ }
2666
+ if (putResult && typeof putResult === "object") {
2667
+ return {
2668
+ receipts: putResult.receipts ?? [],
2669
+ batchRejected: putResult.rejected ?? [],
2670
+ };
2420
2671
  }
2421
- return { transferred, cancelled: false };
2672
+ return { receipts: [], batchRejected: [] };
2422
2673
  }
2423
2674
  async syncEntriesFromStore(sourceStore, targetStore, options) {
2424
2675
  const signal = options?.signal;
@@ -2444,19 +2695,69 @@ class BaseMindooDB {
2444
2695
  }
2445
2696
  async syncEntriesFromStoreImpl(sourceStore, targetStore, options) {
2446
2697
  let transferred = 0;
2698
+ let transferredBytes = 0;
2447
2699
  let scanned = 0;
2700
+ // Per-entry rejections reported by a witnessing target (sync-v5): the
2701
+ // sync completes; rejected entries are surfaced to the caller as warnings.
2702
+ const rejected = [];
2448
2703
  const onProgress = options?.onProgress;
2449
2704
  const pageSize = options?.pageSize ?? 1000;
2450
2705
  const signal = options?.signal;
2451
- const targetBloom = await this.getStoreBloomSummary(targetStore);
2452
- // Fixed progress denominator: the SOURCE's total entry count. The cursor
2453
- // scan below examines every source entry, so `scannedEntries/totalSourceEntries`
2454
- // is a real completion ratio. (Local stores serve this from a cached bloom
2455
- // summary; network stores answer with one extra request.) The target bloom
2456
- // stays dedicated to missing-id pre-screening.
2457
- const sourceBloom = await this.getStoreBloomSummary(sourceStore);
2458
- const totalSourceEstimate = sourceBloom?.totalIds;
2459
2706
  if (this.supportsCursorScan(sourceStore)) {
2707
+ // Persisted-cursor fast path (sync-v5, phase 1): resume the metadata
2708
+ // scan where the previous sync between this (source, target) pair left
2709
+ // off — or skip the sync entirely when the source head shows nothing
2710
+ // new. The heads are fetched up front (cheap; one request per side for
2711
+ // network stores) and also anchor the epochs persisted with the cursor.
2712
+ const cursorKey = this.syncScanCursorKey(sourceStore, targetStore);
2713
+ const persisted = options?.forceFullScan
2714
+ ? null
2715
+ : this.syncScanCursors.get(cursorKey) ?? null;
2716
+ const [sourceHead, targetHead] = await Promise.all([
2717
+ this.getStoreHeadSafe(sourceStore),
2718
+ this.getStoreHeadSafe(targetStore),
2719
+ ]);
2720
+ let cursor = null;
2721
+ if (persisted && sourceHead && targetHead) {
2722
+ if (persisted.sourceEpoch === sourceHead.epoch &&
2723
+ persisted.targetEpoch === targetHead.epoch) {
2724
+ if (sourceHead.maxReceiptOrder <= persisted.cursor.receiptOrder) {
2725
+ this.logger.debug(`Sync skip: source head ${sourceHead.maxReceiptOrder} already covered by persisted cursor (${cursorKey})`);
2726
+ onProgress?.({
2727
+ phase: 'preparing',
2728
+ message: 'Source unchanged since last sync, nothing to scan',
2729
+ transferredEntries: 0,
2730
+ scannedEntries: 0,
2731
+ });
2732
+ return { transferred: 0, scanned: 0, cancelled: false };
2733
+ }
2734
+ cursor = persisted.cursor;
2735
+ this.logger.debug(`Resuming sync scan from persisted cursor receiptOrder=${cursor.receiptOrder} (${cursorKey})`);
2736
+ }
2737
+ else {
2738
+ // Epoch change on either side: the cursor lineage is broken
2739
+ // (store reset / receipt-order migration) — full rescan.
2740
+ this.logger.info(`Sync cursor epoch changed for ${cursorKey}, discarding persisted cursor and re-scanning`);
2741
+ this.syncScanCursors.delete(cursorKey);
2742
+ }
2743
+ }
2744
+ const persistScanCursor = (finalCursor) => {
2745
+ if (finalCursor && sourceHead && targetHead) {
2746
+ this.saveSyncScanCursor(cursorKey, {
2747
+ sourceEpoch: sourceHead.epoch,
2748
+ targetEpoch: targetHead.epoch,
2749
+ cursor: finalCursor,
2750
+ });
2751
+ }
2752
+ };
2753
+ const targetBloom = await this.getStoreBloomSummary(targetStore);
2754
+ // Fixed progress denominator: the SOURCE's total entry count. The cursor
2755
+ // scan below examines every source entry, so `scannedEntries/totalSourceEntries`
2756
+ // is a real completion ratio. (Local stores serve this from a cached bloom
2757
+ // summary; network stores answer with one extra request.) The target bloom
2758
+ // stays dedicated to missing-id pre-screening.
2759
+ const sourceBloom = await this.getStoreBloomSummary(sourceStore);
2760
+ const totalSourceEstimate = sourceBloom?.totalIds;
2460
2761
  onProgress?.({
2461
2762
  phase: 'preparing',
2462
2763
  message: 'Preparing to sync entries...',
@@ -2464,17 +2765,34 @@ class BaseMindooDB {
2464
2765
  scannedEntries: 0,
2465
2766
  totalSourceEntries: totalSourceEstimate,
2466
2767
  });
2467
- let cursor = null;
2468
2768
  let currentPage = 0;
2769
+ // Scan-page pipelining (sync-v5, phase 4): while page N is being
2770
+ // filtered and transferred, page N+1 is already being fetched.
2771
+ let nextPagePromise = null;
2772
+ const discardPrefetch = () => {
2773
+ // Swallow errors of an in-flight prefetch we will never consume
2774
+ // (abort/cancel paths) to avoid unhandled rejections.
2775
+ nextPagePromise?.catch(() => { });
2776
+ nextPagePromise = null;
2777
+ };
2469
2778
  while (true) {
2470
2779
  if (signal?.aborted) {
2471
- return { transferred, scanned, cancelled: true };
2780
+ discardPrefetch();
2781
+ persistScanCursor(cursor);
2782
+ return { transferred, transferredBytes, scanned, cancelled: true, rejected };
2472
2783
  }
2473
- const page = await sourceStore.scanEntriesSince(cursor, pageSize);
2784
+ const page = nextPagePromise
2785
+ ? await nextPagePromise
2786
+ : await sourceStore.scanEntriesSince(cursor, pageSize);
2787
+ nextPagePromise = page.hasMore
2788
+ ? sourceStore.scanEntriesSince(page.nextCursor, pageSize)
2789
+ : null;
2474
2790
  currentPage++;
2475
2791
  scanned += page.entries.length;
2476
2792
  if (signal?.aborted) {
2477
- return { transferred, scanned, cancelled: true };
2793
+ discardPrefetch();
2794
+ persistScanCursor(cursor);
2795
+ return { transferred, transferredBytes, scanned, cancelled: true, rejected };
2478
2796
  }
2479
2797
  if (page.entries.length > 0) {
2480
2798
  onProgress?.({
@@ -2488,18 +2806,27 @@ class BaseMindooDB {
2488
2806
  const ids = page.entries.map((m) => m.id);
2489
2807
  const missingIds = await this.filterMissingIds(targetStore, ids, targetBloom);
2490
2808
  if (signal?.aborted) {
2491
- return { transferred, scanned, cancelled: true };
2809
+ discardPrefetch();
2810
+ persistScanCursor(cursor);
2811
+ return { transferred, transferredBytes, scanned, cancelled: true, rejected };
2492
2812
  }
2493
2813
  if (missingIds.length > 0) {
2494
2814
  const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, missingIds, options, {
2495
2815
  transferred,
2816
+ transferredBytes,
2496
2817
  scanned,
2497
2818
  totalSourceEntries: totalSourceEstimate,
2498
2819
  currentPage,
2499
2820
  });
2500
2821
  transferred = transferResult.transferred;
2822
+ transferredBytes = transferResult.transferredBytes;
2823
+ rejected.push(...transferResult.rejected);
2501
2824
  if (transferResult.cancelled) {
2502
- return { transferred, scanned, cancelled: true };
2825
+ // The current page's transfer did not complete: persist the
2826
+ // boundary of the last fully transferred page instead.
2827
+ discardPrefetch();
2828
+ persistScanCursor(cursor);
2829
+ return { transferred, transferredBytes, scanned, cancelled: true, rejected };
2503
2830
  }
2504
2831
  }
2505
2832
  }
@@ -2507,6 +2834,7 @@ class BaseMindooDB {
2507
2834
  phase: 'transferring',
2508
2835
  message: `Transferred ${transferred} entries (page ${currentPage}, scanned ${scanned})`,
2509
2836
  transferredEntries: transferred,
2837
+ transferredBytes,
2510
2838
  scannedEntries: scanned,
2511
2839
  totalSourceEntries: totalSourceEstimate,
2512
2840
  currentPage,
@@ -2516,7 +2844,8 @@ class BaseMindooDB {
2516
2844
  break;
2517
2845
  }
2518
2846
  }
2519
- return { transferred, scanned, cancelled: false };
2847
+ persistScanCursor(cursor);
2848
+ return { transferred, transferredBytes, scanned, cancelled: false, rejected };
2520
2849
  }
2521
2850
  onProgress?.({
2522
2851
  phase: 'preparing',
@@ -2541,24 +2870,30 @@ class BaseMindooDB {
2541
2870
  });
2542
2871
  const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, sourceNewMetadata.map((m) => m.id), options, {
2543
2872
  transferred,
2873
+ transferredBytes,
2544
2874
  scanned: sourceNewMetadata.length,
2545
2875
  totalSourceEntries: sourceNewMetadata.length,
2546
2876
  });
2547
2877
  transferred = transferResult.transferred;
2878
+ transferredBytes = transferResult.transferredBytes;
2879
+ rejected.push(...transferResult.rejected);
2548
2880
  if (transferResult.cancelled) {
2549
- return { transferred, scanned: sourceNewMetadata.length, cancelled: true };
2881
+ return { transferred, transferredBytes, scanned: sourceNewMetadata.length, cancelled: true, rejected };
2550
2882
  }
2551
2883
  onProgress?.({
2552
2884
  phase: 'transferring',
2553
2885
  message: `Transferred ${transferred} entries`,
2554
2886
  transferredEntries: transferred,
2887
+ transferredBytes,
2555
2888
  scannedEntries: sourceNewMetadata.length,
2556
2889
  totalSourceEntries: sourceNewMetadata.length,
2557
2890
  });
2558
2891
  return {
2559
2892
  transferred,
2893
+ transferredBytes,
2560
2894
  scanned: sourceNewMetadata.length,
2561
2895
  cancelled: false,
2896
+ rejected,
2562
2897
  };
2563
2898
  }
2564
2899
  /**
@@ -2647,29 +2982,50 @@ class BaseMindooDB {
2647
2982
  // ── Phase 5: transfer missing entries in pages ───────────────────
2648
2983
  const pageSize = options?.pageSize ?? 500;
2649
2984
  let transferred = 0;
2985
+ let transferredBytes = 0;
2986
+ const rejected = [];
2650
2987
  for (let offset = 0; offset < missingIds.length; offset += pageSize) {
2651
2988
  if (options?.signal?.aborted) {
2652
- return { transferred, scanned: allNeededArray.length, cancelled: true };
2989
+ return { transferred, transferredBytes, scanned: allNeededArray.length, cancelled: true, rejected };
2653
2990
  }
2654
2991
  const batch = missingIds.slice(offset, offset + pageSize);
2655
2992
  const entries = await sourceStore.getEntries(batch);
2656
2993
  const putResult = await targetStore.putEntries(entries);
2657
2994
  // Persist any witness receipts back onto the local source (see push path
2658
- // in transferEntriesInBatches; docs/accesscontrol.md §5.3).
2659
- if (Array.isArray(putResult) && putResult.length > 0 && sourceStore.applyWitnessReceipts) {
2660
- await sourceStore.applyWitnessReceipts(putResult);
2995
+ // in transferEntriesInBatches; docs/accesscontrol.md §5.3). sync-v5
2996
+ // targets return a structured ack that additionally carries per-entry
2997
+ // rejections (signature-class failures the remote skipped) — surface
2998
+ // them instead of silently counting rejected entries as transferred:
2999
+ // a dropped lifecycle entry (e.g. doc_delete) would otherwise leave the
3000
+ // target believing the document is still alive.
3001
+ const { receipts, batchRejected } = BaseMindooDB.normalizePutResult(putResult);
3002
+ if (receipts.length > 0 && sourceStore.applyWitnessReceipts) {
3003
+ await sourceStore.applyWitnessReceipts(receipts);
3004
+ }
3005
+ const rejectedIds = batchRejected.length > 0 ? new Set(batchRejected.map((entry) => entry.id)) : null;
3006
+ if (rejectedIds) {
3007
+ rejected.push(...batchRejected);
3008
+ this.logger.warn(`Dense sync: target rejected ${batchRejected.length} of ${entries.length} entries in batch at offset ${offset}`);
3009
+ }
3010
+ for (const entry of entries) {
3011
+ if (rejectedIds?.has(entry.id)) {
3012
+ continue;
3013
+ }
3014
+ transferredBytes += entry.encryptedSize ?? entry.encryptedData.length;
2661
3015
  }
2662
- transferred += entries.length;
3016
+ transferred += entries.length - batchRejected.length;
2663
3017
  onProgress?.({
2664
3018
  phase: "transferring",
2665
3019
  message: `Dense sync: transferred ${transferred}/${missingIds.length} entries`,
2666
3020
  transferredEntries: transferred,
3021
+ transferredBytes,
2667
3022
  scannedEntries: allNeededArray.length,
2668
3023
  totalSourceEntries: allNeededArray.length,
2669
3024
  });
2670
3025
  }
2671
- this.logger.info(`Dense sync complete: transferred ${transferred} entries`);
2672
- return { transferred, scanned: allNeededArray.length, cancelled: false };
3026
+ this.logger.info(`Dense sync complete: transferred ${transferred} entries` +
3027
+ (rejected.length > 0 ? `, ${rejected.length} rejected by target` : ""));
3028
+ return { transferred, transferredBytes, scanned: allNeededArray.length, cancelled: false, rejected };
2673
3029
  }
2674
3030
  getStore() {
2675
3031
  return this.store;
@@ -2679,7 +3035,7 @@ class BaseMindooDB {
2679
3035
  }
2680
3036
  async reclaimIncompleteAttachmentUploads(options) {
2681
3037
  const minAgeMs = options?.minAgeMs ?? 5 * 60 * 1000;
2682
- const cutoff = Date.now() - minAgeMs;
3038
+ const cutoff = (0, timeSource_1.semanticNow)() - minAgeMs;
2683
3039
  const ledgers = await this.store.findEntries("pending_attachment_upload", null, cutoff);
2684
3040
  const result = {
2685
3041
  scannedLedgers: ledgers.length,
@@ -2782,6 +3138,7 @@ class BaseMindooDB {
2782
3138
  throw new Error(`createDocuments: invalid document id "${options.id}". ` +
2783
3139
  `Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
2784
3140
  }
3141
+ this.validateIdPrefixOption("createDocuments", options);
2785
3142
  }
2786
3143
  const results = new Array(inputs.length).fill(null);
2787
3144
  const prepared = [];
@@ -2838,18 +3195,68 @@ class BaseMindooDB {
2838
3195
  this.logger.info(`Bulk-created ${prepared.length} documents (${inputs.length - prepared.length} already existed)`);
2839
3196
  return results;
2840
3197
  }
3198
+ /**
3199
+ * Validate `CreateOptions.idPrefix`: mutually exclusive with `id` and must
3200
+ * match {@link DOC_ID_PREFIX_REGEX}.
3201
+ */
3202
+ validateIdPrefixOption(methodName, options) {
3203
+ if (options.idPrefix === undefined) {
3204
+ return;
3205
+ }
3206
+ if (options.id !== undefined) {
3207
+ throw new Error(`${methodName}: id and idPrefix are mutually exclusive`);
3208
+ }
3209
+ if (!types_1.DOC_ID_PREFIX_REGEX.test(options.idPrefix)) {
3210
+ throw new Error(`${methodName}: invalid idPrefix "${options.idPrefix}". ` +
3211
+ `ID prefixes must match ${types_1.DOC_ID_PREFIX_REGEX.source}.`);
3212
+ }
3213
+ }
3214
+ /**
3215
+ * Normalize a caller-supplied listing/iteration `idPrefix`, tolerating one or
3216
+ * more trailing `_` a developer may append when they don't know the exact
3217
+ * syntax (e.g. `"cls_"` → `"cls"`). Valid prefixes never contain `_` (see
3218
+ * {@link DOC_ID_PREFIX_REGEX}), so trimming trailing underscores is lossless
3219
+ * and makes `matchesDocIdPrefix` re-add the single boundary `_` consistently.
3220
+ * Returns `undefined` for an absent or now-empty prefix (i.e. "no filter").
3221
+ */
3222
+ normalizeIdPrefixFilter(idPrefix) {
3223
+ if (idPrefix === undefined) {
3224
+ return undefined;
3225
+ }
3226
+ const stripped = idPrefix.replace(/_+$/, "");
3227
+ return stripped.length > 0 ? stripped : undefined;
3228
+ }
3229
+ /**
3230
+ * Generate a fresh document id (`<prefix>_<22-char-base62(uuidv7)>`, or the
3231
+ * bare base62 part without a prefix) that does not collide with any document
3232
+ * already present in the local index. A collision is practically impossible
3233
+ * (128-bit UUID7), so this is only a cheap O(1) safety net reusing the
3234
+ * existing `indexLookup` map — no extra bookkeeping.
3235
+ */
3236
+ generateUnusedDocId(prefix) {
3237
+ for (;;) {
3238
+ const candidate = (0, idGeneration_1.generateDocId)(prefix);
3239
+ if (this.getDocIndexPosition(candidate) === undefined) {
3240
+ return candidate;
3241
+ }
3242
+ }
3243
+ }
2841
3244
  /**
2842
3245
  * Internal method to create a new document.
2843
3246
  *
2844
- * Handles all four creation flavors expressible through `CreateOptions`:
2845
- * - generated UUID7 ID, current user signs, default tenant key
2846
- * - generated UUID7 ID, custom signing key (e.g. directory admin operations)
3247
+ * Handles the creation flavors expressible through `CreateOptions`:
3248
+ * - generated ID (time-sortable base62-encoded UUID7), current user signs,
3249
+ * default tenant key
3250
+ * - generated ID with custom signing key (e.g. directory admin operations)
3251
+ * - generated ID with a caller-provided `idPrefix` (`<prefix>_<base62>`)
2847
3252
  * - caller-provided ID, current user signs (idempotent on existing IDs)
2848
3253
  * - caller-provided ID with custom signing key
2849
3254
  *
2850
3255
  * For caller-provided IDs the initial Automerge change is seeded from a
2851
3256
  * deterministic, hard-coded change so that two replicas creating the same
2852
3257
  * custom ID converge when synced (see `getCustomIdInitialChangeBytes`).
3258
+ * Prefix-generated IDs are unique by construction, so they take the
3259
+ * generated-ID path: one `doc_create` entry with `initialValues` baked in.
2853
3260
  */
2854
3261
  async createDocumentInternal(options) {
2855
3262
  this.assertWritable("createDocument");
@@ -2860,6 +3267,7 @@ class BaseMindooDB {
2860
3267
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
2861
3268
  const keyId = await this.resolveCreateKeyId(options);
2862
3269
  const useCustomDocId = options.id !== undefined;
3270
+ this.validateIdPrefixOption("createDocument", options);
2863
3271
  // Sanitize caller-provided initial values: internal/reserved fields (those
2864
3272
  // starting with "_", e.g. "_attachments") are managed by MindooDB and must
2865
3273
  // not be seeded by callers. `undefined` values are treated as "not set"
@@ -2893,7 +3301,7 @@ class BaseMindooDB {
2893
3301
  throw new Error("Admin-only database: only the admin key can modify data");
2894
3302
  }
2895
3303
  }
2896
- const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3304
+ const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
2897
3305
  // Idempotent create: when a caller-provided id already exists locally,
2898
3306
  // return the existing document instead of producing a duplicate doc_create.
2899
3307
  if (useCustomDocId) {
@@ -2919,7 +3327,7 @@ class BaseMindooDB {
2919
3327
  // For custom-ID documents we apply a hard-coded initial change so that
2920
3328
  // independent replicas using the same id produce the same Automerge hash
2921
3329
  // and `doc_create` entry id, allowing later changes to merge.
2922
- const now = Date.now();
3330
+ const now = (0, timeSource_1.semanticNow)();
2923
3331
  let newDoc;
2924
3332
  let changeBytes;
2925
3333
  if (useCustomDocId) {
@@ -3101,6 +3509,7 @@ class BaseMindooDB {
3101
3509
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3102
3510
  const keyId = await this.resolveCreateKeyId(options);
3103
3511
  const useCustomDocId = options.id !== undefined;
3512
+ this.validateIdPrefixOption("createDocuments", options);
3104
3513
  // Sanitize caller-provided initial values: internal/reserved fields are
3105
3514
  // managed by MindooDB and must not be seeded by callers. `undefined`
3106
3515
  // values are treated as "not set" (Automerge cannot represent undefined).
@@ -3117,8 +3526,8 @@ class BaseMindooDB {
3117
3526
  throw new Error("Admin-only database: only the admin key can modify data");
3118
3527
  }
3119
3528
  }
3120
- const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3121
- const now = Date.now();
3529
+ const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3530
+ const now = (0, timeSource_1.semanticNow)();
3122
3531
  // Build the initial Automerge document and its first change bytes,
3123
3532
  // following the same seeding rules as the single-create path.
3124
3533
  let seedDoc;
@@ -3281,14 +3690,22 @@ class BaseMindooDB {
3281
3690
  // Filter to document replay metadata plus snapshots up to the timestamp.
3282
3691
  // Attachment chunks are not part of the Automerge replay DAG and must not
3283
3692
  // participate in historical materialization.
3284
- const relevantEntries = allEntryMetadata
3693
+ //
3694
+ // The slice is then pruned to grounded entries (complete causal chains
3695
+ // within the slice). Clock skew or partial sync can put an entry inside
3696
+ // `createdAt <= timestamp` while its dependency falls outside; Automerge
3697
+ // would silently stash such a change, but the metadata-derived isDeleted
3698
+ // below would still count it — pruning keeps content and lifecycle state
3699
+ // consistent: the state at `timestamp` is the deterministic merge of all
3700
+ // complete causal chains up to that instant.
3701
+ const relevantEntries = (0, DocumentDagAnalysis_1.pruneToGroundedEntries)(allEntryMetadata
3285
3702
  .filter((em) => em.createdAt <= timestamp
3286
3703
  && (em.entryType === "doc_create"
3287
3704
  || em.entryType === "doc_change"
3288
3705
  || em.entryType === "doc_delete"
3289
3706
  || em.entryType === "doc_undelete"
3290
3707
  || em.entryType === "doc_snapshot"))
3291
- .sort((a, b) => a.createdAt - b.createdAt);
3708
+ .sort((a, b) => a.createdAt - b.createdAt));
3292
3709
  if (relevantEntries.length === 0) {
3293
3710
  this.performanceCallback?.onHistoryOperation?.({
3294
3711
  operation: "getDocumentAtTimestamp",
@@ -3409,10 +3826,13 @@ class BaseMindooDB {
3409
3826
  this.logger.debug(`Iterating document history for ${docId}`);
3410
3827
  // Get all entry metadata for this document
3411
3828
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
3412
- // Filter to document replay entries (exclude snapshots)
3413
- const relevantEntries = allEntryMetadata
3414
- .filter((em) => this.isDocumentReplayEntry(em))
3415
- .sort((a, b) => a.createdAt - b.createdAt);
3829
+ // Filter to document replay entries (exclude snapshots) and order them
3830
+ // causally: a plain chronological sort can place an effect before its
3831
+ // cause when several entries share the same millisecond (or when replica
3832
+ // clock skew inverts timestamps along a dependency chain), which would
3833
+ // make the history yield states out of causal order. Concurrent entries
3834
+ // are still ordered by (createdAt, id) deterministically.
3835
+ const relevantEntries = (0, DocumentDagAnalysis_1.orderDagEntriesCausally)(allEntryMetadata.filter((em) => this.isDocumentReplayEntry(em)));
3416
3836
  if (relevantEntries.length === 0) {
3417
3837
  return; // Document has no history
3418
3838
  }
@@ -3424,7 +3844,8 @@ class BaseMindooDB {
3424
3844
  let currentDoc = null;
3425
3845
  let createdAt = null;
3426
3846
  let decryptionKeyId = "default";
3427
- for (const entryMetadata of relevantEntries) {
3847
+ for (let entryIndex = 0; entryIndex < relevantEntries.length; entryIndex++) {
3848
+ const entryMetadata = relevantEntries[entryIndex];
3428
3849
  const entryData = entryMap.get(entryMetadata.id);
3429
3850
  if (!entryData) {
3430
3851
  this.logger.warn(`Entry ${entryMetadata.id} not found in store, skipping`);
@@ -3490,8 +3911,12 @@ class BaseMindooDB {
3490
3911
  createdAt: createdAt,
3491
3912
  lastModified: entryMetadata.createdAt,
3492
3913
  decryptionKeyId,
3493
- isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((entry) => entry.createdAt < entryMetadata.createdAt ||
3494
- (entry.createdAt === entryMetadata.createdAt && entry.id <= entryMetadata.id))),
3914
+ // Use the exact prefix of entries applied so far. Rebuilding the
3915
+ // prefix via a (createdAt, id) comparison can disagree with the
3916
+ // actual iteration order when several entries share the same
3917
+ // millisecond (e.g. a fast create→change→delete), which made
3918
+ // intermediate history states wrongly report isDeleted.
3919
+ isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.slice(0, entryIndex + 1)),
3495
3920
  };
3496
3921
  // Wrap and yield (including lifecycle terminal entries)
3497
3922
  const wrappedDoc = this.wrapDocument(internalDoc);
@@ -3546,7 +3971,7 @@ class BaseMindooDB {
3546
3971
  }
3547
3972
  // Trusted time for un-witnessed entries is the current wall clock, computed
3548
3973
  // once per run so all un-witnessed entries share a stable head instant.
3549
- const now = Date.now();
3974
+ const now = (0, timeSource_1.semanticNow)();
3550
3975
  // Affected documents (those with at least one newly-discovered entry), and
3551
3976
  // the set of new entry ids per doc (the trusted-time positions from which the
3552
3977
  // doc's merged state may have changed and must be re-folded / re-emitted).
@@ -3966,24 +4391,28 @@ class BaseMindooDB {
3966
4391
  const offset = Math.max(0, Math.floor(options?.cursor?.offset ?? 0));
3967
4392
  // This API stays metadata-only so large history views can page cheaply
3968
4393
  // without reconstructing every historical Automerge document state.
4394
+ // Entries are ordered causally (dependency-first, (createdAt, id) for
4395
+ // concurrent ties) so the page timeline never shows an effect before its
4396
+ // cause — same ordering as iterateDocumentHistory.
3969
4397
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
3970
- const relevantEntries = allEntryMetadata
3971
- .filter((em) => em.entryType === "doc_create" ||
4398
+ const relevantEntries = (0, DocumentDagAnalysis_1.orderDagEntriesCausally)(allEntryMetadata.filter((em) => em.entryType === "doc_create" ||
3972
4399
  em.entryType === "doc_change" ||
3973
4400
  em.entryType === "doc_delete" ||
3974
- em.entryType === "doc_undelete")
3975
- .sort((a, b) => a.createdAt - b.createdAt);
4401
+ em.entryType === "doc_undelete"));
3976
4402
  // Cursor paging is offset-based because the result is a bounded timeline view
3977
4403
  // over the doc's sorted change metadata, not a resumable store scan cursor.
3978
4404
  const slice = relevantEntries.slice(offset, offset + limit);
3979
- const entries = slice.map((entry) => ({
4405
+ const entries = slice.map((entry, sliceIndex) => ({
3980
4406
  entryId: entry.id,
3981
4407
  entryType: entry.entryType,
3982
4408
  changeCreatedAt: entry.createdAt,
3983
4409
  changeCreatedByPublicKey: entry.createdByPublicKey,
3984
4410
  dependencyIds: [...entry.dependencyIds],
3985
- isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((candidate) => candidate.createdAt < entry.createdAt ||
3986
- (candidate.createdAt === entry.createdAt && candidate.id <= entry.id))),
4411
+ // Deletion state as of this timeline position = derived from the exact
4412
+ // causal prefix of entries up to and including this one. Rebuilding the
4413
+ // prefix via a (createdAt, id) comparison could disagree with the causal
4414
+ // order for same-millisecond entries.
4415
+ isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.slice(0, offset + sliceIndex + 1)),
3987
4416
  }));
3988
4417
  const nextOffset = offset + entries.length;
3989
4418
  const hasMore = nextOffset < relevantEntries.length;
@@ -4594,7 +5023,7 @@ class BaseMindooDB {
4594
5023
  return {
4595
5024
  changeSeqAsOf: latestCursor?.changeSeq ?? 0,
4596
5025
  storeReceiptOrderAsOf: await this.getStoreMaxReceiptOrder(),
4597
- takenAt: Date.now(),
5026
+ takenAt: (0, timeSource_1.semanticNow)(),
4598
5027
  };
4599
5028
  }
4600
5029
  /**
@@ -5242,20 +5671,27 @@ class BaseMindooDB {
5242
5671
  const filteredPaths = normalizedPaths.filter((path) => touchedKeySet.has(this.extractLeafPathSegment(path)));
5243
5672
  return filteredPaths.length > 0 ? filteredPaths : normalizedPaths;
5244
5673
  }
5245
- async getAllDocumentIds() {
5246
- // Return all non-deleted document IDs from index
5674
+ async getAllDocumentIds(options) {
5675
+ // Return all non-deleted document IDs from index, optionally narrowed to a
5676
+ // single id prefix (matched on the `<prefix>_` boundary; see matchesDocIdPrefix).
5677
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
5247
5678
  const docIds = [];
5248
5679
  for (const entry of this.index) {
5249
5680
  if (entry.accessState === "visible" && !entry.isDeleted) {
5681
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
5682
+ continue;
5250
5683
  docIds.push(entry.docId);
5251
5684
  }
5252
5685
  }
5253
5686
  return docIds;
5254
5687
  }
5255
- async getDeletedDocumentIds() {
5688
+ async getDeletedDocumentIds(options) {
5689
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
5256
5690
  const docIds = [];
5257
5691
  for (const entry of this.index) {
5258
5692
  if (entry.accessState === "visible" && entry.isDeleted) {
5693
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
5694
+ continue;
5259
5695
  docIds.push(entry.docId);
5260
5696
  }
5261
5697
  }
@@ -5295,24 +5731,36 @@ class BaseMindooDB {
5295
5731
  }
5296
5732
  const docIds = [];
5297
5733
  for (const [docId, lifecycleEntries] of lifecycleEntriesByDocId.entries()) {
5298
- // Sort deterministically so same-timestamp lifecycle entries resolve the
5299
- // same way on every replica.
5300
- const ordered = lifecycleEntries.sort((a, b) => a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id.localeCompare(b.id));
5301
5734
  // A document cannot exist before its create entry, even if malformed or
5302
5735
  // partial metadata somehow contains later lifecycle entries.
5303
- if (!ordered.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
5736
+ if (!lifecycleEntries.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
5304
5737
  continue;
5305
5738
  }
5306
- const createEntry = ordered.find((entry) => entry.entryType === "doc_create");
5739
+ const createEntry = lifecycleEntries.find((entry) => entry.entryType === "doc_create");
5307
5740
  if (createEntry && !(await this.tenant.hasDecryptionKey(createEntry.decryptionKeyId))) {
5308
5741
  continue;
5309
5742
  }
5310
- // Delete and undelete are terminal lifecycle markers. The latest one at
5311
- // the timestamp decides whether the document existed then.
5312
- const latestTerminal = ordered
5313
- .filter((entry) => entry.entryType === "doc_delete" || entry.entryType === "doc_undelete")
5314
- .at(-1);
5315
- if (!latestTerminal || latestTerminal.entryType === "doc_undelete") {
5743
+ const terminalEntries = lifecycleEntries.filter((entry) => (entry.entryType === "doc_delete" || entry.entryType === "doc_undelete")
5744
+ && entry.createdAt <= timestamp);
5745
+ if (terminalEntries.length === 0) {
5746
+ // Fast path (the vast majority of documents): no delete/undelete ever
5747
+ // happened up to `timestamp`, so the document is alive and we never
5748
+ // need its change metadata.
5749
+ docIds.push(docId);
5750
+ continue;
5751
+ }
5752
+ // Causal path: the doc has terminal lifecycle entries, so alive/deleted
5753
+ // must be derived exactly like getDocumentAtTimestamp derives it —
5754
+ // grounded slice, dependency-based heads, causal supersession — instead
5755
+ // of a purely chronological "latest terminal wins". A same-instant
5756
+ // delete→undelete pair or cross-replica clock skew would otherwise be
5757
+ // decided by id tie-break, which can disagree with causality and with
5758
+ // getDocumentAtTimestamp for the same instant. The extra per-doc
5759
+ // metadata scan is bounded by the number of ever-deleted documents.
5760
+ const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
5761
+ const relevantEntries = (0, DocumentDagAnalysis_1.pruneToGroundedEntries)(allEntryMetadata.filter((entry) => entry.createdAt <= timestamp
5762
+ && (this.isDocumentReplayEntry(entry) || entry.entryType === "doc_snapshot")));
5763
+ if (!this.computeIsDeletedFromMetadata(relevantEntries)) {
5316
5764
  docIds.push(docId);
5317
5765
  }
5318
5766
  }
@@ -5425,17 +5873,31 @@ class BaseMindooDB {
5425
5873
  // Delete and undelete are encoded as normal Automerge changes so the DAG
5426
5874
  // keeps causal ancestry, while the StoreEntry type carries lifecycle intent.
5427
5875
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
5428
- const now = Date.now();
5876
+ const now = (0, timeSource_1.semanticNow)();
5429
5877
  const docId = internalDoc.id;
5430
5878
  // Lifecycle changes are intentionally non-destructive: they only bump
5431
5879
  // `_lastModified` and repair missing legacy attachment arrays. The document
5432
5880
  // body remains available for history and future undeletion.
5433
- const newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, { time: now }, (mutableDoc) => {
5881
+ //
5882
+ // Same-millisecond hazard: the change writes ONLY `_lastModified`, and
5883
+ // Automerge dedupes a same-value assignment into an EMPTY change (heads
5884
+ // unchanged, getLastLocalChange returns the PREVIOUS change). A delete in
5885
+ // the same millisecond as the last change would then reuse the previous
5886
+ // change's hash, collide with its entry id, and the doc_delete would
5887
+ // silently never be persisted — the document resurrects on sync/reload.
5888
+ // If the mutation dedupes to nothing, fall back to an explicit
5889
+ // `emptyChange`, which always advances the heads with a fresh hash while
5890
+ // keeping causal ancestry intact.
5891
+ const headsBefore = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5892
+ let newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, { time: now }, (mutableDoc) => {
5434
5893
  if (!Array.isArray(mutableDoc._attachments)) {
5435
5894
  mutableDoc._attachments = [];
5436
5895
  }
5437
5896
  mutableDoc._lastModified = now;
5438
5897
  }));
5898
+ if (automerge_adapter_1.Automerge.getHeads(newDoc).every((head) => headsBefore.includes(head))) {
5899
+ newDoc = automerge_adapter_1.Automerge.emptyChange(newDoc, { time: now });
5900
+ }
5439
5901
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
5440
5902
  if (!changeBytes) {
5441
5903
  throw new Error("Failed to get change bytes from Automerge document");
@@ -5540,15 +6002,18 @@ class BaseMindooDB {
5540
6002
  // The doc is now witness-era (we just wrote a versioned entry), so it is
5541
6003
  // witnessed iff it is no longer awaiting a receipt.
5542
6004
  internalDoc.witnessed = (0, storeEntryTime_1.isVersioned)(entryMetadata) && !internalDoc.awaitingWitness;
6005
+ // Force the changeSeq bump: a lifecycle entry was just written, so the
6006
+ // changefeed must re-emit the doc even if all tracked index fields happen
6007
+ // to match the previous state in the same millisecond.
5543
6008
  if (options.markDirty) {
5544
6009
  await this.storeCachedDocument(internalDoc);
5545
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
6010
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
5546
6011
  this.markDocDirty(docId);
5547
6012
  }
5548
6013
  else {
5549
6014
  this.docCache.delete(docId);
5550
6015
  this.dirtyDocIds.delete(docId);
5551
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
6016
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
5552
6017
  }
5553
6018
  }
5554
6019
  /**
@@ -5612,7 +6077,7 @@ class BaseMindooDB {
5612
6077
  throw new errors_1.DocumentDeletedError(docId);
5613
6078
  }
5614
6079
  this.validateTextPatch(patch);
5615
- const now = Date.now();
6080
+ const now = (0, timeSource_1.semanticNow)();
5616
6081
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5617
6082
  const baseHeads = patch.baseHeads;
5618
6083
  const applyEdits = (automergeDoc) => {
@@ -5676,7 +6141,7 @@ class BaseMindooDB {
5676
6141
  throw new errors_1.DocumentDeletedError(docId);
5677
6142
  }
5678
6143
  this.validateRichTextPatch(patch);
5679
- const now = Date.now();
6144
+ const now = (0, timeSource_1.semanticNow)();
5680
6145
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5681
6146
  const spansSequence = patch.spansSequence ?? (patch.spans ? [patch.spans] : []);
5682
6147
  const applySpans = (automergeDoc) => {
@@ -5751,7 +6216,7 @@ class BaseMindooDB {
5751
6216
  throw new errors_1.DocumentDeletedError(docId);
5752
6217
  }
5753
6218
  this.validateRichTextStepPatch(patch);
5754
- const now = Date.now();
6219
+ const now = (0, timeSource_1.semanticNow)();
5755
6220
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5756
6221
  const applySteps = (automergeDoc) => {
5757
6222
  this.ensureRichTextPath(automergeDoc, patch.path);
@@ -5873,7 +6338,7 @@ class BaseMindooDB {
5873
6338
  throw new errors_1.DocumentDeletedError(docId);
5874
6339
  }
5875
6340
  await this.hydrateAutomergeHashMappingsFromStore(docId);
5876
- const now = Date.now();
6341
+ const now = (0, timeSource_1.semanticNow)();
5877
6342
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5878
6343
  if (patch.baseHeads?.length) {
5879
6344
  const baseHeadsKey = [...patch.baseHeads].sort().join("|");
@@ -5980,7 +6445,7 @@ class BaseMindooDB {
5980
6445
  throw new errors_1.DocumentDeletedError(docId);
5981
6446
  }
5982
6447
  this.validateJsonPatch(patch);
5983
- const now = Date.now();
6448
+ const now = (0, timeSource_1.semanticNow)();
5984
6449
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5985
6450
  const applyPatch = (automergeDoc) => {
5986
6451
  this.applyJsonPatchOperations(automergeDoc, patch);
@@ -6053,7 +6518,7 @@ class BaseMindooDB {
6053
6518
  throw new errors_1.DocumentDeletedError(docId);
6054
6519
  }
6055
6520
  // Apply the change function
6056
- const now = Date.now();
6521
+ const now = (0, timeSource_1.semanticNow)();
6057
6522
  this.logger.debug(`Applying change function to document ${docId}`);
6058
6523
  if (this.isDebugEnabled())
6059
6524
  this.logger.debug(`Document state before change: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(internalDoc.doc))}`);
@@ -6958,7 +7423,10 @@ class BaseMindooDB {
6958
7423
  || (internalDoc.witnessed ?? false);
6959
7424
  internalDoc.witnessed = versioned && !internalDoc.awaitingWitness;
6960
7425
  await this.storeCachedDocument(internalDoc);
6961
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
7426
+ // Force the changeSeq bump: new change entries were just written, so the
7427
+ // changefeed must re-emit the doc even when `lastModified` collides with
7428
+ // the previous change in the same millisecond.
7429
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
6962
7430
  this.markDocDirty(docId);
6963
7431
  this.logger.info(`Document ${docId} ${successMessage} successfully`);
6964
7432
  const createdByPublicKey = useCustomKey
@@ -7076,7 +7544,7 @@ class BaseMindooDB {
7076
7544
  contentHash,
7077
7545
  docId,
7078
7546
  dependencyIds: headEntryIds,
7079
- createdAt: Date.now(),
7547
+ createdAt: (0, timeSource_1.semanticNow)(),
7080
7548
  createdByPublicKey: options.createdByPublicKey,
7081
7549
  decryptionKeyId: internalDoc.decryptionKeyId,
7082
7550
  snapshotHeadHashes: headHashes,
@@ -7099,7 +7567,7 @@ class BaseMindooDB {
7099
7567
  this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
7100
7568
  }
7101
7569
  }
7102
- async prefetchIterationWindow(indexSnapshot, startIndex) {
7570
+ async prefetchIterationWindow(indexSnapshot, startIndex, idPrefix) {
7103
7571
  if (this.iteratePrefetchWindowDocs <= 0) {
7104
7572
  return 0;
7105
7573
  }
@@ -7115,6 +7583,10 @@ class BaseMindooDB {
7115
7583
  if (entry.accessState === "inaccessible" || entry.isDeleted) {
7116
7584
  continue;
7117
7585
  }
7586
+ // Don't prefetch bodies the prefix-filtered iteration will never yield.
7587
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix)) {
7588
+ continue;
7589
+ }
7118
7590
  const docId = entry.docId;
7119
7591
  if (seen.has(docId) || this.docCache.has(docId)) {
7120
7592
  continue;
@@ -7167,7 +7639,7 @@ class BaseMindooDB {
7167
7639
  return startIndex;
7168
7640
  }
7169
7641
  resolveDagTimestamp(timestamp) {
7170
- return timestamp === "now" ? Date.now() : timestamp;
7642
+ return timestamp === "now" ? (0, timeSource_1.semanticNow)() : timestamp;
7171
7643
  }
7172
7644
  previewChangeValue(value) {
7173
7645
  if (value === undefined) {
@@ -7433,19 +7905,22 @@ class BaseMindooDB {
7433
7905
  witnessed: witnessState.witnessed,
7434
7906
  };
7435
7907
  }
7436
- async *iterateChangeMetadataSince(cursor) {
7908
+ async *iterateChangeMetadataSince(cursor, options) {
7437
7909
  const startedAt = Date.now();
7438
7910
  const actualCursor = cursor ?? {
7439
7911
  changeSeq: 0,
7440
7912
  lastModified: 0,
7441
7913
  docId: "",
7442
7914
  };
7915
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7443
7916
  const indexSnapshot = [...this.index];
7444
7917
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
7445
7918
  let yieldedDocuments = 0;
7446
7919
  try {
7447
7920
  for (let i = startIndex; i < indexSnapshot.length; i++) {
7448
7921
  const entry = indexSnapshot[i];
7922
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
7923
+ continue;
7449
7924
  const currentCursor = {
7450
7925
  changeSeq: entry.changeSeq,
7451
7926
  lastModified: entry.lastModified,
@@ -7487,20 +7962,32 @@ class BaseMindooDB {
7487
7962
  * them. O(log n) binary search on the internal `(changeSeq, docId)` index —
7488
7963
  * used for progress reporting around {@link iterateChangesSince}.
7489
7964
  */
7490
- countChangesSince(cursor) {
7965
+ countChangesSince(cursor, options) {
7491
7966
  const actualCursor = cursor ?? {
7492
7967
  changeSeq: 0,
7493
7968
  lastModified: 0,
7494
7969
  docId: "",
7495
7970
  };
7496
7971
  const startIndex = this.getStartIndexForCursor(this.index, actualCursor);
7497
- return this.index.length - startIndex;
7972
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7973
+ if (!idPrefix) {
7974
+ return this.index.length - startIndex;
7975
+ }
7976
+ // Prefix-filtered count must walk the tail: the index is ordered by
7977
+ // (changeSeq, docId), not by docId, so matches are not contiguous.
7978
+ let count = 0;
7979
+ for (let i = startIndex; i < this.index.length; i++) {
7980
+ if ((0, idGeneration_1.matchesDocIdPrefix)(this.index[i].docId, idPrefix))
7981
+ count++;
7982
+ }
7983
+ return count;
7498
7984
  }
7499
- async *iterateChangesSince(cursor) {
7985
+ async *iterateChangesSince(cursor, options) {
7500
7986
  const startedAt = Date.now();
7501
7987
  // Default to initial cursor if null is provided.
7502
7988
  // Prefer deterministic sequence-based cursoring; keep legacy fallback compatibility.
7503
7989
  const actualCursor = cursor ?? { changeSeq: 0, lastModified: 0, docId: "" };
7990
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7504
7991
  this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
7505
7992
  // Use a stable snapshot of the index for this generator run so concurrent
7506
7993
  // updates do not reorder/skip entries while iterating.
@@ -7508,13 +7995,17 @@ class BaseMindooDB {
7508
7995
  // Find starting position using binary search
7509
7996
  // We want to find the first entry that is greater than the cursor.
7510
7997
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
7511
- let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex);
7998
+ let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex, idPrefix);
7512
7999
  let yieldedDocuments = 0;
7513
8000
  let loadedDocuments = 0;
7514
8001
  try {
7515
8002
  // Iterate through the stable snapshot and yield documents one at a time.
7516
8003
  for (let i = startIndex; i < indexSnapshot.length; i++) {
7517
8004
  const entry = indexSnapshot[i];
8005
+ // Skip entries outside the requested id prefix before any body load,
8006
+ // so prefix-filtered iteration never decrypts/materializes unrelated docs.
8007
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
8008
+ continue;
7518
8009
  if (entry.accessState !== "visible") {
7519
8010
  // The document was readable before but the current KeyBag can no
7520
8011
  // longer decrypt it (e.g. a revoked key, §13). Emit a tombstone so
@@ -7529,7 +8020,7 @@ class BaseMindooDB {
7529
8020
  };
7530
8021
  yieldedDocuments++;
7531
8022
  yield { doc: this.buildInaccessibleDoc(entry), cursor: inaccessibleCursor };
7532
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
8023
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7533
8024
  continue;
7534
8025
  }
7535
8026
  if (entry.isDeleted) {
@@ -7546,7 +8037,7 @@ class BaseMindooDB {
7546
8037
  };
7547
8038
  yieldedDocuments++;
7548
8039
  yield { doc: this.buildDeletedDoc(entry), cursor: deletedCursor };
7549
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
8040
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7550
8041
  continue;
7551
8042
  }
7552
8043
  try {
@@ -7576,7 +8067,7 @@ class BaseMindooDB {
7576
8067
  // Deleted documents are included so external indexes can handle deletions
7577
8068
  yieldedDocuments++;
7578
8069
  yield { doc, cursor: currentCursor };
7579
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
8070
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7580
8071
  }
7581
8072
  catch (error) {
7582
8073
  this.logger.error(`Error processing document ${entry.docId}:`, error);
@@ -8401,7 +8892,7 @@ class BaseMindooDB {
8401
8892
  // A verify-only provider (no signer) routes receipts to the right scheme.
8402
8893
  const subtle = this.getSubtle();
8403
8894
  const receiptVerifier = new Ed25519WitnessProvider_1.Ed25519WitnessProvider({ subtle }).createVerifier();
8404
- const nowMs = Date.now();
8895
+ const nowMs = (0, timeSource_1.semanticNow)();
8405
8896
  // Lifecycle entries only, folded in deterministic causal order. Un-witnessed
8406
8897
  // entries (versioned or legacy) order by their author time `createdAt`, NOT
8407
8898
  // the provisional `now`: this fold computes per-step before/after states for
@@ -8437,7 +8928,7 @@ class BaseMindooDB {
8437
8928
  // Audit record: the entry's own (stable) time — receipt time if
8438
8929
  // witnessed, else author time. Not the provisional head `now`.
8439
8930
  trustedTime: meta.receivedAt ?? meta.createdAt,
8440
- recordedAt: Date.now(),
8931
+ recordedAt: (0, timeSource_1.semanticNow)(),
8441
8932
  });
8442
8933
  };
8443
8934
  for (const meta of dataEntries) {
@@ -8565,7 +9056,7 @@ class BaseMindooDB {
8565
9056
  const payload = currentDoc;
8566
9057
  const isDeleted = this.computeIsDeletedFromMetadata(acceptedMeta);
8567
9058
  const lastAccepted = acceptedMeta[acceptedMeta.length - 1];
8568
- const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : Date.now());
9059
+ const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : (0, timeSource_1.semanticNow)());
8569
9060
  const witnessState = (0, storeEntryTime_1.metadataWitnessState)(acceptedMeta);
8570
9061
  return {
8571
9062
  doc: {
@@ -8936,7 +9427,7 @@ class BaseMindooDB {
8936
9427
  // Get lastModified from payload, or use the timestamp of the last entry
8937
9428
  const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
8938
9429
  const lastModified = payload._lastModified ||
8939
- (lastEntry ? lastEntry.createdAt : Date.now());
9430
+ (lastEntry ? lastEntry.createdAt : (0, timeSource_1.semanticNow)());
8940
9431
  // Get createdAt from the first entry
8941
9432
  const firstEntry = allEntryMetadata.length > 0 ? allEntryMetadata[0] : null;
8942
9433
  const createdAt = firstEntry ? firstEntry.createdAt : lastModified;
@@ -9329,6 +9820,9 @@ class BaseMindooDB {
9329
9820
  const chunks = [];
9330
9821
  let prevChunkId = null;
9331
9822
  let lastChunkId = "";
9823
+ // Guards against case-folded chunk-id collisions on case-insensitive
9824
+ // filesystems; scoped to this single attachment write.
9825
+ const usedCaseFoldedChunkIds = new Set();
9332
9826
  for (let offset = 0; offset < fileData.length; offset += this.chunkSizeBytes) {
9333
9827
  const chunkData = fileData.slice(offset, Math.min(offset + this.chunkSizeBytes, fileData.length));
9334
9828
  // Encrypt chunk
@@ -9336,7 +9830,7 @@ class BaseMindooDB {
9336
9830
  // Compute content hash
9337
9831
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9338
9832
  // Generate chunk ID
9339
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9833
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9340
9834
  lastChunkId = chunkId;
9341
9835
  // Sign the encrypted chunk
9342
9836
  const signature = await this.tenant.signPayload(encryptedData);
@@ -9410,7 +9904,7 @@ class BaseMindooDB {
9410
9904
  dependencyIds: [],
9411
9905
  createdAt,
9412
9906
  attachmentId,
9413
- uploadStartedAt: Date.now(),
9907
+ uploadStartedAt: (0, timeSource_1.semanticNow)(),
9414
9908
  createdByPublicKey,
9415
9909
  decryptionKeyId,
9416
9910
  signature,
@@ -9456,6 +9950,9 @@ class BaseMindooDB {
9456
9950
  const pendingEntries = [];
9457
9951
  let pendingEncryptedBytes = 0;
9458
9952
  let ledgerCreated = false;
9953
+ // Guards against case-folded chunk-id collisions on case-insensitive
9954
+ // filesystems; scoped to this single attachment write.
9955
+ const usedCaseFoldedChunkIds = new Set();
9459
9956
  const flushPendingEntries = async (force = false) => {
9460
9957
  if (pendingEntries.length === 0) {
9461
9958
  return;
@@ -9474,7 +9971,7 @@ class BaseMindooDB {
9474
9971
  // Compute content hash
9475
9972
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9476
9973
  // Generate chunk ID
9477
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9974
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9478
9975
  // Sign the encrypted chunk
9479
9976
  const signature = await this.tenant.signPayload(encryptedData);
9480
9977
  // Create chunk entry
@@ -9562,6 +10059,9 @@ class BaseMindooDB {
9562
10059
  const chunks = [];
9563
10060
  let prevChunkId = prevLastChunkId;
9564
10061
  let lastChunkId = prevLastChunkId;
10062
+ // Guards against case-folded chunk-id collisions on case-insensitive
10063
+ // filesystems; scoped to this single append.
10064
+ const usedCaseFoldedChunkIds = new Set();
9565
10065
  for (let offset = 0; offset < data.length; offset += this.chunkSizeBytes) {
9566
10066
  const chunkData = data.slice(offset, Math.min(offset + this.chunkSizeBytes, data.length));
9567
10067
  // Encrypt chunk
@@ -9569,7 +10069,7 @@ class BaseMindooDB {
9569
10069
  // Compute content hash
9570
10070
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9571
10071
  // Generate chunk ID
9572
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
10072
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9573
10073
  lastChunkId = chunkId;
9574
10074
  // Sign the encrypted chunk
9575
10075
  const signature = await this.tenant.signPayload(encryptedData);
@@ -9692,23 +10192,26 @@ class BaseMindooDB {
9692
10192
  const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
9693
10193
  try {
9694
10194
  const syncResult = await this.syncEntriesFromStore(remoteStore, localStore, options);
10195
+ const transferredBytes = syncResult.transferredBytes ?? 0;
9695
10196
  this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
9696
10197
  if (syncResult.cancelled) {
9697
10198
  this.logger.info(`Pull cancelled after transferring ${syncResult.transferred} entries`);
9698
10199
  return {
9699
10200
  transferredEntries: syncResult.transferred,
10201
+ transferredBytes,
9700
10202
  scannedEntries: syncResult.scanned,
9701
10203
  cancelled: true,
9702
10204
  };
9703
10205
  }
9704
10206
  if (syncResult.transferred === 0) {
9705
10207
  this.logger.debug(`No new entries to pull`);
9706
- return { transferredEntries: 0, scannedEntries: syncResult.scanned, cancelled: false };
10208
+ return { transferredEntries: 0, transferredBytes, scannedEntries: syncResult.scanned, cancelled: false };
9707
10209
  }
9708
10210
  options?.onProgress?.({
9709
10211
  phase: 'processing',
9710
10212
  message: `Processing ${syncResult.transferred} new entries...`,
9711
10213
  transferredEntries: syncResult.transferred,
10214
+ transferredBytes,
9712
10215
  scannedEntries: syncResult.scanned,
9713
10216
  });
9714
10217
  // Sync the local store to process the new entries
@@ -9717,6 +10220,7 @@ class BaseMindooDB {
9717
10220
  this.logger.info(`Pull cancelled before local processing after transferring ${syncResult.transferred} entries`);
9718
10221
  return {
9719
10222
  transferredEntries: syncResult.transferred,
10223
+ transferredBytes,
9720
10224
  scannedEntries: syncResult.scanned,
9721
10225
  cancelled: true,
9722
10226
  };
@@ -9738,6 +10242,7 @@ class BaseMindooDB {
9738
10242
  this.logger.info(`Pull cancelled after local processing for ${storeKind} entries`);
9739
10243
  return {
9740
10244
  transferredEntries: syncResult.transferred,
10245
+ transferredBytes,
9741
10246
  scannedEntries: syncResult.scanned,
9742
10247
  cancelled: true,
9743
10248
  };
@@ -9747,10 +10252,12 @@ class BaseMindooDB {
9747
10252
  phase: 'complete',
9748
10253
  message: `Pull complete: ${syncResult.transferred} ${storeKind} entries synced`,
9749
10254
  transferredEntries: syncResult.transferred,
10255
+ transferredBytes,
9750
10256
  scannedEntries: syncResult.scanned,
9751
10257
  });
9752
10258
  return {
9753
10259
  transferredEntries: syncResult.transferred,
10260
+ transferredBytes,
9754
10261
  scannedEntries: syncResult.scanned,
9755
10262
  cancelled: false,
9756
10263
  };
@@ -9786,13 +10293,21 @@ class BaseMindooDB {
9786
10293
  const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
9787
10294
  try {
9788
10295
  const syncResult = await this.syncEntriesFromStore(localStore, remoteStore, options);
10296
+ const transferredBytes = syncResult.transferredBytes ?? 0;
9789
10297
  this.logger.debug(`Transferred ${syncResult.transferred} entries to remote store`);
10298
+ const rejectedEntries = syncResult.rejected ?? [];
10299
+ if (rejectedEntries.length > 0) {
10300
+ this.logger.warn(`Remote rejected ${rejectedEntries.length} entries during push (skipped, push continued): ` +
10301
+ rejectedEntries.map((r) => r.reason).join("; "));
10302
+ }
9790
10303
  if (syncResult.cancelled) {
9791
10304
  this.logger.info(`Push cancelled after transferring ${syncResult.transferred} entries`);
9792
10305
  return {
9793
10306
  transferredEntries: syncResult.transferred,
10307
+ transferredBytes,
9794
10308
  scannedEntries: syncResult.scanned,
9795
10309
  cancelled: true,
10310
+ ...(rejectedEntries.length > 0 ? { rejectedEntries } : {}),
9796
10311
  };
9797
10312
  }
9798
10313
  if (syncResult.transferred === 0) {
@@ -9805,12 +10320,15 @@ class BaseMindooDB {
9805
10320
  phase: 'complete',
9806
10321
  message: `Push complete: ${syncResult.transferred} entries transferred`,
9807
10322
  transferredEntries: syncResult.transferred,
10323
+ transferredBytes,
9808
10324
  scannedEntries: syncResult.scanned,
9809
10325
  });
9810
10326
  return {
9811
10327
  transferredEntries: syncResult.transferred,
10328
+ transferredBytes,
9812
10329
  scannedEntries: syncResult.scanned,
9813
10330
  cancelled: false,
10331
+ ...(rejectedEntries.length > 0 ? { rejectedEntries } : {}),
9814
10332
  };
9815
10333
  }
9816
10334
  finally {
@@ -9838,6 +10356,13 @@ exports.BaseMindooDB = BaseMindooDB;
9838
10356
  * events frequent and make each POST cheap to retry.
9839
10357
  */
9840
10358
  BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE = 250;
10359
+ /**
10360
+ * Default window of transfer batches running in parallel (sync-v5,
10361
+ * phase 4). Kept small: each in-flight batch holds one decrypted page of
10362
+ * entries in memory, and HTTP/2 multiplexes the requests over a single
10363
+ * connection anyway. Overridable via `SyncOptions.maxConcurrentBatches`.
10364
+ */
10365
+ BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES = 3;
9841
10366
  /** The "access control not enforced" allow decision returned by the
9842
10367
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
9843
10368
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {