mindoodb 0.0.33 → 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 +97 -13
  19. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  20. package/dist/core/BaseMindooDB.js +590 -163
  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 +13 -5
  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 +105 -9
  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) {
@@ -2281,18 +2346,20 @@ class BaseMindooDB {
2281
2346
  };
2282
2347
  }
2283
2348
  /**
2284
- * Fetch the target store's bloom filter summary, returning null if
2285
- * unsupported or on error (callers fall back to exact checks).
2349
+ * Fetch a store's bloom filter summary, returning null if unsupported or on
2350
+ * error. Used on the target for missing-id pre-screening (callers fall back
2351
+ * to exact checks) and on the source for the total-entry-count progress
2352
+ * denominator.
2286
2353
  */
2287
- async getTargetBloomSummary(targetStore) {
2288
- if (typeof targetStore.getIdBloomSummary !== "function") {
2354
+ async getStoreBloomSummary(store) {
2355
+ if (typeof store.getIdBloomSummary !== "function") {
2289
2356
  return null;
2290
2357
  }
2291
2358
  try {
2292
- return await targetStore.getIdBloomSummary();
2359
+ return await store.getIdBloomSummary();
2293
2360
  }
2294
2361
  catch (error) {
2295
- this.logger.warn("Failed to get bloom summary from target store, falling back to exact checks", error);
2362
+ this.logger.warn("Failed to get bloom summary from store, falling back to exact checks", error);
2296
2363
  return null;
2297
2364
  }
2298
2365
  }
@@ -2301,6 +2368,46 @@ class BaseMindooDB {
2301
2368
  store.setSyncAbortSignal(signal);
2302
2369
  }
2303
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
+ }
2304
2411
  /**
2305
2412
  * From a list of candidate IDs, return only those the target store is
2306
2413
  * missing. Uses bloom-filter pre-screening when available, then falls
@@ -2329,16 +2436,6 @@ class BaseMindooDB {
2329
2436
  }
2330
2437
  return missingIds;
2331
2438
  }
2332
- /**
2333
- * Determine how many entry IDs to fetch per `getEntries` call during sync.
2334
- *
2335
- * This is intentionally separate from the metadata scan `pageSize` so that
2336
- * scanning can page through large ID lists quickly while the heavier
2337
- * payload downloads use a smaller batch to keep progress responsive and
2338
- * cancellation timely.
2339
- *
2340
- * Priority: explicit option > attachment default (100) > pageSize fallback.
2341
- */
2342
2439
  resolveTransferBatchSize(options) {
2343
2440
  if (options?.transferBatchSize && options.transferBatchSize > 0) {
2344
2441
  return options.transferBatchSize;
@@ -2346,7 +2443,7 @@ class BaseMindooDB {
2346
2443
  if (options?.storeKind === types_2.StoreKind.attachments) {
2347
2444
  return 100;
2348
2445
  }
2349
- return options?.pageSize ?? 1000;
2446
+ return Math.min(options?.pageSize ?? 1000, BaseMindooDB.DEFAULT_DOCS_TRANSFER_BATCH_SIZE);
2350
2447
  }
2351
2448
  /**
2352
2449
  * Transfer a set of entry IDs from source to target in fixed-size batches,
@@ -2368,65 +2465,120 @@ class BaseMindooDB {
2368
2465
  */
2369
2466
  async transferEntriesInBatches(sourceStore, targetStore, entryIds, options, state) {
2370
2467
  if (entryIds.length === 0) {
2371
- return { transferred: state.transferred, cancelled: false };
2468
+ return { transferred: state.transferred, cancelled: false, rejected: [] };
2372
2469
  }
2373
2470
  const onProgress = options?.onProgress;
2374
2471
  const signal = options?.signal;
2375
2472
  const transferBatchSize = this.resolveTransferBatchSize(options);
2376
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);
2377
2479
  let transferred = state.transferred;
2378
- for (let offset = 0; offset < entryIds.length; offset += transferBatchSize) {
2379
- if (signal?.aborted) {
2380
- return { transferred, cancelled: true };
2381
- }
2382
- const currentTransferBatch = Math.floor(offset / transferBatchSize) + 1;
2383
- const batchIds = entryIds.slice(offset, offset + transferBatchSize);
2384
- const pageSummary = state.currentPage ? `page ${state.currentPage}, ` : "";
2385
- onProgress?.({
2386
- phase: "transferring",
2387
- message: `Transferring batch ${currentTransferBatch}/${totalTransferBatches} (${batchIds.length} entries, ${pageSummary}scanned ${state.scanned})...`,
2388
- transferredEntries: transferred,
2389
- scannedEntries: state.scanned,
2390
- totalSourceEntries: state.totalSourceEntries,
2391
- currentPage: state.currentPage,
2392
- currentTransferBatch,
2393
- totalTransferBatches,
2394
- transferBatchSize,
2395
- });
2396
- try {
2397
- const batchEntries = await sourceStore.getEntries(batchIds);
2398
- if (signal?.aborted) {
2399
- 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;
2400
2492
  }
2401
- const putResult = await targetStore.putEntries(batchEntries);
2402
- // On push, the remote (witnessing) target returns receipts for accepted
2403
- // entries; persist them back onto the local source so the revision feed
2404
- // re-anchors them from the provisional head to their committed
2405
- // `receivedAt` (docs/accesscontrol.md §5.3). Pull targets return void.
2406
- if (Array.isArray(putResult) && putResult.length > 0 && sourceStore.applyWitnessReceipts) {
2407
- await sourceStore.applyWitnessReceipts(putResult);
2493
+ const batchIndex = nextBatchIndex++;
2494
+ const offset = batchIndex * transferBatchSize;
2495
+ if (offset >= entryIds.length) {
2496
+ return;
2408
2497
  }
2409
- transferred += batchEntries.length;
2410
- }
2411
- catch (error) {
2412
- if (signal?.aborted) {
2413
- 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;
2414
2534
  }
2415
- 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
+ });
2416
2556
  }
2417
- onProgress?.({
2418
- phase: "transferring",
2419
- message: `Transferred ${transferred} entries after batch ${currentTransferBatch}/${totalTransferBatches}`,
2420
- transferredEntries: transferred,
2421
- scannedEntries: state.scanned,
2422
- totalSourceEntries: state.totalSourceEntries,
2423
- currentPage: state.currentPage,
2424
- currentTransferBatch,
2425
- totalTransferBatches,
2426
- transferBatchSize,
2427
- });
2557
+ };
2558
+ await Promise.all(Array.from({ length: maxConcurrent }, () => runWorker()));
2559
+ if (signal?.aborted || cancelled) {
2560
+ return { transferred, cancelled: true, rejected };
2428
2561
  }
2429
- 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: [] };
2430
2582
  }
