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