mindoodb 0.0.34 → 0.0.35

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 (91) hide show
  1. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +37 -3
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +158 -4
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  5. package/dist/appendonlystores/network/HttpTransport.d.ts +54 -3
  6. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  7. package/dist/appendonlystores/network/HttpTransport.js +344 -17
  8. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  9. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +39 -2
  10. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +93 -11
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  13. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +9 -1
  14. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +27 -0
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  17. package/dist/browser/index.mjs +7 -0
  18. package/dist/core/BaseMindooDB.d.ts +82 -9
  19. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  20. package/dist/core/BaseMindooDB.js +556 -150
  21. package/dist/core/BaseMindooDB.js.map +1 -1
  22. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  23. package/dist/core/BaseMindooTenant.js +2 -1
  24. package/dist/core/BaseMindooTenant.js.map +1 -1
  25. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  26. package/dist/core/BaseMindooTenantDirectory.js +4 -3
  27. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  28. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  29. package/dist/core/BaseMindooTenantFactory.js +2 -1
  30. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  31. package/dist/core/DocumentDagAnalysis.d.ts +39 -0
  32. package/dist/core/DocumentDagAnalysis.d.ts.map +1 -1
  33. package/dist/core/DocumentDagAnalysis.js +147 -3
  34. package/dist/core/DocumentDagAnalysis.js.map +1 -1
  35. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +7 -1
  36. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  37. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +15 -0
  38. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  39. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +55 -6
  40. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  41. package/dist/core/appendonlystores/network/binaryEntryFraming.d.ts +58 -0
  42. package/dist/core/appendonlystores/network/binaryEntryFraming.d.ts.map +1 -0
  43. package/dist/core/appendonlystores/network/binaryEntryFraming.js +121 -0
  44. package/dist/core/appendonlystores/network/binaryEntryFraming.js.map +1 -0
  45. package/dist/core/appendonlystores/network/types.d.ts +68 -0
  46. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  47. package/dist/core/appendonlystores/network/types.js.map +1 -1
  48. package/dist/core/appendonlystores/types.d.ts +58 -1
  49. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  50. package/dist/core/appendonlystores/types.js.map +1 -1
  51. package/dist/core/crypto/RSAEncryption.d.ts +11 -0
  52. package/dist/core/crypto/RSAEncryption.d.ts.map +1 -1
  53. package/dist/core/crypto/RSAEncryption.js +25 -0
  54. package/dist/core/crypto/RSAEncryption.js.map +1 -1
  55. package/dist/core/index.d.ts +2 -2
  56. package/dist/core/index.d.ts.map +1 -1
  57. package/dist/core/index.js +8 -2
  58. package/dist/core/index.js.map +1 -1
  59. package/dist/core/types.d.ts +100 -8
  60. package/dist/core/types.d.ts.map +1 -1
  61. package/dist/core/types.js +9 -1
  62. package/dist/core/types.js.map +1 -1
  63. package/dist/core/utils/idGeneration.d.ts +52 -0
  64. package/dist/core/utils/idGeneration.d.ts.map +1 -1
  65. package/dist/core/utils/idGeneration.js +78 -1
  66. package/dist/core/utils/idGeneration.js.map +1 -1
  67. package/dist/core/utils/index.d.ts +2 -1
  68. package/dist/core/utils/index.d.ts.map +1 -1
  69. package/dist/core/utils/index.js +8 -1
  70. package/dist/core/utils/index.js.map +1 -1
  71. package/dist/core/utils/timeSource.d.ts +34 -0
  72. package/dist/core/utils/timeSource.d.ts.map +1 -0
  73. package/dist/core/utils/timeSource.js +49 -0
  74. package/dist/core/utils/timeSource.js.map +1 -0
  75. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +17 -1
  76. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  77. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +43 -0
  78. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  79. package/dist/node/server/MindooDBServer.d.ts +38 -0
  80. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  81. package/dist/node/server/MindooDBServer.js +262 -6
  82. package/dist/node/server/MindooDBServer.js.map +1 -1
  83. package/dist/node/server/SyncEventBus.d.ts +29 -0
  84. package/dist/node/server/SyncEventBus.d.ts.map +1 -0
  85. package/dist/node/server/SyncEventBus.js +41 -0
  86. package/dist/node/server/SyncEventBus.js.map +1 -0
  87. package/dist/node/server/compressionMiddleware.d.ts +25 -0
  88. package/dist/node/server/compressionMiddleware.d.ts.map +1 -0
  89. package/dist/node/server/compressionMiddleware.js +140 -0
  90. package/dist/node/server/compressionMiddleware.js.map +1 -0
  91. package/package.json +1 -1
@@ -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
@@ -1110,6 +1124,11 @@ class BaseMindooDB {
1110
1124
  hashMap[docId] = Object.fromEntries(inner);
1111
1125
  }
1112
1126
  checkpoint.automergeHashToEntryId = hashMap;
1127
+ // Persisted sync scan cursors (sync-v5, phase 1). Additive field: older
1128
+ // readers ignore it, older checkpoints simply restore an empty map.
1129
+ if (this.syncScanCursors.size > 0) {
1130
+ checkpoint.syncScanCursors = Object.fromEntries(this.syncScanCursors);
1131
+ }
1113
1132
  // For stores without cursor scan, include processedEntryIds
