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