2431
2583
  async syncEntriesFromStore(sourceStore, targetStore, options) {
2432
2584
  const signal = options?.signal;
@@ -2453,12 +2605,67 @@ class BaseMindooDB {
2453
2605
  async syncEntriesFromStoreImpl(sourceStore, targetStore, options) {
2454
2606
  let transferred = 0;
2455
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 = [];
2456
2611
  const onProgress = options?.onProgress;
2457
2612
  const pageSize = options?.pageSize ?? 1000;
2458
2613
  const signal = options?.signal;
2459
- const targetBloom = await this.getTargetBloomSummary(targetStore);
2460
- const totalSourceEstimate = targetBloom?.totalIds;
2461
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;
2462
2669
  onProgress?.({
2463
2670
  phase: 'preparing',
2464
2671
  message: 'Preparing to sync entries...',
@@ -2466,17 +2673,34 @@ class BaseMindooDB {
2466
2673
  scannedEntries: 0,
2467
2674
  totalSourceEntries: totalSourceEstimate,
2468
2675
  });
2469
- let cursor = null;
2470
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
+ };
2471
2686
  while (true) {
2472
2687
  if (signal?.aborted) {
2473
- return { transferred, scanned, cancelled: true };
2688
+ discardPrefetch();
2689
+ persistScanCursor(cursor);
2690
+ return { transferred, scanned, cancelled: true, rejected };
2474
2691
  }
2475
- 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;
2476
2698
  currentPage++;
2477
2699
  scanned += page.entries.length;
2478
2700
  if (signal?.aborted) {
2479
- return { transferred, scanned, cancelled: true };
2701
+ discardPrefetch();
2702
+ persistScanCursor(cursor);
2703
+ return { transferred, scanned, cancelled: true, rejected };
2480
2704
  }
2481
2705
  if (page.entries.length > 0) {
2482
2706
  onProgress?.({
@@ -2490,7 +2714,9 @@ class BaseMindooDB {
2490
2714
  const ids = page.entries.map((m) => m.id);
2491
2715
  const missingIds = await this.filterMissingIds(targetStore, ids, targetBloom);
2492
2716
  if (signal?.aborted) {
2493
- return { transferred, scanned, cancelled: true };
2717
+ discardPrefetch();
2718
+ persistScanCursor(cursor);
2719
+ return { transferred, scanned, cancelled: true, rejected };
2494
2720
  }
2495
2721
  if (missingIds.length > 0) {
2496
2722
  const transferResult = await this.transferEntriesInBatches(sourceStore, targetStore, missingIds, options, {
@@ -2500,8 +2726,13 @@ class BaseMindooDB {
2500
2726
  currentPage,
2501
2727
  });
2502
2728
  transferred = transferResult.transferred;
2729
+ rejected.push(...transferResult.rejected);
2503
2730
  if (transferResult.cancelled) {
2504
- 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 };
2505
2736
  }
2506
2737
  }
2507
2738
  }
@@ -2518,7 +2749,8 @@ class BaseMindooDB {
2518
2749
  break;
2519
2750
  }
2520
2751
  }
2521
- return { transferred, scanned, cancelled: false };
2752
+ persistScanCursor(cursor);
2753
+ return { transferred, scanned, cancelled: false, rejected };
2522
2754
  }
2523
2755
  onProgress?.({
2524
2756
  phase: 'preparing',
@@ -2547,8 +2779,9 @@ class BaseMindooDB {
2547
2779
  totalSourceEntries: sourceNewMetadata.length,
2548
2780
  });
2549
2781
  transferred = transferResult.transferred;
2782
+ rejected.push(...transferResult.rejected);
2550
2783
  if (transferResult.cancelled) {
2551
- return { transferred, scanned: sourceNewMetadata.length, cancelled: true };
2784
+ return { transferred, scanned: sourceNewMetadata.length, cancelled: true, rejected };
2552
2785
  }
2553
2786
  onProgress?.({
2554
2787
  phase: 'transferring',
@@ -2561,6 +2794,7 @@ class BaseMindooDB {
2561
2794
  transferred,
2562
2795
  scanned: sourceNewMetadata.length,
2563
2796
  cancelled: false,
2797
+ rejected,
2564
2798
  };
2565
2799
  }
2566
2800
  /**
@@ -2636,7 +2870,7 @@ class BaseMindooDB {
2636
2870
  }
2637
2871
  // ── Phase 4: filter out entries the target already has ───────────
2638
2872
  const allNeededArray = Array.from(neededIds);
2639
- const targetBloom = await this.getTargetBloomSummary(targetStore);
2873
+ const targetBloom = await this.getStoreBloomSummary(targetStore);
2640
2874
  const missingIds = await this.filterMissingIds(targetStore, allNeededArray, targetBloom);
2641
2875
  this.logger.info(`Dense sync: ${missingIds.length} entries to transfer ` +
2642
2876
  `(${allNeededArray.length - missingIds.length} already present)`);
@@ -2649,19 +2883,30 @@ class BaseMindooDB {
2649
2883
  // ── Phase 5: transfer missing entries in pages ───────────────────
2650
2884
  const pageSize = options?.pageSize ?? 500;
2651
2885
  let transferred = 0;
2886
+ const rejected = [];
2652
2887
  for (let offset = 0; offset < missingIds.length; offset += pageSize) {
2653
2888
  if (options?.signal?.aborted) {
2654
- return { transferred, scanned: allNeededArray.length, cancelled: true };
2889
+ return { transferred, scanned: allNeededArray.length, cancelled: true, rejected };
2655
2890
  }
2656
2891
  const batch = missingIds.slice(offset, offset + pageSize);
2657
2892
  const entries = await sourceStore.getEntries(batch);
2658
2893
  const putResult = await targetStore.putEntries(entries);
2659
2894
  // Persist any witness receipts back onto the local source (see push path
2660
- // in transferEntriesInBatches; docs/accesscontrol.md §5.3).
2661
- if (Array.isArray(putResult) && putResult.length > 0 && sourceStore.applyWitnessReceipts) {
2662
- await sourceStore.applyWitnessReceipts(putResult);
2663
- }
2664
- 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;
2665
2910
  onProgress?.({
2666
2911
  phase: "transferring",
2667
2912
  message: `Dense sync: transferred ${transferred}/${missingIds.length} entries`,
@@ -2670,8 +2915,9 @@ class BaseMindooDB {
2670
2915
  totalSourceEntries: allNeededArray.length,
2671
2916
  });
2672
2917
  }
2673
- this.logger.info(`Dense sync complete: transferred ${transferred} entries`);
2674
- 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 };
2675
2921
  }
2676
2922
  getStore() {
2677
2923
  return this.store;
@@ -2681,7 +2927,7 @@ class BaseMindooDB {
2681
2927
  }
2682
2928
  async reclaimIncompleteAttachmentUploads(options) {
2683
2929
  const minAgeMs = options?.minAgeMs ?? 5 * 60 * 1000;
2684
- const cutoff = Date.now() - minAgeMs;
2930
+ const cutoff = (0, timeSource_1.semanticNow)() - minAgeMs;
2685
2931
  const ledgers = await this.store.findEntries("pending_attachment_upload", null, cutoff);
2686
2932
  const result = {
2687
2933
  scannedLedgers: ledgers.length,
@@ -2784,6 +3030,7 @@ class BaseMindooDB {
2784
3030
  throw new Error(`createDocuments: invalid document id "${options.id}". ` +
2785
3031
  `Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
2786
3032
  }
3033
+ this.validateIdPrefixOption("createDocuments", options);
2787
3034
  }
2788
3035
  const results = new Array(inputs.length).fill(null);
2789
3036
  const prepared = [];
@@ -2840,18 +3087,68 @@ class BaseMindooDB {
2840
3087
  this.logger.info(`Bulk-created ${prepared.length} documents (${inputs.length - prepared.length} already existed)`);
2841
3088
  return results;
2842
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
+ }
2843
3136
  /**
2844
3137
  * Internal method to create a new document.
2845
3138
  *
2846
- * Handles all four creation flavors expressible through `CreateOptions`:
2847
- * - generated UUID7 ID, current user signs, default tenant key
2848
- * - 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>`)
2849
3144
  * - caller-provided ID, current user signs (idempotent on existing IDs)
2850
3145
  * - caller-provided ID with custom signing key
2851
3146
  *
2852
3147
  * For caller-provided IDs the initial Automerge change is seeded from a
2853
3148
  * deterministic, hard-coded change so that two replicas creating the same
2854
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.
2855
3152
  */
2856
3153
  async createDocumentInternal(options) {
2857
3154
  this.assertWritable("createDocument");
@@ -2862,6 +3159,7 @@ class BaseMindooDB {
2862
3159
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
2863
3160
  const keyId = await this.resolveCreateKeyId(options);
2864
3161
  const useCustomDocId = options.id !== undefined;
3162
+ this.validateIdPrefixOption("createDocument", options);
2865
3163
  // Sanitize caller-provided initial values: internal/reserved fields (those
2866
3164
  // starting with "_", e.g. "_attachments") are managed by MindooDB and must
2867
3165
  // not be seeded by callers. `undefined` values are treated as "not set"
@@ -2895,7 +3193,7 @@ class BaseMindooDB {
2895
3193
  throw new Error("Admin-only database: only the admin key can modify data");
2896
3194
  }
2897
3195
  }
2898
- const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3196
+ const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
2899
3197
  // Idempotent create: when a caller-provided id already exists locally,
2900
3198
  // return the existing document instead of producing a duplicate doc_create.
2901
3199
  if (useCustomDocId) {
@@ -2921,7 +3219,7 @@ class BaseMindooDB {
2921
3219
  // For custom-ID documents we apply a hard-coded initial change so that
2922
3220
  // independent replicas using the same id produce the same Automerge hash
2923
3221
  // and `doc_create` entry id, allowing later changes to merge.
2924
- const now = Date.now();
3222
+ const now = (0, timeSource_1.semanticNow)();
2925
3223
  let newDoc;
2926
3224
  let changeBytes;
2927
3225
  if (useCustomDocId) {
@@ -3103,6 +3401,7 @@ class BaseMindooDB {
3103
3401
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
3104
3402
  const keyId = await this.resolveCreateKeyId(options);
3105
3403
  const useCustomDocId = options.id !== undefined;
3404
+ this.validateIdPrefixOption("createDocuments", options);
3106
3405
  // Sanitize caller-provided initial values: internal/reserved fields are
3107
3406
  // managed by MindooDB and must not be seeded by callers. `undefined`
3108
3407
  // values are treated as "not set" (Automerge cannot represent undefined).
@@ -3119,8 +3418,8 @@ class BaseMindooDB {
3119
3418
  throw new Error("Admin-only database: only the admin key can modify data");
3120
3419
  }
3121
3420
  }
3122
- const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
3123
- const now = Date.now();
3421
+ const docId = useCustomDocId ? options.id : this.generateUnusedDocId(options.idPrefix);
3422
+ const now = (0, timeSource_1.semanticNow)();
3124
3423
  // Build the initial Automerge document and its first change bytes,
3125
3424
  // following the same seeding rules as the single-create path.
3126
3425
  let seedDoc;
@@ -3283,14 +3582,22 @@ class BaseMindooDB {
3283
3582
  // Filter to document replay metadata plus snapshots up to the timestamp.
3284
3583
  // Attachment chunks are not part of the Automerge replay DAG and must not
3285
3584
  // participate in historical materialization.
3286
- 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
3287
3594
  .filter((em) => em.createdAt <= timestamp
3288
3595
  && (em.entryType === "doc_create"
3289
3596
  || em.entryType === "doc_change"
3290
3597
  || em.entryType === "doc_delete"
3291
3598
  || em.entryType === "doc_undelete"
3292
3599
  || em.entryType === "doc_snapshot"))
3293
- .sort((a, b) => a.createdAt - b.createdAt);
3600
+ .sort((a, b) => a.createdAt - b.createdAt));
3294
3601
  if (relevantEntries.length === 0) {
3295
3602
  this.performanceCallback?.onHistoryOperation?.({
3296
3603
  operation: "getDocumentAtTimestamp",
@@ -3411,10 +3718,13 @@ class BaseMindooDB {
3411
3718
  this.logger.debug(`Iterating document history for ${docId}`);
3412
3719
  // Get all entry metadata for this document
3413
3720
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
3414
- // Filter to document replay entries (exclude snapshots)
3415
- const relevantEntries = allEntryMetadata
3416
- .filter((em) => this.isDocumentReplayEntry(em))
3417
- .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)));
3418
3728
  if (relevantEntries.length === 0) {
3419
3729
  return; // Document has no history
3420
3730
  }
@@ -3426,7 +3736,8 @@ class BaseMindooDB {
3426
3736
  let currentDoc = null;
3427
3737
  let createdAt = null;
3428
3738
  let decryptionKeyId = "default";
3429
- for (const entryMetadata of relevantEntries) {
3739
+ for (let entryIndex = 0; entryIndex < relevantEntries.length; entryIndex++) {
3740
+ const entryMetadata = relevantEntries[entryIndex];
3430
3741
  const entryData = entryMap.get(entryMetadata.id);
3431
3742
  if (!entryData) {
3432
3743
  this.logger.warn(`Entry ${entryMetadata.id} not found in store, skipping`);
@@ -3492,8 +3803,12 @@ class BaseMindooDB {
3492
3803
  createdAt: createdAt,
3493
3804
  lastModified: entryMetadata.createdAt,
3494
3805
  decryptionKeyId,
3495
- isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((entry) => entry.createdAt < entryMetadata.createdAt ||
3496
- (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)),
3497
3812
  };
3498
3813
  // Wrap and yield (including lifecycle terminal entries)
3499
3814
  const wrappedDoc = this.wrapDocument(internalDoc);
@@ -3548,7 +3863,7 @@ class BaseMindooDB {
3548
3863
  }
3549
3864
  // Trusted time for un-witnessed entries is the current wall clock, computed
3550
3865
  // once per run so all un-witnessed entries share a stable head instant.
3551
- const now = Date.now();
3866
+ const now = (0, timeSource_1.semanticNow)();
3552
3867
  // Affected documents (those with at least one newly-discovered entry), and
3553
3868
  // the set of new entry ids per doc (the trusted-time positions from which the
3554
3869
  // doc's merged state may have changed and must be re-folded / re-emitted).
@@ -3968,24 +4283,28 @@ class BaseMindooDB {
3968
4283
  const offset = Math.max(0, Math.floor(options?.cursor?.offset ?? 0));
3969
4284
  // This API stays metadata-only so large history views can page cheaply
3970
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.
3971
4289
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
3972
- const relevantEntries = allEntryMetadata
3973
- .filter((em) => em.entryType === "doc_create" ||
4290
+ const relevantEntries = (0, DocumentDagAnalysis_1.orderDagEntriesCausally)(allEntryMetadata.filter((em) => em.entryType === "doc_create" ||
3974
4291
  em.entryType === "doc_change" ||
3975
4292
  em.entryType === "doc_delete" ||
3976
- em.entryType === "doc_undelete")
3977
- .sort((a, b) => a.createdAt - b.createdAt);
4293
+ em.entryType === "doc_undelete"));
3978
4294
  // Cursor paging is offset-based because the result is a bounded timeline view
3979
4295
  // over the doc's sorted change metadata, not a resumable store scan cursor.
3980
4296
  const slice = relevantEntries.slice(offset, offset + limit);
3981
- const entries = slice.map((entry) => ({
4297
+ const entries = slice.map((entry, sliceIndex) => ({
3982
4298
  entryId: entry.id,
3983
4299
  entryType: entry.entryType,
3984
4300
  changeCreatedAt: entry.createdAt,
3985
4301
  changeCreatedByPublicKey: entry.createdByPublicKey,
3986
4302
  dependencyIds: [...entry.dependencyIds],
3987
- isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((candidate) => candidate.createdAt < entry.createdAt ||
3988
- (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)),
3989
4308
  }));
3990
4309
  const nextOffset = offset + entries.length;
3991
4310
  const hasMore = nextOffset < relevantEntries.length;
@@ -4596,7 +4915,7 @@ class BaseMindooDB {
4596
4915
  return {
4597
4916
  changeSeqAsOf: latestCursor?.changeSeq ?? 0,
4598
4917
  storeReceiptOrderAsOf: await this.getStoreMaxReceiptOrder(),
4599
- takenAt: Date.now(),
4918
+ takenAt: (0, timeSource_1.semanticNow)(),
4600
4919
  };
4601
4920
  }
4602
4921
  /**
@@ -5244,20 +5563,27 @@ class BaseMindooDB {
5244
5563
  const filteredPaths = normalizedPaths.filter((path) => touchedKeySet.has(this.extractLeafPathSegment(path)));
5245
5564
  return filteredPaths.length > 0 ? filteredPaths : normalizedPaths;
5246
5565
  }
5247
- async getAllDocumentIds() {
5248
- // 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);
5249
5570
  const docIds = [];
5250
5571
  for (const entry of this.index) {
5251
5572
  if (entry.accessState === "visible" && !entry.isDeleted) {
5573
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
5574
+ continue;
5252
5575
  docIds.push(entry.docId);
5253
5576
  }
5254
5577
  }
5255
5578
  return docIds;
5256
5579
  }
5257
- async getDeletedDocumentIds() {
5580
+ async getDeletedDocumentIds(options) {
5581
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
5258
5582
  const docIds = [];
5259
5583
  for (const entry of this.index) {
5260
5584
  if (entry.accessState === "visible" && entry.isDeleted) {
5585
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
5586
+ continue;
5261
5587
  docIds.push(entry.docId);
5262
5588
  }
5263
5589
  }
@@ -5297,24 +5623,36 @@ class BaseMindooDB {
5297
5623
  }
5298
5624
  const docIds = [];
5299
5625
  for (const [docId, lifecycleEntries] of lifecycleEntriesByDocId.entries()) {
5300
- // Sort deterministically so same-timestamp lifecycle entries resolve the
5301
- // same way on every replica.
5302
- const ordered = lifecycleEntries.sort((a, b) => a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id.localeCompare(b.id));
5303
5626
  // A document cannot exist before its create entry, even if malformed or
5304
5627
  // partial metadata somehow contains later lifecycle entries.
5305
- if (!ordered.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
5628
+ if (!lifecycleEntries.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
5306
5629
  continue;
5307
5630
  }
5308
- const createEntry = ordered.find((entry) => entry.entryType === "doc_create");
5631
+ const createEntry = lifecycleEntries.find((entry) => entry.entryType === "doc_create");
5309
5632
  if (createEntry && !(await this.tenant.hasDecryptionKey(createEntry.decryptionKeyId))) {
5310
5633
  continue;
5311
5634
  }
5312
- // Delete and undelete are terminal lifecycle markers. The latest one at
5313
- // the timestamp decides whether the document existed then.
5314
- const latestTerminal = ordered
5315
- .filter((entry) => entry.entryType === "doc_delete" || entry.entryType === "doc_undelete")
5316
- .at(-1);
5317
- 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)) {
5318
5656
  docIds.push(docId);
5319
5657
  }
5320
5658
  }
@@ -5427,17 +5765,31 @@ class BaseMindooDB {
5427
5765
  // Delete and undelete are encoded as normal Automerge changes so the DAG
5428
5766
  // keeps causal ancestry, while the StoreEntry type carries lifecycle intent.
5429
5767
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
5430
- const now = Date.now();
5768
+ const now = (0, timeSource_1.semanticNow)();
5431
5769
  const docId = internalDoc.id;
5432
5770
  // Lifecycle changes are intentionally non-destructive: they only bump
5433
5771
  // `_lastModified` and repair missing legacy attachment arrays. The document
5434
5772
  // body remains available for history and future undeletion.
5435
- 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) => {
5436
5785
  if (!Array.isArray(mutableDoc._attachments)) {
5437
5786
  mutableDoc._attachments = [];
5438
5787
  }
5439
5788
  mutableDoc._lastModified = now;
5440
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
+ }
5441
5793
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
5442
5794
  if (!changeBytes) {
5443
5795
  throw new Error("Failed to get change bytes from Automerge document");
@@ -5542,15 +5894,18 @@ class BaseMindooDB {
5542
5894
  // The doc is now witness-era (we just wrote a versioned entry), so it is
5543
5895
  // witnessed iff it is no longer awaiting a receipt.
5544
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.
5545
5900
  if (options.markDirty) {
5546
5901
  await this.storeCachedDocument(internalDoc);
5547
- 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);
5548
5903
  this.markDocDirty(docId);
5549
5904
  }
5550
5905
  else {
5551
5906
  this.docCache.delete(docId);
5552
5907
  this.dirtyDocIds.delete(docId);
5553
- 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);
5554
5909
  }
5555
5910
  }
5556
5911
  /**
@@ -5614,7 +5969,7 @@ class BaseMindooDB {
5614
5969
  throw new errors_1.DocumentDeletedError(docId);
5615
5970
  }
5616
5971
  this.validateTextPatch(patch);
5617
- const now = Date.now();
5972
+ const now = (0, timeSource_1.semanticNow)();
5618
5973
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5619
5974
  const baseHeads = patch.baseHeads;
5620
5975
  const applyEdits = (automergeDoc) => {
@@ -5678,7 +6033,7 @@ class BaseMindooDB {
5678
6033
  throw new errors_1.DocumentDeletedError(docId);
5679
6034
  }
5680
6035
  this.validateRichTextPatch(patch);
5681
- const now = Date.now();
6036
+ const now = (0, timeSource_1.semanticNow)();
5682
6037
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5683
6038
  const spansSequence = patch.spansSequence ?? (patch.spans ? [patch.spans] : []);
5684
6039
  const applySpans = (automergeDoc) => {
@@ -5753,7 +6108,7 @@ class BaseMindooDB {
5753
6108
  throw new errors_1.DocumentDeletedError(docId);
5754
6109
  }
5755
6110
  this.validateRichTextStepPatch(patch);
5756
- const now = Date.now();
6111
+ const now = (0, timeSource_1.semanticNow)();
5757
6112
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5758
6113
  const applySteps = (automergeDoc) => {
5759
6114
  this.ensureRichTextPath(automergeDoc, patch.path);
@@ -5875,7 +6230,7 @@ class BaseMindooDB {
5875
6230
  throw new errors_1.DocumentDeletedError(docId);
5876
6231
  }
5877
6232
  await this.hydrateAutomergeHashMappingsFromStore(docId);
5878
- const now = Date.now();
6233
+ const now = (0, timeSource_1.semanticNow)();
5879
6234
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5880
6235
  if (patch.baseHeads?.length) {
5881
6236
  const baseHeadsKey = [...patch.baseHeads].sort().join("|");
@@ -5982,7 +6337,7 @@ class BaseMindooDB {
5982
6337
  throw new errors_1.DocumentDeletedError(docId);
5983
6338
  }
5984
6339
  this.validateJsonPatch(patch);
5985
- const now = Date.now();
6340
+ const now = (0, timeSource_1.semanticNow)();
5986
6341
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
5987
6342
  const applyPatch = (automergeDoc) => {
5988
6343
  this.applyJsonPatchOperations(automergeDoc, patch);
@@ -6055,7 +6410,7 @@ class BaseMindooDB {
6055
6410
  throw new errors_1.DocumentDeletedError(docId);
6056
6411
  }
6057
6412
  // Apply the change function
6058
- const now = Date.now();
6413
+ const now = (0, timeSource_1.semanticNow)();
6059
6414
  this.logger.debug(`Applying change function to document ${docId}`);
6060
6415
  if (this.isDebugEnabled())
6061
6416
  this.logger.debug(`Document state before change: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(internalDoc.doc))}`);
@@ -6960,7 +7315,10 @@ class BaseMindooDB {
6960
7315
  || (internalDoc.witnessed ?? false);
6961
7316
  internalDoc.witnessed = versioned && !internalDoc.awaitingWitness;
6962
7317
  await this.storeCachedDocument(internalDoc);
6963
- 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);
6964
7322
  this.markDocDirty(docId);
6965
7323
  this.logger.info(`Document ${docId} ${successMessage} successfully`);
6966
7324
  const createdByPublicKey = useCustomKey
@@ -7078,7 +7436,7 @@ class BaseMindooDB {
7078
7436
  contentHash,
7079
7437
  docId,
7080
7438
  dependencyIds: headEntryIds,
7081
- createdAt: Date.now(),
7439
+ createdAt: (0, timeSource_1.semanticNow)(),
7082
7440
  createdByPublicKey: options.createdByPublicKey,
7083
7441
  decryptionKeyId: internalDoc.decryptionKeyId,
7084
7442
  snapshotHeadHashes: headHashes,
@@ -7101,7 +7459,7 @@ class BaseMindooDB {
7101
7459
  this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
7102
7460
  }
7103
7461
  }
7104
- async prefetchIterationWindow(indexSnapshot, startIndex) {
7462
+ async prefetchIterationWindow(indexSnapshot, startIndex, idPrefix) {
7105
7463
  if (this.iteratePrefetchWindowDocs <= 0) {
7106
7464
  return 0;
7107
7465
  }
@@ -7117,6 +7475,10 @@ class BaseMindooDB {
7117
7475
  if (entry.accessState === "inaccessible" || entry.isDeleted) {
7118
7476
  continue;
7119
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
+ }
7120
7482
  const docId = entry.docId;
7121
7483
  if (seen.has(docId) || this.docCache.has(docId)) {
7122
7484
  continue;
@@ -7169,7 +7531,7 @@ class BaseMindooDB {
7169
7531
  return startIndex;
7170
7532
  }
7171
7533
  resolveDagTimestamp(timestamp) {
7172
- return timestamp === "now" ? Date.now() : timestamp;
7534
+ return timestamp === "now" ? (0, timeSource_1.semanticNow)() : timestamp;
7173
7535
  }
7174
7536
  previewChangeValue(value) {
7175
7537
  if (value === undefined) {
@@ -7435,19 +7797,22 @@ class BaseMindooDB {
7435
7797
  witnessed: witnessState.witnessed,
7436
7798
  };
7437
7799
  }
7438
- async *iterateChangeMetadataSince(cursor) {
7800
+ async *iterateChangeMetadataSince(cursor, options) {
7439
7801
  const startedAt = Date.now();
7440
7802
  const actualCursor = cursor ?? {
7441
7803
  changeSeq: 0,
7442
7804
  lastModified: 0,
7443
7805
  docId: "",
7444
7806
  };
7807
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7445
7808
  const indexSnapshot = [...this.index];
7446
7809
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
7447
7810
  let yieldedDocuments = 0;
7448
7811
  try {
7449
7812
  for (let i = startIndex; i < indexSnapshot.length; i++) {
7450
7813
  const entry = indexSnapshot[i];
7814
+ if (idPrefix && !(0, idGeneration_1.matchesDocIdPrefix)(entry.docId, idPrefix))
7815
+ continue;
7451
7816
  const currentCursor = {
7452
7817
  changeSeq: entry.changeSeq,
7453
7818
  lastModified: entry.lastModified,
@@ -7489,20 +7854,32 @@ class BaseMindooDB {
7489
7854
  * them. O(log n) binary search on the internal `(changeSeq, docId)` index —
7490
7855
  * used for progress reporting around {@link iterateChangesSince}.
7491
7856
  */
7492
- countChangesSince(cursor) {
7857
+ countChangesSince(cursor, options) {
7493
7858
  const actualCursor = cursor ?? {
7494
7859
  changeSeq: 0,
7495
7860
  lastModified: 0,
7496
7861
  docId: "",
7497
7862
  };
7498
7863
  const startIndex = this.getStartIndexForCursor(this.index, actualCursor);
7499
- 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;
7500
7876
  }
7501
- async *iterateChangesSince(cursor) {
7877
+ async *iterateChangesSince(cursor, options) {
7502
7878
  const startedAt = Date.now();
7503
7879
  // Default to initial cursor if null is provided.
7504
7880
  // Prefer deterministic sequence-based cursoring; keep legacy fallback compatibility.
7505
7881
  const actualCursor = cursor ?? { changeSeq: 0, lastModified: 0, docId: "" };
7882
+ const idPrefix = this.normalizeIdPrefixFilter(options?.idPrefix);
7506
7883
  this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
7507
7884
  // Use a stable snapshot of the index for this generator run so concurrent
7508
7885
  // updates do not reorder/skip entries while iterating.
@@ -7510,13 +7887,17 @@ class BaseMindooDB {
7510
7887
  // Find starting position using binary search
7511
7888
  // We want to find the first entry that is greater than the cursor.
7512
7889
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
7513
- let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex);
7890
+ let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex, idPrefix);
7514
7891
  let yieldedDocuments = 0;
7515
7892
  let loadedDocuments = 0;
7516
7893
  try {
7517
7894
  // Iterate through the stable snapshot and yield documents one at a time.
7518
7895
  for (let i = startIndex; i < indexSnapshot.length; i++) {
7519
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;
7520
7901
  if (entry.accessState !== "visible") {
7521
7902
  // The document was readable before but the current KeyBag can no
7522
7903
  // longer decrypt it (e.g. a revoked key, §13). Emit a tombstone so
@@ -7531,7 +7912,7 @@ class BaseMindooDB {
7531
7912
  };
7532
7913
  yieldedDocuments++;
7533
7914
  yield { doc: this.buildInaccessibleDoc(entry), cursor: inaccessibleCursor };
7534
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7915
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7535
7916
  continue;
7536
7917
  }
7537
7918
  if (entry.isDeleted) {
@@ -7548,7 +7929,7 @@ class BaseMindooDB {
7548
7929
  };
7549
7930
  yieldedDocuments++;
7550
7931
  yield { doc: this.buildDeletedDoc(entry), cursor: deletedCursor };
7551
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7932
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7552
7933
  continue;
7553
7934
  }
7554
7935
  try {
@@ -7578,7 +7959,7 @@ class BaseMindooDB {
7578
7959
  // Deleted documents are included so external indexes can handle deletions
7579
7960
  yieldedDocuments++;
7580
7961
  yield { doc, cursor: currentCursor };
7581
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
7962
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
7582
7963
  }
7583
7964
  catch (error) {
7584
7965
  this.logger.error(`Error processing document ${entry.docId}:`, error);
@@ -8223,7 +8604,11 @@ class BaseMindooDB {
8223
8604
  op: input.op,
8224
8605
  dbid,
8225
8606
  signingKey: input.signerKey,
8226
- trustedTime: Date.now(),
8607
+ // Head selector, NOT Date.now(): un-witnessed directory revisions get a
8608
+ // provisional trusted time stamped at fold time, which can land AFTER a
8609
+ // wall clock captured here; an at-`now` node would then miss just-written
8610
+ // policy docs. Same rationale as the provisional-entry judgment below.
8611
+ trustedTime: Number.MAX_SAFE_INTEGER,
8227
8612
  isAuthor: input.isAuthor,
8228
8613
  beforeDoc,
8229
8614
  afterDoc,
@@ -8399,7 +8784,7 @@ class BaseMindooDB {
8399
8784
  // A verify-only provider (no signer) routes receipts to the right scheme.
8400
8785
  const subtle = this.getSubtle();
8401
8786
  const receiptVerifier = new Ed25519WitnessProvider_1.Ed25519WitnessProvider({ subtle }).createVerifier();
8402
- const nowMs = Date.now();
8787
+ const nowMs = (0, timeSource_1.semanticNow)();
8403
8788
  // Lifecycle entries only, folded in deterministic causal order. Un-witnessed
8404
8789
  // entries (versioned or legacy) order by their author time `createdAt`, NOT
8405
8790
  // the provisional `now`: this fold computes per-step before/after states for
@@ -8435,7 +8820,7 @@ class BaseMindooDB {
8435
8820
  // Audit record: the entry's own (stable) time — receipt time if
8436
8821
  // witnessed, else author time. Not the provisional head `now`.
8437
8822
  trustedTime: meta.receivedAt ?? meta.createdAt,
8438
- recordedAt: Date.now(),
8823
+ recordedAt: (0, timeSource_1.semanticNow)(),
8439
8824
  });
8440
8825
  };
8441
8826
  for (const meta of dataEntries) {
@@ -8563,7 +8948,7 @@ class BaseMindooDB {
8563
8948
  const payload = currentDoc;
8564
8949
  const isDeleted = this.computeIsDeletedFromMetadata(acceptedMeta);
8565
8950
  const lastAccepted = acceptedMeta[acceptedMeta.length - 1];
8566
- const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : Date.now());
8951
+ const lastModified = payload._lastModified || (lastAccepted ? lastAccepted.createdAt : (0, timeSource_1.semanticNow)());
8567
8952
  const witnessState = (0, storeEntryTime_1.metadataWitnessState)(acceptedMeta);
8568
8953
  return {
8569
8954
  doc: {
@@ -8934,7 +9319,7 @@ class BaseMindooDB {
8934
9319
  // Get lastModified from payload, or use the timestamp of the last entry
8935
9320
  const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
8936
9321
  const lastModified = payload._lastModified ||
8937
- (lastEntry ? lastEntry.createdAt : Date.now());
9322
+ (lastEntry ? lastEntry.createdAt : (0, timeSource_1.semanticNow)());
8938
9323
  // Get createdAt from the first entry
8939
9324
  const firstEntry = allEntryMetadata.length > 0 ? allEntryMetadata[0] : null;
8940
9325
  const createdAt = firstEntry ? firstEntry.createdAt : lastModified;
@@ -9327,6 +9712,9 @@ class BaseMindooDB {
9327
9712
  const chunks = [];
9328
9713
  let prevChunkId = null;
9329
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();
9330
9718
  for (let offset = 0; offset < fileData.length; offset += this.chunkSizeBytes) {
9331
9719
  const chunkData = fileData.slice(offset, Math.min(offset + this.chunkSizeBytes, fileData.length));
9332
9720
  // Encrypt chunk
@@ -9334,7 +9722,7 @@ class BaseMindooDB {
9334
9722
  // Compute content hash
9335
9723
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9336
9724
  // Generate chunk ID
9337
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9725
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9338
9726
  lastChunkId = chunkId;
9339
9727
  // Sign the encrypted chunk
9340
9728
  const signature = await this.tenant.signPayload(encryptedData);
@@ -9408,7 +9796,7 @@ class BaseMindooDB {
9408
9796
  dependencyIds: [],
9409
9797
  createdAt,
9410
9798
  attachmentId,
9411
- uploadStartedAt: Date.now(),
9799
+ uploadStartedAt: (0, timeSource_1.semanticNow)(),
9412
9800
  createdByPublicKey,
9413
9801
  decryptionKeyId,
9414
9802
  signature,
@@ -9454,6 +9842,9 @@ class BaseMindooDB {
9454
9842
  const pendingEntries = [];
9455
9843
  let pendingEncryptedBytes = 0;
9456
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();
9457
9848
  const flushPendingEntries = async (force = false) => {
9458
9849
  if (pendingEntries.length === 0) {
9459
9850
  return;
@@ -9472,7 +9863,7 @@ class BaseMindooDB {
9472
9863
  // Compute content hash
9473
9864
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9474
9865
  // Generate chunk ID
9475
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9866
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9476
9867
  // Sign the encrypted chunk
9477
9868
  const signature = await this.tenant.signPayload(encryptedData);
9478
9869
  // Create chunk entry
@@ -9560,6 +9951,9 @@ class BaseMindooDB {
9560
9951
  const chunks = [];
9561
9952
  let prevChunkId = prevLastChunkId;
9562
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();
9563
9957
  for (let offset = 0; offset < data.length; offset += this.chunkSizeBytes) {
9564
9958
  const chunkData = data.slice(offset, Math.min(offset + this.chunkSizeBytes, data.length));
9565
9959
  // Encrypt chunk
@@ -9567,7 +9961,7 @@ class BaseMindooDB {
9567
9961
  // Compute content hash
9568
9962
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
9569
9963
  // Generate chunk ID
9570
- const chunkId = (0, idGeneration_1.generateAttachmentChunkId)(docId, attachmentId);
9964
+ const chunkId = (0, idGeneration_1.generateUniqueAttachmentChunkId)(docId, attachmentId, usedCaseFoldedChunkIds);
9571
9965
  lastChunkId = chunkId;
9572
9966
  // Sign the encrypted chunk
9573
9967
  const signature = await this.tenant.signPayload(encryptedData);
@@ -9785,12 +10179,18 @@ class BaseMindooDB {
9785
10179
  try {
9786
10180
  const syncResult = await this.syncEntriesFromStore(localStore, remoteStore, options);
9787
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
+ }
9788
10187
  if (syncResult.cancelled) {
9789
10188
  this.logger.info(`Push cancelled after transferring ${syncResult.transferred} entries`);
9790
10189
  return {
9791
10190
  transferredEntries: syncResult.transferred,
9792
10191
  scannedEntries: syncResult.scanned,
9793
10192
  cancelled: true,
10193
+ ...(rejectedEntries.length > 0 ? { rejectedEntries } : {}),
9794
10194
  };
9795
10195
  }
9796
10196
  if (syncResult.transferred === 0) {
@@ -9809,6 +10209,7 @@ class BaseMindooDB {
9809
10209
  transferredEntries: syncResult.transferred,
9810
10210
  scannedEntries: syncResult.scanned,
9811
10211
  cancelled: false,
10212
+ ...(rejectedEntries.length > 0 ? { rejectedEntries } : {}),
9812
10213
  };
9813
10214
  }
9814
10215
  finally {
@@ -9817,6 +10218,32 @@ class BaseMindooDB {
9817
10218
  }
9818
10219
  }
9819
10220
  exports.BaseMindooDB = BaseMindooDB;
10221
+ /**
10222
+ * Determine how many entry IDs to fetch per `getEntries` call during sync.
10223
+ *
10224
+ * This is intentionally separate from the metadata scan `pageSize` so that
10225
+ * scanning can page through large ID lists quickly while the heavier
10226
+ * payload downloads use a smaller batch to keep progress responsive and
10227
+ * cancellation timely.
10228
+ *
10229
+ * Priority: explicit option > attachment default (100) > docs default (250,
10230
+ * capped by pageSize).
10231
+ *
10232
+ * The docs default is intentionally smaller than the metadata scan pageSize
10233
+ * (1000): a full scan page transferred as one `putEntries` call serializes
10234
+ * to a multi-megabyte JSON body whose upload cannot report progress, risks
10235
+ * the HTTP timeout on slow uplinks (each retry restarts the whole body),
10236
+ * and can exceed the remote JSON body limit. Smaller batches keep progress
10237
+ * events frequent and make each POST cheap to retry.
10238
+ */
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;
9820
10247
  /** The "access control not enforced" allow decision returned by the
9821
10248
  * non-throwing `canCreate`/`canChange`/`canDelete` prediction helpers. */
9822
10249
  BaseMindooDB.ACL_NOT_ENFORCED_DECISION = {