1114
1133
  if (!this.supportsCursorScan(this.store)) {
1115
1134
  checkpoint.processedEntryIds = this.processedEntryIds;
@@ -1149,6 +1168,21 @@ class BaseMindooDB {
1149
1168
  }
1150
1169
  this.lastReconciledKeyBagFingerprint =
1151
1170
  typeof checkpoint.keyBagFingerprint === "string" ? checkpoint.keyBagFingerprint : null;
1171
+ this.syncScanCursors.clear();
1172
+ if (checkpoint.syncScanCursors && typeof checkpoint.syncScanCursors === "object") {
1173
+ for (const [key, value] of Object.entries(checkpoint.syncScanCursors)) {
1174
+ if (typeof value?.sourceEpoch === "string" &&
1175
+ typeof value?.targetEpoch === "string" &&
1176
+ typeof value?.cursor?.receiptOrder === "number" &&
1177
+ typeof value?.cursor?.id === "string") {
1178
+ this.syncScanCursors.set(key, {
1179
+ sourceEpoch: value.sourceEpoch,
1180
+ targetEpoch: value.targetEpoch,
1181
+ cursor: { receiptOrder: value.cursor.receiptOrder, id: value.cursor.id },
1182
+ });
1183
+ }
1184
+ }
1185
+ }
1152
1186
  // Rebuild indexLookup from index
1153
1187
  this.indexLookup.clear();
1154
1188
  this.indexLookupStaleFrom = null;
@@ -1405,7 +1439,7 @@ class BaseMindooDB {
1405
1439
  if (cutoff === undefined) {
1406
1440
  return false;
1407
1441
  }
1408
- return (0, storeEntryTime_1.entryTrustedTime)(entry, Date.now()) >= cutoff;
1442
+ return (0, storeEntryTime_1.entryTrustedTime)(entry, (0, timeSource_1.semanticNow)()) >= cutoff;
1409
1443
  }
1410
1444
  catch (error) {
1411
1445
  this.logger.warn(`Failed to resolve metadata-signature floor: ${String(error)}`);
@@ -1437,14 +1471,23 @@ class BaseMindooDB {
1437
1471
  * keeps repeated visibility reconciliations from inflating the
1438
1472
  * changefeed.
1439
1473
  *
1474
+ * `lastModified` has millisecond resolution, so two genuine content
1475
+ * changes landing in the same millisecond are indistinguishable by the
1476
+ * tracked fields alone. Call sites that KNOW new content entries were
1477
+ * just written/applied (changeDoc, delete/undelete, sync ingest) must
1478
+ * pass `forceChangeSeqBump: true` so the change is re-emitted on the
1479
+ * changefeed instead of being swallowed by the idempotency check.
1480
+ *
1440
1481
  * @param docId The document ID
1441
1482
  * @param lastModified The new last modified timestamp
1442
1483
  * @param isDeleted Whether the document is a deletion/inaccessibility tombstone
1443
1484
  * @param decryptionKeyId Encryption key id derived from the doc's store metadata
1444
1485
  * @param accessState `"visible"` for readable docs, `"inaccessible"` for
1445
1486
  * tombstones produced by the key visibility layer
1487
+ * @param forceChangeSeqBump Bypass the no-op identity check because the
1488
+ * caller knows the document content changed (new store entries)
1446
1489
  */
1447
- updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false) {
1490
+ updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false, forceChangeSeqBump = false) {
1448
1491
  const startedAt = Date.now();
1449
1492
  const assignedSeq = this.nextChangeSeq;
1450
1493
  const newEntry = {
@@ -1464,8 +1507,12 @@ class BaseMindooDB {
1464
1507
  // If only metadata flags stayed identical and caller replays same state, skip.
1465
1508
  // `awaitingWitness` is part of this identity so a witness receipt (which
1466
1509
  // changes only `receivedAt`, not Automerge content) still bumps changeSeq
1467
- // and re-emits the doc on the metadata feed.
1468
- if (existingEntry.lastModified === lastModified
1510
+ // and re-emits the doc on the metadata feed. Content-change call sites
1511
+ // bypass this via forceChangeSeqBump: two changes within the same
1512
+ // millisecond would otherwise be indistinguishable here and the second
1513
+ // one would silently vanish from the changefeed.
1514
+ if (!forceChangeSeqBump
1515
+ && existingEntry.lastModified === lastModified
1469
1516
  && existingEntry.isDeleted === isDeleted
1470
1517
  && existingEntry.decryptionKeyId === decryptionKeyId
1471
1518
  && existingEntry.accessState === accessState
@@ -2048,8 +2095,11 @@ class BaseMindooDB {
2048
2095
  updatedDoc = await this.applyNewEntriesToCachedDocument(cachedDoc, entryMetadataList);
2049
2096
  if (updatedDoc) {
2050
2097
  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);
2098
+ // Only update index if document actually changed. A non-null
2099
+ // result means something observable changed (heads or witness
2100
+ // state), so force the changeSeq bump — same-millisecond
2101
+ // lastModified collisions must not swallow the re-emit.
2102
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
2053
2103
  this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted}, awaitingWitness: ${updatedDoc.awaitingWitness ?? false})`);
2054
2104
  }
2055
2105
  else {
@@ -2062,7 +2112,8 @@ class BaseMindooDB {
2062
2112
  this.docCache.delete(docId);
2063
2113
  updatedDoc = await this.loadDocumentInternal(docId);
2064
2114
  if (updatedDoc) {
2065
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false);
2115
+ // New entries triggered this reload, so force the re-emit.
2116
+ this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible", updatedDoc.awaitingWitness ?? false, updatedDoc.witnessed ?? false, true);
2066
2117
  }
2067
2118
  }
2068
2119
  }
@@ -2096,7 +2147,11 @@ class BaseMindooDB {
2096
2147
  const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
2097
2148
  const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
2098
2149
  const { awaitingWitness, witnessed } = (0, storeEntryTime_1.metadataWitnessState)(allDocMetadata);
2099
- this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed);
2150
+ // Force the re-emit only when the batch contains actual replay
2151
+ // entries (create/change/delete/undelete): a snapshot-only batch
2152
+ // changes nothing observable and may keep the idempotent skip.
2153
+ const batchHasReplayEntries = docLifecycleEntries.some((e) => this.isDocumentReplayEntry(e));
2154
+ this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed, batchHasReplayEntries);
2100
2155
  this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted}, awaitingWitness: ${awaitingWitness})`);
2101
2156
  }
2102
2157
  }
@@ -2169,6 +2224,16 @@ class BaseMindooDB {
2169
2224
  };
2170
2225
  const documentEntries = Array.from(entriesByDoc.entries())
2171
2226
  .sort((a, b) => {
2227
+ // Prefer the store's receiptOrder (unique, reflects the actual write
2228
+ // order) so a cold rebuild assigns changeSeqs in the same order the
2229
+ // live session did — `createdAt` has millisecond resolution and ties
2230
+ // for same-ms writes, which would otherwise flip the feed order to
2231
+ // the docId tie-break after a restart.
2232
+ const aMinReceipt = Math.min(...a[1].map((e) => e.receiptOrder ?? Number.MAX_SAFE_INTEGER));
2233
+ const bMinReceipt = Math.min(...b[1].map((e) => e.receiptOrder ?? Number.MAX_SAFE_INTEGER));
2234
+ if (aMinReceipt !== bMinReceipt) {
2235
+ return aMinReceipt - bMinReceipt;
2236
+ }
2172
2237
  const aMinCreatedAt = Math.min(...a[1].map((e) => e.createdAt));
2173
2238
  const bMinCreatedAt = Math.min(...b[1].map((e) => e.createdAt));
2174
2239
  if (aMinCreatedAt !== bMinCreatedAt) {
@@ -2303,6 +2368,46 @@ class BaseMindooDB {
2303
2368
  store.setSyncAbortSignal(signal);
2304
2369
  }
2305
2370
  }
2371
+ /**
2372
+ * Fetch a store's head descriptor (`{ epoch, maxReceiptOrder }`), returning
2373
+ * null when the store (or the remote server behind it) does not support it
2374
+ * or the request fails. A null head simply disables the persisted-cursor
2375
+ * fast path for this sync.
2376
+ */
2377
+ async getStoreHeadSafe(store) {
2378
+ if (typeof store.getStoreHead !== "function") {
2379
+ return null;
2380
+ }
2381
+ try {
2382
+ return await store.getStoreHead();
2383
+ }
2384
+ catch (error) {
2385
+ this.logger.debug("Store head unavailable, falling back to full scan", error);
2386
+ return null;
2387
+ }
2388
+ }
2389
+ /** Stable identity for a store in the persisted sync-cursor map. */
2390
+ static syncStoreIdentity(store) {
2391
+ return store.getCacheIdentity?.() ?? `${store.getId()}/${store.getStoreKind()}`;
2392
+ }
2393
+ /** Key for {@link syncScanCursors}: one record per (source, target) pair. */
2394
+ syncScanCursorKey(sourceStore, targetStore) {
2395
+ return `${BaseMindooDB.syncStoreIdentity(sourceStore)}->${BaseMindooDB.syncStoreIdentity(targetStore)}`;
2396
+ }
2397
+ /** Persist a sync scan cursor and schedule a checkpoint flush. */
2398
+ saveSyncScanCursor(key, record) {
2399
+ const existing = this.syncScanCursors.get(key);
2400
+ if (existing &&
2401
+ existing.sourceEpoch === record.sourceEpoch &&
2402
+ existing.targetEpoch === record.targetEpoch &&
2403
+ existing.cursor.receiptOrder === record.cursor.receiptOrder &&
2404
+ existing.cursor.id === record.cursor.id) {
2405
+ return;
2406
+ }
2407
+ this.syncScanCursors.set(key, record);
2408
+ this.cacheMetaDirty = true;
2409
+ this.cacheManager?.markDirty();
2410
+ }
2306
2411
  /**
2307
2412
  * From a list of candidate IDs, return only those the target store is
2308
2413
  * missing. Uses bloom-filter pre-screening when available, then falls
@@ -2360,65 +2465,120 @@ class BaseMindooDB {
2360
2465
  */
2361
2466
  async transferEntriesInBatches(sourceStore, targetStore, entryIds, options, state) {
2362
2467
  if (entryIds.length === 0) {
2363
- return { transferred: state.transferred, cancelled: false };
2468
+ return { transferred: state.transferred, cancelled: false, rejected: [] };
2364
2469
  }
2365
2470
  const onProgress = options?.onProgress;
2366
2471
  const signal = options?.signal;
2367
2472
  const transferBatchSize = this.resolveTransferBatchSize(options);
2368
2473
  const totalTransferBatches = Math.max(1, Math.ceil(entryIds.length / transferBatchSize));
2474
+ // Parallel transfer window (sync-v5, phase 4): each batch is one
2475
+ // getEntries + putEntries round trip; a small window of concurrent
2476
+ // batches overlaps network latency and server-side crypto. Witness
2477
+ // receipts are applied per batch and are order-independent.
2478
+ const maxConcurrent = Math.min(Math.max(1, Math.floor(options?.maxConcurrentBatches ?? BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES)), totalTransferBatches);
2369
2479
  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 };
2480
+ let cancelled = false;
2481
+ let firstError = null;
2482
+ let nextBatchIndex = 0;
2483
+ // Per-entry rejections reported by a witnessing target (sync-v5): the
2484
+ // push continues, the rejected entries are surfaced to the caller.
2485
+ const rejected = [];
2486
+ const runWorker = async () => {
2487
+ while (true) {
2488
+ if (firstError || cancelled || signal?.aborted) {
2489
+ if (signal?.aborted)
2490
+ cancelled = true;
2491
+ return;
2392
2492
  }
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);
2493
+ const batchIndex = nextBatchIndex++;
2494
+ const offset = batchIndex * transferBatchSize;
2495
+ if (offset >= entryIds.length) {
2496
+ return;
2400
2497
  }
2401
- transferred += batchEntries.length;
2402
- }
2403
- catch (error) {
2404
- if (signal?.aborted) {
2405
- return { transferred, cancelled: true };
2498
+ const currentTransferBatch = batchIndex + 1;
2499
+ const batchIds = entryIds.slice(offset, offset + transferBatchSize);
2500
+ const pageSummary = state.currentPage ? `page ${state.currentPage}, ` : "";
2501
+ onProgress?.({
2502
+ phase: "transferring",
2503
+ message: `Transferring batch ${currentTransferBatch}/${totalTransferBatches} (${batchIds.length} entries, ${pageSummary}scanned ${state.scanned})...`,
2504
+ transferredEntries: transferred,
2505
+ scannedEntries: state.scanned,
2506
+ totalSourceEntries: state.totalSourceEntries,
2507
+ currentPage: state.currentPage,
2508
+ currentTransferBatch,
2509
+ totalTransferBatches,
2510
+ transferBatchSize,
2511
+ });
2512
+ try {
2513
+ const batchEntries = await sourceStore.getEntries(batchIds);
2514
+ if (signal?.aborted) {
2515
+ cancelled = true;
2516
+ return;
2517
+ }
2518
+ const putResult = await targetStore.putEntries(batchEntries);
2519
+ // On push, the remote (witnessing) target returns receipts for accepted
2520
+ // entries; persist them back onto the local source so the revision feed
2521
+ // re-anchors them from the provisional head to their committed
2522
+ // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
2523
+ // sync-v5 targets return a structured ack that additionally carries
2524
+ // per-entry rejections (signature-class failures the remote skipped).
2525
+ const { receipts, batchRejected } = BaseMindooDB.normalizePutResult(putResult);
2526
+ if (receipts.length > 0 && sourceStore.applyWitnessReceipts) {
2527
+ await sourceStore.applyWitnessReceipts(receipts);
2528
+ }
2529
+ if (batchRejected.length > 0) {
2530
+ rejected.push(...batchRejected);
2531
+ this.logger.warn(`Target rejected ${batchRejected.length} of ${batchEntries.length} entries in transfer batch ${currentTransferBatch}/${totalTransferBatches}`);
2532
+ }
2533
+ transferred += batchEntries.length - batchRejected.length;
2406
2534
  }
2407
- throw error;
2535
+ catch (error) {
2536
+ if (signal?.aborted) {
2537
+ cancelled = true;
2538
+ return;
2539
+ }
2540
+ if (!firstError) {
2541
+ firstError = error;
2542
+ }
2543
+ return;
2544
+ }
2545
+ onProgress?.({
2546
+ phase: "transferring",
2547
+ message: `Transferred ${transferred} entries after batch ${currentTransferBatch}/${totalTransferBatches}`,
2548
+ transferredEntries: transferred,
2549
+ scannedEntries: state.scanned,
2550
+ totalSourceEntries: state.totalSourceEntries,
2551
+ currentPage: state.currentPage,
2552
+ currentTransferBatch,
2553
+ totalTransferBatches,
2554
+ transferBatchSize,
2555
+ });
2408
2556
  }
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
- });
2557
+ };
2558
+ await Promise.all(Array.from({ length: maxConcurrent }, () => runWorker()));
2559
+ if (signal?.aborted || cancelled) {
2560
+ return { transferred, cancelled: true, rejected };
2420
2561
  }
2421
- return { transferred, cancelled: false };
2562
+ if (firstError) {
2563
+ throw firstError;
2564
+ }
2565
+ return { transferred, cancelled: false, rejected };
2566
+ }
2567
+ /**
2568
+ * Normalize the polymorphic putEntries result (`void`, receipts array, or
2569
+ * structured {@link PutEntriesAck}) into receipts + per-entry rejections.
2570
+ */
2571
+ static normalizePutResult(putResult) {
2572
+ if (Array.isArray(putResult)) {
2573
+ return { receipts: putResult, batchRejected: [] };
2574
+ }
2575
+ if (putResult && typeof putResult === "object") {
2576
+ return {
2577
+ receipts: putResult.receipts ?? [],
2578
+ batchRejected: putResult.rejected ?? [],
2579
+ };
2580
+ }
2581
+ return { receipts: [], batchRejected: [] };
2422
2582
  }
2423
2583
  async syncEntriesFromStore(sourceStore, targetStore, options) {
2424
2584
  const signal = options?.signal;
@@ -2445,18 +2605,67 @@ class BaseMindooDB {
2445
2605
  async syncEntriesFromStoreImpl(sourceStore, targetStore, options) {
2446
2606
  let transferred = 0;
2447
2607
  let scanned = 0;
2608
+ // Per-entry rejections reported by a witnessing target (sync-v5): the
2609
+ // sync completes; rejected entries are surfaced to the caller as warnings.
2610
+ const rejected = [];
2448
2611
  const onProgress = options?.onProgress;
2449
2612
  const pageSize = options?.pageSize ?? 1000;
2450
2613
  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
2614
  if (this.supportsCursorScan(sourceStore)) {
2615
+ // Persisted-cursor fast path (sync-v5, phase 1): resume the metadata
2616
+ // scan where the previous sync between this (source, target) pair left
2617
+ // off — or skip the sync entirely when the source head shows nothing
2618
+ // new. The heads are fetched up front (cheap; one request per side for
2619
+ // network stores) and also anchor the epochs persisted with the cursor.
2620
+ const cursorKey = this.syncScanCursorKey(sourceStore, targetStore);
2621
+ const persisted = options?.forceFullScan
2622
+ ? null
2623
+ : this.syncScanCursors.get(cursorKey) ?? null;
2624
+ const [sourceHead, targetHead] = await Promise.all([
2625
+ this.getStoreHeadSafe(sourceStore),
2626
+ this.getStoreHeadSafe(targetStore),
2627
+ ]);
2628
+ let cursor = null;
2629
+ if (persisted && sourceHead && targetHead) {
2630
+ if (persisted.sourceEpoch === sourceHead.epoch &&
2631
+ persisted.targetEpoch === targetHead.epoch) {
2632
+ if (sourceHead.maxReceiptOrder <= persisted.cursor.receiptOrder) {
2633
+ this.logger.debug(`Sync skip: source head ${sourceHead.maxReceiptOrder} already covered by persisted cursor (${cursorKey})`);
2634
+ onProgress?.({
2635
+ phase: 'preparing',
2636
+ message: 'Source unchanged since last sync, nothing to scan',
2637
+ transferredEntries: 0,
2638
+ scannedEntries: 0,
2639
+ });
2640
+ return { transferred: 0, scanned: 0, cancelled: false };
2641
+ }
2642
+ cursor = persisted.cursor;
2643
+ this.logger.debug(`Resuming sync scan from persisted cursor receiptOrder=${cursor.receiptOrder} (${cursorKey})`);
2644
+ }
2645
+ else {
2646
+ // Epoch change on either side: the cursor lineage is broken
2647
+ // (store reset / receipt-order migration) — full rescan.
2648
+ this.logger.info(`Sync cursor epoch changed for ${cursorKey}, discarding persisted cursor and re-scanning`);
2649
+ this.syncScanCursors.delete(cursorKey);
2650
+ }
2651
+ }
2652
+ const persistScanCursor = (finalCursor) => {
2653
+ if (finalCursor && sourceHead && targetHead) {
2654
+ this.saveSyncScanCursor(cursorKey, {
2655
+ sourceEpoch: sourceHead.epoch,
2656
+ targetEpoch: targetHead.epoch,
2657
+ cursor: finalCursor,
2658
+ });
2659
+ }
2660
+ };
2661
+ const targetBloom = await this.getStoreBloomSummary(targetStore);
2662
+ // Fixed progress denominator: the SOURCE's total entry count. The cursor
2663
+ // scan below examines every source entry, so `scannedEntries/totalSourceEntries`
2664
+ // is a real completion ratio. (Local stores serve this from a cached bloom
2665
+ // summary; network stores answer with one extra request.) The target bloom
2666
+ // stays dedicated to missing-id pre-screening.
2667
+ const sourceBloom = await this.getStoreBloomSummary(sourceStore);
2668
+ const totalSourceEstimate = sourceBloom?.totalIds;
2460
2669
  onProgress?.({
2461
2670
  phase: 'preparing',
2462
2671
  message: 'Preparing to sync entries...',
@@ -2464,17 +2673,34 @@ class BaseMindooDB {
2464
2673
  scannedEntries: 0,
2465
2674
  totalSourceEntries: totalSourceEstimate,
2466
2675
  });
2467
- let cursor = null;
2468
2676
  let currentPage = 0;
2677
+ // Scan-page pipelining (sync-v5, phase 4): while page N is being
2678
+ // filtered and transferred, page N+1 is already being fetched.
2679
+ let nextPagePromise = null;
2680
+ const discardPrefetch = () => {
2681
+ // Swallow errors of an in-flight prefetch we will never consume
2682
+ // (abort/cancel paths) to avoid unhandled rejections.
2683
+ nextPagePromise?.catch(() => { });
2684
+ nextPagePromise = null;
2685
+ };
2469
2686
  while (true) {
2470
2687
  if (signal?.aborted) {
2471
- return { transferred, scanned, cancelled: true };
2688
+ discardPrefetch();
2689
+ persistScanCursor(cursor);
2690
+ return { transferred, scanned, cancelled: true, rejected };
2472
2691
  }
2473
- const page = await sourceStore.scanEntriesSince(cursor, pageSize);
2692
+ const page = nextPagePromise
2693
+ ? await nextPagePromise
2694
+ : await sourceStore.scanEntriesSince(cursor, pageSize);
2695
+ nextPagePromise = page.hasMore
2696
+ ? sourceStore.scanEntriesSince(page.nextCursor, pageSize)
2697
+ : null;
2474
2698
  currentPage++;
2475
2699
  scanned += page.entries.length;
2476
2700
  if (signal?.aborted) {
2477
- return { transferred, scanned, cancelled: true };
2701
+ discardPrefetch();
2702
+ persistScanCursor(cursor);
2703
+ return { transferred, scanned, cancelled: true, rejected };
2478
2704
  }
2479
2705
  if (page.entries.length > 0) {
2480
2706
  onProgress?.({
@@ -2488,7 +2714,9 @@ class BaseMindooDB {
2488
2714
  const ids = page.entries.map((m) => m.id);
2489
2715
  const missingIds = await this.filterMissingIds(targetStore, ids, targetBloom);
2490
2716
  if (signal?.aborted) {
2491
- return { transferred, scanned, cancelled: true };
2717
+ discardPrefetch();
2718
+ persistScanCursor(cursor);
2719
+ return { transferred, scanned, cancelled: true, rejected };
2492
2720
  }
2493
2721
  if (missingIds.length > 0) {
2494
2722
  const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, missingIds, options, {
@@ -2498,8 +2726,13 @@ class BaseMindooDB {
2498
2726
  currentPage,
2499
2727
  });
2500
2728
  transferred = transferResult.transferred;
2729
+ rejected.push(...transferResult.rejected);
2501
2730
  if (transferResult.cancelled) {
2502
- return { transferred, scanned, cancelled: true };
2731
+ // The current page's transfer did not complete: persist the
2732
+ // boundary of the last fully transferred page instead.
2733
+ discardPrefetch();
2734
+ persistScanCursor(cursor);
2735
+ return { transferred, scanned, cancelled: true, rejected };
2503
2736
  }
2504
2737
  }
2505
2738
  }
@@ -2516,7 +2749,8 @@ class BaseMindooDB {
2516
2749
  break;
2517
2750
  }
2518
2751
  }
2519
- return { transferred, scanned, cancelled: false };
2752
+ persistScanCursor(cursor);
2753
+ return { transferred, scanned, cancelled: false, rejected };
2520
2754
  }
2521
2755
  onProgress?.({
2522
2756
  phase: 'preparing',
@@ -2545,8 +2779,9 @@ class BaseMindooDB {
2545
2779
  totalSourceEntries: sourceNewMetadata.length,
2546
2780
  });
2547
2781
  transferred = transferResult.transferred;
2782
+ rejected.push(...transferResult.rejected);
2548
2783
  if (transferResult.cancelled) {
2549
- return { transferred, scanned: sourceNewMetadata.length, cancelled: true };
2784
+ return { transferred, scanned: sourceNewMetadata.length, cancelled: true, rejected };
2550
2785
  }
2551
2786
  onProgress?.({
2552
2787
  phase: 'transferring',
@@ -2559,6 +2794,7 @@ class BaseMindooDB {
2559
2794
  transferred,
2560
2795
  scanned: sourceNewMetadata.length,
2561
2796
  cancelled: false,
2797
+ rejected,
2562
2798
  };
2563
2799
  }
2564
2800
  /**
@@ -2647,19 +2883,30 @@ class BaseMindooDB {
2647
2883
  // ── Phase 5: transfer missing entries in pages ───────────────────
2648
2884
  const pageSize = options?.pageSize ?? 500;
2649
2885
  let transferred = 0;
2886
+ const rejected = [];
2650
2887
  for (let offset = 0; offset < missingIds.length; offset += pageSize) {
2651
2888
  if (options?.signal?.aborted) {
2652
- return { transferred, scanned: allNeededArray.length, cancelled: true };
2889
+ return { transferred, scanned: allNeededArray.length, cancelled: true, rejected };
2653
2890
  }
2654
2891
  const batch = missingIds.slice(offset, offset + pageSize);
2655
2892
  const entries = await sourceStore.getEntries(batch);
2656
2893
  const putResult = await targetStore.putEntries(entries);
2657
2894
  // 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);
2661
- }
2662
- transferred += entries.length;
2895
+ // in transferEntriesInBatches; docs/accesscontrol.md §5.3). sync-v5
2896
+ // targets return a structured ack that additionally carries per-entry
2897
+ // rejections (signature-class failures the remote skipped) — surface
2898
+ // them instead of silently counting rejected entries as transferred:
2899
+ // a dropped lifecycle entry (e.g. doc_delete) would otherwise leave the
2900
+ // target believing the document is still alive.
2901
+ const { receipts, batchRejected } = BaseMindooDB.normalizePutResult(putResult);
2902
+ if (receipts.length > 0 && sourceStore.applyWitnessReceipts) {
2903
+ await sourceStore.applyWitnessReceipts(receipts);
2904
+ }
2905
+ if (batchRejected.length > 0) {
2906
+ rejected.push(...batchRejected);
2907
+ this.logger.warn(`Dense sync: target rejected ${batchRejected.length} of ${entries.length} entries in batch at offset ${offset}`);
2908
+ }
2909
+ transferred += entries.length - batchRejected.length;
2663
2910
  onProgress?.({
2664
2911
  phase: "transferring",
2665
2912
  message: `Dense sync: transferred ${transferred}/${missingIds.length} entries`,
@@ -2668,8 +2915,9 @@ class BaseMindooDB {
2668
2915
  totalSourceEntries: allNeededArray.length,
2669
2916
  });
2670
2917
  }
2671
- this.logger.info(`Dense sync complete: transferred ${transferred} entries`);
2672
- return { transferred, scanned: allNeededArray.length, cancelled: false };
2918
+ this.logger.info(`Dense sync complete: transferred ${transferred} entries` +
2919
+ (rejected.length > 0 ? `, ${rejected.length} rejected by target` : ""));
2920
+ return { transferred, scanned: allNeededArray.length, cancelled: false, rejected };
2673
2921
  }
2674
2922
  getStore() {
2675
2923
  return this.store;
@@ -2679,7 +2927,7 @@ class BaseMindooDB {
2679
2927
  }
2680
2928
  async reclaimIncompleteAttachmentUploads(options) {
2681
2929
  const minAgeMs = options?.minAgeMs ?? 5 * 60 * 1000;
2682
- const cutoff = Date.now() - minAgeMs;
2930
+ const cutoff = (0, timeSource_1.semanticNow)() - minAgeMs;
2683
2931
  const ledgers = await this.store.findEntries("pending_attachment_upload", null, cutoff);
2684
2932
  const result = {
2685
2933
  scannedLedgers: ledgers.length,
@@ -2782,6 +3030,7 @@ class BaseMindooDB {
2782
3030
  throw new Error(`createDocuments: invalid document id "${options.id}". ` +
2783
3031
  `Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
2784
3032
  }
3033
+ this.validateIdPrefixOption("createDocuments", options);
2785
3034
  }
2786
3035
  const results = new Array(inputs.length).fill(null);
2787
3036
  const prepared = [];
@@ -2838,18 +3087,68 @@ class BaseMindooDB {
2838
3087
  this.logger.info(`Bulk-created ${prepared.length} documents (${inputs.length - prepared.length} already existed)`);
2839
3088
  return results;
2840
3089
  }
3090
+ /**
3091
+ * Validate `CreateOptions.idPrefix`: mutually exclusive with `id` and must
3092
+ * match {@link DOC_ID_PREFIX_REGEX}.
3093
+ */
3094
+ validateIdPrefixOption(methodName, options) {
3095
+ if (options.idPrefix === undefined) {
3096
+ return;
3097
+ }
3098
+ if (options.id !== undefined) {
3099
+ throw new Error(`${methodName}: id and idPrefix are mutually exclusive`);
3100
+ }
3101
+ if (!types_1.DOC_ID_PREFIX_REGEX.test(options.idPrefix)) {
3102
+ throw new Error(`${methodName}: invalid idPrefix "${options.idPrefix}". ` +
3103
+ `ID prefixes must match ${types_1.DOC_ID_PREFIX_REGEX.source}.`);
3104
+ }
3105
+ }
3106
+ /**
3107
+ * Normalize a caller-supplied listing/iteration `idPrefix`, tolerating one or
3108
+ * more trailing `_` a developer may append when they don't know the exact
3109
+ * syntax (e.g. `"cls_"` → `"cls"`). Valid prefixes never contain `_` (see
3110
+ * {@link DOC_ID_PREFIX_REGEX}), so trimming trailing underscores is lossless
3111
+ * and makes `matchesDocIdPrefix` re-add the single boundary `_` consistently.
3112
+ * Returns `undefined` for an absent or now-empty prefix (i.e. "no filter").
3113
+ */
3114
+ normalizeIdPrefixFilter(idPrefix) {
3115
+ if (idPrefix === undefined) {
3116
+ return undefined;
3117
+ }
3118
+ const stripped = idPrefix.replace(/_+$/, "");
3119
+ return stripped.length > 0 ? stripped : undefined;
3120
+ }
3121
+ /**
3122
+ * Generate a fresh document id (`<prefix>_<22-char-base62(uuidv7)>`, or the
3123
+ * bare base62 part without a prefix) that does not collide with any document
3124
+ * already present in the local index. A collision is practically impossible
3125
+ * (128-bit UUID7), so this is only a cheap O(1) safety net reusing the
3126
+ * existing `indexLookup` map — no extra bookkeeping.
3127
+ */
3128
+ generateUnusedDocId(prefix) {
3129
+ for (;;) {
3130
+ const candidate = (0, idGeneration_1.generateDocId)(prefix);
3131
+ if (this.getDocIndexPosition(candidate) === undefined) {
3132
+ return candidate;
3133
+ }
3134
+ }
3135
+ }
2841
3136
  /**
2842
3137
  * Internal method to create a new document.
2843
3138
  *
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)
3139
+ * Handles the creation flavors expressible through `CreateOptions`:
3140
+ * - generated ID (time-sortable base62-encoded UUID7), current user signs,
3141
+ * default tenant key
3142
+ * - generated ID with custom signing key (e.g. directory admin operations)
3143
+ * - generated ID with a caller-provided `idPrefix` (`<prefix>_<base62>`)
2847
3144
  * - caller-provided ID, current user signs (idempotent on existing IDs)
2848
3145
  * - caller-provided ID with custom signing key
2849
3146
  *
2850
3147
  * For caller-provided IDs the initial Automerge change is seeded from a
2851
3148
  * deterministic, hard-coded change so that two replicas creating the same
2852
3149
  * custom ID converge when synced (see `getCustomIdInitialChangeBytes`).
3150
+ * Prefix-generated IDs are unique by construction, so they take the
3151
+ * generated-ID path: one `doc_create` entry with `initialValues` baked in.
2853
3152
  */
2854
3153
  async createDocumentInternal(options) {
2855
3154
  this.assertWritable("createDocument");
@@ -2860,6 +3159,7 @@ class BaseMindooDB {
2860
3159
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
2861
3160
  const keyId = await this.resolveCreateKeyId(options);
2862
3161
  const useCustomDocId = options.id !== undefined;
3162
+ this.validateIdPrefixOption("createDocument", options);
2863
3163
  // Sanitize caller-provided initial values: internal/reserved fields (those
2864
3164
  // starting with "_", e.g. "_attachments") are managed by MindooDB and must
2865
3165
  // not be seeded by callers. `undefined` values are treated as "not set"
@@ -2893,7 +3193,7 @@ class BaseMindooDB {
2893
3193
  throw new Error("Admin-only database: only the admin key can modify data");
2894
3194
  }
2895
3195
  }
2896
- const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3196
+ const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
2897
3197
  // Idempotent create: when a caller-provided id already exists locally,
2898
3198
  // return the existing document instead of producing a duplicate doc_create.
2899
3199
  if (useCustomDocId) {
@@ -2919,7 +3219,7 @@ class BaseMindooDB {
2919
3219
  // For custom-ID documents we apply a hard-coded initial change so that
2920
3220
  // independent replicas using the same id produce the same Automerge hash
2921
3221
  // and `doc_create` entry id, allowing later changes to merge.
2922
- const now = Date.now();
3222
+ const now = (0, timeSource_1.semanticNow)();
2923
3223
  let newDoc;
2924
3224
  let changeBytes;
2925
3225
  if (useCustomDocId) {
@@ -3101,6 +3401,7 @@ class BaseMindooDB {
3101
3401
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3102
3402
  const keyId = await this.resolveCreateKeyId(options);
3103
3403
  const useCustomDocId = options.id !== undefined;
3404
+ this.validateIdPrefixOption("createDocuments", options);
3104
3405
  // Sanitize caller-provided initial values: internal/reserved fields are
3105
3406
  // managed by MindooDB and must not be seeded by callers. `undefined`
3106
3407
  // values are treated as "not set" (Automerge cannot represent undefined).
@@ -3117,8 +3418,8 @@ class BaseMindooDB {
3117
3418
  throw new Error("Admin-only database: only the admin key can modify data");
3118
3419
  }
3119
3420
  }
3120
- const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3121
- const now = Date.now();
3421
+ const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3422
+ const now = (0, timeSource_1.semanticNow)();
3122
3423
  // Build the initial Automerge document and its first change bytes,
3123
3424
  // following the same seeding rules as the single-create path.
3124
3425
  let seedDoc;
@@ -3281,14 +3582,22 @@ class BaseMindooDB {
3281
3582
  // Filter to document replay metadata plus snapshots up to the timestamp.
3282
3583
  // Attachment chunks are not part of the Automerge replay DAG and must not
3283
3584
  // participate in historical materialization.
3284
- const relevantEntries = allEntryMetadata
3585
+ //
3586
+ // The slice is then pruned to grounded entries (complete causal chains
3587
+ // within the slice). Clock skew or partial sync can put an entry inside
3588
+ // `createdAt <= timestamp` while its dependency falls outside; Automerge
3589
+ // would silently stash such a change, but the metadata-derived isDeleted
3590
+ // below would still count it — pruning keeps content and lifecycle state
3591
+ // consistent: the state at `timestamp` is the deterministic merge of all
3592
+ // complete causal chains up to that instant.
3593
+ const relevantEntries = (0, DocumentDagAnalysis_1.pruneToGroundedEntries)(allEntryMetadata
3285
3594
  .filter((em) => em.createdAt <= timestamp
3286
3595
  && (em.entryType === "doc_create"
3287
3596
  || em.entryType === "doc_change"
3288
3597
  || em.entryType === "doc_delete"
3289
3598
  || em.entryType === "doc_undelete"
3290
3599
  || em.entryType === "doc_snapshot"))
3291
- .sort((a, b) => a.createdAt - b.createdAt);
3600
+ .sort((a, b) => a.createdAt - b.createdAt));
3292
3601
  if (relevantEntries.length === 0) {
3293
3602
  this.performanceCallback?.onHistoryOperation?.({
3294
3603
  operation: "getDocumentAtTimestamp",
@@ -3409,10 +3718,13 @@ class BaseMindooDB {
3409
3718
  this.logger.debug(`Iterating document history for ${docId}`);
3410
3719
  // Get all entry metadata for this document
3411
3720
  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);
3721
+ // Filter to document replay entries (exclude snapshots) and order them
3722
+ // causally: a plain chronological sort can place an effect before its
3723
+ // cause when several entries share the same millisecond (or when replica
3724
+ // clock skew inverts timestamps along a dependency chain), which would
3725
+ // make the history yield states out of causal order. Concurrent entries
3726
+ // are still ordered by (createdAt, id) deterministically.
3727
+ const relevantEntries = (0, DocumentDagAnalysis_1.orderDagEntriesCausally)(allEntryMetadata.filter((em) => this.isDocumentReplayEntry(em)));
3416
3728
  if (relevantEntries.length === 0) {
3417
3729
  return; // Document has no history
3418
3730
  }
@@ -3424,7 +3736,8 @@ class BaseMindooDB {
3424
3736
  let currentDoc = null;
3425
3737
  let createdAt = null;
3426
3738
  let decryptionKeyId = "default";
3427
- for (const entryMetadata of relevantEntries) {
3739
+ for (let entryIndex = 0; entryIndex < relevantEntries.length; entryIndex++) {
3740
+ const entryMetadata = relevantEntries[entryIndex];
3428
3741
  const entryData = entryMap.get(entryMetadata.id);
3429
3742
  if (!entryData) {
3430
3743
  this.logger.warn(`Entry ${entryMetadata.id} not found in store, skipping`);
@@ -3490,8 +3803,12 @@ class BaseMindooDB {
3490
3803
  createdAt: createdAt,
3491
3804
  lastModified: entryMetadata.createdAt,
3492
3805
  decryptionKeyId,
3493
- isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((entry) => entry.createdAt < entryMetadata.createdAt ||
3494
- (entry.createdAt === entryMetadata.createdAt && entry.id <= entryMetadata.id))),
3806
+ // Use the exact prefix of entries applied so far. Rebuilding the
3807
+ // prefix via a (createdAt, id) comparison can disagree with the
3808
+ // actual iteration order when several entries share the same
3809
+ // millisecond (e.g. a fast create→change→delete), which made
3810
+ // intermediate history states wrongly report isDeleted.
3811
+ isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.slice(0, entryIndex + 1)),
3495
3812
  };
3496
3813
  // Wrap and yield (including lifecycle terminal entries)
3497
3814
  const wrappedDoc = this.wrapDocument(internalDoc);
@@ -3546,7 +3863,7 @@ class BaseMindooDB {
3546
3863
  }
3547
3864
  // Trusted time for un-witnessed entries is the current wall clock, computed
3548
3865
  // once per run so all un-witnessed entries share a stable head instant.
3549
- const now = Date.now();
3866
+ const now = (0, timeSource_1.semanticNow)();
3550
3867
  // Affected documents (those with at least one newly-discovered entry), and
3551
3868
  // the set of new entry ids per doc (the trusted-time positions from which the
3552
3869
  // doc's merged state may have changed and must be re-folded / re-emitted).
@@ -3966,24 +4283,28 @@ class BaseMindooDB {
3966
4283
  const offset = Math.max(0, Math.floor(options?.cursor?.offset ?? 0));
3967
4284
  // This API stays metadata-only so large history views can page cheaply
3968
4285
  // without reconstructing every historical Automerge document state.
4286
+ // Entries are ordered causally (dependency-first, (createdAt, id) for
4287
+ // concurrent ties) so the page timeline never shows an effect before its
4288
+ // cause — same ordering as iterateDocumentHistory.
3969
4289
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
3970
- const relevantEntries = allEntryMetadata
3971
- .filter((em) => em.entryType === "doc_create" ||
4290
+ const relevantEntries = (0, DocumentDagAnalysis_1.orderDagEntriesCausally)(allEntryMetadata.filter((em) => em.entryType === "doc_create" ||
3972
4291
  em.entryType === "doc_change" ||
3973
4292
  em.entryType === "doc_delete" ||
3974
- em.entryType === "doc_undelete")
3975
- .sort((a, b) => a.createdAt - b.createdAt);
4293
+ em.entryType === "doc_undelete"));
3976
4294
  // Cursor paging is offset-based because the result is a bounded timeline view
3977
4295
  // over the doc's sorted change metadata, not a resumable store scan cursor.
3978
4296
  const slice = relevantEntries.slice(offset, offset + limit);
3979
- const entries = slice.map((entry) => ({
4297
+ const entries = slice.map((entry, sliceIndex) => ({
3980
4298
  entryId: entry.id,
3981
4299
  entryType: entry.entryType,
3982
4300
  changeCreatedAt: entry.createdAt,
3983
4301
  changeCreatedByPublicKey: entry.createdByPublicKey,
3984
4302
  dependencyIds: [...entry.dependencyIds],
3985
- isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((candidate) => candidate.createdAt < entry.createdAt ||
3986
- (candidate.createdAt === entry.createdAt && candidate.id <= entry.id))),
4303
+ // Deletion state as of this timeline position = derived from the exact
4304
+ // causal prefix of entries up to and including this one. Rebuilding the
4305
+ // prefix via a (createdAt, id) comparison could disagree with the causal
4306
+ // order for same-millisecond entries.
4307
+ isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.slice(0, offset + sliceIndex + 1)),
3987
4308
  }));
3988
4309
  const nextOffset = offset + entries.length;
3989
4310
  const hasMore = nextOffset < relevantEntries.length;
@@ -4594,7 +4915,7 @@ class BaseMindooDB {
4594
4915
  return {
4595
4916
  changeSeqAsOf: latestCursor?.changeSeq ?? 0,
4596
4917
  storeReceiptOrderAsOf: await this.getStoreMaxReceiptOrder(),
4597
- takenAt: Date.now(),
4918
+ takenAt: (0, timeSource_1.semanticNow)(),
4598
4919
  };
4599
4920
  }
4600
4921
  /**
@@ -5242,20 +5563,27 @@ class BaseMindooDB {
5242
5563
  const filteredPaths = normalizedPaths.filter((path) => touchedKeySet.has(this.extractLeafPathSegment(path)));
5243
5564
  return filteredPaths.length > 0 ? filteredPaths : normalizedPaths;
5244
5565
  }
5245
- async getAllDocumentIds() {
5246
- // Return all non-deleted document IDs from index
5566
+ async getAllDocumentIds(options) {
5567
+ // Return all non-deleted document IDs from index, optionally narrowed to a
5568
+ // single id prefix (matched on the `<prefix>_` boundary; see matchesDocIdPrefix).
5569
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
5247
5570
  const docIds = [];
5248
5571
  for (const entry of this.index) {
5249
5572
  if (entry.accessState === "visible" && !entry.isDeleted) {
5573
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
5574
+ continue;
5250
5575
  docIds.push(entry.docId);
5251
5576
  }
5252
5577
  }
5253
5578
  return docIds;
5254
5579
  }
5255
- async getDeletedDocumentIds() {
5580
+ async getDeletedDocumentIds(options) {
5581
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
5256
5582
  const docIds = [];
5257
5583
  for (const entry of this.index) {
5258
5584
  if (entry.accessState === "visible" && entry.isDeleted) {
5585
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
5586
+ continue;
5259
5587
  docIds.push(entry.docId);
5260
5588
  }
5261
5589
  }
@@ -5295,24 +5623,36 @@ class BaseMindooDB {
5295
5623
  }
5296
5624
  const docIds = [];
5297
5625
  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
5626
  // A document cannot exist before its create entry, even if malformed or
5302
5627
  // partial metadata somehow contains later lifecycle entries.
5303
- if (!ordered.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
5628
+ if (!lifecycleEntries.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
5304
5629
  continue;
5305
5630
  }
5306
- const createEntry = ordered.find((entry) => entry.entryType === "doc_create");
5631
+ const createEntry = lifecycleEntries.find((entry) => entry.entryType === "doc_create");
5307
5632
  if (createEntry && !(await this.tenant.hasDecryptionKey(createEntry.decryptionKeyId))) {
5308
5633
  continue;
5309
5634
  }
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") {
5635
+ const terminalEntries = lifecycleEntries.filter((entry) => (entry.entryType === "doc_delete" || entry.entryType === "doc_undelete")
5636
+ && entry.createdAt <= timestamp);
5637
+ if (terminalEntries.length === 0) {
5638
+ // Fast path (the vast majority of documents): no delete/undelete ever
5639
+ // happened up to `timestamp`, so the document is alive and we never
5640
+ // need its change metadata.
5641
+ docIds.push(docId);
5642
+ continue;
5643
+ }
5644
+ // Causal path: the doc has terminal lifecycle entries, so alive/deleted
5645
+ // must be derived exactly like getDocumentAtTimestamp derives it —
5646
+ // grounded slice, dependency-based heads, causal supersession — instead
5647
+ // of a purely chronological "latest terminal wins". A same-instant
5648
+ // delete→undelete pair or cross-replica clock skew would otherwise be
5649
+ // decided by id tie-break, which can disagree with causality and with
5650
+ // getDocumentAtTimestamp for the same instant. The extra per-doc
5651
+ // metadata scan is bounded by the number of ever-deleted documents.
5652
+ const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
5653
+ const relevantEntries = (0, DocumentDagAnalysis_1.pruneToGroundedEntries)(allEntryMetadata.filter((entry) => entry.createdAt <= timestamp
5654
+ && (this.isDocumentReplayEntry(entry) || entry.entryType === "doc_snapshot")));
5655
+ if (!this.computeIsDeletedFromMetadata(relevantEntries)) {
5316
5656
  docIds.push(docId);
5317
5657
  }
5318
5658
  }
@@ -5425,17 +5765,31 @@ class BaseMindooDB {
5425
5765
  // Delete and undelete are encoded as normal Automerge changes so the DAG
5426
5766
  // keeps causal ancestry, while the StoreEntry type carries lifecycle intent.
5427
5767
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
5428
- const now = Date.now();
5768
+ const now = (0, timeSource_1.semanticNow)();
5429
5769
  const docId = internalDoc.id;
5430
5770
  // Lifecycle changes are intentionally non-destructive: they only bump
5431
5771
  // `_lastModified` and repair missing legacy attachment arrays. The document
5432
5772
  // body remains available for history and future undeletion.
5433
- const newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, { time: now }, (mutableDoc) => {
5773
+ //
5774
+ // Same-millisecond hazard: the change writes ONLY `_lastModified`, and
5775
+ // Automerge dedupes a same-value assignment into an EMPTY change (heads
5776
+ // unchanged, getLastLocalChange returns the PREVIOUS change). A delete in
5777
+ // the same millisecond as the last change would then reuse the previous
5778
+ // change's hash, collide with its entry id, and the doc_delete would
5779
+ // silently never be persisted — the document resurrects on sync/reload.
5780
+ // If the mutation dedupes to nothing, fall back to an explicit
5781
+ // `emptyChange`, which always advances the heads with a fresh hash while
5782
+ // keeping causal ancestry intact.
5783
+ const headsBefore = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5784
+ let newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, { time: now }, (mutableDoc) => {
5434
5785
  if (!Array.isArray(mutableDoc._attachments)) {
5435
5786
  mutableDoc._attachments = [];
5436
5787
  }
5437
5788
  mutableDoc._lastModified = now;
5438
5789
  }));
5790
+ if (automerge_adapter_1.Automerge.getHeads(newDoc).every((head) => headsBefore.includes(head))) {
5791
+ newDoc = automerge_adapter_1.Automerge.emptyChange(newDoc, { time: now });
5792
+ }
5439
5793
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
5440
5794
  if (!changeBytes) {
5441
5795
  throw new Error("Failed to get change bytes from Automerge document");
@@ -5540,15 +5894,18 @@ class BaseMindooDB {
5540
5894
  // The doc is now witness-era (we just wrote a versioned entry), so it is
5541
5895
  // witnessed iff it is no longer awaiting a receipt.
5542
5896
  internalDoc.witnessed = (0, storeEntryTime_1.isVersioned)(entryMetadata) && !internalDoc.awaitingWitness;
5897
+ // Force the changeSeq bump: a lifecycle entry was just written, so the
5898
+ // changefeed must re-emit the doc even if all tracked index fields happen
5899
+ // to match the previous state in the same millisecond.
5543
5900
  if (options.markDirty) {
5544
5901
  await this.storeCachedDocument(internalDoc);
5545
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
5902
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
5546
5903
  this.markDocDirty(docId);
5547
5904
  }
5548
5905
  else {
5549
5906
  this.docCache.delete(docId);
5550
5907
  this.dirtyDocIds.delete(docId);
5551
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
5908
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
5552
5909
  }
5553
5910
  }
5554
5911
  /**
@@ -5612,7 +5969,7 @@ class BaseMindooDB {
5612
5969
  throw new errors_1.DocumentDeletedError(docId);
5613
5970
  }
5614
5971
  this.validateTextPatch(patch);
5615
- const now = Date.now();
5972
+ const now = (0, timeSource_1.semanticNow)();
5616
5973
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5617
5974
  const baseHeads = patch.baseHeads;
5618
5975
  const applyEdits = (automergeDoc) => {
@@ -5676,7 +6033,7 @@ class BaseMindooDB {
5676
6033
  throw new errors_1.DocumentDeletedError(docId);
5677
6034
  }
5678
6035
  this.validateRichTextPatch(patch);
5679
- const now = Date.now();
6036
+ const now = (0, timeSource_1.semanticNow)();
5680
6037
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5681
6038
  const spansSequence = patch.spansSequence ?? (patch.spans ? [patch.spans] : []);
5682
6039
  const applySpans = (automergeDoc) => {
@@ -5751,7 +6108,7 @@ class BaseMindooDB {
5751
6108
  throw new errors_1.DocumentDeletedError(docId);
5752
6109
  }
5753
6110
  this.validateRichTextStepPatch(patch);
5754
- const now = Date.now();
6111
+ const now = (0, timeSource_1.semanticNow)();
5755
6112
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5756
6113
  const applySteps = (automergeDoc) => {
5757
6114
  this.ensureRichTextPath(automergeDoc, patch.path);
@@ -5873,7 +6230,7 @@ class BaseMindooDB {
5873
6230
  throw new errors_1.DocumentDeletedError(docId);
5874
6231
  }
5875
6232
  await this.hydrateAutomergeHashMappingsFromStore(docId);
5876
- const now = Date.now();
6233
+ const now = (0, timeSource_1.semanticNow)();
5877
6234
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5878
6235
  if (patch.baseHeads?.length) {
5879
6236
  const baseHeadsKey = [...patch.baseHeads].sort().join("|");
@@ -5980,7 +6337,7 @@ class BaseMindooDB {
5980
6337
  throw new errors_1.DocumentDeletedError(docId);
5981
6338
  }
5982
6339
  this.validateJsonPatch(patch);
5983
- const now = Date.now();
6340
+ const now = (0, timeSource_1.semanticNow)();
5984
6341
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5985
6342
  const applyPatch = (automergeDoc) => {
5986
6343
  this.applyJsonPatchOperations(automergeDoc, patch);
@@ -6053,7 +6410,7 @@ class BaseMindooDB {
6053
6410
  throw new errors_1.DocumentDeletedError(docId);
6054
6411
  }
6055
6412
  // Apply the change function
6056
- const now = Date.now();
6413
+ const now = (0, timeSource_1.semanticNow)();
6057
6414
  this.logger.debug(`Applying change function to document ${docId}`);
6058
6415
  if (this.isDebugEnabled())
6059
6416
  this.logger.debug(`Document state before change: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(internalDoc.doc))}`);
@@ -6958,7 +7315,10 @@ class BaseMindooDB {
6958
7315
  || (internalDoc.witnessed ?? false);
6959
7316
  internalDoc.witnessed = versioned && !internalDoc.awaitingWitness;
6960
7317
  await this.storeCachedDocument(internalDoc);
6961
- this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed);
7318
+ // Force the changeSeq bump: new change entries were just written, so the
7319
+ // changefeed must re-emit the doc even when `lastModified` collides with
7320
+ // the previous change in the same millisecond.
7321
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible", internalDoc.awaitingWitness, internalDoc.witnessed, true);
6962
7322
  this.markDocDirty(docId);
6963
7323
  this.logger.info(`Document ${docId} ${successMessage} successfully`);
6964
7324
  const createdByPublicKey = useCustomKey
@@ -7076,7 +7436,7 @@ class BaseMindooDB {
7076
7436
  contentHash,
7077
7437
  docId,
7078
7438
  dependencyIds: headEntryIds,
7079
- createdAt: Date.now(),
7439
+ createdAt: (0, timeSource_1.semanticNow)(),
7080
7440
  createdByPublicKey: options.createdByPublicKey,
7081
7441
  decryptionKeyId: internalDoc.decryptionKeyId,
7082
7442
  snapshotHeadHashes: headHashes,
@@ -7099,7 +7459,7 @@ class BaseMindooDB {
7099
7459
  this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
7100
7460
  }
7101
7461
  }
7102
- async prefetchIterationWindow(indexSnapshot, startIndex) {
7462
+ async prefetchIterationWindow(indexSnapshot, startIndex, idPrefix) {
7103
7463
  if (this.iteratePrefetchWindowDocs <= 0) {
7104
7464
  return 0;
7105
7465
  }
@@ -7115,6 +7475,10 @@ class BaseMindooDB {
7115
7475
  if (entry.accessState === "inaccessible" || entry.isDeleted) {
7116
7476
  continue;
7117
7477
  }
7478
+ // Don't prefetch bodies the prefix-filtered iteration will never yield.
7479
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix)) {
7480
+ continue;
7481
+ }
7118
7482
  const docId = entry.docId;
7119
7483
  if (seen.has(docId) || this.docCache.has(docId)) {
7120
7484
  continue;
@@ -7167,7 +7531,7 @@ class BaseMindooDB {
7167
7531
  return startIndex;
7168
7532
  }
7169
7533
  resolveDagTimestamp(timestamp) {
7170
- return timestamp === "now" ? Date.now() : timestamp;
7534
+ return timestamp === "now" ? (0, timeSource_1.semanticNow)() : timestamp;
7171
7535
  }
7172
7536
  previewChangeValue(value) {
7173
7537
  if (value === undefined) {
@@ -7433,19 +7797,22 @@ class BaseMindooDB {
7433
7797
  witnessed: witnessState.witnessed,
7434
7798
  };
7435
7799
  }
7436
- async *iterateChangeMetadataSince(cursor) {
7800
+ async *iterateChangeMetadataSince(cursor, options) {
7437
7801
  const startedAt = Date.now();
7438
7802
  const actualCursor = cursor ?? {
7439
7803
  changeSeq: 0,
7440
7804
  lastModified: 0,
7441
7805
  docId: "",
7442
7806
  };
7807
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7443
7808
  const indexSnapshot = [...this.index];
7444
7809
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
7445
7810
  let yieldedDocuments = 0;
7446
7811
  try {
7447
7812
  for (let i = startIndex; i < indexSnapshot.length; i++) {
7448
7813
  const entry = indexSnapshot[i];
7814
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
7815
+ continue;
7449
7816
  const currentCursor = {
7450
7817
  changeSeq: entry.changeSeq,
7451
7818
  lastModified: entry.lastModified,
@@ -7487,20 +7854,32 @@ class BaseMindooDB {
7487
7854
  * them. O(log n) binary search on the internal `(changeSeq, docId)` index —
7488
7855
  * used for progress reporting around {@link iterateChangesSince}.
7489
7856
  */
7490
- countChangesSince(cursor) {
7857
+ countChangesSince(cursor, options) {
7491
7858
  const actualCursor = cursor ?? {
7492
7859
  changeSeq: 0,
7493
7860
  lastModified: 0,
7494
7861
  docId: "",
7495
7862
  };
7496
7863
  const startIndex = this.getStartIndexForCursor(this.index, actualCursor);
7497
- return this.index.length - startIndex;
7864
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7865
+ if (!idPrefix) {
7866
+ return this.index.length - startIndex;
7867
+ }
7868
+ // Prefix-filtered count must walk the tail: the index is ordered by
7869
+ // (changeSeq, docId), not by docId, so matches are not contiguous.
7870
+ let count = 0;
7871
+ for (let i = startIndex; i < this.index.length; i++) {
7872
+ if ((0, idGeneration_1.matchesDocIdPrefix)(this.index[i].docId, idPrefix))
7873
+ count++;
7874
+ }
7875
+ return count;
7498
7876
  }
7499
- async *iterateChangesSince(cursor) {
7877
+ async *iterateChangesSince(cursor, options) {
7500
7878
  const startedAt = Date.now();
7501
7879
  // Default to initial cursor if null is provided.
7502
7880
  // Prefer deterministic sequence-based cursoring; keep legacy fallback compatibility.
7503
7881
  const actualCursor = cursor ?? { changeSeq: 0, lastModified: 0, docId: "" };
7882
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7504
7883
  this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
7505
7884
  // Use a stable snapshot of the index for this generator run so concurrent
7506
7885
  // updates do not reorder/skip entries while iterating.
@@ -7508,13 +7887,17 @@ class BaseMindooDB {
7508
7887
  // Find starting position using binary search
7509
7888
  // We want to find the first entry that is greater than the cursor.
7510
7889
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
7511
- let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex);
7890
+ let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex, idPrefix);
7512
7891
  let yieldedDocuments = 0;
7513
7892
  let loadedDocuments = 0;
7514
7893
  try {
7515
7894
  // Iterate through the stable snapshot and yield documents one at a time.
7516
7895
  for (let i = startIndex; i < indexSnapshot.length; i++) {
7517
7896
  const entry = indexSnapshot[i];
7897
+ // Skip entries outside the requested id prefix before any body load,
7898
+ // so prefix-filtered iteration never decrypts/materializes unrelated docs.
7899
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
7900
+ continue;
7518
7901
  if (entry.accessState !== "visible") {
7519
7902
  // The document was readable before but the current KeyBag can no
7520
7903
  // longer decrypt it (e.g. a revoked key, §13). Emit a tombstone so
@@ -7529,7 +7912,7 @@ class BaseMindooDB {
7529
7912
  };
7530
7913
  yieldedDocuments++;
7531
7914
  yield { doc: this.buildInaccessibleDoc(entry), cursor: inaccessibleCursor };
7532
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7915
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7533
7916
  continue;
7534
7917
  }
7535
7918
  if (entry.isDeleted) {
@@ -7546,7 +7929,7 @@ class BaseMindooDB {
7546
7929
  };
7547
7930
  yieldedDocuments++;
7548
7931
  yield { doc: this.buildDeletedDoc(entry), cursor: deletedCursor };
7549
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7932
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7550
7933
  continue;
7551
7934
  }
7552
7935
  try {
@@ -7576,7 +7959,7 @@ class BaseMindooDB {
7576
7959
  // Deleted documents are included so external indexes can handle deletions
7577
7960
  yieldedDocuments++;
7578
7961
  yield { doc, cursor: currentCursor };
7579
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7962
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7580
7963
  }
7581
7964
  catch (error) {
7582
7965
  this.logger.error(`Error processing document ${entry.docId}:`, error);
@@ -8401,7 +8784,7 @@ class BaseMindooDB {
8401
8784
  // A verify-only provider (no signer) routes receipts to the right scheme.
8402
8785
  const subtle = this.getSubtle();
8403
8786
  const receiptVerifier = new Ed25519WitnessProvider_1.Ed25519WitnessProvider({ subtle }).createVerifier();
8404
- const nowMs = Date.now();
8787
+ const nowMs = (0, timeSource_1.semanticNow)();
8405
8788
  // Lifecycle entries only, folded in deterministic causal order. Un-witnessed
8406
8789
  // entries (versioned or legacy) order by their author time `createdAt`, NOT
8407
8790
  // the provisional `now`: this fold computes per-step before/after states for
@@ -8437,7 +8820,7 @@ class BaseMindooDB {
8437
8820
  // Audit record: the entry's own (stable) time — receipt time if
8438
8821
  // witnessed, else author time. Not the provisional head `now`.
8439
8822
  trustedTime: meta.receivedAt ?? meta.createdAt,
8440
- recordedAt: Date.now(),
8823
+ recordedAt: (0, timeSource_1.semanticNow)(),
8441
8824
  });
8442
8825
  };
8443
8826
  for (const meta of dataEntries) {
@@ -8565,7 +8948,7 @@ class BaseMindooDB {
8565
8948
  const payload = currentDoc;
8566
8949
  const isDeleted = this.computeIsDeletedFromMetadata(acceptedMeta);
8567
8950
  const lastAccepted = acceptedMeta[acceptedMeta.length - 1];
8568
- const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : Date.now());
8951
+ const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : (0, timeSource_1.semanticNow)());
8569
8952
  const witnessState = (0, storeEntryTime_1.metadataWitnessState)(acceptedMeta);
8570
8953
  return {
8571
8954
  doc: {
@@ -8936,7 +9319,7 @@ class BaseMindooDB {
8936
9319
  // Get lastModified from payload, or use the timestamp of the last entry
8937
9320
  const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
8938
9321
  const lastModified = payload._lastModified ||
8939
- (lastEntry ? lastEntry.createdAt : Date.now());
9322
+ (lastEntry ? lastEntry.createdAt : (0, timeSource_1.semanticNow)());
8940
9323
  // Get createdAt from the first entry
8941
9324
  const firstEntry = allEntryMetadata.length > 0 ? allEntryMetadata[0] : null;
8942
9325
  const createdAt = firstEntry ? firstEntry.createdAt : lastModified;
@@ -9329,6 +9712,9 @@ class BaseMindooDB {
9329
9712
  const chunks = [];
9330
9713
  let prevChunkId = null;
9331
9714
  let lastChunkId = "";
9715
+ // Guards against case-folded chunk-id collisions on case-insensitive
9716
+ // filesystems; scoped to this single attachment write.
9717
+ const usedCaseFoldedChunkIds = new Set();
9332
9718
  for (let offset = 0; offset < fileData.length; offset += this.chunkSizeBytes) {
9333
9719
  const chunkData = fileData.slice(offset, Math.min(offset + this.chunkSizeBytes, fileData.length));
9334
9720
  // Encrypt chunk
@@ -9336,7 +9722,7 @@ class BaseMindooDB {
9336
9722
  // Compute content hash
9337
9723
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9338
9724
  // Generate chunk ID
9339
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9725
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9340
9726
  lastChunkId = chunkId;
9341
9727
  // Sign the encrypted chunk
9342
9728
  const signature = await this.tenant.signPayload(encryptedData);
@@ -9410,7 +9796,7 @@ class BaseMindooDB {
9410
9796
  dependencyIds: [],
9411
9797
  createdAt,
9412
9798
  attachmentId,
9413
- uploadStartedAt: Date.now(),
9799
+ uploadStartedAt: (0, timeSource_1.semanticNow)(),
9414
9800
  createdByPublicKey,
9415
9801
  decryptionKeyId,
9416
9802
  signature,
@@ -9456,6 +9842,9 @@ class BaseMindooDB {
9456
9842
  const pendingEntries = [];
9457
9843
  let pendingEncryptedBytes = 0;
9458
9844
  let ledgerCreated = false;
9845
+ // Guards against case-folded chunk-id collisions on case-insensitive
9846
+ // filesystems; scoped to this single attachment write.
9847
+ const usedCaseFoldedChunkIds = new Set();
9459
9848
  const flushPendingEntries = async (force = false) => {
9460
9849
  if (pendingEntries.length === 0) {
9461
9850
  return;
@@ -9474,7 +9863,7 @@ class BaseMindooDB {
9474
9863
  // Compute content hash
9475
9864
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9476
9865
  // Generate chunk ID
9477
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9866
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9478
9867
  // Sign the encrypted chunk
9479
9868
  const signature = await this.tenant.signPayload(encryptedData);
9480
9869
  // Create chunk entry
@@ -9562,6 +9951,9 @@ class BaseMindooDB {
9562
9951
  const chunks = [];
9563
9952
  let prevChunkId = prevLastChunkId;
9564
9953
  let lastChunkId = prevLastChunkId;
9954
+ // Guards against case-folded chunk-id collisions on case-insensitive
9955
+ // filesystems; scoped to this single append.
9956
+ const usedCaseFoldedChunkIds = new Set();
9565
9957
  for (let offset = 0; offset < data.length; offset += this.chunkSizeBytes) {
9566
9958
  const chunkData = data.slice(offset, Math.min(offset + this.chunkSizeBytes, data.length));
9567
9959
  // Encrypt chunk
@@ -9569,7 +9961,7 @@ class BaseMindooDB {
9569
9961
  // Compute content hash
9570
9962
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9571
9963
  // Generate chunk ID
9572
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9964
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9573
9965
  lastChunkId = chunkId;
9574
9966
  // Sign the encrypted chunk
9575
9967
  const signature = await this.tenant.signPayload(encryptedData);
@@ -9787,12 +10179,18 @@ class BaseMindooDB {
9787
10179
  try {
9788
10180
  const syncResult = await this.syncEntriesFromStore(localStore, remoteStore, options);
9789
10181
  this.logger.debug(`Transferred ${syncResult.transferred} entries to remote store`);
10182
+ const rejectedEntries = syncResult.rejected ?? [];
10183
+ if (rejectedEntries.length > 0) {
10184
+ this.logger.warn(`Remote rejected ${rejectedEntries.length} entries during push (skipped, push continued): ` +
10185
+ rejectedEntries.map((r) => r.reason).join("; "));
10186
+ }
9790
10187
  if (syncResult.cancelled) {
9791
10188
  this.logger.info(`Push cancelled after transferring ${syncResult.transferred} entries`);
9792
10189
  return {
9793
10190
  transferredEntries: syncResult.transferred,
9794
10191
  scannedEntries: syncResult.scanned,
9795
10192
  cancelled: true,
10193
+ ...(rejectedEntries.length > 0 ? { rejectedEntries } : {}),
9796
10194
  };
9797
10195
  }
9798
10196
  if (syncResult.transferred === 0) {
@@ -9811,6 +10209,7 @@ class BaseMindooDB {
9811
10209
  transferredEntries: syncResult.transferred,
9812
10210
  scannedEntries: syncResult.scanned,
9813
10211
  cancelled: false,
10212
+ ...(rejectedEntries.length > 0 ? { rejectedEntries } : {}),
9814
10213
  };
9815
10214
  }
9816
10215
  finally {
@@ -9838,6 +10237,13 @@ exports.BaseMindooDB = BaseMindooDB;
9838
10237
  * events frequent and make each POST cheap to retry.
9839
10238
  */
9840
10239
  BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE = 250;
10240
+ /**
10241
+ * Default window of transfer batches running in parallel (sync-v5,
10242
+ * phase 4). Kept small: each in-flight batch holds one decrypted page of
10243
+ * entries in memory, and HTTP/2 multiplexes the requests over a single
10244
+ * connection anyway. Overridable via `SyncOptions.maxConcurrentBatches`.
10245
+ */
10246
+ BaseMindooDB.DEFAULT_MAX_CONCURRENT_TRANSFER_BATCHES = 3;
9841
10247
  /** The "access control not enforced" allow decision returned by the
9842
10248
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
9843
10249
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {