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