mindoodb 0.0.19 → 0.0.21
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/LICENSE +200 -0
- package/README-server.md +14 -14
- package/README.md +12 -10
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +6 -0
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +519 -373
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
- package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts +12 -0
- package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts.map +1 -1
- package/dist/browser/cache/IndexedDBLocalCacheStore.js +152 -65
- package/dist/browser/cache/IndexedDBLocalCacheStore.js.map +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +2 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/index.mjs +14 -0
- package/dist/core/BaseMindooDB.d.ts +594 -6
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +2478 -191
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts +65 -1
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +250 -35
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantFactory.js +47 -18
- package/dist/core/BaseMindooTenantFactory.js.map +1 -1
- package/dist/core/DocumentConflictAnalysis.d.ts +22 -0
- package/dist/core/DocumentConflictAnalysis.d.ts.map +1 -0
- package/dist/core/DocumentConflictAnalysis.js +101 -0
- package/dist/core/DocumentConflictAnalysis.js.map +1 -0
- package/dist/core/MindooDBServerAdmin.d.ts +26 -1
- package/dist/core/MindooDBServerAdmin.d.ts.map +1 -1
- package/dist/core/MindooDBServerAdmin.js +19 -3
- package/dist/core/MindooDBServerAdmin.js.map +1 -1
- package/dist/core/appendonlystores/types.d.ts +9 -0
- package/dist/core/appendonlystores/types.d.ts.map +1 -1
- package/dist/core/automerge-adapter.d.ts.map +1 -1
- package/dist/core/automerge-adapter.js +26 -14
- package/dist/core/automerge-adapter.js.map +1 -1
- package/dist/core/cache/EncryptedLocalCacheStore.d.ts +51 -1
- package/dist/core/cache/EncryptedLocalCacheStore.d.ts.map +1 -1
- package/dist/core/cache/EncryptedLocalCacheStore.js +48 -9
- package/dist/core/cache/EncryptedLocalCacheStore.js.map +1 -1
- package/dist/core/cache/LocalCacheStore.d.ts +34 -0
- package/dist/core/cache/LocalCacheStore.d.ts.map +1 -1
- package/dist/core/cache/LocalCacheStore.js +16 -0
- package/dist/core/cache/LocalCacheStore.js.map +1 -1
- package/dist/core/databaseIdValidation.d.ts +13 -0
- package/dist/core/databaseIdValidation.d.ts.map +1 -0
- package/dist/core/databaseIdValidation.js +38 -0
- package/dist/core/databaseIdValidation.js.map +1 -0
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +5 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualView.d.ts +14 -0
- package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualView.js +38 -6
- package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewDataChange.d.ts +9 -2
- package/dist/core/indexing/virtualviews/VirtualViewDataChange.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewDataChange.js +10 -3
- package/dist/core/indexing/virtualviews/VirtualViewDataChange.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +23 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +17 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
- package/dist/core/indexing/virtualviews/types.d.ts +8 -0
- package/dist/core/indexing/virtualviews/types.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/types.js.map +1 -1
- package/dist/core/keys/KeyBag.d.ts +244 -7
- package/dist/core/keys/KeyBag.d.ts.map +1 -1
- package/dist/core/keys/KeyBag.js +299 -82
- package/dist/core/keys/KeyBag.js.map +1 -1
- package/dist/core/tenantIdValidation.d.ts +19 -0
- package/dist/core/tenantIdValidation.d.ts.map +1 -0
- package/dist/core/tenantIdValidation.js +43 -0
- package/dist/core/tenantIdValidation.js.map +1 -0
- package/dist/core/types.d.ts +703 -12
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/node/cache/FileSystemLocalCacheStore.d.ts +2 -1
- package/dist/node/cache/FileSystemLocalCacheStore.d.ts.map +1 -1
- package/dist/node/cache/FileSystemLocalCacheStore.js +4 -0
- package/dist/node/cache/FileSystemLocalCacheStore.js.map +1 -1
- package/dist/node/server/ServerSync.d.ts.map +1 -1
- package/dist/node/server/ServerSync.js +3 -1
- package/dist/node/server/ServerSync.js.map +1 -1
- package/dist/node/server/TenantManager.d.ts +1 -0
- package/dist/node/server/TenantManager.d.ts.map +1 -1
- package/dist/node/server/TenantManager.js +11 -3
- package/dist/node/server/TenantManager.js.map +1 -1
- package/dist/node/server/validation.d.ts +2 -6
- package/dist/node/server/validation.d.ts.map +1 -1
- package/dist/node/server/validation.js +14 -24
- package/dist/node/server/validation.js.map +1 -1
- package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts +1 -0
- package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts.map +1 -1
- package/dist/reactnative/cache/MMKVLocalCacheStore.js +14 -0
- package/dist/reactnative/cache/MMKVLocalCacheStore.js.map +1 -1
- package/package.json +25 -23
|
@@ -11,19 +11,59 @@ const types_2 = require("./appendonlystores/types");
|
|
|
11
11
|
const bloom_1 = require("./appendonlystores/bloom");
|
|
12
12
|
const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
|
|
13
13
|
const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
|
|
14
|
+
const DocumentConflictAnalysis_1 = require("./DocumentConflictAnalysis");
|
|
14
15
|
const AttachmentReadPlanner_1 = require("./appendonlystores/AttachmentReadPlanner");
|
|
15
16
|
const idGeneration_1 = require("./utils/idGeneration");
|
|
16
17
|
const errors_1 = require("./errors");
|
|
17
18
|
const logging_1 = require("./logging");
|
|
19
|
+
const databaseIdValidation_1 = require("./databaseIdValidation");
|
|
18
20
|
/**
|
|
19
21
|
* Default chunk size for attachments: 256KB
|
|
20
22
|
*/
|
|
21
23
|
const DEFAULT_CHUNK_SIZE_BYTES = 256 * 1024;
|
|
24
|
+
const ATTACHMENT_WRITE_BATCH_BYTES = 16 * 1024 * 1024;
|
|
25
|
+
const ATTACHMENT_WRITE_RETRY_DELAYS_MS = [50, 250, 1000];
|
|
22
26
|
const DEFAULT_ATTACHMENT_STREAM_BATCH_SIZE = 4;
|
|
23
27
|
const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
|
|
24
28
|
const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
|
|
25
29
|
const DEFAULT_MAX_CACHED_DOCS = 128;
|
|
26
30
|
const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 0;
|
|
31
|
+
const DEFAULT_WARMER_BATCH_SIZE = 50;
|
|
32
|
+
/**
|
|
33
|
+
* Default number of cached documents fetched per `getMany` batch during
|
|
34
|
+
* the eager startup restore. 256 is a balance between IDB transaction
|
|
35
|
+
* overhead (favours larger batches) and transient memory pressure
|
|
36
|
+
* during decryption (favours smaller batches): at ~50 KB/doc this is
|
|
37
|
+
* roughly 25 MB of transient encrypted+decrypted bytes per batch.
|
|
38
|
+
* Tunable via {@link DocumentCacheConfig.restoreBatchSize}.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_RESTORE_BATCH_SIZE = 256;
|
|
41
|
+
/**
|
|
42
|
+
* Default {@link WarmerScheduler}: a simple `setTimeout(0)` yield that
|
|
43
|
+
* works in Node, browsers, and React Native without pulling in any
|
|
44
|
+
* runtime-specific APIs.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_WARMER_SCHEDULER = {
|
|
47
|
+
yield() {
|
|
48
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* On-disk format version for L2 (persisted) document cache records.
|
|
53
|
+
*
|
|
54
|
+
* v1 (implicit): legacy header with `id, createdAt, lastModified,
|
|
55
|
+
* decryptionKeyId, isDeleted` only. No version field.
|
|
56
|
+
*
|
|
57
|
+
* v2: explicit `version: 2` header, plus `changeSeq` and
|
|
58
|
+
* `automergeHeads` so the L2 read path can detect freshness without
|
|
59
|
+
* touching the document store, and apply only missing changes when
|
|
60
|
+
* stale (see Phase 1c of the L1/L2 cache plan).
|
|
61
|
+
*
|
|
62
|
+
* v1 records are silently ignored on restore - the next read of that
|
|
63
|
+
* doc falls through to the normal materialization path and re-flushes
|
|
64
|
+
* with a v2 header.
|
|
65
|
+
*/
|
|
66
|
+
const DOC_CACHE_HEADER_VERSION = 2;
|
|
27
67
|
/**
|
|
28
68
|
* Hard-coded initial Automerge change bytes that seed every document created
|
|
29
69
|
* with a caller-provided id (`createDocument({ id })`).
|
|
@@ -88,7 +128,7 @@ function getCustomIdInitialChangeBytes() {
|
|
|
88
128
|
* - Automerge.applyChanges(doc, changes) - verify method signature
|
|
89
129
|
*/
|
|
90
130
|
class BaseMindooDB {
|
|
91
|
-
constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback) {
|
|
131
|
+
constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback, timeTravelDate = null) {
|
|
92
132
|
// Internal changefeed index: sorted by (changeSeq, docId) for deterministic iteration.
|
|
93
133
|
// lastModified remains available for UX metadata but is not the primary cursor key.
|
|
94
134
|
this.index = [];
|
|
@@ -111,6 +151,46 @@ class BaseMindooDB {
|
|
|
111
151
|
this.cachePrefix = null;
|
|
112
152
|
this.dirtyDocIds = new Set();
|
|
113
153
|
this.cacheMetaDirty = false;
|
|
154
|
+
/**
|
|
155
|
+
* Tenant KeyBag fingerprint observed at the time the in-memory index
|
|
156
|
+
* was last reconciled with the underlying store.
|
|
157
|
+
*
|
|
158
|
+
* Persisted alongside the cache checkpoint so warm starts can compare
|
|
159
|
+
* the current fingerprint against the stored one and skip a full
|
|
160
|
+
* metadata scan (and the associated REST round-trips against remote
|
|
161
|
+
* stores) when the bag composition has not changed since the last
|
|
162
|
+
* flush.
|
|
163
|
+
*
|
|
164
|
+
* Set to `null` when no reconciliation has been performed yet, or
|
|
165
|
+
* when the tenant does not expose a fingerprint method.
|
|
166
|
+
*/
|
|
167
|
+
this.lastReconciledKeyBagFingerprint = null;
|
|
168
|
+
/**
|
|
169
|
+
* Resolves when the currently-running warmer pass finishes. `null`
|
|
170
|
+
* when no warmer is running. Re-used to enforce single-flight: a
|
|
171
|
+
* second `startBackgroundWarmer()` call returns the same promise.
|
|
172
|
+
*/
|
|
173
|
+
this.warmerPromise = null;
|
|
174
|
+
/**
|
|
175
|
+
* AbortController for the in-flight warmer. `null` when no warmer is
|
|
176
|
+
* running. {@link stopBackgroundWarmer} aborts via this controller.
|
|
177
|
+
*/
|
|
178
|
+
this.warmerAbort = null;
|
|
179
|
+
/**
|
|
180
|
+
* Snapshot of the most recent warmer pass's progress. Updated
|
|
181
|
+
* in-place by {@link runBackgroundWarmer} so {@link getBackgroundWarmerProgress}
|
|
182
|
+
* can return the current state without additional bookkeeping. Set
|
|
183
|
+
* back to a fresh `{ processed: 0, total, phase: "warming" }` at the
|
|
184
|
+
* start of each new pass; the final value (`"done"` or
|
|
185
|
+
* `"cancelled"`) lingers between passes so a UI that mounts after
|
|
186
|
+
* the warmer settled can still display its outcome.
|
|
187
|
+
*/
|
|
188
|
+
this.warmerProgress = null;
|
|
189
|
+
(0, databaseIdValidation_1.validateDatabaseId)(store.getId(), "dbId");
|
|
190
|
+
(0, databaseIdValidation_1.validateDatabaseId)(attachmentStore.getId(), "dbId");
|
|
191
|
+
if (store.getId() !== attachmentStore.getId()) {
|
|
192
|
+
throw new Error(`[BaseMindooDB] Expected document and attachment stores to share dbId but received ${store.getId()} and ${attachmentStore.getId()}`);
|
|
193
|
+
}
|
|
114
194
|
if (store.getStoreKind() !== types_2.StoreKind.docs) {
|
|
115
195
|
throw new Error(`[BaseMindooDB] Expected primary store kind ${types_2.StoreKind.docs} but received ${store.getStoreKind()}`);
|
|
116
196
|
}
|
|
@@ -122,11 +202,17 @@ class BaseMindooDB {
|
|
|
122
202
|
this.attachmentStore = attachmentStore;
|
|
123
203
|
this.chunkSizeBytes = attachmentConfig?.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES;
|
|
124
204
|
this._isAdminOnlyDb = adminOnlyDb;
|
|
205
|
+
this.timeTravelDate = timeTravelDate;
|
|
125
206
|
this.maxCachedDocs = Math.max(1, Math.floor(documentCacheConfig?.maxEntries ?? DEFAULT_MAX_CACHED_DOCS));
|
|
126
207
|
this.iteratePrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
|
|
127
208
|
DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS));
|
|
128
209
|
this.cacheRestoreLimit = Math.max(1, Math.floor(documentCacheConfig?.restoreLimit ?? this.maxCachedDocs));
|
|
210
|
+
this.cacheRestoreBatchSize = Math.max(1, Math.floor(documentCacheConfig?.restoreBatchSize ?? DEFAULT_RESTORE_BATCH_SIZE));
|
|
129
211
|
this.reconcileRestoredIndexOnInit = documentCacheConfig?.reconcileRestoredIndexOnInit ?? false;
|
|
212
|
+
this.restoreToL2 = documentCacheConfig?.restoreToL2 ?? false;
|
|
213
|
+
this.warmerBatchSize = Math.max(1, Math.floor(documentCacheConfig?.warmer?.batchSize ?? DEFAULT_WARMER_BATCH_SIZE));
|
|
214
|
+
this.warmerScheduler =
|
|
215
|
+
documentCacheConfig?.warmer?.scheduler ?? DEFAULT_WARMER_SCHEDULER;
|
|
130
216
|
this.snapshotMinChanges = Math.max(1, Math.floor(snapshotConfig?.minChanges ?? DEFAULT_SNAPSHOT_MIN_CHANGES));
|
|
131
217
|
this.snapshotCooldownMs = Math.max(0, Math.floor(snapshotConfig?.cooldownMs ?? DEFAULT_SNAPSHOT_COOLDOWN_MS));
|
|
132
218
|
// Create logger if not provided (for backward compatibility)
|
|
@@ -145,6 +231,39 @@ class BaseMindooDB {
|
|
|
145
231
|
isAdminOnlyDb() {
|
|
146
232
|
return this._isAdminOnlyDb;
|
|
147
233
|
}
|
|
234
|
+
isTimeTravelMode() {
|
|
235
|
+
return this.timeTravelDate !== null;
|
|
236
|
+
}
|
|
237
|
+
isReadOnly() {
|
|
238
|
+
return this.isTimeTravelMode();
|
|
239
|
+
}
|
|
240
|
+
getTimeTravelDate() {
|
|
241
|
+
return this.timeTravelDate;
|
|
242
|
+
}
|
|
243
|
+
assertWritable(operation) {
|
|
244
|
+
if (this.isReadOnly()) {
|
|
245
|
+
throw new Error(`${operation} is not allowed because database "${this.store.getId()}" is opened in time travel read-only mode.`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
metadataVisibleAtTimeTravelDate(metadata) {
|
|
249
|
+
return this.timeTravelDate == null || metadata.createdAt < this.timeTravelDate;
|
|
250
|
+
}
|
|
251
|
+
applyTimeTravelFilter(metadata) {
|
|
252
|
+
return this.timeTravelDate == null ? metadata : metadata.filter((entry) => this.metadataVisibleAtTimeTravelDate(entry));
|
|
253
|
+
}
|
|
254
|
+
mergeTimeTravelScanFilters(filters) {
|
|
255
|
+
if (this.timeTravelDate == null) {
|
|
256
|
+
return filters;
|
|
257
|
+
}
|
|
258
|
+
const existingUntil = filters?.creationDateUntil ?? null;
|
|
259
|
+
const creationDateUntil = existingUntil == null
|
|
260
|
+
? this.timeTravelDate
|
|
261
|
+
: Math.min(existingUntil, this.timeTravelDate);
|
|
262
|
+
return {
|
|
263
|
+
...filters,
|
|
264
|
+
creationDateUntil,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
148
267
|
// ---------------------------------------------------------------------------
|
|
149
268
|
// Local cache support (ICacheable)
|
|
150
269
|
// ---------------------------------------------------------------------------
|
|
@@ -166,7 +285,9 @@ class BaseMindooDB {
|
|
|
166
285
|
clearDirty() {
|
|
167
286
|
this.dirtyDocIds.clear();
|
|
168
287
|
this.cacheMetaDirty = false;
|
|
169
|
-
|
|
288
|
+
// Safe to use the sync eviction here: a successful flush has just
|
|
289
|
+
// drained `dirtyDocIds`, so there is nothing left to flush-before-evict.
|
|
290
|
+
this.evictCleanCachedDocsIfNeeded();
|
|
170
291
|
}
|
|
171
292
|
markDocDirty(docId) {
|
|
172
293
|
this.dirtyDocIds.add(docId);
|
|
@@ -185,11 +306,69 @@ class BaseMindooDB {
|
|
|
185
306
|
this.docCache.delete(docId);
|
|
186
307
|
this.docCache.set(docId, internalDoc);
|
|
187
308
|
}
|
|
188
|
-
storeCachedDocument(internalDoc) {
|
|
309
|
+
async storeCachedDocument(internalDoc) {
|
|
189
310
|
this.touchCachedDocument(internalDoc.id, internalDoc);
|
|
190
|
-
this.evictCachedDocsIfNeeded(new Set([internalDoc.id]));
|
|
311
|
+
await this.evictCachedDocsIfNeeded(new Set([internalDoc.id]));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Bring the in-memory L1 cache back under `maxCachedDocs` after a new
|
|
315
|
+
* insert, performing flush-before-evict for any dirty entry that would
|
|
316
|
+
* otherwise be dropped.
|
|
317
|
+
*
|
|
318
|
+
* Without flush-before-evict, an evicted dirty doc loses its in-memory
|
|
319
|
+
* state before the periodic `CacheManager` flush has a chance to
|
|
320
|
+
* persist it, forcing the next read to fall through to the expensive
|
|
321
|
+
* full-materialization path. By writing the doc to L2 inline first, we
|
|
322
|
+
* guarantee the next read can recover it via the cheap L2 read path
|
|
323
|
+
* (Phase 1c) - either as a fresh hit (changeSeq matches) or as a
|
|
324
|
+
* stale hit that only needs the missing deltas re-applied.
|
|
325
|
+
*
|
|
326
|
+
* If no `cacheManager` is attached (i.e. there is no L2 store
|
|
327
|
+
* available), we fall back to the legacy behavior and just drop the
|
|
328
|
+
* dirty marker - matching pre-Phase-1 semantics so embedders who
|
|
329
|
+
* never opted into L2 see no behavior change.
|
|
330
|
+
*/
|
|
331
|
+
async evictCachedDocsIfNeeded(protectedDocIds) {
|
|
332
|
+
if (this.docCache.size <= this.maxCachedDocs) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const store = this.cacheManager?.getStore() ?? null;
|
|
336
|
+
const prefix = store ? this.getCachePrefix() : null;
|
|
337
|
+
// Snapshot the iteration order to avoid surprises if a flush ever
|
|
338
|
+
// re-touches a doc mid-loop. Map iteration in insertion order is the
|
|
339
|
+
// LRU eviction order we want.
|
|
340
|
+
const candidates = [];
|
|
341
|
+
for (const docId of this.docCache.keys()) {
|
|
342
|
+
candidates.push(docId);
|
|
343
|
+
}
|
|
344
|
+
for (const docId of candidates) {
|
|
345
|
+
if (this.docCache.size <= this.maxCachedDocs) {
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
if (protectedDocIds?.has(docId)) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (store && prefix && this.dirtyDocIds.has(docId)) {
|
|
352
|
+
const internal = this.docCache.get(docId);
|
|
353
|
+
if (internal) {
|
|
354
|
+
try {
|
|
355
|
+
await this.flushDirtyDocToCache(store, prefix, docId, internal);
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
this.logger.warn(`Flush-before-evict failed for doc ${docId}; dropping dirty marker without persisting: ${e}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
this.docCache.delete(docId);
|
|
363
|
+
this.dirtyDocIds.delete(docId);
|
|
364
|
+
}
|
|
191
365
|
}
|
|
192
|
-
|
|
366
|
+
/**
|
|
367
|
+
* Synchronous eviction variant used after the periodic flush has
|
|
368
|
+
* already drained `dirtyDocIds`. No L2 work is required because there
|
|
369
|
+
* are no dirty docs left to persist.
|
|
370
|
+
*/
|
|
371
|
+
evictCleanCachedDocsIfNeeded(protectedDocIds) {
|
|
193
372
|
if (this.docCache.size <= this.maxCachedDocs) {
|
|
194
373
|
return;
|
|
195
374
|
}
|
|
@@ -214,22 +393,7 @@ class BaseMindooDB {
|
|
|
214
393
|
const internal = this.docCache.get(docId);
|
|
215
394
|
if (!internal)
|
|
216
395
|
continue;
|
|
217
|
-
|
|
218
|
-
const header = JSON.stringify({
|
|
219
|
-
id: internal.id,
|
|
220
|
-
createdAt: internal.createdAt,
|
|
221
|
-
lastModified: internal.lastModified,
|
|
222
|
-
decryptionKeyId: internal.decryptionKeyId,
|
|
223
|
-
isDeleted: internal.isDeleted,
|
|
224
|
-
});
|
|
225
|
-
const headerBytes = new TextEncoder().encode(header);
|
|
226
|
-
// Format: 4-byte header length (big-endian) + header JSON + Automerge binary
|
|
227
|
-
const value = new Uint8Array(4 + headerBytes.length + amBinary.length);
|
|
228
|
-
const view = new DataView(value.buffer);
|
|
229
|
-
view.setUint32(0, headerBytes.length, false);
|
|
230
|
-
value.set(headerBytes, 4);
|
|
231
|
-
value.set(amBinary, 4 + headerBytes.length);
|
|
232
|
-
await store.put("doc", `${prefix}/${docId}`, value);
|
|
396
|
+
await this.flushDirtyDocToCache(store, prefix, docId, internal);
|
|
233
397
|
written++;
|
|
234
398
|
}
|
|
235
399
|
// Write metadata checkpoint
|
|
@@ -238,6 +402,50 @@ class BaseMindooDB {
|
|
|
238
402
|
written++;
|
|
239
403
|
return written;
|
|
240
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Serialize and persist a single document into the L2 cache store.
|
|
407
|
+
*
|
|
408
|
+
* Header format (v2):
|
|
409
|
+
* `{ version: 2, id, createdAt, lastModified, decryptionKeyId,
|
|
410
|
+
* isDeleted, changeSeq, automergeHeads }`
|
|
411
|
+
*
|
|
412
|
+
* `changeSeq` is read from the in-memory changefeed index so that on
|
|
413
|
+
* the next open we can compare it against the live index entry and
|
|
414
|
+
* decide whether the persisted Automerge state is up to date or needs
|
|
415
|
+
* incremental delta application. `automergeHeads` snapshots
|
|
416
|
+
* `Automerge.getHeads(doc)` so a future read can identify which entries
|
|
417
|
+
* (if any) still need to be applied to bring the persisted doc current.
|
|
418
|
+
*
|
|
419
|
+
* Both fields are derived inline (no extra index lookups beyond the
|
|
420
|
+
* O(1) `indexLookup`) so this stays cheap to call from the periodic
|
|
421
|
+
* flush as well as from the upcoming flush-before-evict path.
|
|
422
|
+
*/
|
|
423
|
+
async flushDirtyDocToCache(store, prefix, docId, internal) {
|
|
424
|
+
const amBinary = automerge_adapter_1.Automerge.save(internal.doc);
|
|
425
|
+
const indexEntryIdx = this.indexLookup.get(docId);
|
|
426
|
+
const changeSeq = indexEntryIdx === undefined
|
|
427
|
+
? 0
|
|
428
|
+
: this.index[indexEntryIdx].changeSeq;
|
|
429
|
+
const automergeHeads = automerge_adapter_1.Automerge.getHeads(internal.doc);
|
|
430
|
+
const header = JSON.stringify({
|
|
431
|
+
version: DOC_CACHE_HEADER_VERSION,
|
|
432
|
+
id: internal.id,
|
|
433
|
+
createdAt: internal.createdAt,
|
|
434
|
+
lastModified: internal.lastModified,
|
|
435
|
+
decryptionKeyId: internal.decryptionKeyId,
|
|
436
|
+
isDeleted: internal.isDeleted,
|
|
437
|
+
changeSeq,
|
|
438
|
+
automergeHeads,
|
|
439
|
+
});
|
|
440
|
+
const headerBytes = new TextEncoder().encode(header);
|
|
441
|
+
// Format: 4-byte header length (big-endian) + header JSON + Automerge binary
|
|
442
|
+
const value = new Uint8Array(4 + headerBytes.length + amBinary.length);
|
|
443
|
+
const view = new DataView(value.buffer);
|
|
444
|
+
view.setUint32(0, headerBytes.length, false);
|
|
445
|
+
value.set(headerBytes, 4);
|
|
446
|
+
value.set(amBinary, 4 + headerBytes.length);
|
|
447
|
+
await store.put("doc", `${prefix}/${docId}`, value);
|
|
448
|
+
}
|
|
241
449
|
exportMetadataCheckpoint() {
|
|
242
450
|
const checkpoint = {
|
|
243
451
|
version: 2,
|
|
@@ -255,6 +463,13 @@ class BaseMindooDB {
|
|
|
255
463
|
if (!this.supportsCursorScan(this.store)) {
|
|
256
464
|
checkpoint.processedEntryIds = this.processedEntryIds;
|
|
257
465
|
}
|
|
466
|
+
// Persist the KeyBag fingerprint observed at the last reconciliation
|
|
467
|
+
// so the next restore can decide whether to skip the visibility
|
|
468
|
+
// metadata scan. `null` is acceptable - older checkpoints simply
|
|
469
|
+
// force one reconcile on the next startup.
|
|
470
|
+
if (this.lastReconciledKeyBagFingerprint !== null) {
|
|
471
|
+
checkpoint.keyBagFingerprint = this.lastReconciledKeyBagFingerprint;
|
|
472
|
+
}
|
|
258
473
|
return new TextEncoder().encode(JSON.stringify(checkpoint));
|
|
259
474
|
}
|
|
260
475
|
/**
|
|
@@ -281,12 +496,20 @@ class BaseMindooDB {
|
|
|
281
496
|
if (checkpoint.processedEntryIds) {
|
|
282
497
|
this.processedEntryIds = checkpoint.processedEntryIds;
|
|
283
498
|
}
|
|
499
|
+
this.lastReconciledKeyBagFingerprint =
|
|
500
|
+
typeof checkpoint.keyBagFingerprint === "string" ? checkpoint.keyBagFingerprint : null;
|
|
284
501
|
// Rebuild indexLookup from index
|
|
285
502
|
this.indexLookup.clear();
|
|
286
503
|
for (let i = 0; i < this.index.length; i++) {
|
|
287
504
|
if (typeof this.index[i].changeSeq !== "number") {
|
|
288
505
|
this.index[i].changeSeq = i + 1;
|
|
289
506
|
}
|
|
507
|
+
if (!this.index[i].decryptionKeyId) {
|
|
508
|
+
this.index[i].decryptionKeyId = "default";
|
|
509
|
+
}
|
|
510
|
+
if (!this.index[i].accessState) {
|
|
511
|
+
this.index[i].accessState = "visible";
|
|
512
|
+
}
|
|
290
513
|
this.indexLookup.set(this.index[i].docId, i);
|
|
291
514
|
}
|
|
292
515
|
if (this.index.length > 0 && this.nextChangeSeq <= this.index.length) {
|
|
@@ -302,26 +525,71 @@ class BaseMindooDB {
|
|
|
302
525
|
// 3. Load cached documents
|
|
303
526
|
const docIds = await store.list("doc");
|
|
304
527
|
const docPrefix = `${prefix}/`;
|
|
528
|
+
const allOwnedKeys = docIds.filter((id) => id.startsWith(docPrefix));
|
|
529
|
+
if (this.restoreToL2) {
|
|
530
|
+
// L2-only restore: leave L1 empty; let lazy reads via
|
|
531
|
+
// tryLoadFromL2 promote individual docs as they are needed. We
|
|
532
|
+
// still log how many records are sitting in L2 so operators can
|
|
533
|
+
// sanity-check cache size.
|
|
534
|
+
this.logger.info(`restoreToL2 enabled for ${prefix}: deferring L1 fill; ${allOwnedKeys.length} cached document records will load lazily on demand`);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
// Eager restore mode (default): bulk-load doc records via the
|
|
538
|
+
// batched getMany API. This avoids N independent IndexedDB
|
|
539
|
+
// transactions for N docs while preserving exactly the legacy
|
|
540
|
+
// semantics (capped by `cacheRestoreLimit`, populates L1). The
|
|
541
|
+
// batch size is bounded by `cacheRestoreBatchSize` so that the
|
|
542
|
+
// transient encrypted+decrypted byte buffers held in memory at
|
|
543
|
+
// any one time stay predictable - see
|
|
544
|
+
// `DocumentCacheConfig.restoreBatchSize`.
|
|
545
|
+
const targetKeys = allOwnedKeys.slice(0, this.cacheRestoreLimit);
|
|
305
546
|
let restoredDocs = 0;
|
|
306
|
-
|
|
547
|
+
let skippedLegacyDocs = 0;
|
|
548
|
+
for (let offset = 0; offset < targetKeys.length; offset += this.cacheRestoreBatchSize) {
|
|
307
549
|
if (restoredDocs >= this.cacheRestoreLimit) {
|
|
308
550
|
break;
|
|
309
551
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
552
|
+
const batch = targetKeys.slice(offset, offset + this.cacheRestoreBatchSize);
|
|
553
|
+
const batchValues = await store.getMany("doc", batch);
|
|
554
|
+
for (let i = 0; i < batch.length; i++) {
|
|
555
|
+
if (restoredDocs >= this.cacheRestoreLimit) {
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
const docBytes = batchValues[i];
|
|
559
|
+
if (!docBytes)
|
|
560
|
+
continue;
|
|
561
|
+
try {
|
|
562
|
+
const deserialized = this.deserializeDoc(docBytes);
|
|
563
|
+
if (deserialized === null) {
|
|
564
|
+
skippedLegacyDocs++;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
// Defence-in-depth: an L2 record holds the decrypted Automerge
|
|
568
|
+
// state. If the current KeyBag can no longer resolve the doc's
|
|
569
|
+
// key (e.g. the user lost access between sessions) we evict
|
|
570
|
+
// both the L2 record and any L1 entry instead of restoring
|
|
571
|
+
// plaintext into memory. The post-restore call to
|
|
572
|
+
// `reconcileKeyVisibility` will then flip the index entry to
|
|
573
|
+
// `"inaccessible"` so future reads behave correctly.
|
|
574
|
+
const canRead = await this.tenant.hasDecryptionKey(deserialized.internal.decryptionKeyId);
|
|
575
|
+
if (!canRead) {
|
|
576
|
+
const docId = batch[i].slice(docPrefix.length);
|
|
577
|
+
await store.delete("doc", batch[i]);
|
|
578
|
+
this.docCache.delete(docId);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
await this.storeCachedDocument(deserialized.internal);
|
|
582
|
+
restoredDocs++;
|
|
583
|
+
}
|
|
584
|
+
catch (e) {
|
|
585
|
+
const docId = batch[i].slice(docPrefix.length);
|
|
586
|
+
this.logger.warn(`Failed to restore cached doc ${docId}, will reload from store: ${e}`);
|
|
587
|
+
}
|
|
323
588
|
}
|
|
324
589
|
}
|
|
590
|
+
if (skippedLegacyDocs > 0) {
|
|
591
|
+
this.logger.info(`Skipped ${skippedLegacyDocs} legacy (v1) cached documents during restore; they will be re-flushed in v2 format on next access.`);
|
|
592
|
+
}
|
|
325
593
|
this.logger.info(`Restored ${restoredDocs} cached documents for ${this.store.getId()}`);
|
|
326
594
|
this.logger.info(`Restored ${restoredDocs} documents from cache for ${prefix}`);
|
|
327
595
|
return true;
|
|
@@ -331,14 +599,29 @@ class BaseMindooDB {
|
|
|
331
599
|
return false;
|
|
332
600
|
}
|
|
333
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Decode a persisted L2 document cache record.
|
|
604
|
+
*
|
|
605
|
+
* Returns `null` for legacy (v1) records - they predate the
|
|
606
|
+
* `changeSeq + automergeHeads` freshness sentinels and so cannot
|
|
607
|
+
* participate in the L2 read-path freshness check; callers should
|
|
608
|
+
* fall back to the normal materialization path when they encounter
|
|
609
|
+
* one. Throws on truly malformed records (caught by callers).
|
|
610
|
+
*/
|
|
334
611
|
deserializeDoc(value) {
|
|
335
612
|
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
|
|
336
613
|
const headerLen = view.getUint32(0, false);
|
|
337
614
|
const headerBytes = value.slice(4, 4 + headerLen);
|
|
338
615
|
const amBinary = value.slice(4 + headerLen);
|
|
339
616
|
const header = JSON.parse(new TextDecoder().decode(headerBytes));
|
|
617
|
+
if (header.version !== DOC_CACHE_HEADER_VERSION) {
|
|
618
|
+
// v1 records (no `version`) and unknown future versions: skip
|
|
619
|
+
// gracefully so they get re-flushed with the current header on
|
|
620
|
+
// the next dirty cycle.
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
340
623
|
const doc = automerge_adapter_1.Automerge.load(amBinary);
|
|
341
|
-
|
|
624
|
+
const internal = {
|
|
342
625
|
id: header.id,
|
|
343
626
|
doc,
|
|
344
627
|
createdAt: header.createdAt,
|
|
@@ -346,6 +629,11 @@ class BaseMindooDB {
|
|
|
346
629
|
decryptionKeyId: header.decryptionKeyId,
|
|
347
630
|
isDeleted: header.isDeleted,
|
|
348
631
|
};
|
|
632
|
+
return {
|
|
633
|
+
internal,
|
|
634
|
+
persistedChangeSeq: typeof header.changeSeq === "number" ? header.changeSeq : 0,
|
|
635
|
+
persistedHeads: Array.isArray(header.automergeHeads) ? header.automergeHeads : [],
|
|
636
|
+
};
|
|
349
637
|
}
|
|
350
638
|
/**
|
|
351
639
|
* Get or import a CryptoKey for signature verification, with caching.
|
|
@@ -398,25 +686,45 @@ class BaseMindooDB {
|
|
|
398
686
|
}
|
|
399
687
|
/**
|
|
400
688
|
* Update the index entry for a document.
|
|
401
|
-
* When a document changes, it gets a new monotonic sequence and is moved to
|
|
402
|
-
* maintain order by (changeSeq, docId).
|
|
403
689
|
*
|
|
404
|
-
*
|
|
690
|
+
* When a document changes, it gets a new monotonic sequence and is
|
|
691
|
+
* moved within the sorted index to maintain `(changeSeq, docId)` order.
|
|
692
|
+
* The lookup map is only patched for the range of entries that actually
|
|
693
|
+
* shifted.
|
|
694
|
+
*
|
|
695
|
+
* Idempotent: if every tracked field (`lastModified`, `isDeleted`,
|
|
696
|
+
* `decryptionKeyId`, `accessState`) matches the existing entry the
|
|
697
|
+
* call is a no-op and no new `changeSeq` is consumed. This is what
|
|
698
|
+
* keeps repeated visibility reconciliations from inflating the
|
|
699
|
+
* changefeed.
|
|
405
700
|
*
|
|
406
701
|
* @param docId The document ID
|
|
407
702
|
* @param lastModified The new last modified timestamp
|
|
408
|
-
* @param isDeleted Whether the document is
|
|
703
|
+
* @param isDeleted Whether the document is a deletion/inaccessibility tombstone
|
|
704
|
+
* @param decryptionKeyId Encryption key id derived from the doc's store metadata
|
|
705
|
+
* @param accessState `"visible"` for readable docs, `"inaccessible"` for
|
|
706
|
+
* tombstones produced by the key visibility layer
|
|
409
707
|
*/
|
|
410
|
-
updateIndex(docId, lastModified, isDeleted) {
|
|
708
|
+
updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible") {
|
|
411
709
|
const startedAt = Date.now();
|
|
412
710
|
const assignedSeq = this.nextChangeSeq;
|
|
413
|
-
const newEntry = {
|
|
711
|
+
const newEntry = {
|
|
712
|
+
docId,
|
|
713
|
+
changeSeq: assignedSeq,
|
|
714
|
+
lastModified,
|
|
715
|
+
isDeleted,
|
|
716
|
+
decryptionKeyId,
|
|
717
|
+
accessState,
|
|
718
|
+
};
|
|
414
719
|
const existingIndex = this.indexLookup.get(docId);
|
|
415
720
|
// Check if the entry already exists and hasn't changed position
|
|
416
721
|
if (existingIndex !== undefined) {
|
|
417
722
|
const existingEntry = this.index[existingIndex];
|
|
418
723
|
// If only metadata flags stayed identical and caller replays same state, skip.
|
|
419
|
-
if (existingEntry.lastModified === lastModified
|
|
724
|
+
if (existingEntry.lastModified === lastModified
|
|
725
|
+
&& existingEntry.isDeleted === isDeleted
|
|
726
|
+
&& existingEntry.decryptionKeyId === decryptionKeyId
|
|
727
|
+
&& existingEntry.accessState === accessState) {
|
|
420
728
|
return; // No change needed
|
|
421
729
|
}
|
|
422
730
|
// Remove from current position
|
|
@@ -592,6 +900,23 @@ class BaseMindooDB {
|
|
|
592
900
|
if (restored) {
|
|
593
901
|
this.logger.info("Restored from cache, processing delta only");
|
|
594
902
|
await this.syncStoreChanges();
|
|
903
|
+
// Visibility reconciliation runs a full metadata scan over the
|
|
904
|
+
// store, which on a remote store is one or more REST round-trips.
|
|
905
|
+
// Skip it on warm starts when we can prove the KeyBag composition
|
|
906
|
+
// has not changed since the last flush by comparing fingerprints.
|
|
907
|
+
// Fall back to the unconditional reconcile when the tenant does
|
|
908
|
+
// not expose a fingerprint so we never silently miss a key
|
|
909
|
+
// transition.
|
|
910
|
+
const currentFingerprint = await this.computeCurrentKeyBagFingerprint();
|
|
911
|
+
const fingerprintMatches = currentFingerprint !== null && currentFingerprint === this.lastReconciledKeyBagFingerprint;
|
|
912
|
+
if (!fingerprintMatches) {
|
|
913
|
+
await this.reconcileKeyVisibility();
|
|
914
|
+
}
|
|
915
|
+
this.markKeyBagFingerprintReconciled(currentFingerprint);
|
|
916
|
+
// If `reconcileRestoredIndexOnInit` triggers a full rebuild,
|
|
917
|
+
// that rebuild reruns `syncStoreChanges` from scratch, which
|
|
918
|
+
// applies per-doc visibility checks itself. We do not need to
|
|
919
|
+
// call `reconcileKeyVisibility` again afterwards.
|
|
595
920
|
if (this.reconcileRestoredIndexOnInit) {
|
|
596
921
|
await this.reconcileRestoredIndexWithStore();
|
|
597
922
|
}
|
|
@@ -599,6 +924,195 @@ class BaseMindooDB {
|
|
|
599
924
|
}
|
|
600
925
|
}
|
|
601
926
|
await this.syncStoreChanges();
|
|
927
|
+
// First-time materialization: `syncStoreChanges` already filters out
|
|
928
|
+
// docs whose keys are unavailable, so the only thing left to do is
|
|
929
|
+
// record the current fingerprint for the next warm start.
|
|
930
|
+
this.markKeyBagFingerprintReconciled(await this.computeCurrentKeyBagFingerprint());
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Compute the current tenant KeyBag fingerprint, or `null` when the
|
|
934
|
+
* tenant does not expose one (older `MindooTenant` implementations).
|
|
935
|
+
*/
|
|
936
|
+
async computeCurrentKeyBagFingerprint() {
|
|
937
|
+
if (typeof this.tenant.getDocKeyFingerprint !== "function") {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return this.tenant.getDocKeyFingerprint();
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Record that the in-memory index now reflects the given KeyBag
|
|
944
|
+
* fingerprint. Marks the cache metadata dirty whenever the recorded
|
|
945
|
+
* value changes so the new fingerprint reaches the persisted
|
|
946
|
+
* checkpoint at the next flush.
|
|
947
|
+
*/
|
|
948
|
+
markKeyBagFingerprintReconciled(fingerprint) {
|
|
949
|
+
if (this.lastReconciledKeyBagFingerprint === fingerprint) {
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
this.lastReconciledKeyBagFingerprint = fingerprint;
|
|
953
|
+
this.cacheMetaDirty = true;
|
|
954
|
+
this.cacheManager?.markDirty();
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Derive the per-doc visibility summary needed to reconcile the index
|
|
958
|
+
* against the current KeyBag state.
|
|
959
|
+
*
|
|
960
|
+
* - `decryptionKeyId` is taken from the chronologically first lifecycle
|
|
961
|
+
* entry, mirroring how {@link syncStoreChanges} materializes a doc.
|
|
962
|
+
* Replay entries inside a doc cannot retroactively re-key it.
|
|
963
|
+
* - `lastModified` is the latest `createdAt` among the entries that
|
|
964
|
+
* would be replayed for this doc (or, if none exist, the lifecycle
|
|
965
|
+
* entries themselves). This matches the user-visible timestamp the
|
|
966
|
+
* existing read path produces.
|
|
967
|
+
* - `isDeleted` is the lifecycle state derived from the same metadata
|
|
968
|
+
* {@link syncStoreChanges} would have observed.
|
|
969
|
+
*
|
|
970
|
+
* Returns `null` when there are no lifecycle entries for the doc,
|
|
971
|
+
* meaning there is nothing to reconcile.
|
|
972
|
+
*/
|
|
973
|
+
deriveDocumentVisibilityMetadata(metadata) {
|
|
974
|
+
const lifecycleEntries = metadata.filter((entry) => entry.entryType === "doc_create"
|
|
975
|
+
|| entry.entryType === "doc_change"
|
|
976
|
+
|| entry.entryType === "doc_delete"
|
|
977
|
+
|| entry.entryType === "doc_undelete"
|
|
978
|
+
|| entry.entryType === "doc_snapshot");
|
|
979
|
+
if (lifecycleEntries.length === 0) {
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
lifecycleEntries.sort((left, right) => left.createdAt !== right.createdAt ? left.createdAt - right.createdAt : left.id.localeCompare(right.id));
|
|
983
|
+
const replayEntries = metadata.filter((entry) => this.isDocumentReplayEntry(entry));
|
|
984
|
+
const entriesForLastModified = replayEntries.length > 0 ? replayEntries : lifecycleEntries;
|
|
985
|
+
return {
|
|
986
|
+
decryptionKeyId: lifecycleEntries[0].decryptionKeyId,
|
|
987
|
+
lastModified: Math.max(...entriesForLastModified.map((entry) => entry.createdAt)),
|
|
988
|
+
isDeleted: this.computeIsDeletedFromMetadata(metadata),
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Evict every locally cached form of a document.
|
|
993
|
+
*
|
|
994
|
+
* Used by the visibility layer whenever a document transitions to
|
|
995
|
+
* `"inaccessible"` (the user lost the decryption key) or when a stale
|
|
996
|
+
* L2 record for an inaccessible doc is discovered. The point is to
|
|
997
|
+
* make sure no decrypted plaintext or in-memory Automerge state for
|
|
998
|
+
* the doc survives beyond the key revocation.
|
|
999
|
+
*
|
|
1000
|
+
* - L1 (`docCache`) is cleared and the dirty marker dropped so we do
|
|
1001
|
+
* not later persist a stale entry.
|
|
1002
|
+
* - The automerge-hash lookup is cleared so any future re-add starts
|
|
1003
|
+
* with a clean re-materialization.
|
|
1004
|
+
* - The L2 record is deleted via the {@link LocalCacheStore} (if a
|
|
1005
|
+
* cache manager is attached). Failures are logged but never thrown:
|
|
1006
|
+
* a leftover encrypted record is preferable to crashing the read
|
|
1007
|
+
* path that triggered the purge.
|
|
1008
|
+
*/
|
|
1009
|
+
async purgeMaterializedDocument(docId) {
|
|
1010
|
+
this.docCache.delete(docId);
|
|
1011
|
+
this.dirtyDocIds.delete(docId);
|
|
1012
|
+
this.automergeHashToEntryId.delete(docId);
|
|
1013
|
+
const store = this.cacheManager?.getStore();
|
|
1014
|
+
if (store) {
|
|
1015
|
+
try {
|
|
1016
|
+
await store.delete("doc", `${this.getCachePrefix()}/${docId}`);
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
// Best-effort: failure to evict the L2 record only means a stale
|
|
1020
|
+
// cached doc may linger on disk; the index entry below still
|
|
1021
|
+
// marks the doc inaccessible so read paths refuse to surface it.
|
|
1022
|
+
this.logger.warn(`Failed to evict L2 cache record for inaccessible doc ${docId}: ${error}`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Reconcile the in-memory index with the current tenant KeyBag.
|
|
1028
|
+
*
|
|
1029
|
+
* This is the core entry point for "the user's keys changed; refresh
|
|
1030
|
+
* what this database thinks is visible". It is safe to call repeatedly,
|
|
1031
|
+
* including from concurrent live updates: the underlying `updateIndex`
|
|
1032
|
+
* call only consumes a new `changeSeq` when state actually transitions.
|
|
1033
|
+
*
|
|
1034
|
+
* Behaviour for each doc that exists in the underlying store:
|
|
1035
|
+
*
|
|
1036
|
+
* - **Key now available, no entry / inaccessible entry** -> mark
|
|
1037
|
+
* `"visible"` and emit a new index revision so view providers can
|
|
1038
|
+
* re-add the doc. The doc body will be materialized lazily on the
|
|
1039
|
+
* next read.
|
|
1040
|
+
* - **Key now available, already visible** -> no-op.
|
|
1041
|
+
* - **Key now missing, currently visible** -> purge any materialized
|
|
1042
|
+
* state and mark `"inaccessible"` with `isDeleted: true` so
|
|
1043
|
+
* metadata-only feeds emit a single tombstone and views remove
|
|
1044
|
+
* the entry.
|
|
1045
|
+
* - **Key now missing, already inaccessible (or not yet in index)** ->
|
|
1046
|
+
* no index change, but we still call {@link purgeMaterializedDocument}
|
|
1047
|
+
* defensively to scrub any leftover plaintext cache that might exist.
|
|
1048
|
+
*
|
|
1049
|
+
* Time-travel views are immutable; the function returns early for them.
|
|
1050
|
+
* After running, if any transition occurred we mark the cache metadata
|
|
1051
|
+
* dirty so the new visibility state survives a process restart.
|
|
1052
|
+
*/
|
|
1053
|
+
async reconcileKeyVisibility() {
|
|
1054
|
+
if (this.isTimeTravelMode()) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const lifecycleMetadata = await this.scanAllMetadata(this.store);
|
|
1058
|
+
const metadataByDoc = new Map();
|
|
1059
|
+
for (const entry of lifecycleMetadata) {
|
|
1060
|
+
if (entry.entryType !== "doc_create"
|
|
1061
|
+
&& entry.entryType !== "doc_change"
|
|
1062
|
+
&& entry.entryType !== "doc_delete"
|
|
1063
|
+
&& entry.entryType !== "doc_undelete"
|
|
1064
|
+
&& entry.entryType !== "doc_snapshot") {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
const entries = metadataByDoc.get(entry.docId) ?? [];
|
|
1068
|
+
entries.push(entry);
|
|
1069
|
+
metadataByDoc.set(entry.docId, entries);
|
|
1070
|
+
}
|
|
1071
|
+
let changed = false;
|
|
1072
|
+
// Deterministic ordering by docId keeps the resulting changefeed
|
|
1073
|
+
// stable between runs, which simplifies test expectations.
|
|
1074
|
+
const docIds = Array.from(metadataByDoc.keys()).sort((left, right) => left.localeCompare(right));
|
|
1075
|
+
for (const docId of docIds) {
|
|
1076
|
+
const visibility = this.deriveDocumentVisibilityMetadata(metadataByDoc.get(docId));
|
|
1077
|
+
if (!visibility) {
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
const existingIndex = this.indexLookup.get(docId);
|
|
1081
|
+
const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
|
|
1082
|
+
const canRead = await this.tenant.hasDecryptionKey(visibility.decryptionKeyId);
|
|
1083
|
+
if (canRead) {
|
|
1084
|
+
// Reveal-on-add only triggers when the previous state was
|
|
1085
|
+
// missing or inaccessible; visible->visible transitions stay
|
|
1086
|
+
// idempotent via the existing `updateIndex` short-circuit.
|
|
1087
|
+
if (!existing || existing.accessState === "inaccessible") {
|
|
1088
|
+
this.updateIndex(docId, visibility.lastModified, visibility.isDeleted, visibility.decryptionKeyId, "visible");
|
|
1089
|
+
changed = true;
|
|
1090
|
+
}
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (existing?.accessState === "visible") {
|
|
1094
|
+
// Key was revoked while we still had plaintext state in cache:
|
|
1095
|
+
// wipe it and flip the index entry to an inaccessible tombstone
|
|
1096
|
+
// with `isDeleted: true` so view feeds emit a clean removal.
|
|
1097
|
+
await this.purgeMaterializedDocument(docId);
|
|
1098
|
+
this.updateIndex(docId, visibility.lastModified, true, visibility.decryptionKeyId, "inaccessible");
|
|
1099
|
+
changed = true;
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
// Already inaccessible (or never seen). Scrub defensively in
|
|
1103
|
+
// case a previous session left an L2 record behind.
|
|
1104
|
+
await this.purgeMaterializedDocument(docId);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (changed) {
|
|
1108
|
+
this.cacheMetaDirty = true;
|
|
1109
|
+
this.cacheManager?.markDirty();
|
|
1110
|
+
}
|
|
1111
|
+
// Whatever path triggered the reconcile (init, KeyBag listener,
|
|
1112
|
+
// explicit caller), record the fingerprint observed during this
|
|
1113
|
+
// pass so a subsequent warm start with identical bag composition
|
|
1114
|
+
// can skip the scan.
|
|
1115
|
+
this.markKeyBagFingerprintReconciled(await this.computeCurrentKeyBagFingerprint());
|
|
602
1116
|
}
|
|
603
1117
|
async reconcileRestoredIndexWithStore() {
|
|
604
1118
|
const lifecycleMetadata = await this.scanAllMetadata(this.store);
|
|
@@ -633,6 +1147,12 @@ class BaseMindooDB {
|
|
|
633
1147
|
* Sync changes from the content-addressed store by finding new entries and processing them.
|
|
634
1148
|
* This method can be called multiple times to incrementally sync new entries.
|
|
635
1149
|
* On first call (when processedEntryIds is empty), it will process all entries.
|
|
1150
|
+
*
|
|
1151
|
+
* Sync intentionally does not trigger the L2 background warmer.
|
|
1152
|
+
* Callers that want to warm the L2 cache after a sync (e.g. the Haven
|
|
1153
|
+
* sync page) must call {@link startBackgroundWarmer} explicitly so
|
|
1154
|
+
* casual sync calls stay cheap and the warmer cost is paid only when
|
|
1155
|
+
* the workload is about to benefit from it.
|
|
636
1156
|
*/
|
|
637
1157
|
async syncStoreChanges() {
|
|
638
1158
|
const syncStartedAt = Date.now();
|
|
@@ -682,7 +1202,7 @@ class BaseMindooDB {
|
|
|
682
1202
|
if (updatedDoc) {
|
|
683
1203
|
this.logger.debug(`Successfully updated cached document ${docId} incrementally`);
|
|
684
1204
|
// Only update index if document actually changed
|
|
685
|
-
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted);
|
|
1205
|
+
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible");
|
|
686
1206
|
this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted})`);
|
|
687
1207
|
}
|
|
688
1208
|
else {
|
|
@@ -695,7 +1215,7 @@ class BaseMindooDB {
|
|
|
695
1215
|
this.docCache.delete(docId);
|
|
696
1216
|
updatedDoc = await this.loadDocumentInternal(docId);
|
|
697
1217
|
if (updatedDoc) {
|
|
698
|
-
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted);
|
|
1218
|
+
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted, updatedDoc.decryptionKeyId, "visible");
|
|
699
1219
|
}
|
|
700
1220
|
}
|
|
701
1221
|
}
|
|
@@ -728,19 +1248,58 @@ class BaseMindooDB {
|
|
|
728
1248
|
const entriesForLastModified = mutationEntries.length > 0 ? mutationEntries : docLifecycleEntries;
|
|
729
1249
|
const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
|
|
730
1250
|
const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
|
|
731
|
-
this.updateIndex(docId, lastModified, isDeleted);
|
|
1251
|
+
this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible");
|
|
732
1252
|
this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted})`);
|
|
733
1253
|
}
|
|
734
1254
|
}
|
|
735
1255
|
}
|
|
736
1256
|
}
|
|
737
1257
|
catch (error) {
|
|
738
|
-
//
|
|
1258
|
+
// Missing symmetric key during live sync: the doc exists in the
|
|
1259
|
+
// store but we cannot decrypt it. Treat this as a visibility
|
|
1260
|
+
// transition rather than a hard failure:
|
|
1261
|
+
// - If the doc had previously been visible, purge any plaintext
|
|
1262
|
+
// we still hold and flip the index entry to `"inaccessible"`
|
|
1263
|
+
// with a deletion tombstone so view feeds remove it cleanly.
|
|
1264
|
+
// - If the doc was never visible (first time we see it without
|
|
1265
|
+
// a key), we simply skip; `reconcileKeyVisibility` will mark
|
|
1266
|
+
// it appropriately the next time it runs.
|
|
1267
|
+
// Either way we advance the processed-entries marker so a
|
|
1268
|
+
// subsequent sync doesn't endlessly retry the same untranslatable
|
|
1269
|
+
// doc.
|
|
739
1270
|
if (error instanceof errors_1.SymmetricKeyNotFoundError) {
|
|
740
1271
|
this.logger.debug(`Skipping document ${docId} - missing key: ${error.keyId}`);
|
|
741
|
-
//
|
|
1272
|
+
// Avoid a per-doc scanAllMetadata round-trip here: everything
|
|
1273
|
+
// we need is already in this batch's entryMetadataList plus the
|
|
1274
|
+
// existing index entry (if any). We do NOT need a second pass
|
|
1275
|
+
// over the underlying store - the key is missing, so we just
|
|
1276
|
+
// record an inaccessibility tombstone.
|
|
1277
|
+
const existingIndex = this.indexLookup.get(docId);
|
|
1278
|
+
const existing = existingIndex === undefined ? undefined : this.index[existingIndex];
|
|
1279
|
+
if (existing?.accessState === "visible") {
|
|
1280
|
+
const lifecycleEntriesInBatch = entryMetadataList.filter((entry) => entry.entryType === "doc_create"
|
|
1281
|
+
|| entry.entryType === "doc_change"
|
|
1282
|
+
|| entry.entryType === "doc_delete"
|
|
1283
|
+
|| entry.entryType === "doc_undelete"
|
|
1284
|
+
|| entry.entryType === "doc_snapshot");
|
|
1285
|
+
// Prefer a lifecycle entry from the failing batch for the
|
|
1286
|
+
// key id (they all share the same `decryptionKeyId` for a
|
|
1287
|
+
// doc), then fall back to any entry, then to the existing
|
|
1288
|
+
// index entry. One of these must be present whenever we
|
|
1289
|
+
// reach this branch.
|
|
1290
|
+
const representative = lifecycleEntriesInBatch[0] ?? entryMetadataList[0];
|
|
1291
|
+
const decryptionKeyId = representative?.decryptionKeyId ?? existing.decryptionKeyId;
|
|
1292
|
+
const batchLastModified = entryMetadataList.length > 0
|
|
1293
|
+
? Math.max(...entryMetadataList.map((entry) => entry.createdAt))
|
|
1294
|
+
: 0;
|
|
1295
|
+
const lastModified = Math.max(existing.lastModified, batchLastModified);
|
|
1296
|
+
await this.purgeMaterializedDocument(docId);
|
|
1297
|
+
this.updateIndex(docId, lastModified, true, decryptionKeyId, "inaccessible");
|
|
1298
|
+
this.cacheMetaDirty = true;
|
|
1299
|
+
this.cacheManager?.markDirty();
|
|
1300
|
+
}
|
|
742
1301
|
this.processedEntryIds.push(...entryMetadataList.map(em => em.id));
|
|
743
|
-
return;
|
|
1302
|
+
return;
|
|
744
1303
|
}
|
|
745
1304
|
this.logger.error(`===== ERROR processing document ${docId} in syncStoreChanges =====`, error);
|
|
746
1305
|
// Re-throw the error so we can see what's happening in the test
|
|
@@ -791,16 +1350,31 @@ class BaseMindooDB {
|
|
|
791
1350
|
return typeof store.scanEntriesSince === "function";
|
|
792
1351
|
}
|
|
793
1352
|
async scanAllMetadata(store, filters) {
|
|
1353
|
+
const effectiveFilters = this.mergeTimeTravelScanFilters(filters);
|
|
794
1354
|
if (!this.supportsCursorScan(store)) {
|
|
1355
|
+
let entries;
|
|
795
1356
|
if (filters?.docId) {
|
|
796
|
-
|
|
1357
|
+
entries = await store.findNewEntriesForDoc([], filters.docId);
|
|
797
1358
|
}
|
|
798
|
-
|
|
1359
|
+
else {
|
|
1360
|
+
entries = await store.findNewEntries([]);
|
|
1361
|
+
}
|
|
1362
|
+
if (effectiveFilters?.entryTypes?.length) {
|
|
1363
|
+
const allowedTypes = new Set(effectiveFilters.entryTypes);
|
|
1364
|
+
entries = entries.filter((entry) => allowedTypes.has(entry.entryType));
|
|
1365
|
+
}
|
|
1366
|
+
if (effectiveFilters?.creationDateFrom != null) {
|
|
1367
|
+
entries = entries.filter((entry) => entry.createdAt >= effectiveFilters.creationDateFrom);
|
|
1368
|
+
}
|
|
1369
|
+
if (effectiveFilters?.creationDateUntil != null) {
|
|
1370
|
+
entries = entries.filter((entry) => entry.createdAt < effectiveFilters.creationDateUntil);
|
|
1371
|
+
}
|
|
1372
|
+
return this.applyTimeTravelFilter(entries);
|
|
799
1373
|
}
|
|
800
1374
|
const all = [];
|
|
801
1375
|
let cursor = null;
|
|
802
1376
|
while (true) {
|
|
803
|
-
const page = await store.scanEntriesSince(cursor, 1000,
|
|
1377
|
+
const page = await store.scanEntriesSince(cursor, 1000, effectiveFilters);
|
|
804
1378
|
all.push(...page.entries);
|
|
805
1379
|
cursor = page.nextCursor;
|
|
806
1380
|
if (!page.hasMore) {
|
|
@@ -812,7 +1386,7 @@ class BaseMindooDB {
|
|
|
812
1386
|
async getNewEntryMetadataForSync() {
|
|
813
1387
|
const startedAt = Date.now();
|
|
814
1388
|
if (!this.supportsCursorScan(this.store)) {
|
|
815
|
-
const result = await this.store.findNewEntries(this.processedEntryIds);
|
|
1389
|
+
const result = this.applyTimeTravelFilter(await this.store.findNewEntries(this.processedEntryIds));
|
|
816
1390
|
this.performanceCallback?.onSyncOperation?.({
|
|
817
1391
|
operation: "findNewEntries",
|
|
818
1392
|
time: Date.now() - startedAt,
|
|
@@ -830,7 +1404,7 @@ class BaseMindooDB {
|
|
|
830
1404
|
const allNew = [];
|
|
831
1405
|
let cursor = this.processedEntryCursor;
|
|
832
1406
|
while (true) {
|
|
833
|
-
const page = await this.store.scanEntriesSince(cursor, 1000);
|
|
1407
|
+
const page = await this.store.scanEntriesSince(cursor, 1000, this.mergeTimeTravelScanFilters());
|
|
834
1408
|
allNew.push(...page.entries);
|
|
835
1409
|
cursor = page.nextCursor;
|
|
836
1410
|
if (!page.hasMore) {
|
|
@@ -1238,6 +1812,50 @@ class BaseMindooDB {
|
|
|
1238
1812
|
getAttachmentStore() {
|
|
1239
1813
|
return this.attachmentStore;
|
|
1240
1814
|
}
|
|
1815
|
+
async reclaimIncompleteAttachmentUploads(options) {
|
|
1816
|
+
const minAgeMs = options?.minAgeMs ?? 5 * 60 * 1000;
|
|
1817
|
+
const cutoff = Date.now() - minAgeMs;
|
|
1818
|
+
const ledgers = await this.store.findEntries("pending_attachment_upload", null, cutoff);
|
|
1819
|
+
const result = {
|
|
1820
|
+
scannedLedgers: ledgers.length,
|
|
1821
|
+
reclaimedUploads: 0,
|
|
1822
|
+
reclaimedChunks: 0,
|
|
1823
|
+
keptCommittedUploads: 0,
|
|
1824
|
+
keptRecentUploads: 0,
|
|
1825
|
+
};
|
|
1826
|
+
for (const ledger of ledgers) {
|
|
1827
|
+
const attachmentId = ledger.attachmentId;
|
|
1828
|
+
if (!attachmentId) {
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
if ((ledger.uploadStartedAt ?? ledger.createdAt) > cutoff) {
|
|
1832
|
+
result.keptRecentUploads++;
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
const liveDoc = await this.getDocument(ledger.docId).catch(() => null);
|
|
1836
|
+
if (liveDoc?.getAttachments().some((attachment) => attachment.attachmentId === attachmentId)) {
|
|
1837
|
+
await this.clearPendingAttachmentUploadLedger(attachmentId);
|
|
1838
|
+
result.keptCommittedUploads++;
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1841
|
+
const docEntries = await this.scanAllMetadata(this.store, { docId: ledger.docId });
|
|
1842
|
+
const mentionedByHistory = docEntries.some((entry) => entry.attachmentIds?.includes(attachmentId));
|
|
1843
|
+
if (mentionedByHistory) {
|
|
1844
|
+
await this.clearPendingAttachmentUploadLedger(attachmentId);
|
|
1845
|
+
result.keptCommittedUploads++;
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1848
|
+
const deletedChunks = await this.getEffectiveAttachmentStore()
|
|
1849
|
+
.deleteEntriesForAttachment?.(ledger.docId, attachmentId) ?? 0;
|
|
1850
|
+
await this.clearPendingAttachmentUploadLedger(attachmentId);
|
|
1851
|
+
result.reclaimedUploads++;
|
|
1852
|
+
result.reclaimedChunks += deletedChunks;
|
|
1853
|
+
}
|
|
1854
|
+
if (result.scannedLedgers > 0) {
|
|
1855
|
+
this.logger.info(`[idb-orphan-sweep] reclaimed ${result.reclaimedUploads} incomplete uploads (${result.reclaimedChunks} chunks), kept ${result.keptCommittedUploads} committed, kept ${result.keptRecentUploads} recent`);
|
|
1856
|
+
}
|
|
1857
|
+
return result;
|
|
1858
|
+
}
|
|
1241
1859
|
getTenant() {
|
|
1242
1860
|
return this.tenant;
|
|
1243
1861
|
}
|
|
@@ -1270,6 +1888,7 @@ class BaseMindooDB {
|
|
|
1270
1888
|
* custom ID converge when synced (see `getCustomIdInitialChangeBytes`).
|
|
1271
1889
|
*/
|
|
1272
1890
|
async createDocumentInternal(options) {
|
|
1891
|
+
this.assertWritable("createDocument");
|
|
1273
1892
|
const { signingKeyPair, signingKeyPassword } = options;
|
|
1274
1893
|
if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
|
|
1275
1894
|
throw new Error("createDocument: signingKeyPair and signingKeyPassword must be provided together");
|
|
@@ -1432,8 +2051,8 @@ class BaseMindooDB {
|
|
|
1432
2051
|
isDeleted: false,
|
|
1433
2052
|
};
|
|
1434
2053
|
// Update cache and index
|
|
1435
|
-
this.storeCachedDocument(internalDoc);
|
|
1436
|
-
this.updateIndex(docId, internalDoc.lastModified, false);
|
|
2054
|
+
await this.storeCachedDocument(internalDoc);
|
|
2055
|
+
this.updateIndex(docId, internalDoc.lastModified, false, internalDoc.decryptionKeyId, "visible");
|
|
1437
2056
|
this.markDocDirty(docId);
|
|
1438
2057
|
this.logger.info(`Document ${docId} created successfully`);
|
|
1439
2058
|
this.logger.debug(`Document ${docId} cached and indexed (lastModified: ${internalDoc.lastModified})`);
|
|
@@ -1912,77 +2531,1081 @@ class BaseMindooDB {
|
|
|
1912
2531
|
return result;
|
|
1913
2532
|
}
|
|
1914
2533
|
/**
|
|
1915
|
-
*
|
|
2534
|
+
* Streams conflict-analysis events for one or more documents.
|
|
1916
2535
|
*
|
|
1917
|
-
* This
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
2536
|
+
* This is the UI/server friendly entry point for conflict analysis. It first
|
|
2537
|
+
* does a metadata-only preflight for each document, then only decrypts and
|
|
2538
|
+
* replays document changes when the DAG contains concurrency candidates. The
|
|
2539
|
+
* yielded DTOs intentionally expose MindooDB concepts only; callers never get
|
|
2540
|
+
* direct access to Automerge documents, patches, operation IDs, or objects.
|
|
2541
|
+
*
|
|
2542
|
+
* @param docIds Document IDs to analyze. Documents are processed sequentially
|
|
2543
|
+
* so browser callers can render progress and stop early without a large
|
|
2544
|
+
* burst of synchronous work.
|
|
2545
|
+
* @param options Controls quick/full mode, value detail level, cancellation,
|
|
2546
|
+
* per-document conflict limits, and cooperative event-loop yielding.
|
|
2547
|
+
* @returns An async generator yielding progress, conflict, resolution, done,
|
|
2548
|
+
* and error events.
|
|
1920
2549
|
*/
|
|
1921
|
-
async
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
const
|
|
1927
|
-
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2550
|
+
async *analyzeDocumentConflicts(docIds, options = {}) {
|
|
2551
|
+
const scanCheckpoint = await this.getConflictScanCheckpoint();
|
|
2552
|
+
const candidateDocIds = await this.resolveConflictAnalysisCandidateDocIds(docIds, options);
|
|
2553
|
+
const totalDocs = candidateDocIds.length;
|
|
2554
|
+
let scannedDocs = 0;
|
|
2555
|
+
const mode = options.mode ?? "quick";
|
|
2556
|
+
let lastYieldAt = Date.now();
|
|
2557
|
+
for (const docId of candidateDocIds) {
|
|
2558
|
+
this.throwIfConflictAnalysisAborted(options.signal);
|
|
2559
|
+
const docStartedAt = Date.now();
|
|
2560
|
+
yield { type: "docStart", docId };
|
|
2561
|
+
try {
|
|
2562
|
+
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
2563
|
+
yield {
|
|
2564
|
+
type: "progress",
|
|
2565
|
+
scannedDocs,
|
|
2566
|
+
totalDocs,
|
|
2567
|
+
docId,
|
|
2568
|
+
scannedEntries: allEntryMetadata.length,
|
|
2569
|
+
message: `Scanned conflict metadata for document ${docId}`,
|
|
2570
|
+
};
|
|
2571
|
+
const plan = (0, DocumentConflictAnalysis_1.computeDocumentConflictAnalysisPlan)(docId, allEntryMetadata);
|
|
2572
|
+
let conflictsFound = 0;
|
|
2573
|
+
let hadConflicts = false;
|
|
2574
|
+
if (plan.hasConcurrencyCandidates) {
|
|
2575
|
+
const maxConflictsPerDoc = options.maxConflictsPerDoc ?? (mode === "quick" ? 1 : undefined);
|
|
2576
|
+
const detection = await this.detectDocumentConflictsFromPlan(docId, plan, {
|
|
2577
|
+
...options,
|
|
2578
|
+
mode,
|
|
2579
|
+
maxConflictsPerDoc,
|
|
2580
|
+
});
|
|
2581
|
+
for (const conflict of detection.conflicts) {
|
|
2582
|
+
this.throwIfConflictAnalysisAborted(options.signal);
|
|
2583
|
+
hadConflicts = true;
|
|
2584
|
+
conflictsFound += conflict.paths.length;
|
|
2585
|
+
yield {
|
|
2586
|
+
type: "conflictDetected",
|
|
2587
|
+
conflict,
|
|
2588
|
+
quick: mode === "quick",
|
|
2589
|
+
};
|
|
2590
|
+
lastYieldAt = await this.maybeYieldForConflictAnalysis(options.yieldEveryMs, lastYieldAt);
|
|
2591
|
+
}
|
|
2592
|
+
if (mode === "full") {
|
|
2593
|
+
for (const resolution of detection.resolutions) {
|
|
2594
|
+
this.throwIfConflictAnalysisAborted(options.signal);
|
|
2595
|
+
yield resolution;
|
|
2596
|
+
lastYieldAt = await this.maybeYieldForConflictAnalysis(options.yieldEveryMs, lastYieldAt);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
scannedDocs++;
|
|
2601
|
+
yield {
|
|
2602
|
+
type: "docDone",
|
|
2603
|
+
docId,
|
|
2604
|
+
hadConflicts,
|
|
2605
|
+
conflictsFound,
|
|
2606
|
+
entriesScanned: allEntryMetadata.length,
|
|
2607
|
+
};
|
|
2608
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
2609
|
+
operation: "analyzeDocumentConflicts",
|
|
2610
|
+
docId,
|
|
2611
|
+
time: Date.now() - docStartedAt,
|
|
2612
|
+
scannedEntries: allEntryMetadata.length,
|
|
2613
|
+
returnedEntries: conflictsFound,
|
|
2614
|
+
bounded: true,
|
|
2615
|
+
});
|
|
1937
2616
|
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
return [];
|
|
1945
|
-
}
|
|
1946
|
-
const touchedKeySet = new Set(touchedKeys);
|
|
1947
|
-
if (touchedKeySet.size === 0) {
|
|
1948
|
-
return normalizedPaths;
|
|
1949
|
-
}
|
|
1950
|
-
const filteredPaths = normalizedPaths.filter((path) => touchedKeySet.has(this.extractLeafPathSegment(path)));
|
|
1951
|
-
return filteredPaths.length > 0 ? filteredPaths : normalizedPaths;
|
|
1952
|
-
}
|
|
1953
|
-
async getAllDocumentIds() {
|
|
1954
|
-
// Return all non-deleted document IDs from index
|
|
1955
|
-
const docIds = [];
|
|
1956
|
-
for (const entry of this.index) {
|
|
1957
|
-
if (!entry.isDeleted) {
|
|
1958
|
-
docIds.push(entry.docId);
|
|
2617
|
+
catch (error) {
|
|
2618
|
+
if (options.signal?.aborted) {
|
|
2619
|
+
throw error;
|
|
2620
|
+
}
|
|
2621
|
+
yield { type: "error", docId, error };
|
|
2622
|
+
scannedDocs++;
|
|
1959
2623
|
}
|
|
2624
|
+
yield {
|
|
2625
|
+
type: "progress",
|
|
2626
|
+
scannedDocs,
|
|
2627
|
+
totalDocs,
|
|
2628
|
+
docId,
|
|
2629
|
+
message: `Completed conflict analysis for document ${docId}`,
|
|
2630
|
+
};
|
|
2631
|
+
lastYieldAt = await this.maybeYieldForConflictAnalysis(options.yieldEveryMs, lastYieldAt);
|
|
1960
2632
|
}
|
|
1961
|
-
|
|
2633
|
+
yield {
|
|
2634
|
+
type: "scanCheckpoint",
|
|
2635
|
+
checkpoint: scanCheckpoint,
|
|
2636
|
+
};
|
|
1962
2637
|
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2638
|
+
/**
|
|
2639
|
+
* Builds a complete conflict report for a single document.
|
|
2640
|
+
*
|
|
2641
|
+
* This is a convenience wrapper around `analyzeDocumentConflicts()`. It forces
|
|
2642
|
+
* full analysis mode, consumes the event stream internally, and returns a
|
|
2643
|
+
* stable report object suitable for server-side jobs or UI detail panels.
|
|
2644
|
+
*
|
|
2645
|
+
* @param docId Document ID to analyze.
|
|
2646
|
+
* @param options Report options. The wrapper ignores quick-mode limits because
|
|
2647
|
+
* reports are intended to describe the full known conflict history.
|
|
2648
|
+
* @returns Aggregated conflict and resolution information for the document.
|
|
2649
|
+
*/
|
|
2650
|
+
async getDocumentConflictReport(docId, options = {}) {
|
|
2651
|
+
const startedAt = Date.now();
|
|
2652
|
+
const report = {
|
|
2653
|
+
docId,
|
|
2654
|
+
hadConflicts: false,
|
|
2655
|
+
conflictsFound: 0,
|
|
2656
|
+
conflicts: [],
|
|
2657
|
+
resolutions: [],
|
|
2658
|
+
entriesScanned: 0,
|
|
2659
|
+
errors: [],
|
|
2660
|
+
scanCheckpoint: null,
|
|
2661
|
+
};
|
|
2662
|
+
for await (const event of this.analyzeDocumentConflicts([docId], {
|
|
2663
|
+
...options,
|
|
2664
|
+
mode: "full",
|
|
2665
|
+
})) {
|
|
2666
|
+
if (event.type === "conflictDetected") {
|
|
2667
|
+
report.hadConflicts = true;
|
|
2668
|
+
report.conflicts.push(event.conflict);
|
|
2669
|
+
report.conflictsFound += event.conflict.paths.length;
|
|
2670
|
+
}
|
|
2671
|
+
else if (event.type === "conflictResolved") {
|
|
2672
|
+
report.resolutions.push(event);
|
|
2673
|
+
}
|
|
2674
|
+
else if (event.type === "docDone") {
|
|
2675
|
+
report.entriesScanned = event.entriesScanned;
|
|
2676
|
+
}
|
|
2677
|
+
else if (event.type === "error") {
|
|
2678
|
+
report.errors.push(event);
|
|
2679
|
+
}
|
|
2680
|
+
else if (event.type === "scanCheckpoint") {
|
|
2681
|
+
report.scanCheckpoint = event.checkpoint;
|
|
1968
2682
|
}
|
|
1969
2683
|
}
|
|
1970
|
-
|
|
2684
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
2685
|
+
operation: "getDocumentConflictReport",
|
|
2686
|
+
docId,
|
|
2687
|
+
time: Date.now() - startedAt,
|
|
2688
|
+
scannedEntries: report.entriesScanned,
|
|
2689
|
+
returnedEntries: report.conflictsFound,
|
|
2690
|
+
bounded: true,
|
|
2691
|
+
});
|
|
2692
|
+
return report;
|
|
1971
2693
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2694
|
+
/**
|
|
2695
|
+
* Resolves the value each conflicted path had at its merge base.
|
|
2696
|
+
*
|
|
2697
|
+
* For every query, the database identifies the heads that contributed to
|
|
2698
|
+
* the conflict (the merging entry's parents for `entry-after` /
|
|
2699
|
+
* `merge-deps` conflicts, or the active heads for `active-heads`
|
|
2700
|
+
* conflicts), computes the most recent common ancestor of those heads,
|
|
2701
|
+
* materializes the document at that ancestor, and reads the path.
|
|
2702
|
+
*
|
|
2703
|
+
* Queries that share a merge base are coalesced: the document is
|
|
2704
|
+
* materialized at most once per unique base entry.
|
|
2705
|
+
*
|
|
2706
|
+
* @param docId Document identifier the queries refer to.
|
|
2707
|
+
* @param queries One entry per (location, path) lookup the caller wants.
|
|
2708
|
+
* @returns A `DocumentConflictBaseValue` aligned 1:1 with `queries`.
|
|
2709
|
+
*/
|
|
2710
|
+
async getDocumentConflictBaseValues(docId, queries) {
|
|
2711
|
+
const startedAt = Date.now();
|
|
2712
|
+
if (queries.length === 0) {
|
|
2713
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
2714
|
+
operation: "getDocumentConflictBaseValues",
|
|
2715
|
+
docId,
|
|
2716
|
+
time: Date.now() - startedAt,
|
|
2717
|
+
scannedEntries: 0,
|
|
2718
|
+
returnedEntries: 0,
|
|
2719
|
+
bounded: true,
|
|
2720
|
+
});
|
|
2721
|
+
return [];
|
|
2722
|
+
}
|
|
2723
|
+
const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
2724
|
+
const metadataById = new Map(allEntryMetadata.map((entry) => [entry.id, entry]));
|
|
2725
|
+
const groups = new Map();
|
|
2726
|
+
for (let index = 0; index < queries.length; index += 1) {
|
|
2727
|
+
const query = queries[index];
|
|
2728
|
+
const parents = this.resolveConflictBaseParents(query.location, metadataById);
|
|
2729
|
+
const key = parents.length === 0 ? "" : parents.slice().sort().join("|");
|
|
2730
|
+
let group = groups.get(key);
|
|
2731
|
+
if (!group) {
|
|
2732
|
+
group = { parents, queries: [] };
|
|
2733
|
+
groups.set(key, group);
|
|
2734
|
+
}
|
|
2735
|
+
group.queries.push({ originalIndex: index, query });
|
|
2736
|
+
}
|
|
2737
|
+
const results = new Array(queries.length);
|
|
2738
|
+
for (const group of groups.values()) {
|
|
2739
|
+
if (group.parents.length === 0) {
|
|
2740
|
+
for (const grouped of group.queries) {
|
|
2741
|
+
results[grouped.originalIndex] = {
|
|
2742
|
+
pathString: grouped.query.pathString,
|
|
2743
|
+
baseEntryId: null,
|
|
2744
|
+
status: "missing-entry",
|
|
2745
|
+
preview: null,
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
continue;
|
|
2749
|
+
}
|
|
2750
|
+
const baseEntryId = this.computeConflictMergeBaseEntryId(metadataById, group.parents);
|
|
2751
|
+
if (baseEntryId === null) {
|
|
2752
|
+
for (const grouped of group.queries) {
|
|
2753
|
+
results[grouped.originalIndex] = {
|
|
2754
|
+
pathString: grouped.query.pathString,
|
|
2755
|
+
baseEntryId: null,
|
|
2756
|
+
status: "no-base",
|
|
2757
|
+
preview: null,
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
const internalDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, baseEntryId);
|
|
2763
|
+
if (!internalDoc || internalDoc.isDeleted) {
|
|
2764
|
+
for (const grouped of group.queries) {
|
|
2765
|
+
results[grouped.originalIndex] = {
|
|
2766
|
+
pathString: grouped.query.pathString,
|
|
2767
|
+
baseEntryId,
|
|
2768
|
+
status: "no-prior-value",
|
|
2769
|
+
preview: null,
|
|
2770
|
+
};
|
|
2771
|
+
}
|
|
2772
|
+
continue;
|
|
2773
|
+
}
|
|
2774
|
+
for (const grouped of group.queries) {
|
|
2775
|
+
const value = this.readValueAtDocumentPath(internalDoc.doc, grouped.query.path);
|
|
2776
|
+
if (value === undefined) {
|
|
2777
|
+
results[grouped.originalIndex] = {
|
|
2778
|
+
pathString: grouped.query.pathString,
|
|
2779
|
+
baseEntryId,
|
|
2780
|
+
status: "no-prior-value",
|
|
2781
|
+
preview: null,
|
|
2782
|
+
};
|
|
2783
|
+
}
|
|
2784
|
+
else {
|
|
2785
|
+
results[grouped.originalIndex] = {
|
|
2786
|
+
pathString: grouped.query.pathString,
|
|
2787
|
+
baseEntryId,
|
|
2788
|
+
status: "available",
|
|
2789
|
+
preview: this.previewChangeValue(value),
|
|
2790
|
+
value: this.toJsonSafeConflictValue(value),
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
this.performanceCallback?.onHistoryOperation?.({
|
|
2796
|
+
operation: "getDocumentConflictBaseValues",
|
|
2797
|
+
docId,
|
|
2798
|
+
time: Date.now() - startedAt,
|
|
2799
|
+
scannedEntries: allEntryMetadata.length,
|
|
2800
|
+
returnedEntries: results.length,
|
|
2801
|
+
bounded: true,
|
|
2802
|
+
});
|
|
2803
|
+
return results;
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Returns the head entry IDs whose merge base should be resolved for one
|
|
2807
|
+
* conflict location. Filters out IDs that aren't present in the local
|
|
2808
|
+
* metadata so callers don't try to materialize against missing entries.
|
|
2809
|
+
*/
|
|
2810
|
+
resolveConflictBaseParents(location, metadataById) {
|
|
2811
|
+
if (location.kind === "active-heads") {
|
|
2812
|
+
return location.headEntryIds.filter((id) => metadataById.has(id));
|
|
2813
|
+
}
|
|
2814
|
+
if (!location.entryId) {
|
|
2815
|
+
return [];
|
|
2816
|
+
}
|
|
2817
|
+
const entry = metadataById.get(location.entryId);
|
|
2818
|
+
if (!entry) {
|
|
2819
|
+
return [];
|
|
2820
|
+
}
|
|
2821
|
+
return entry.dependencyIds.filter((id) => metadataById.has(id));
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Computes the most recent common ancestor of `parents` in the document's
|
|
2825
|
+
* dependency DAG.
|
|
2826
|
+
*
|
|
2827
|
+
* Returns the deepest common ancestor (an ancestor that is not itself a
|
|
2828
|
+
* proper ancestor of another common ancestor). If multiple deepest
|
|
2829
|
+
* candidates exist, the one with the highest `createdAt` wins so the UI
|
|
2830
|
+
* shows a base value as close to the conflict as possible.
|
|
2831
|
+
*/
|
|
2832
|
+
computeConflictMergeBaseEntryId(metadataById, parents) {
|
|
2833
|
+
if (parents.length === 0) {
|
|
2834
|
+
return null;
|
|
2835
|
+
}
|
|
2836
|
+
if (parents.length === 1) {
|
|
2837
|
+
return parents[0];
|
|
2838
|
+
}
|
|
2839
|
+
const ancestorCache = new Map();
|
|
2840
|
+
const collectAncestors = (startId) => {
|
|
2841
|
+
const cached = ancestorCache.get(startId);
|
|
2842
|
+
if (cached) {
|
|
2843
|
+
return cached;
|
|
2844
|
+
}
|
|
2845
|
+
const visited = new Set();
|
|
2846
|
+
const queue = [startId];
|
|
2847
|
+
while (queue.length > 0) {
|
|
2848
|
+
const id = queue.shift();
|
|
2849
|
+
if (visited.has(id)) {
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
visited.add(id);
|
|
2853
|
+
const meta = metadataById.get(id);
|
|
2854
|
+
if (!meta) {
|
|
2855
|
+
continue;
|
|
2856
|
+
}
|
|
2857
|
+
for (const dep of meta.dependencyIds) {
|
|
2858
|
+
if (!visited.has(dep)) {
|
|
2859
|
+
queue.push(dep);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
ancestorCache.set(startId, visited);
|
|
2864
|
+
return visited;
|
|
2865
|
+
};
|
|
2866
|
+
let common = null;
|
|
2867
|
+
for (const parent of parents) {
|
|
2868
|
+
const ancestors = collectAncestors(parent);
|
|
2869
|
+
if (common === null) {
|
|
2870
|
+
common = new Set(ancestors);
|
|
2871
|
+
}
|
|
2872
|
+
else {
|
|
2873
|
+
const next = new Set();
|
|
2874
|
+
for (const id of common) {
|
|
2875
|
+
if (ancestors.has(id)) {
|
|
2876
|
+
next.add(id);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
common = next;
|
|
2880
|
+
}
|
|
2881
|
+
if (common.size === 0) {
|
|
2882
|
+
return null;
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
if (!common || common.size === 0) {
|
|
2886
|
+
return null;
|
|
2887
|
+
}
|
|
2888
|
+
const isProperAncestorOfAnother = new Set();
|
|
2889
|
+
for (const id of common) {
|
|
2890
|
+
const ancestors = collectAncestors(id);
|
|
2891
|
+
for (const ancestorId of ancestors) {
|
|
2892
|
+
if (ancestorId !== id && common.has(ancestorId)) {
|
|
2893
|
+
isProperAncestorOfAnother.add(ancestorId);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
const deepest = [];
|
|
2898
|
+
for (const id of common) {
|
|
2899
|
+
if (!isProperAncestorOfAnother.has(id)) {
|
|
2900
|
+
deepest.push(id);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (deepest.length === 0) {
|
|
2904
|
+
return null;
|
|
2905
|
+
}
|
|
2906
|
+
deepest.sort((a, b) => {
|
|
2907
|
+
const tA = metadataById.get(a)?.createdAt ?? 0;
|
|
2908
|
+
const tB = metadataById.get(b)?.createdAt ?? 0;
|
|
2909
|
+
if (tA !== tB) {
|
|
2910
|
+
return tB - tA;
|
|
2911
|
+
}
|
|
2912
|
+
return a.localeCompare(b);
|
|
2913
|
+
});
|
|
2914
|
+
return deepest[0];
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Captures the current local receipt/index position for future incremental
|
|
2918
|
+
* conflict scans.
|
|
2919
|
+
*/
|
|
2920
|
+
async getConflictScanCheckpoint() {
|
|
2921
|
+
const latestCursor = this.getLatestChangeCursor();
|
|
2922
|
+
return {
|
|
2923
|
+
changeSeqAsOf: latestCursor?.changeSeq ?? 0,
|
|
2924
|
+
storeReceiptOrderAsOf: await this.getStoreMaxReceiptOrder(),
|
|
2925
|
+
takenAt: Date.now(),
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Narrows scan work to documents whose indexed latest state changed after the
|
|
2930
|
+
* caller's checkpoint. When the caller asks for pre-existing unresolved
|
|
2931
|
+
* conflicts, every requested document remains a candidate because an old active
|
|
2932
|
+
* conflict may still need to be returned.
|
|
2933
|
+
*/
|
|
2934
|
+
async resolveConflictAnalysisCandidateDocIds(docIds, options) {
|
|
2935
|
+
if (!options.since || options.includeUnresolvedFromBefore) {
|
|
2936
|
+
return docIds;
|
|
2937
|
+
}
|
|
2938
|
+
const requestedDocIds = new Set(docIds);
|
|
2939
|
+
const movedDocIds = new Set();
|
|
2940
|
+
const cursor = {
|
|
2941
|
+
changeSeq: options.since.changeSeqAsOf,
|
|
2942
|
+
lastModified: 0,
|
|
2943
|
+
docId: "",
|
|
2944
|
+
};
|
|
2945
|
+
for await (const summary of this.iterateChangeMetadataSince(cursor)) {
|
|
2946
|
+
if (requestedDocIds.has(summary.docId)) {
|
|
2947
|
+
movedDocIds.add(summary.docId);
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
return docIds.filter((docId) => movedDocIds.has(docId));
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Finds the latest local store receipt order. This is intentionally metadata
|
|
2954
|
+
* only and works with stores that do not expose a direct max-receipt query.
|
|
2955
|
+
*/
|
|
2956
|
+
async getStoreMaxReceiptOrder() {
|
|
2957
|
+
let maxReceiptOrder;
|
|
2958
|
+
const metadata = await this.scanAllMetadata(this.store);
|
|
2959
|
+
for (const entry of metadata) {
|
|
2960
|
+
if (typeof entry.receiptOrder !== "number") {
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2963
|
+
maxReceiptOrder = maxReceiptOrder === undefined
|
|
2964
|
+
? entry.receiptOrder
|
|
2965
|
+
: Math.max(maxReceiptOrder, entry.receiptOrder);
|
|
2966
|
+
}
|
|
2967
|
+
return maxReceiptOrder;
|
|
2968
|
+
}
|
|
2969
|
+
/**
|
|
2970
|
+
* Replays verified document changes and detects conflict/resolution events.
|
|
2971
|
+
*
|
|
2972
|
+
* The input `plan` is metadata-only and has already determined that analyzing
|
|
2973
|
+
* this document is worth the cost. This method performs the private Automerge
|
|
2974
|
+
* work: decrypting changes, replaying them in dependency order, inspecting
|
|
2975
|
+
* changed paths, and optionally resolving conflicting value summaries. All
|
|
2976
|
+
* Automerge-specific objects are converted to MindooDB DTOs before returning.
|
|
2977
|
+
*
|
|
2978
|
+
* Conflict detection has three phases:
|
|
2979
|
+
* 1. Before applying a change, inspect existing multi-head states. This catches
|
|
2980
|
+
* conflicts that exist between active heads and conflicts that are about to
|
|
2981
|
+
* be resolved by a merge/resolution change.
|
|
2982
|
+
* 2. After applying a change, inspect Automerge patches for changed paths and
|
|
2983
|
+
* confirm conflicts with `getConflicts()` on those changed paths only.
|
|
2984
|
+
* 3. After replay completes, inspect remaining active heads so unresolved
|
|
2985
|
+
* conflicts are still reported for documents that never wrote a merge change.
|
|
2986
|
+
*
|
|
2987
|
+
* @param docId Document being analyzed.
|
|
2988
|
+
* @param plan Metadata-only replay/concurrency plan for the document.
|
|
2989
|
+
* @param options Analysis options propagated from the public API.
|
|
2990
|
+
* @returns Conflict findings, resolution events, and the number of entries
|
|
2991
|
+
* successfully replayed.
|
|
2992
|
+
*/
|
|
2993
|
+
async detectDocumentConflictsFromPlan(docId, plan, options) {
|
|
2994
|
+
const verifiedChanges = await this.loadVerifiedReplayChanges(plan.replayEntries);
|
|
2995
|
+
const hashToEntryId = this.buildAutomergeHashToEntryId(plan.replayEntries, verifiedChanges);
|
|
2996
|
+
const activeConflictPaths = new Map();
|
|
2997
|
+
const conflicts = [];
|
|
2998
|
+
const resolutions = [];
|
|
2999
|
+
const maxConflictsPerDoc = options.maxConflictsPerDoc;
|
|
3000
|
+
let entriesApplied = 0;
|
|
3001
|
+
let doc = automerge_adapter_1.Automerge.init();
|
|
3002
|
+
for (const entryId of plan.orderedReplayEntryIds) {
|
|
3003
|
+
this.throwIfConflictAnalysisAborted(options.signal);
|
|
3004
|
+
const change = verifiedChanges.get(entryId);
|
|
3005
|
+
if (!change) {
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
3008
|
+
const beforeHeads = automerge_adapter_1.Automerge.getHeads(doc);
|
|
3009
|
+
// A multi-head state can already contain conflicts before the next change
|
|
3010
|
+
// is applied. This is especially important for resolution changes: the
|
|
3011
|
+
// conflict exists in the dependency heads and disappears after the write.
|
|
3012
|
+
if (beforeHeads.length > 1) {
|
|
3013
|
+
const alreadyReported = new Set([
|
|
3014
|
+
...conflicts.flatMap((conflict) => conflict.paths.map((path) => path.pathString)),
|
|
3015
|
+
...activeConflictPaths.keys(),
|
|
3016
|
+
]);
|
|
3017
|
+
const preApplyConflictPaths = this.collectDocumentConflictPaths(doc, options.detail === "values").filter((path) => !alreadyReported.has(path.pathString));
|
|
3018
|
+
const conflictPathCount = conflicts.reduce((count, conflict) => count + conflict.paths.length, 0);
|
|
3019
|
+
const remainingBudget = maxConflictsPerDoc === undefined
|
|
3020
|
+
? preApplyConflictPaths.length
|
|
3021
|
+
: Math.max(0, maxConflictsPerDoc - conflictPathCount);
|
|
3022
|
+
const boundedPreApplyConflictPaths = preApplyConflictPaths.slice(0, remainingBudget);
|
|
3023
|
+
if (boundedPreApplyConflictPaths.length > 0) {
|
|
3024
|
+
for (const path of boundedPreApplyConflictPaths) {
|
|
3025
|
+
activeConflictPaths.set(path.pathString, path);
|
|
3026
|
+
}
|
|
3027
|
+
if (this.shouldEmitConflictObservation(change.entry, options)) {
|
|
3028
|
+
conflicts.push({
|
|
3029
|
+
docId,
|
|
3030
|
+
location: {
|
|
3031
|
+
kind: change.entry.dependencyIds.length > 1 ? "merge-deps" : "active-heads",
|
|
3032
|
+
entryId: change.entry.id,
|
|
3033
|
+
createdAt: change.entry.createdAt,
|
|
3034
|
+
receiptOrder: change.entry.receiptOrder,
|
|
3035
|
+
createdByPublicKey: change.entry.createdByPublicKey,
|
|
3036
|
+
headEntryIds: beforeHeads
|
|
3037
|
+
.map((head) => hashToEntryId.get(head))
|
|
3038
|
+
.filter((headEntryId) => typeof headEntryId === "string"),
|
|
3039
|
+
automergeHeads: [...beforeHeads],
|
|
3040
|
+
},
|
|
3041
|
+
paths: boundedPreApplyConflictPaths,
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
const afterDoc = automerge_adapter_1.Automerge.loadIncremental(doc, change.changeBytes);
|
|
3047
|
+
const afterHeads = automerge_adapter_1.Automerge.getHeads(afterDoc);
|
|
3048
|
+
const patches = this.diffAutomergeHeads(afterDoc, beforeHeads, afterHeads);
|
|
3049
|
+
const detectedPaths = new Map();
|
|
3050
|
+
// Patches tell us which paths changed, keeping analysis proportional to
|
|
3051
|
+
// the change size. We then confirm conflict state with getConflicts() for
|
|
3052
|
+
// those paths only, rather than walking arbitrary JSON after every change.
|
|
3053
|
+
for (const patch of patches) {
|
|
3054
|
+
const path = this.getConflictPatchPath(patch);
|
|
3055
|
+
if (!path) {
|
|
3056
|
+
continue;
|
|
3057
|
+
}
|
|
3058
|
+
if (this.isInternalConflictPath(path)) {
|
|
3059
|
+
continue;
|
|
3060
|
+
}
|
|
3061
|
+
const pathString = (0, DocumentConflictAnalysis_1.formatDocumentConflictPath)(path);
|
|
3062
|
+
const action = this.getConflictPatchAction(patch);
|
|
3063
|
+
const isConflictPut = action === "put" && (this.isConflictPatch(patch) || this.hasDocumentConflictAtPath(afterDoc, path));
|
|
3064
|
+
if (isConflictPut) {
|
|
3065
|
+
const conflictPath = this.buildDocumentConflictPath(afterDoc, path, options.detail === "values");
|
|
3066
|
+
detectedPaths.set(pathString, conflictPath);
|
|
3067
|
+
activeConflictPaths.set(pathString, conflictPath);
|
|
3068
|
+
continue;
|
|
3069
|
+
}
|
|
3070
|
+
if ((action === "put" || action === "del") && activeConflictPaths.has(pathString)) {
|
|
3071
|
+
if (this.shouldEmitConflictObservation(change.entry, options)) {
|
|
3072
|
+
resolutions.push({
|
|
3073
|
+
type: "conflictResolved",
|
|
3074
|
+
docId,
|
|
3075
|
+
entryId: change.entry.id,
|
|
3076
|
+
createdAt: change.entry.createdAt,
|
|
3077
|
+
receiptOrder: change.entry.receiptOrder,
|
|
3078
|
+
createdByPublicKey: change.entry.createdByPublicKey,
|
|
3079
|
+
path: activeConflictPaths.get(pathString),
|
|
3080
|
+
automergeHash: change.automergeHash,
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
activeConflictPaths.delete(pathString);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
if (detectedPaths.size > 0 && this.shouldEmitConflictObservation(change.entry, options)) {
|
|
3087
|
+
conflicts.push({
|
|
3088
|
+
docId,
|
|
3089
|
+
location: {
|
|
3090
|
+
kind: "entry-after",
|
|
3091
|
+
entryId: change.entry.id,
|
|
3092
|
+
createdAt: change.entry.createdAt,
|
|
3093
|
+
receiptOrder: change.entry.receiptOrder,
|
|
3094
|
+
createdByPublicKey: change.entry.createdByPublicKey,
|
|
3095
|
+
headEntryIds: afterHeads
|
|
3096
|
+
.map((head) => hashToEntryId.get(head))
|
|
3097
|
+
.filter((headEntryId) => typeof headEntryId === "string"),
|
|
3098
|
+
automergeHeads: [...afterHeads],
|
|
3099
|
+
},
|
|
3100
|
+
paths: Array.from(detectedPaths.values()).sort((left, right) => left.pathString.localeCompare(right.pathString)),
|
|
3101
|
+
});
|
|
3102
|
+
}
|
|
3103
|
+
doc = afterDoc;
|
|
3104
|
+
entriesApplied++;
|
|
3105
|
+
const conflictPathCount = conflicts.reduce((count, conflict) => count + conflict.paths.length, 0);
|
|
3106
|
+
if (maxConflictsPerDoc !== undefined && conflictPathCount >= maxConflictsPerDoc) {
|
|
3107
|
+
break;
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
// If the document still has multiple active heads after replay, there may
|
|
3111
|
+
// be unresolved conflicts with no later patch to surface them. Do one final
|
|
3112
|
+
// tree scan in that bounded case so active conflicts are visible.
|
|
3113
|
+
if (plan.activeHeadEntryIds.length > 1
|
|
3114
|
+
&& (maxConflictsPerDoc === undefined
|
|
3115
|
+
|| conflicts.reduce((count, conflict) => count + conflict.paths.length, 0) < maxConflictsPerDoc)) {
|
|
3116
|
+
const alreadyReported = new Set(conflicts.flatMap((conflict) => conflict.paths.map((path) => path.pathString)));
|
|
3117
|
+
const activeConflictPaths = this.collectDocumentConflictPaths(doc, options.detail === "values").filter((path) => !alreadyReported.has(path.pathString));
|
|
3118
|
+
const remainingBudget = maxConflictsPerDoc === undefined
|
|
3119
|
+
? activeConflictPaths.length
|
|
3120
|
+
: Math.max(0, maxConflictsPerDoc - conflicts.reduce((count, conflict) => count + conflict.paths.length, 0));
|
|
3121
|
+
const boundedActiveConflictPaths = activeConflictPaths.slice(0, remainingBudget);
|
|
3122
|
+
if (boundedActiveConflictPaths.length > 0) {
|
|
3123
|
+
const activeHeadEntries = plan.activeHeadEntryIds
|
|
3124
|
+
.map((entryId) => plan.replayById.get(entryId))
|
|
3125
|
+
.filter((entry) => entry !== undefined);
|
|
3126
|
+
const latestActiveHeadEntry = activeHeadEntries
|
|
3127
|
+
.sort((left, right) => {
|
|
3128
|
+
const leftOrder = left.receiptOrder ?? left.createdAt;
|
|
3129
|
+
const rightOrder = right.receiptOrder ?? right.createdAt;
|
|
3130
|
+
return leftOrder !== rightOrder ? rightOrder - leftOrder : right.id.localeCompare(left.id);
|
|
3131
|
+
})[0];
|
|
3132
|
+
if (options.includeUnresolvedFromBefore || this.shouldEmitConflictObservation(latestActiveHeadEntry, options)) {
|
|
3133
|
+
conflicts.push({
|
|
3134
|
+
docId,
|
|
3135
|
+
location: {
|
|
3136
|
+
kind: "active-heads",
|
|
3137
|
+
entryId: latestActiveHeadEntry?.id,
|
|
3138
|
+
createdAt: latestActiveHeadEntry?.createdAt,
|
|
3139
|
+
receiptOrder: latestActiveHeadEntry?.receiptOrder,
|
|
3140
|
+
createdByPublicKey: latestActiveHeadEntry?.createdByPublicKey,
|
|
3141
|
+
headEntryIds: [...plan.activeHeadEntryIds],
|
|
3142
|
+
automergeHeads: automerge_adapter_1.Automerge.getHeads(doc),
|
|
3143
|
+
},
|
|
3144
|
+
paths: boundedActiveConflictPaths,
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
return { conflicts, resolutions, entriesApplied };
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Applies incremental-scan visibility rules to conflict and resolution events.
|
|
3153
|
+
*
|
|
3154
|
+
* Receipt order is preferred because it represents local arrival. Created-at is
|
|
3155
|
+
* only a fallback for legacy metadata that predates receipt-order assignment.
|
|
3156
|
+
*/
|
|
3157
|
+
shouldEmitConflictObservation(entry, options) {
|
|
3158
|
+
if (!options.since) {
|
|
3159
|
+
return true;
|
|
3160
|
+
}
|
|
3161
|
+
if (!entry) {
|
|
3162
|
+
return options.includeUnresolvedFromBefore === true;
|
|
3163
|
+
}
|
|
3164
|
+
if (typeof entry.receiptOrder === "number"
|
|
3165
|
+
&& typeof options.since.storeReceiptOrderAsOf === "number") {
|
|
3166
|
+
return entry.receiptOrder > options.since.storeReceiptOrderAsOf;
|
|
3167
|
+
}
|
|
3168
|
+
return entry.createdAt > options.since.takenAt;
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Loads, verifies, decrypts, and decodes replay changes for conflict analysis.
|
|
3172
|
+
*
|
|
3173
|
+
* This mirrors the security checks used by normal materialization paths:
|
|
3174
|
+
* admin-only databases ignore non-admin entries, signatures are verified
|
|
3175
|
+
* before decryption, and malformed/missing entries are skipped with warnings.
|
|
3176
|
+
*
|
|
3177
|
+
* @param replayEntries Metadata entries selected by the conflict planner.
|
|
3178
|
+
* @returns Map keyed by store entry ID containing decrypted change bytes and
|
|
3179
|
+
* decoded Automerge hash/dependency metadata for private replay.
|
|
3180
|
+
*/
|
|
3181
|
+
async loadVerifiedReplayChanges(replayEntries) {
|
|
3182
|
+
const loadedEntries = replayEntries.length > 0
|
|
3183
|
+
? await this.store.getEntries(replayEntries.map((entry) => entry.id))
|
|
3184
|
+
: [];
|
|
3185
|
+
const entryById = new Map(loadedEntries.map((entry) => [entry.id, entry]));
|
|
3186
|
+
const result = new Map();
|
|
3187
|
+
for (const metadata of replayEntries) {
|
|
3188
|
+
const entry = entryById.get(metadata.id);
|
|
3189
|
+
if (!entry) {
|
|
3190
|
+
this.logger.warn(`Conflict analysis: entry ${metadata.id} not found in store, skipping`);
|
|
3191
|
+
continue;
|
|
3192
|
+
}
|
|
3193
|
+
if (this._isAdminOnlyDb && entry.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
3194
|
+
this.logger.warn(`Admin-only DB: skipping conflict analysis entry ${entry.id} not signed by admin key`);
|
|
3195
|
+
continue;
|
|
3196
|
+
}
|
|
3197
|
+
const isValid = await this.tenant.verifySignature(entry.encryptedData, entry.signature, entry.createdByPublicKey);
|
|
3198
|
+
if (!isValid) {
|
|
3199
|
+
this.logger.warn(`Invalid signature for conflict analysis entry ${entry.id}, skipping`);
|
|
3200
|
+
continue;
|
|
3201
|
+
}
|
|
3202
|
+
const changeBytes = await this.tenant.decryptPayload(entry.encryptedData, entry.decryptionKeyId);
|
|
3203
|
+
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
3204
|
+
result.set(metadata.id, {
|
|
3205
|
+
entry: metadata,
|
|
3206
|
+
changeBytes,
|
|
3207
|
+
automergeHash: typeof decodedChange.hash === "string" ? decodedChange.hash : null,
|
|
3208
|
+
dependencyHashes: Array.isArray(decodedChange.deps)
|
|
3209
|
+
? decodedChange.deps.filter((dep) => typeof dep === "string")
|
|
3210
|
+
: [],
|
|
3211
|
+
});
|
|
3212
|
+
}
|
|
3213
|
+
return result;
|
|
3214
|
+
}
|
|
3215
|
+
/**
|
|
3216
|
+
* Builds a private lookup from Automerge change hashes to MindooDB entry IDs.
|
|
3217
|
+
*
|
|
3218
|
+
* Public conflict DTOs use MindooDB entry IDs, while Automerge heads are
|
|
3219
|
+
* hashes. This lookup lets us translate internal head sets back to stable
|
|
3220
|
+
* store-entry references for UI and report consumers.
|
|
3221
|
+
*
|
|
3222
|
+
* @param replayEntries Store metadata for replay entries.
|
|
3223
|
+
* @param verifiedChanges Decrypted/decoded changes keyed by entry ID.
|
|
3224
|
+
* @returns Map from Automerge hash to MindooDB store entry ID.
|
|
3225
|
+
*/
|
|
3226
|
+
buildAutomergeHashToEntryId(replayEntries, verifiedChanges) {
|
|
3227
|
+
const result = new Map();
|
|
3228
|
+
for (const entry of replayEntries) {
|
|
3229
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(entry.id);
|
|
3230
|
+
if (parsed) {
|
|
3231
|
+
result.set(parsed.automergeHash, entry.id);
|
|
3232
|
+
}
|
|
3233
|
+
const verified = verifiedChanges.get(entry.id);
|
|
3234
|
+
if (verified?.automergeHash) {
|
|
3235
|
+
result.set(verified.automergeHash, entry.id);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
return result;
|
|
3239
|
+
}
|
|
3240
|
+
/**
|
|
3241
|
+
* Calls Automerge's diff API through a defensive private adapter.
|
|
3242
|
+
*
|
|
3243
|
+
* The public API must not depend on Automerge patch types. This wrapper keeps
|
|
3244
|
+
* the cast and optional-method handling localized so a future Automerge API
|
|
3245
|
+
* change affects only this internal method.
|
|
3246
|
+
*
|
|
3247
|
+
* @param doc Materialized internal Automerge document containing both head sets.
|
|
3248
|
+
* @param beforeHeads Heads before applying the current change.
|
|
3249
|
+
* @param afterHeads Heads after applying the current change.
|
|
3250
|
+
* @returns Raw patch-like records for internal inspection only.
|
|
3251
|
+
*/
|
|
3252
|
+
diffAutomergeHeads(doc, beforeHeads, afterHeads) {
|
|
3253
|
+
const automergeApi = automerge_adapter_1.Automerge;
|
|
3254
|
+
if (typeof automergeApi.diff !== "function") {
|
|
3255
|
+
return [];
|
|
3256
|
+
}
|
|
3257
|
+
return automergeApi.diff(doc, beforeHeads, afterHeads);
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* Reads a patch action without leaking Automerge patch types into the codebase.
|
|
3261
|
+
*
|
|
3262
|
+
* @param patch Raw patch-like record returned by the private diff adapter.
|
|
3263
|
+
* @returns Patch action string, or null when unavailable.
|
|
3264
|
+
*/
|
|
3265
|
+
getConflictPatchAction(patch) {
|
|
3266
|
+
return typeof patch.action === "string" ? patch.action : null;
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Normalizes a raw patch path into a JSON-safe MindooDB path array.
|
|
3270
|
+
*
|
|
3271
|
+
* @param patch Raw patch-like record returned by the private diff adapter.
|
|
3272
|
+
* @returns A path made of string/number segments, or null for unsupported
|
|
3273
|
+
* patch shapes.
|
|
3274
|
+
*/
|
|
3275
|
+
getConflictPatchPath(patch) {
|
|
3276
|
+
const path = patch.path;
|
|
3277
|
+
if (!Array.isArray(path) || path.length === 0) {
|
|
3278
|
+
return null;
|
|
3279
|
+
}
|
|
3280
|
+
const normalized = path.filter((segment) => typeof segment === "string" || typeof segment === "number");
|
|
3281
|
+
return normalized.length === path.length ? normalized : null;
|
|
3282
|
+
}
|
|
3283
|
+
/**
|
|
3284
|
+
* Checks Automerge's optional conflict marker on a raw patch.
|
|
3285
|
+
*
|
|
3286
|
+
* Some conflict states are not surfaced solely by this flag, so callers also
|
|
3287
|
+
* verify changed paths with `hasDocumentConflictAtPath()`.
|
|
3288
|
+
*
|
|
3289
|
+
* @param patch Raw patch-like record returned by the private diff adapter.
|
|
3290
|
+
* @returns True when the patch explicitly says it wrote a conflicted value.
|
|
3291
|
+
*/
|
|
3292
|
+
isConflictPatch(patch) {
|
|
3293
|
+
return patch.conflict === true;
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Filters MindooDB bookkeeping fields out of app-facing conflict results.
|
|
3297
|
+
*
|
|
3298
|
+
* `_lastModified` can conflict naturally because independent replicas update
|
|
3299
|
+
* it during every change. Reporting it would be noisy and not useful to app
|
|
3300
|
+
* developers analyzing their own JSON payload.
|
|
3301
|
+
*
|
|
3302
|
+
* @param path Candidate conflict path.
|
|
3303
|
+
* @returns True when the path belongs to MindooDB internals.
|
|
3304
|
+
*/
|
|
3305
|
+
isInternalConflictPath(path) {
|
|
3306
|
+
return path[0] === "_lastModified";
|
|
3307
|
+
}
|
|
3308
|
+
/**
|
|
3309
|
+
* Confirms whether a specific document path currently has multiple values.
|
|
3310
|
+
*
|
|
3311
|
+
* This is the narrow, path-level use of Automerge conflict inspection. It is
|
|
3312
|
+
* called only for paths surfaced by patches or by the bounded active-head scan.
|
|
3313
|
+
*
|
|
3314
|
+
* @param doc Internal document or nested object containing the candidate path.
|
|
3315
|
+
* @param path Path relative to `doc`.
|
|
3316
|
+
* @returns True when Automerge reports more than one conflicting value.
|
|
3317
|
+
*/
|
|
3318
|
+
hasDocumentConflictAtPath(doc, path) {
|
|
3319
|
+
if (path.length === 0) {
|
|
3320
|
+
return false;
|
|
3321
|
+
}
|
|
3322
|
+
const prop = path[path.length - 1];
|
|
3323
|
+
const parent = this.readValueAtDocumentPath(doc, path.slice(0, -1));
|
|
3324
|
+
if (parent === undefined || parent === null) {
|
|
3325
|
+
return false;
|
|
3326
|
+
}
|
|
3327
|
+
const conflicts = automerge_adapter_1.Automerge.getConflicts(parent, prop);
|
|
3328
|
+
return !!conflicts && Object.keys(conflicts).length > 1;
|
|
3329
|
+
}
|
|
3330
|
+
/**
|
|
3331
|
+
* Converts a conflict path into the public DTO representation.
|
|
3332
|
+
*
|
|
3333
|
+
* @param doc Internal document used to optionally resolve value summaries.
|
|
3334
|
+
* @param path JSON-style path segments for the conflicted field.
|
|
3335
|
+
* @param includeValues Whether to include winner/loser value summaries.
|
|
3336
|
+
* @returns Public path DTO with optional conflict values.
|
|
3337
|
+
*/
|
|
3338
|
+
buildDocumentConflictPath(doc, path, includeValues) {
|
|
3339
|
+
const pathString = (0, DocumentConflictAnalysis_1.formatDocumentConflictPath)(path);
|
|
3340
|
+
const result = { path: [...path], pathString };
|
|
3341
|
+
if (includeValues) {
|
|
3342
|
+
result.values = this.getDocumentConflictValueSummaries(doc, path);
|
|
3343
|
+
}
|
|
3344
|
+
return result;
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* Scans a materialized document for current conflicts.
|
|
3348
|
+
*
|
|
3349
|
+
* This is intentionally used only for bounded multi-head states: before a
|
|
3350
|
+
* resolving change is applied and after replay if unresolved active heads
|
|
3351
|
+
* remain. Regular per-change analysis stays patch-driven for performance.
|
|
3352
|
+
*
|
|
3353
|
+
* @param doc Internal materialized document to inspect.
|
|
3354
|
+
* @param includeValues Whether to include value summaries in returned paths.
|
|
3355
|
+
* @returns Sorted conflict path DTOs.
|
|
3356
|
+
*/
|
|
3357
|
+
collectDocumentConflictPaths(doc, includeValues) {
|
|
3358
|
+
const results = new Map();
|
|
3359
|
+
const seen = new WeakSet();
|
|
3360
|
+
this.collectDocumentConflictPathsFromParent(doc, doc, [], includeValues, results, seen);
|
|
3361
|
+
return Array.from(results.values()).sort((left, right) => left.pathString.localeCompare(right.pathString));
|
|
3362
|
+
}
|
|
3363
|
+
/**
|
|
3364
|
+
* Recursive worker for `collectDocumentConflictPaths()`.
|
|
3365
|
+
*
|
|
3366
|
+
* @param root Root internal document used when value summaries need absolute
|
|
3367
|
+
* paths.
|
|
3368
|
+
* @param parent Current object/array being inspected.
|
|
3369
|
+
* @param basePath Path from `root` to `parent`.
|
|
3370
|
+
* @param includeValues Whether to include value summaries.
|
|
3371
|
+
* @param results Mutable result map keyed by path string.
|
|
3372
|
+
* @param seen Object identity set used to avoid cycles/proxy revisits.
|
|
3373
|
+
*/
|
|
3374
|
+
collectDocumentConflictPathsFromParent(root, parent, basePath, includeValues, results, seen) {
|
|
3375
|
+
if (parent === null || parent === undefined || typeof parent !== "object") {
|
|
3376
|
+
return;
|
|
3377
|
+
}
|
|
3378
|
+
if (seen.has(parent)) {
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
seen.add(parent);
|
|
3382
|
+
const keys = Array.isArray(parent)
|
|
3383
|
+
? parent.map((_value, index) => index)
|
|
3384
|
+
: Object.keys(parent);
|
|
3385
|
+
for (const key of keys) {
|
|
3386
|
+
const path = [...basePath, key];
|
|
3387
|
+
if (this.isInternalConflictPath(path)) {
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
if (this.hasDocumentConflictAtPath(parent, [key])) {
|
|
3391
|
+
const conflictPath = this.buildDocumentConflictPath(root, path, includeValues);
|
|
3392
|
+
results.set(conflictPath.pathString, conflictPath);
|
|
3393
|
+
}
|
|
3394
|
+
this.collectDocumentConflictPathsFromParent(root, parent[key], path, includeValues, results, seen);
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* Reads winner/loser summaries for one conflicted path.
|
|
3399
|
+
*
|
|
3400
|
+
* Values are converted into JSON-safe summaries so callers can inspect them
|
|
3401
|
+
* without seeing Automerge-specific wrappers or mutable document objects.
|
|
3402
|
+
*
|
|
3403
|
+
* @param doc Internal document containing the conflict.
|
|
3404
|
+
* @param path Absolute path to the conflicted field.
|
|
3405
|
+
* @returns Stable list of conflict value summaries.
|
|
3406
|
+
*/
|
|
3407
|
+
getDocumentConflictValueSummaries(doc, path) {
|
|
3408
|
+
if (path.length === 0) {
|
|
3409
|
+
return [];
|
|
3410
|
+
}
|
|
3411
|
+
const prop = path[path.length - 1];
|
|
3412
|
+
const parent = this.readValueAtDocumentPath(doc, path.slice(0, -1));
|
|
3413
|
+
if (parent === undefined || parent === null) {
|
|
3414
|
+
return [];
|
|
3415
|
+
}
|
|
3416
|
+
const conflicts = automerge_adapter_1.Automerge.getConflicts(parent, prop);
|
|
3417
|
+
if (!conflicts) {
|
|
3418
|
+
return [];
|
|
3419
|
+
}
|
|
3420
|
+
const winner = this.readValueAtDocumentPath(doc, path);
|
|
3421
|
+
const winnerFingerprint = this.fingerprintConflictValue(winner);
|
|
3422
|
+
return Object.entries(conflicts)
|
|
3423
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
3424
|
+
.map(([conflictId, value]) => ({
|
|
3425
|
+
conflictId,
|
|
3426
|
+
preview: this.previewChangeValue(value),
|
|
3427
|
+
value: this.toJsonSafeConflictValue(value),
|
|
3428
|
+
isWinner: this.fingerprintConflictValue(value) === winnerFingerprint,
|
|
3429
|
+
}));
|
|
3430
|
+
}
|
|
3431
|
+
/**
|
|
3432
|
+
* Reads a nested value using the public path representation.
|
|
3433
|
+
*
|
|
3434
|
+
* @param root Root object/document to read from.
|
|
3435
|
+
* @param path String/number path segments.
|
|
3436
|
+
* @returns The value at the path, or undefined when any segment is missing.
|
|
3437
|
+
*/
|
|
3438
|
+
readValueAtDocumentPath(root, path) {
|
|
3439
|
+
let current = root;
|
|
3440
|
+
for (const segment of path) {
|
|
3441
|
+
if (current === null || current === undefined) {
|
|
3442
|
+
return undefined;
|
|
3443
|
+
}
|
|
3444
|
+
current = current[segment];
|
|
3445
|
+
}
|
|
3446
|
+
return current;
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* Produces a stable comparison key for conflict winner detection.
|
|
3450
|
+
*
|
|
3451
|
+
* @param value Value to fingerprint.
|
|
3452
|
+
* @returns String key derived from the JSON-safe representation.
|
|
3453
|
+
*/
|
|
3454
|
+
fingerprintConflictValue(value) {
|
|
3455
|
+
try {
|
|
3456
|
+
return JSON.stringify(this.toJsonSafeConflictValue(value));
|
|
3457
|
+
}
|
|
3458
|
+
catch {
|
|
3459
|
+
return String(value);
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
/**
|
|
3463
|
+
* Converts arbitrary conflict values to bounded, JSON-safe data.
|
|
3464
|
+
*
|
|
3465
|
+
* This avoids exposing live Automerge objects and prevents very deep or cyclic
|
|
3466
|
+
* structures from overwhelming reports.
|
|
3467
|
+
*
|
|
3468
|
+
* @param value Value to convert.
|
|
3469
|
+
* @param depth Current recursion depth.
|
|
3470
|
+
* @param seen Objects already visited during this conversion.
|
|
3471
|
+
* @returns JSON-safe scalar, array/object subset, or compact placeholder.
|
|
3472
|
+
*/
|
|
3473
|
+
toJsonSafeConflictValue(value, depth = 0, seen = new WeakSet()) {
|
|
3474
|
+
if (value === null
|
|
3475
|
+
|| typeof value === "string"
|
|
3476
|
+
|| typeof value === "number"
|
|
3477
|
+
|| typeof value === "boolean") {
|
|
3478
|
+
return value;
|
|
3479
|
+
}
|
|
3480
|
+
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
|
|
3481
|
+
return undefined;
|
|
3482
|
+
}
|
|
3483
|
+
if (value instanceof Uint8Array) {
|
|
3484
|
+
return { type: "Uint8Array", byteLength: value.byteLength };
|
|
3485
|
+
}
|
|
3486
|
+
if (typeof value !== "object") {
|
|
3487
|
+
return String(value);
|
|
3488
|
+
}
|
|
3489
|
+
if (seen.has(value)) {
|
|
3490
|
+
return "[Circular]";
|
|
3491
|
+
}
|
|
3492
|
+
if (depth >= 4) {
|
|
3493
|
+
return this.previewChangeValue(value);
|
|
3494
|
+
}
|
|
3495
|
+
seen.add(value);
|
|
3496
|
+
if (Array.isArray(value)) {
|
|
3497
|
+
return value.slice(0, 50).map((item) => this.toJsonSafeConflictValue(item, depth + 1, seen));
|
|
3498
|
+
}
|
|
3499
|
+
const result = {};
|
|
3500
|
+
for (const [key, child] of Object.entries(value).slice(0, 50)) {
|
|
3501
|
+
result[key] = this.toJsonSafeConflictValue(child, depth + 1, seen);
|
|
3502
|
+
}
|
|
3503
|
+
return result;
|
|
3504
|
+
}
|
|
3505
|
+
/**
|
|
3506
|
+
* Throws when the caller cancels conflict analysis.
|
|
3507
|
+
*
|
|
3508
|
+
* @param signal Optional `AbortSignal` supplied through analysis options.
|
|
3509
|
+
*/
|
|
3510
|
+
throwIfConflictAnalysisAborted(signal) {
|
|
3511
|
+
if (signal?.aborted) {
|
|
3512
|
+
throw new Error("Conflict analysis aborted");
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
/**
|
|
3516
|
+
* Cooperatively yields to the event loop during long browser/server scans.
|
|
3517
|
+
*
|
|
3518
|
+
* @param yieldEveryMs Minimum elapsed time before yielding. Undefined disables
|
|
3519
|
+
* cooperative yielding; `0` yields at every call site.
|
|
3520
|
+
* @param lastYieldAt Timestamp of the previous yield.
|
|
3521
|
+
* @returns Updated timestamp to carry into the next call.
|
|
3522
|
+
*/
|
|
3523
|
+
async maybeYieldForConflictAnalysis(yieldEveryMs, lastYieldAt) {
|
|
3524
|
+
if (yieldEveryMs === undefined || yieldEveryMs < 0) {
|
|
3525
|
+
return lastYieldAt;
|
|
3526
|
+
}
|
|
3527
|
+
const now = Date.now();
|
|
3528
|
+
if (now - lastYieldAt < yieldEveryMs) {
|
|
3529
|
+
return lastYieldAt;
|
|
3530
|
+
}
|
|
3531
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3532
|
+
return Date.now();
|
|
3533
|
+
}
|
|
3534
|
+
/**
|
|
3535
|
+
* Derives human-readable changed field paths for one DAG entry.
|
|
3536
|
+
*
|
|
3537
|
+
* This compares the branch-local document state at the selected entry with each
|
|
3538
|
+
* reachable parent state, then normalizes array indices so the UI can show stable
|
|
3539
|
+
* paths such as `_attachments[].fileName` instead of raw Automerge op keys only.
|
|
3540
|
+
*/
|
|
3541
|
+
async deriveReadableTouchedPaths(docId, entryMetadata, allEntryMetadata, touchedKeys) {
|
|
3542
|
+
if (entryMetadata.entryType === "doc_snapshot") {
|
|
3543
|
+
return [];
|
|
3544
|
+
}
|
|
3545
|
+
const currentDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, entryMetadata.id);
|
|
3546
|
+
const currentData = this.getReadableDiffValueForDoc(currentDoc);
|
|
3547
|
+
const parentEntryIds = entryMetadata.dependencyIds.filter((dependencyId) => allEntryMetadata.some((entry) => entry.id === dependencyId));
|
|
3548
|
+
const diffPaths = new Set();
|
|
3549
|
+
if (parentEntryIds.length === 0) {
|
|
3550
|
+
this.collectReadableDiffPaths(undefined, currentData, "", diffPaths);
|
|
3551
|
+
}
|
|
3552
|
+
else {
|
|
3553
|
+
for (const parentEntryId of parentEntryIds) {
|
|
3554
|
+
const parentDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, parentEntryId);
|
|
3555
|
+
const parentData = this.getReadableDiffValueForDoc(parentDoc);
|
|
3556
|
+
this.collectReadableDiffPaths(parentData, currentData, "", diffPaths);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
const normalizedPaths = Array.from(diffPaths)
|
|
3560
|
+
.map((path) => path.replace(/\[\d+\]/g, "[]"))
|
|
3561
|
+
.filter((path, index, allPaths) => path.length > 0 && allPaths.indexOf(path) === index)
|
|
3562
|
+
.sort();
|
|
3563
|
+
if (normalizedPaths.length === 0) {
|
|
3564
|
+
return [];
|
|
3565
|
+
}
|
|
3566
|
+
const touchedKeySet = new Set(touchedKeys);
|
|
3567
|
+
if (touchedKeySet.size === 0) {
|
|
3568
|
+
return normalizedPaths;
|
|
3569
|
+
}
|
|
3570
|
+
const filteredPaths = normalizedPaths.filter((path) => touchedKeySet.has(this.extractLeafPathSegment(path)));
|
|
3571
|
+
return filteredPaths.length > 0 ? filteredPaths : normalizedPaths;
|
|
3572
|
+
}
|
|
3573
|
+
async getAllDocumentIds() {
|
|
3574
|
+
// Return all non-deleted document IDs from index
|
|
3575
|
+
const docIds = [];
|
|
3576
|
+
for (const entry of this.index) {
|
|
3577
|
+
if (entry.accessState === "visible" && !entry.isDeleted) {
|
|
3578
|
+
docIds.push(entry.docId);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
return docIds;
|
|
3582
|
+
}
|
|
3583
|
+
async getDeletedDocumentIds() {
|
|
3584
|
+
const docIds = [];
|
|
3585
|
+
for (const entry of this.index) {
|
|
3586
|
+
if (entry.accessState === "visible" && entry.isDeleted) {
|
|
3587
|
+
docIds.push(entry.docId);
|
|
3588
|
+
}
|
|
3589
|
+
}
|
|
3590
|
+
return docIds;
|
|
3591
|
+
}
|
|
3592
|
+
async getAllDocumentIdsAtTimestamp(timestamp) {
|
|
3593
|
+
// findEntries() uses an exclusive upper bound (createdAt < creationDateUntil).
|
|
3594
|
+
// Query up to timestamp + 1 so entries created exactly at `timestamp` are included
|
|
3595
|
+
// for the strict checks below (createTime < timestamp, deleteTime > timestamp).
|
|
3596
|
+
let upperBoundExclusive = timestamp === Number.MAX_SAFE_INTEGER ? timestamp : timestamp + 1;
|
|
3597
|
+
if (this.timeTravelDate != null) {
|
|
3598
|
+
upperBoundExclusive = Math.min(upperBoundExclusive, this.timeTravelDate);
|
|
3599
|
+
}
|
|
3600
|
+
// We only need lifecycle metadata for this query. Changes and snapshots can
|
|
3601
|
+
// affect document contents, but create/delete/undelete determine existence.
|
|
3602
|
+
const [creates, deletes, undeletes] = await Promise.all([
|
|
3603
|
+
this.store.findEntries("doc_create", null, upperBoundExclusive),
|
|
3604
|
+
this.store.findEntries("doc_delete", null, upperBoundExclusive),
|
|
3605
|
+
this.store.findEntries("doc_undelete", null, upperBoundExclusive),
|
|
3606
|
+
]);
|
|
3607
|
+
const lifecycleEntriesByDocId = new Map();
|
|
3608
|
+
const addLifecycleEntry = (entry) => {
|
|
1986
3609
|
// Group entries by document so each document's lifecycle can be evaluated
|
|
1987
3610
|
// independently without materializing its Automerge state.
|
|
1988
3611
|
const entries = lifecycleEntriesByDocId.get(entry.docId) ?? [];
|
|
@@ -2008,6 +3631,10 @@ class BaseMindooDB {
|
|
|
2008
3631
|
if (!ordered.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
|
|
2009
3632
|
continue;
|
|
2010
3633
|
}
|
|
3634
|
+
const createEntry = ordered.find((entry) => entry.entryType === "doc_create");
|
|
3635
|
+
if (createEntry && !(await this.tenant.hasDecryptionKey(createEntry.decryptionKeyId))) {
|
|
3636
|
+
continue;
|
|
3637
|
+
}
|
|
2011
3638
|
// Delete and undelete are terminal lifecycle markers. The latest one at
|
|
2012
3639
|
// the timestamp decides whether the document existed then.
|
|
2013
3640
|
const latestTerminal = ordered
|
|
@@ -2029,6 +3656,7 @@ class BaseMindooDB {
|
|
|
2029
3656
|
return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
|
|
2030
3657
|
}
|
|
2031
3658
|
async assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword) {
|
|
3659
|
+
this.assertWritable("document lifecycle mutation");
|
|
2032
3660
|
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
2033
3661
|
if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
|
|
2034
3662
|
throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
|
|
@@ -2115,8 +3743,8 @@ class BaseMindooDB {
|
|
|
2115
3743
|
internalDoc.doc = newDoc;
|
|
2116
3744
|
internalDoc.isDeleted = entryType === "doc_delete";
|
|
2117
3745
|
internalDoc.lastModified = now;
|
|
2118
|
-
this.storeCachedDocument(internalDoc);
|
|
2119
|
-
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
|
|
3746
|
+
await this.storeCachedDocument(internalDoc);
|
|
3747
|
+
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
|
|
2120
3748
|
this.markDocDirty(docId);
|
|
2121
3749
|
}
|
|
2122
3750
|
/**
|
|
@@ -2157,9 +3785,73 @@ class BaseMindooDB {
|
|
|
2157
3785
|
async changeDocWithSigningKey(doc, changeFunc, signingKeyPair, signingKeyPassword) {
|
|
2158
3786
|
return this.changeDoc(doc, changeFunc, { signingKeyPair, signingKeyPassword });
|
|
2159
3787
|
}
|
|
2160
|
-
async applyTextPatch(doc, patch) {
|
|
3788
|
+
async applyTextPatch(doc, patch) {
|
|
3789
|
+
this.assertWritable("applyTextPatch");
|
|
3790
|
+
const docId = doc.getId();
|
|
3791
|
+
this.logger.debug(`===== applyTextPatch called for document ${docId} =====`);
|
|
3792
|
+
if (this._isAdminOnlyDb) {
|
|
3793
|
+
const adminPublicKey = this.getAdminPublicKey();
|
|
3794
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
3795
|
+
if (currentUser.userSigningPublicKey !== adminPublicKey) {
|
|
3796
|
+
throw new Error("Admin-only database: only the admin key can modify data");
|
|
3797
|
+
}
|
|
3798
|
+
}
|
|
3799
|
+
let internalDoc = this.getCachedDocument(docId);
|
|
3800
|
+
if (!internalDoc) {
|
|
3801
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
3802
|
+
if (!loadedDoc) {
|
|
3803
|
+
throw new Error(`Document ${docId} not found`);
|
|
3804
|
+
}
|
|
3805
|
+
internalDoc = loadedDoc;
|
|
3806
|
+
}
|
|
3807
|
+
if (internalDoc.isDeleted) {
|
|
3808
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
3809
|
+
}
|
|
3810
|
+
this.validateTextPatch(patch);
|
|
3811
|
+
const now = Date.now();
|
|
3812
|
+
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
3813
|
+
const baseHeads = patch.baseHeads;
|
|
3814
|
+
const applyEdits = (automergeDoc) => {
|
|
3815
|
+
this.ensureTextPath(automergeDoc, patch.path);
|
|
3816
|
+
for (const edit of patch.edits) {
|
|
3817
|
+
automerge_adapter_1.Automerge.splice(automergeDoc, patch.path, edit.index, edit.deleteCount, edit.insert ?? "");
|
|
3818
|
+
}
|
|
3819
|
+
automergeDoc._lastModified = now;
|
|
3820
|
+
};
|
|
3821
|
+
let newDoc;
|
|
3822
|
+
try {
|
|
3823
|
+
if (baseHeads && baseHeads.length > 0) {
|
|
3824
|
+
const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads, applyEdits);
|
|
3825
|
+
newDoc = result.newDoc;
|
|
3826
|
+
}
|
|
3827
|
+
else {
|
|
3828
|
+
newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyEdits);
|
|
3829
|
+
}
|
|
3830
|
+
this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3831
|
+
}
|
|
3832
|
+
catch (error) {
|
|
3833
|
+
this.logger.error(`Error applying text patch for document ${docId}:`, error);
|
|
3834
|
+
throw error;
|
|
3835
|
+
}
|
|
3836
|
+
await this.persistDocumentChange({
|
|
3837
|
+
internalDoc,
|
|
3838
|
+
newDoc,
|
|
3839
|
+
now,
|
|
3840
|
+
headsBeforeChange,
|
|
3841
|
+
useCustomKey: false,
|
|
3842
|
+
successMessage: "text patched",
|
|
3843
|
+
});
|
|
3844
|
+
const wrapped = this.wrapDocument(internalDoc);
|
|
3845
|
+
return {
|
|
3846
|
+
doc: wrapped,
|
|
3847
|
+
heads: wrapped.getHeads(),
|
|
3848
|
+
data: wrapped.getData(),
|
|
3849
|
+
};
|
|
3850
|
+
}
|
|
3851
|
+
async applyJsonPatch(doc, patch) {
|
|
3852
|
+
this.assertWritable("applyJsonPatch");
|
|
2161
3853
|
const docId = doc.getId();
|
|
2162
|
-
this.logger.debug(`=====
|
|
3854
|
+
this.logger.debug(`===== applyJsonPatch called for document ${docId} =====`);
|
|
2163
3855
|
if (this._isAdminOnlyDb) {
|
|
2164
3856
|
const adminPublicKey = this.getAdminPublicKey();
|
|
2165
3857
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
@@ -2178,30 +3870,26 @@ class BaseMindooDB {
|
|
|
2178
3870
|
if (internalDoc.isDeleted) {
|
|
2179
3871
|
throw new Error(`Document ${docId} has been deleted`);
|
|
2180
3872
|
}
|
|
2181
|
-
this.
|
|
3873
|
+
this.validateJsonPatch(patch);
|
|
2182
3874
|
const now = Date.now();
|
|
2183
3875
|
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2186
|
-
this.ensureTextPath(automergeDoc, patch.path);
|
|
2187
|
-
for (const edit of patch.edits) {
|
|
2188
|
-
automerge_adapter_1.Automerge.splice(automergeDoc, patch.path, edit.index, edit.deleteCount, edit.insert ?? "");
|
|
2189
|
-
}
|
|
3876
|
+
const applyPatch = (automergeDoc) => {
|
|
3877
|
+
this.applyJsonPatchOperations(automergeDoc, patch);
|
|
2190
3878
|
automergeDoc._lastModified = now;
|
|
2191
3879
|
};
|
|
2192
3880
|
let newDoc;
|
|
2193
3881
|
try {
|
|
2194
|
-
if (baseHeads && baseHeads.length > 0) {
|
|
2195
|
-
const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads,
|
|
3882
|
+
if (patch.baseHeads && patch.baseHeads.length > 0) {
|
|
3883
|
+
const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, patch.baseHeads, applyPatch);
|
|
2196
3884
|
newDoc = result.newDoc;
|
|
2197
3885
|
}
|
|
2198
3886
|
else {
|
|
2199
|
-
newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc,
|
|
3887
|
+
newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyPatch);
|
|
2200
3888
|
}
|
|
2201
|
-
this.logger.debug(`Successfully applied
|
|
3889
|
+
this.logger.debug(`Successfully applied JSON patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
2202
3890
|
}
|
|
2203
3891
|
catch (error) {
|
|
2204
|
-
this.logger.error(`Error applying
|
|
3892
|
+
this.logger.error(`Error applying JSON patch for document ${docId}:`, error);
|
|
2205
3893
|
throw error;
|
|
2206
3894
|
}
|
|
2207
3895
|
await this.persistDocumentChange({
|
|
@@ -2210,7 +3898,7 @@ class BaseMindooDB {
|
|
|
2210
3898
|
now,
|
|
2211
3899
|
headsBeforeChange,
|
|
2212
3900
|
useCustomKey: false,
|
|
2213
|
-
successMessage: "
|
|
3901
|
+
successMessage: "JSON patched",
|
|
2214
3902
|
});
|
|
2215
3903
|
const wrapped = this.wrapDocument(internalDoc);
|
|
2216
3904
|
return {
|
|
@@ -2220,6 +3908,7 @@ class BaseMindooDB {
|
|
|
2220
3908
|
};
|
|
2221
3909
|
}
|
|
2222
3910
|
async changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword) {
|
|
3911
|
+
this.assertWritable("changeDoc");
|
|
2223
3912
|
const docId = doc.getId();
|
|
2224
3913
|
if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
|
|
2225
3914
|
throw new Error("changeDoc: signingKeyPair and signingKeyPassword must be provided together");
|
|
@@ -2370,7 +4059,8 @@ class BaseMindooDB {
|
|
|
2370
4059
|
// If it was added in this same changeDoc call, just remove from pending
|
|
2371
4060
|
const pendingIndex = pendingAttachmentAdditions.findIndex(a => a.attachmentId === attachmentId);
|
|
2372
4061
|
if (pendingIndex >= 0) {
|
|
2373
|
-
pendingAttachmentAdditions.splice(pendingIndex, 1);
|
|
4062
|
+
const [pendingRef] = pendingAttachmentAdditions.splice(pendingIndex, 1);
|
|
4063
|
+
await db.cleanupIncompleteAttachmentUpload(docId, pendingRef.attachmentId);
|
|
2374
4064
|
}
|
|
2375
4065
|
else {
|
|
2376
4066
|
// Mark for removal from existing attachments
|
|
@@ -2427,7 +4117,13 @@ class BaseMindooDB {
|
|
|
2427
4117
|
},
|
|
2428
4118
|
};
|
|
2429
4119
|
// Execute the async callback (this may do async operations like signing)
|
|
2430
|
-
|
|
4120
|
+
try {
|
|
4121
|
+
await changeFunc(collectingDoc);
|
|
4122
|
+
}
|
|
4123
|
+
catch (error) {
|
|
4124
|
+
await Promise.all(pendingAttachmentAdditions.map((ref) => this.cleanupIncompleteAttachmentUpload(docId, ref.attachmentId)));
|
|
4125
|
+
throw error;
|
|
4126
|
+
}
|
|
2431
4127
|
// Deactivate the callback guard - no more changes can be made via collectingDoc
|
|
2432
4128
|
isCallbackActive = false;
|
|
2433
4129
|
// Now apply the collected changes synchronously in Automerge.change()
|
|
@@ -2477,18 +4173,27 @@ class BaseMindooDB {
|
|
|
2477
4173
|
}
|
|
2478
4174
|
catch (error) {
|
|
2479
4175
|
this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
|
|
4176
|
+
await Promise.all(pendingAttachmentAdditions.map((ref) => this.cleanupIncompleteAttachmentUpload(docId, ref.attachmentId)));
|
|
2480
4177
|
throw error;
|
|
2481
4178
|
}
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
4179
|
+
try {
|
|
4180
|
+
await this.persistDocumentChange({
|
|
4181
|
+
internalDoc,
|
|
4182
|
+
newDoc,
|
|
4183
|
+
now,
|
|
4184
|
+
headsBeforeChange,
|
|
4185
|
+
useCustomKey: Boolean(useCustomKey),
|
|
4186
|
+
signingKeyPair,
|
|
4187
|
+
signingKeyPassword,
|
|
4188
|
+
successMessage: useCustomKey ? "changed with custom signing key" : "changed",
|
|
4189
|
+
attachmentIds: pendingAttachmentAdditions.map((ref) => ref.attachmentId),
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
catch (error) {
|
|
4193
|
+
await Promise.all(pendingAttachmentAdditions.map((ref) => this.cleanupIncompleteAttachmentUpload(docId, ref.attachmentId)));
|
|
4194
|
+
throw error;
|
|
4195
|
+
}
|
|
4196
|
+
await Promise.all(pendingAttachmentAdditions.map((ref) => this.clearPendingAttachmentUploadLedger(ref.attachmentId)));
|
|
2492
4197
|
}
|
|
2493
4198
|
validateTextPatch(patch) {
|
|
2494
4199
|
if (!Array.isArray(patch.path) || patch.path.length === 0) {
|
|
@@ -2514,6 +4219,108 @@ class BaseMindooDB {
|
|
|
2514
4219
|
}
|
|
2515
4220
|
}
|
|
2516
4221
|
}
|
|
4222
|
+
validateJsonPatch(patch) {
|
|
4223
|
+
const operationCount = (patch.set?.length ?? 0)
|
|
4224
|
+
+ (patch.unset?.length ?? 0)
|
|
4225
|
+
+ (patch.listDelete?.length ?? 0)
|
|
4226
|
+
+ (patch.listInsert?.length ?? 0);
|
|
4227
|
+
if (operationCount === 0) {
|
|
4228
|
+
throw new Error("JSON patch must include at least one operation");
|
|
4229
|
+
}
|
|
4230
|
+
for (const operation of patch.set ?? []) {
|
|
4231
|
+
this.validateJsonPath(operation.path, "JSON set");
|
|
4232
|
+
}
|
|
4233
|
+
for (const operation of patch.unset ?? []) {
|
|
4234
|
+
this.validateJsonPath(operation.path, "JSON unset");
|
|
4235
|
+
}
|
|
4236
|
+
for (const operation of patch.listDelete ?? []) {
|
|
4237
|
+
this.validateJsonPath(operation.path, "JSON listDelete");
|
|
4238
|
+
if (!Number.isInteger(operation.index) || operation.index < 0) {
|
|
4239
|
+
throw new Error("JSON listDelete index must be a non-negative integer");
|
|
4240
|
+
}
|
|
4241
|
+
if (!Number.isInteger(operation.deleteCount) || operation.deleteCount < 0) {
|
|
4242
|
+
throw new Error("JSON listDelete deleteCount must be a non-negative integer");
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
for (const operation of patch.listInsert ?? []) {
|
|
4246
|
+
this.validateJsonPath(operation.path, "JSON listInsert");
|
|
4247
|
+
if (!Number.isInteger(operation.index) || operation.index < 0) {
|
|
4248
|
+
throw new Error("JSON listInsert index must be a non-negative integer");
|
|
4249
|
+
}
|
|
4250
|
+
if (!Array.isArray(operation.values)) {
|
|
4251
|
+
throw new Error("JSON listInsert values must be an array");
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
validateJsonPath(path, label) {
|
|
4256
|
+
if (!Array.isArray(path) || path.length === 0) {
|
|
4257
|
+
throw new Error(`${label} path must contain at least one segment`);
|
|
4258
|
+
}
|
|
4259
|
+
for (const segment of path) {
|
|
4260
|
+
if (typeof segment !== "string" && typeof segment !== "number") {
|
|
4261
|
+
throw new Error(`${label} path segments must be strings or numbers`);
|
|
4262
|
+
}
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
applyJsonPatchOperations(automergeDoc, patch) {
|
|
4266
|
+
for (const operation of patch.set ?? []) {
|
|
4267
|
+
this.setJsonValueAtPath(automergeDoc, operation.path, structuredClone(operation.value));
|
|
4268
|
+
}
|
|
4269
|
+
for (const operation of patch.unset ?? []) {
|
|
4270
|
+
this.unsetJsonValueAtPath(automergeDoc, operation.path);
|
|
4271
|
+
}
|
|
4272
|
+
for (const operation of patch.listDelete ?? []) {
|
|
4273
|
+
const list = this.readJsonListAtPath(automergeDoc, operation.path);
|
|
4274
|
+
list.splice(operation.index, operation.deleteCount);
|
|
4275
|
+
}
|
|
4276
|
+
for (const operation of patch.listInsert ?? []) {
|
|
4277
|
+
const list = this.readJsonListAtPath(automergeDoc, operation.path);
|
|
4278
|
+
list.splice(operation.index, 0, ...structuredClone(operation.values));
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
setJsonValueAtPath(target, path, value) {
|
|
4282
|
+
const parent = this.ensureJsonParentAtPath(target, path);
|
|
4283
|
+
parent[path[path.length - 1]] = value;
|
|
4284
|
+
}
|
|
4285
|
+
unsetJsonValueAtPath(target, path) {
|
|
4286
|
+
const parent = this.readJsonParentAtPath(target, path);
|
|
4287
|
+
delete parent[path[path.length - 1]];
|
|
4288
|
+
}
|
|
4289
|
+
readJsonListAtPath(target, path) {
|
|
4290
|
+
let value = target;
|
|
4291
|
+
for (const segment of path) {
|
|
4292
|
+
value = value?.[segment];
|
|
4293
|
+
}
|
|
4294
|
+
if (!Array.isArray(value)) {
|
|
4295
|
+
throw new Error(`Cannot apply JSON list operation to non-array value at '${path.map(String).join(".")}'`);
|
|
4296
|
+
}
|
|
4297
|
+
return value;
|
|
4298
|
+
}
|
|
4299
|
+
ensureJsonParentAtPath(target, path) {
|
|
4300
|
+
let parent = target;
|
|
4301
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
4302
|
+
const segment = path[index];
|
|
4303
|
+
const nextSegment = path[index + 1];
|
|
4304
|
+
if (parent[segment] === undefined || parent[segment] === null) {
|
|
4305
|
+
parent[segment] = typeof nextSegment === "number" ? [] : {};
|
|
4306
|
+
}
|
|
4307
|
+
parent = parent[segment];
|
|
4308
|
+
if (parent === null || typeof parent !== "object") {
|
|
4309
|
+
throw new Error(`Cannot apply JSON patch through non-object path segment '${String(segment)}'`);
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
return parent;
|
|
4313
|
+
}
|
|
4314
|
+
readJsonParentAtPath(target, path) {
|
|
4315
|
+
let parent = target;
|
|
4316
|
+
for (let index = 0; index < path.length - 1; index += 1) {
|
|
4317
|
+
parent = parent?.[path[index]];
|
|
4318
|
+
if (parent === null || typeof parent !== "object") {
|
|
4319
|
+
throw new Error(`Cannot resolve JSON patch path '${path.map(String).join(".")}'`);
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
4322
|
+
return parent;
|
|
4323
|
+
}
|
|
2517
4324
|
ensureTextPath(automergeDoc, path) {
|
|
2518
4325
|
let target = automergeDoc;
|
|
2519
4326
|
for (let i = 0; i < path.length - 1; i++) {
|
|
@@ -2538,7 +4345,7 @@ class BaseMindooDB {
|
|
|
2538
4345
|
}
|
|
2539
4346
|
}
|
|
2540
4347
|
async persistDocumentChange(options) {
|
|
2541
|
-
const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage } = options;
|
|
4348
|
+
const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds } = options;
|
|
2542
4349
|
const docId = internalDoc.id;
|
|
2543
4350
|
this.logger.debug(`Getting change bytes from document ${docId}`);
|
|
2544
4351
|
const changesSincePreviousHeads = headsBeforeChange
|
|
@@ -2586,6 +4393,7 @@ class BaseMindooDB {
|
|
|
2586
4393
|
signature,
|
|
2587
4394
|
originalSize: changeBytes.length,
|
|
2588
4395
|
encryptedSize: encryptedPayload.length,
|
|
4396
|
+
attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined,
|
|
2589
4397
|
};
|
|
2590
4398
|
const fullEntry = {
|
|
2591
4399
|
...entryMetadata,
|
|
@@ -2595,8 +4403,8 @@ class BaseMindooDB {
|
|
|
2595
4403
|
this.registerAutomergeHashMapping(docId, automergeHash, entryId);
|
|
2596
4404
|
internalDoc.doc = newDoc;
|
|
2597
4405
|
internalDoc.lastModified = now;
|
|
2598
|
-
this.storeCachedDocument(internalDoc);
|
|
2599
|
-
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
|
|
4406
|
+
await this.storeCachedDocument(internalDoc);
|
|
4407
|
+
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
|
|
2600
4408
|
this.markDocDirty(docId);
|
|
2601
4409
|
this.logger.info(`Document ${docId} ${successMessage} successfully`);
|
|
2602
4410
|
await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
|
|
@@ -2672,7 +4480,11 @@ class BaseMindooDB {
|
|
|
2672
4480
|
// Only look ahead a bounded number of uncached docs so iteration does not
|
|
2673
4481
|
// materialize the entire remaining changefeed tail up front.
|
|
2674
4482
|
for (let i = startIndex; i < indexSnapshot.length && docIds.length < this.iteratePrefetchWindowDocs; i++) {
|
|
2675
|
-
const
|
|
4483
|
+
const entry = indexSnapshot[i];
|
|
4484
|
+
if (entry.accessState === "inaccessible") {
|
|
4485
|
+
continue;
|
|
4486
|
+
}
|
|
4487
|
+
const docId = entry.docId;
|
|
2676
4488
|
if (seen.has(docId) || this.docCache.has(docId)) {
|
|
2677
4489
|
continue;
|
|
2678
4490
|
}
|
|
@@ -3046,6 +4858,9 @@ class BaseMindooDB {
|
|
|
3046
4858
|
// Iterate through the stable snapshot and yield documents one at a time.
|
|
3047
4859
|
for (let i = startIndex; i < indexSnapshot.length; i++) {
|
|
3048
4860
|
const entry = indexSnapshot[i];
|
|
4861
|
+
if (entry.accessState !== "visible") {
|
|
4862
|
+
continue;
|
|
4863
|
+
}
|
|
3049
4864
|
try {
|
|
3050
4865
|
this.logger.debug(`Yielding document ${entry.docId} from index (lastModified: ${entry.lastModified}, isDeleted: ${entry.isDeleted})`);
|
|
3051
4866
|
let internalDoc = this.getCachedDocument(entry.docId);
|
|
@@ -3220,20 +5035,381 @@ class BaseMindooDB {
|
|
|
3220
5035
|
isDeleted,
|
|
3221
5036
|
};
|
|
3222
5037
|
// Update cache
|
|
3223
|
-
this.storeCachedDocument(updatedInternalDoc);
|
|
5038
|
+
await this.storeCachedDocument(updatedInternalDoc);
|
|
3224
5039
|
this.markDocDirty(docId);
|
|
3225
5040
|
return updatedInternalDoc;
|
|
3226
5041
|
}
|
|
5042
|
+
/**
|
|
5043
|
+
* Try to satisfy a document load from the persisted L2 cache without
|
|
5044
|
+
* going through the full signature-verify + decrypt + Automerge replay
|
|
5045
|
+
* pipeline.
|
|
5046
|
+
*
|
|
5047
|
+
* Three outcomes:
|
|
5048
|
+
*
|
|
5049
|
+
* 1. **L2 miss / unusable** (no record, legacy v1 record, deserialize
|
|
5050
|
+
* failure, no `cacheManager`, etc.): returns `null`. The caller
|
|
5051
|
+
* falls through to the existing full-materialization path.
|
|
5052
|
+
*
|
|
5053
|
+
* 2. **L2 fresh hit** (`persistedChangeSeq === currentChangeSeq`):
|
|
5054
|
+
* promote the deserialized doc to L1 and return it. No store
|
|
5055
|
+
* traffic, no crypto, no Automerge replay. This is the common case
|
|
5056
|
+
* for warm databases and is what makes view rebuilds fast.
|
|
5057
|
+
*
|
|
5058
|
+
* 3. **L2 stale hit** (`persistedChangeSeq < currentChangeSeq`): scan
|
|
5059
|
+
* the doc's store metadata, identify entries whose `automergeHash`
|
|
5060
|
+
* is *not* already in the persisted doc's history, and re-use the
|
|
5061
|
+
* existing {@link applyNewEntriesToCachedDocument} pipeline to
|
|
5062
|
+
* verify+decrypt+apply only those deltas. Pre-filtering by hash
|
|
5063
|
+
* means we never pay signature/decrypt cost for changes already
|
|
5064
|
+
* captured in the persisted Automerge state.
|
|
5065
|
+
*
|
|
5066
|
+
* Any unexpected error degrades gracefully to the full path - the L2
|
|
5067
|
+
* read path is purely an optimization and must never lose data.
|
|
5068
|
+
*/
|
|
5069
|
+
async tryLoadFromL2(docId) {
|
|
5070
|
+
const store = this.cacheManager?.getStore() ?? null;
|
|
5071
|
+
if (!store)
|
|
5072
|
+
return null;
|
|
5073
|
+
const prefix = this.getCachePrefix();
|
|
5074
|
+
const key = `${prefix}/${docId}`;
|
|
5075
|
+
let bytes;
|
|
5076
|
+
try {
|
|
5077
|
+
bytes = await store.get("doc", key);
|
|
5078
|
+
}
|
|
5079
|
+
catch (e) {
|
|
5080
|
+
this.logger.warn(`L2 read failed for ${docId}; falling back to full materialization: ${e}`);
|
|
5081
|
+
return null;
|
|
5082
|
+
}
|
|
5083
|
+
if (!bytes)
|
|
5084
|
+
return null;
|
|
5085
|
+
let deserialized;
|
|
5086
|
+
try {
|
|
5087
|
+
deserialized = this.deserializeDoc(bytes);
|
|
5088
|
+
}
|
|
5089
|
+
catch (e) {
|
|
5090
|
+
this.logger.warn(`L2 deserialize failed for ${docId}, evicting stale record: ${e}`);
|
|
5091
|
+
try {
|
|
5092
|
+
await store.delete("doc", key);
|
|
5093
|
+
}
|
|
5094
|
+
catch (deleteError) {
|
|
5095
|
+
this.logger.warn(`Failed to evict corrupt L2 record for ${docId}: ${deleteError}`);
|
|
5096
|
+
}
|
|
5097
|
+
return null;
|
|
5098
|
+
}
|
|
5099
|
+
if (deserialized === null) {
|
|
5100
|
+
// Legacy v1 record - fall through; the full path will rewrite a v2 record.
|
|
5101
|
+
return null;
|
|
5102
|
+
}
|
|
5103
|
+
const { internal, persistedChangeSeq } = deserialized;
|
|
5104
|
+
// L2 records persist the decrypted Automerge state. If the current
|
|
5105
|
+
// KeyBag cannot resolve the doc's key, scrub the cached copy and
|
|
5106
|
+
// refuse to surface it instead of returning plaintext for which the
|
|
5107
|
+
// caller no longer has authorization.
|
|
5108
|
+
if (!(await this.tenant.hasDecryptionKey(internal.decryptionKeyId))) {
|
|
5109
|
+
await this.purgeMaterializedDocument(docId);
|
|
5110
|
+
return null;
|
|
5111
|
+
}
|
|
5112
|
+
const indexEntryIdx = this.indexLookup.get(docId);
|
|
5113
|
+
if (indexEntryIdx === undefined) {
|
|
5114
|
+
// Doc isn't in the in-memory changefeed index. Either the cache is
|
|
5115
|
+
// ahead of the index (very rare race during initial restore) or the
|
|
5116
|
+
// L2 record is orphaned. Drop and fall through.
|
|
5117
|
+
try {
|
|
5118
|
+
await store.delete("doc", key);
|
|
5119
|
+
}
|
|
5120
|
+
catch (deleteError) {
|
|
5121
|
+
this.logger.warn(`Failed to evict orphaned L2 record for ${docId}: ${deleteError}`);
|
|
5122
|
+
}
|
|
5123
|
+
return null;
|
|
5124
|
+
}
|
|
5125
|
+
const currentChangeSeq = this.index[indexEntryIdx].changeSeq;
|
|
5126
|
+
// Fresh hit: index agrees with the persisted state.
|
|
5127
|
+
if (persistedChangeSeq === currentChangeSeq) {
|
|
5128
|
+
await this.storeCachedDocument(internal);
|
|
5129
|
+
return internal;
|
|
5130
|
+
}
|
|
5131
|
+
// L2 newer than index: should not happen in a well-behaved system,
|
|
5132
|
+
// but trust the persisted record and re-mark dirty so the next flush
|
|
5133
|
+
// re-anchors the changeSeq pointer.
|
|
5134
|
+
if (persistedChangeSeq > currentChangeSeq) {
|
|
5135
|
+
this.logger.warn(`L2 record for ${docId} reports changeSeq ${persistedChangeSeq} > index changeSeq ${currentChangeSeq}; trusting L2`);
|
|
5136
|
+
await this.storeCachedDocument(internal);
|
|
5137
|
+
this.markDocDirty(docId);
|
|
5138
|
+
return internal;
|
|
5139
|
+
}
|
|
5140
|
+
// Stale L2. Identify the entries the persisted doc is missing and
|
|
5141
|
+
// apply only those via the standard incremental path.
|
|
5142
|
+
let allMetadata;
|
|
5143
|
+
try {
|
|
5144
|
+
allMetadata = await this.scanAllMetadata(this.store, { docId });
|
|
5145
|
+
}
|
|
5146
|
+
catch (e) {
|
|
5147
|
+
this.logger.warn(`L2 stale-doc metadata scan failed for ${docId}: ${e}`);
|
|
5148
|
+
return null;
|
|
5149
|
+
}
|
|
5150
|
+
// Enumerate Automerge change hashes already in the persisted doc.
|
|
5151
|
+
const persistedHashes = new Set();
|
|
5152
|
+
try {
|
|
5153
|
+
for (const ch of automerge_adapter_1.Automerge.getAllChanges(internal.doc)) {
|
|
5154
|
+
const decoded = automerge_adapter_1.Automerge.decodeChange(ch);
|
|
5155
|
+
persistedHashes.add(decoded.hash);
|
|
5156
|
+
}
|
|
5157
|
+
}
|
|
5158
|
+
catch (e) {
|
|
5159
|
+
this.logger.warn(`Failed to enumerate persisted changes for ${docId}: ${e}`);
|
|
5160
|
+
return null;
|
|
5161
|
+
}
|
|
5162
|
+
const missingMetadata = allMetadata.filter((em) => {
|
|
5163
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(em.id);
|
|
5164
|
+
if (!parsed)
|
|
5165
|
+
return true;
|
|
5166
|
+
return !persistedHashes.has(parsed.automergeHash);
|
|
5167
|
+
});
|
|
5168
|
+
if (missingMetadata.length === 0) {
|
|
5169
|
+
// No real deltas (changeSeq pointer drift only). Promote and return.
|
|
5170
|
+
await this.storeCachedDocument(internal);
|
|
5171
|
+
return internal;
|
|
5172
|
+
}
|
|
5173
|
+
let updated;
|
|
5174
|
+
try {
|
|
5175
|
+
updated = await this.applyNewEntriesToCachedDocument(internal, missingMetadata);
|
|
5176
|
+
}
|
|
5177
|
+
catch (e) {
|
|
5178
|
+
this.logger.warn(`L2 incremental apply failed for ${docId}: ${e}`);
|
|
5179
|
+
return null;
|
|
5180
|
+
}
|
|
5181
|
+
if (updated === null) {
|
|
5182
|
+
// applyNewEntriesToCachedDocument returns null when no signed entries
|
|
5183
|
+
// produced a head change. Treat the persisted doc as authoritative
|
|
5184
|
+
// and put it in L1 ourselves (the helper only caches when it
|
|
5185
|
+
// actually applied work).
|
|
5186
|
+
await this.storeCachedDocument(internal);
|
|
5187
|
+
return internal;
|
|
5188
|
+
}
|
|
5189
|
+
return updated;
|
|
5190
|
+
}
|
|
5191
|
+
// ---------------------------------------------------------------------------
|
|
5192
|
+
// Background L2 warmer (Phase 4)
|
|
5193
|
+
// ---------------------------------------------------------------------------
|
|
5194
|
+
/**
|
|
5195
|
+
* Returns whether a background warmer pass is currently running.
|
|
5196
|
+
*
|
|
5197
|
+
* @see startBackgroundWarmer
|
|
5198
|
+
* @see stopBackgroundWarmer
|
|
5199
|
+
*/
|
|
5200
|
+
isWarmerRunning() {
|
|
5201
|
+
return this.warmerPromise !== null;
|
|
5202
|
+
}
|
|
5203
|
+
/**
|
|
5204
|
+
* Start the L2 background warmer.
|
|
5205
|
+
*
|
|
5206
|
+
* Walks a snapshot of the in-memory document index and, for every
|
|
5207
|
+
* doc not already in L1, calls {@link tryLoadFromL2}. That path
|
|
5208
|
+
* either:
|
|
5209
|
+
*
|
|
5210
|
+
* - hits L2 fresh and just promotes the doc to L1, or
|
|
5211
|
+
* - hits L2 stale and applies missing deltas (re-flushed via the
|
|
5212
|
+
* next CacheManager flush), or
|
|
5213
|
+
* - falls through to full materialization for L2 misses, which then
|
|
5214
|
+
* flushes a fresh L2 record via flush-before-evict.
|
|
5215
|
+
*
|
|
5216
|
+
* In all cases, after the warmer has visited every doc, every L2
|
|
5217
|
+
* record has the current `changeSeq + automergeHeads`. Subsequent
|
|
5218
|
+
* read-heavy workloads (most importantly virtual view rebuilds) can
|
|
5219
|
+
* then satisfy every doc lookup via the cheap L2 read path.
|
|
5220
|
+
*
|
|
5221
|
+
* Single-flight: if a warmer is already running, the existing promise
|
|
5222
|
+
* is returned. Yields to {@link warmerScheduler} between batches of
|
|
5223
|
+
* `warmerBatchSize` docs so the foreground stays responsive.
|
|
5224
|
+
*
|
|
5225
|
+
* Cancellable via {@link stopBackgroundWarmer} or by passing an
|
|
5226
|
+
* external {@link AbortSignal}. Cancellation is observed at batch
|
|
5227
|
+
* boundaries; the in-flight doc is allowed to complete.
|
|
5228
|
+
*/
|
|
5229
|
+
startBackgroundWarmer(options) {
|
|
5230
|
+
if (this.warmerPromise) {
|
|
5231
|
+
// Single-flight: return the in-flight promise. We deliberately do
|
|
5232
|
+
// not honor a new external signal in this case - the caller can
|
|
5233
|
+
// either stopBackgroundWarmer() to kill the existing run and
|
|
5234
|
+
// re-start, or wait for the in-flight pass to finish.
|
|
5235
|
+
return this.warmerPromise;
|
|
5236
|
+
}
|
|
5237
|
+
const internalAbort = new AbortController();
|
|
5238
|
+
this.warmerAbort = internalAbort;
|
|
5239
|
+
// If the caller passed an external signal, propagate aborts.
|
|
5240
|
+
const externalSignal = options?.signal;
|
|
5241
|
+
let externalListener = null;
|
|
5242
|
+
if (externalSignal) {
|
|
5243
|
+
if (externalSignal.aborted) {
|
|
5244
|
+
internalAbort.abort();
|
|
5245
|
+
}
|
|
5246
|
+
else {
|
|
5247
|
+
externalListener = () => internalAbort.abort();
|
|
5248
|
+
externalSignal.addEventListener("abort", externalListener);
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
const run = this.runBackgroundWarmer(internalAbort.signal, options?.onProgress).finally(() => {
|
|
5252
|
+
this.warmerPromise = null;
|
|
5253
|
+
this.warmerAbort = null;
|
|
5254
|
+
if (externalSignal && externalListener) {
|
|
5255
|
+
externalSignal.removeEventListener("abort", externalListener);
|
|
5256
|
+
}
|
|
5257
|
+
});
|
|
5258
|
+
this.warmerPromise = run;
|
|
5259
|
+
return run;
|
|
5260
|
+
}
|
|
5261
|
+
/**
|
|
5262
|
+
* Returns a snapshot of the most recent background warmer pass's
|
|
5263
|
+
* progress, or `null` if no warmer has ever run on this instance.
|
|
5264
|
+
*
|
|
5265
|
+
* The returned object is the live snapshot used internally by
|
|
5266
|
+
* {@link runBackgroundWarmer}; callers MUST NOT mutate it. The snapshot
|
|
5267
|
+
* is replaced (not mutated in place) on the next
|
|
5268
|
+
* {@link startBackgroundWarmer} call, so consumers polling this
|
|
5269
|
+
* method see consistent integer readings.
|
|
5270
|
+
*
|
|
5271
|
+
* @see startBackgroundWarmer
|
|
5272
|
+
*/
|
|
5273
|
+
getBackgroundWarmerProgress() {
|
|
5274
|
+
return this.warmerProgress;
|
|
5275
|
+
}
|
|
5276
|
+
/**
|
|
5277
|
+
* Cancel the in-flight warmer (if any) and wait for it to settle.
|
|
5278
|
+
* Safe to call when no warmer is running - returns a resolved
|
|
5279
|
+
* promise immediately.
|
|
5280
|
+
*/
|
|
5281
|
+
async stopBackgroundWarmer() {
|
|
5282
|
+
const inFlight = this.warmerPromise;
|
|
5283
|
+
if (!inFlight)
|
|
5284
|
+
return;
|
|
5285
|
+
this.warmerAbort?.abort();
|
|
5286
|
+
try {
|
|
5287
|
+
await inFlight;
|
|
5288
|
+
}
|
|
5289
|
+
catch {
|
|
5290
|
+
// Errors are already logged inside the warmer body. Swallow here
|
|
5291
|
+
// so callers waiting only for "warmer is no longer running" do
|
|
5292
|
+
// not have to wrap stopBackgroundWarmer in try/catch.
|
|
5293
|
+
}
|
|
5294
|
+
}
|
|
5295
|
+
/**
|
|
5296
|
+
* Body of one warmer pass. Pulled out so {@link startBackgroundWarmer}
|
|
5297
|
+
* can wrap it in single-flight + abort plumbing without nesting.
|
|
5298
|
+
*
|
|
5299
|
+
* Maintains `this.warmerProgress` as a fresh snapshot for
|
|
5300
|
+
* {@link getBackgroundWarmerProgress} consumers and invokes
|
|
5301
|
+
* `onProgress` once per scheduler yield (per `warmerBatchSize` docs)
|
|
5302
|
+
* plus once at the end of the pass with the terminal phase.
|
|
5303
|
+
*/
|
|
5304
|
+
async runBackgroundWarmer(signal, onProgress) {
|
|
5305
|
+
if (!this.cacheManager) {
|
|
5306
|
+
this.logger.debug("Warmer skipped: no cacheManager attached, no L2 to warm.");
|
|
5307
|
+
return;
|
|
5308
|
+
}
|
|
5309
|
+
// Snapshot the docId list so concurrent index updates don't shift
|
|
5310
|
+
// our iteration mid-pass. Iterating the index directly would also
|
|
5311
|
+
// work but a snapshot makes the loop's behavior easier to reason
|
|
5312
|
+
// about under sync activity.
|
|
5313
|
+
const docIds = this.index.map((entry) => entry.docId);
|
|
5314
|
+
const total = docIds.length;
|
|
5315
|
+
// Progress snapshot is replaced (not mutated in place) so polled
|
|
5316
|
+
// readers see consistent values. We initialize even when total=0
|
|
5317
|
+
// so the UI can display a "done" terminal state.
|
|
5318
|
+
this.warmerProgress = { processed: 0, total, phase: "warming" };
|
|
5319
|
+
const emitProgress = (progress) => {
|
|
5320
|
+
this.warmerProgress = progress;
|
|
5321
|
+
if (!onProgress)
|
|
5322
|
+
return;
|
|
5323
|
+
try {
|
|
5324
|
+
onProgress(progress);
|
|
5325
|
+
}
|
|
5326
|
+
catch (e) {
|
|
5327
|
+
// A buggy progress consumer must not break the warmer.
|
|
5328
|
+
this.logger.warn(`Warmer onProgress callback threw: ${e}`);
|
|
5329
|
+
}
|
|
5330
|
+
};
|
|
5331
|
+
if (total === 0) {
|
|
5332
|
+
this.logger.debug("Warmer: nothing to warm, index is empty.");
|
|
5333
|
+
emitProgress({ processed: 0, total: 0, phase: "done" });
|
|
5334
|
+
return;
|
|
5335
|
+
}
|
|
5336
|
+
const startedAt = Date.now();
|
|
5337
|
+
let processed = 0;
|
|
5338
|
+
let warmed = 0;
|
|
5339
|
+
let skippedAlreadyHot = 0;
|
|
5340
|
+
let errored = 0;
|
|
5341
|
+
try {
|
|
5342
|
+
for (const docId of docIds) {
|
|
5343
|
+
if (signal.aborted) {
|
|
5344
|
+
this.logger.info(`Warmer: aborted after ${processed}/${total} docs (warmed=${warmed}, skipped=${skippedAlreadyHot})`);
|
|
5345
|
+
emitProgress({ processed, total, phase: "cancelled" });
|
|
5346
|
+
return;
|
|
5347
|
+
}
|
|
5348
|
+
processed++;
|
|
5349
|
+
if (this.docCache.has(docId)) {
|
|
5350
|
+
skippedAlreadyHot++;
|
|
5351
|
+
}
|
|
5352
|
+
else {
|
|
5353
|
+
try {
|
|
5354
|
+
const fromL2 = await this.tryLoadFromL2(docId);
|
|
5355
|
+
if (fromL2 === null) {
|
|
5356
|
+
// L2 miss -> full materialization. flush-before-evict on
|
|
5357
|
+
// the next L1 churn will turn this into an L2 record so
|
|
5358
|
+
// future reads are fast.
|
|
5359
|
+
await this.loadDocumentInternal(docId);
|
|
5360
|
+
}
|
|
5361
|
+
warmed++;
|
|
5362
|
+
}
|
|
5363
|
+
catch (e) {
|
|
5364
|
+
errored++;
|
|
5365
|
+
this.logger.warn(`Warmer: failed to warm doc ${docId}: ${e}`);
|
|
5366
|
+
}
|
|
5367
|
+
}
|
|
5368
|
+
if (processed % this.warmerBatchSize === 0) {
|
|
5369
|
+
// Emit progress BEFORE yielding so the UI updates while we
|
|
5370
|
+
// are off the critical path. Use the still-warming phase
|
|
5371
|
+
// since the loop may still have docs left.
|
|
5372
|
+
emitProgress({ processed, total, phase: "warming" });
|
|
5373
|
+
await this.warmerScheduler.yield();
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
this.logger.info(`Warmer finished in ${Date.now() - startedAt}ms: processed=${processed}, ` +
|
|
5377
|
+
`warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, errored=${errored}`);
|
|
5378
|
+
emitProgress({ processed, total, phase: "done" });
|
|
5379
|
+
}
|
|
5380
|
+
catch (e) {
|
|
5381
|
+
// Defensive: any unexpected error is logged and swallowed - the
|
|
5382
|
+
// warmer is purely an optimization. Surface the partial progress
|
|
5383
|
+
// as `cancelled` since the pass did not complete normally.
|
|
5384
|
+
this.logger.warn(`Warmer crashed: ${e}`);
|
|
5385
|
+
emitProgress({ processed, total, phase: "cancelled" });
|
|
5386
|
+
}
|
|
5387
|
+
}
|
|
3227
5388
|
/**
|
|
3228
5389
|
* Internal method to load a document from the content-addressed store
|
|
3229
5390
|
*/
|
|
3230
5391
|
async loadDocumentInternal(docId) {
|
|
3231
5392
|
const startedAt = Date.now();
|
|
3232
5393
|
const cacheCheckStartedAt = Date.now();
|
|
3233
|
-
//
|
|
5394
|
+
// Short-circuit on the visibility layer: a doc marked
|
|
5395
|
+
// `"inaccessible"` is logically absent for this database, regardless
|
|
5396
|
+
// of whether L1/L2 still hold a cached copy. This keeps the public
|
|
5397
|
+
// contract of `loadDocumentInternal` aligned with `getDocument` /
|
|
5398
|
+
// `getAllDocumentIds`.
|
|
5399
|
+
const indexEntryIdx = this.indexLookup.get(docId);
|
|
5400
|
+
if (indexEntryIdx !== undefined && this.index[indexEntryIdx].accessState === "inaccessible") {
|
|
5401
|
+
return null;
|
|
5402
|
+
}
|
|
3234
5403
|
if (this.docCache.has(docId)) {
|
|
3235
5404
|
this.logger.debug(`Document ${docId} found in cache, returning cached version`);
|
|
3236
5405
|
const cached = this.getCachedDocument(docId);
|
|
5406
|
+
// Belt-and-braces: even if the index says visible, recheck the
|
|
5407
|
+
// KeyBag before returning plaintext. Catches races where a key
|
|
5408
|
+
// was just removed but the index has not been reconciled yet.
|
|
5409
|
+
if (!(await this.tenant.hasDecryptionKey(cached.decryptionKeyId))) {
|
|
5410
|
+
await this.purgeMaterializedDocument(docId);
|
|
5411
|
+
return null;
|
|
5412
|
+
}
|
|
3237
5413
|
this.performanceCallback?.onDocumentLoad?.({
|
|
3238
5414
|
docId,
|
|
3239
5415
|
cacheHit: true,
|
|
@@ -3250,6 +5426,28 @@ class BaseMindooDB {
|
|
|
3250
5426
|
});
|
|
3251
5427
|
return cached;
|
|
3252
5428
|
}
|
|
5429
|
+
// L1 missed. Try the persistent L2 cache before falling through to
|
|
5430
|
+
// the full signature-verify + decrypt + Automerge-replay pipeline.
|
|
5431
|
+
// The L2 path either returns a ready-to-use InternalDoc (already
|
|
5432
|
+
// promoted into L1) or null - in which case we proceed below.
|
|
5433
|
+
const l2Doc = await this.tryLoadFromL2(docId);
|
|
5434
|
+
if (l2Doc !== null) {
|
|
5435
|
+
this.performanceCallback?.onDocumentLoad?.({
|
|
5436
|
+
docId,
|
|
5437
|
+
cacheHit: true,
|
|
5438
|
+
metadataEntriesScanned: 0,
|
|
5439
|
+
replayEntriesLoaded: 0,
|
|
5440
|
+
snapshotUsed: false,
|
|
5441
|
+
cacheCheckTime: Date.now() - cacheCheckStartedAt,
|
|
5442
|
+
storeQueryTime: 0,
|
|
5443
|
+
entryLoadTime: 0,
|
|
5444
|
+
signatureVerificationTime: 0,
|
|
5445
|
+
decryptionTime: 0,
|
|
5446
|
+
automergeTime: 0,
|
|
5447
|
+
totalTime: Date.now() - startedAt,
|
|
5448
|
+
});
|
|
5449
|
+
return l2Doc;
|
|
5450
|
+
}
|
|
3253
5451
|
this.logger.debug(`===== Starting to load document ${docId} from store =====`);
|
|
3254
5452
|
const cacheCheckTime = Date.now() - cacheCheckStartedAt;
|
|
3255
5453
|
let storeQueryTime = 0;
|
|
@@ -3509,7 +5707,7 @@ class BaseMindooDB {
|
|
|
3509
5707
|
isDeleted,
|
|
3510
5708
|
};
|
|
3511
5709
|
// Update cache
|
|
3512
|
-
this.storeCachedDocument(internalDoc);
|
|
5710
|
+
await this.storeCachedDocument(internalDoc);
|
|
3513
5711
|
this.markDocDirty(docId);
|
|
3514
5712
|
this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
|
|
3515
5713
|
this.performanceCallback?.onDocumentLoad?.({
|
|
@@ -3845,6 +6043,7 @@ class BaseMindooDB {
|
|
|
3845
6043
|
docId,
|
|
3846
6044
|
dependencyIds: prevChunkId ? [prevChunkId] : [],
|
|
3847
6045
|
createdAt,
|
|
6046
|
+
attachmentId,
|
|
3848
6047
|
createdByPublicKey: currentUser.userSigningPublicKey,
|
|
3849
6048
|
decryptionKeyId,
|
|
3850
6049
|
signature,
|
|
@@ -3871,6 +6070,66 @@ class BaseMindooDB {
|
|
|
3871
6070
|
};
|
|
3872
6071
|
return ref;
|
|
3873
6072
|
}
|
|
6073
|
+
pendingAttachmentUploadLedgerId(attachmentId) {
|
|
6074
|
+
return `pending_upload_${attachmentId}`;
|
|
6075
|
+
}
|
|
6076
|
+
async putEntriesWithRetry(store, entries, description) {
|
|
6077
|
+
let lastError;
|
|
6078
|
+
for (let attempt = 0; attempt <= ATTACHMENT_WRITE_RETRY_DELAYS_MS.length; attempt++) {
|
|
6079
|
+
try {
|
|
6080
|
+
await store.putEntries(entries);
|
|
6081
|
+
return;
|
|
6082
|
+
}
|
|
6083
|
+
catch (error) {
|
|
6084
|
+
lastError = error;
|
|
6085
|
+
if (attempt >= ATTACHMENT_WRITE_RETRY_DELAYS_MS.length) {
|
|
6086
|
+
break;
|
|
6087
|
+
}
|
|
6088
|
+
this.logger.warn(`Attachment write ${description} failed; retrying batch (attempt ${attempt + 1})`, error);
|
|
6089
|
+
await new Promise((resolve) => setTimeout(resolve, ATTACHMENT_WRITE_RETRY_DELAYS_MS[attempt]));
|
|
6090
|
+
}
|
|
6091
|
+
}
|
|
6092
|
+
throw lastError;
|
|
6093
|
+
}
|
|
6094
|
+
async createPendingAttachmentUploadLedger(docId, attachmentId, decryptionKeyId, createdAt, createdByPublicKey) {
|
|
6095
|
+
const encryptedData = new Uint8Array(0);
|
|
6096
|
+
const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedData, this.getSubtle());
|
|
6097
|
+
const signature = await this.tenant.signPayload(encryptedData);
|
|
6098
|
+
await this.putEntriesWithRetry(this.store, [{
|
|
6099
|
+
entryType: "pending_attachment_upload",
|
|
6100
|
+
id: this.pendingAttachmentUploadLedgerId(attachmentId),
|
|
6101
|
+
contentHash,
|
|
6102
|
+
docId,
|
|
6103
|
+
dependencyIds: [],
|
|
6104
|
+
createdAt,
|
|
6105
|
+
attachmentId,
|
|
6106
|
+
uploadStartedAt: Date.now(),
|
|
6107
|
+
createdByPublicKey,
|
|
6108
|
+
decryptionKeyId,
|
|
6109
|
+
signature,
|
|
6110
|
+
originalSize: 0,
|
|
6111
|
+
encryptedSize: 0,
|
|
6112
|
+
encryptedData,
|
|
6113
|
+
}], `pending ledger ${attachmentId}`);
|
|
6114
|
+
}
|
|
6115
|
+
async clearPendingAttachmentUploadLedger(attachmentId) {
|
|
6116
|
+
const deleteEntriesById = this.store.deleteEntriesById;
|
|
6117
|
+
if (!deleteEntriesById) {
|
|
6118
|
+
return;
|
|
6119
|
+
}
|
|
6120
|
+
await deleteEntriesById.call(this.store, [this.pendingAttachmentUploadLedgerId(attachmentId)]);
|
|
6121
|
+
}
|
|
6122
|
+
async cleanupIncompleteAttachmentUpload(docId, attachmentId) {
|
|
6123
|
+
const store = this.getEffectiveAttachmentStore();
|
|
6124
|
+
try {
|
|
6125
|
+
await store.deleteEntriesForAttachment?.(docId, attachmentId);
|
|
6126
|
+
await this.clearPendingAttachmentUploadLedger(attachmentId);
|
|
6127
|
+
this.logger.info(`Cleaned up incomplete attachment upload ${attachmentId}`);
|
|
6128
|
+
}
|
|
6129
|
+
catch (cleanupError) {
|
|
6130
|
+
this.logger.warn(`Failed to clean up incomplete attachment upload ${attachmentId}; boot recovery can retry`, cleanupError);
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
3874
6133
|
/**
|
|
3875
6134
|
* Internal method to add an attachment from a streaming data source.
|
|
3876
6135
|
* Memory efficient - processes data chunk by chunk without loading entire file into memory.
|
|
@@ -3880,21 +6139,26 @@ class BaseMindooDB {
|
|
|
3880
6139
|
const store = this.getEffectiveAttachmentStore();
|
|
3881
6140
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
3882
6141
|
const attachmentId = (0, idGeneration_1.generateFileUuid7)();
|
|
3883
|
-
// Buffer to accumulate incoming data until we have a full chunk
|
|
3884
|
-
let buffer = new Uint8Array(0);
|
|
3885
6142
|
let totalSize = 0;
|
|
3886
6143
|
let prevChunkId = null;
|
|
3887
6144
|
let lastChunkId = "";
|
|
3888
6145
|
let chunkCount = 0;
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
6146
|
+
const pendingEntries = [];
|
|
6147
|
+
let pendingEncryptedBytes = 0;
|
|
6148
|
+
let ledgerCreated = false;
|
|
6149
|
+
const flushPendingEntries = async (force = false) => {
|
|
6150
|
+
if (pendingEntries.length === 0) {
|
|
6151
|
+
return;
|
|
6152
|
+
}
|
|
6153
|
+
if (!force && pendingEncryptedBytes < ATTACHMENT_WRITE_BATCH_BYTES) {
|
|
6154
|
+
return;
|
|
6155
|
+
}
|
|
6156
|
+
const batch = pendingEntries.splice(0, pendingEntries.length);
|
|
6157
|
+
pendingEncryptedBytes = 0;
|
|
6158
|
+
await this.putEntriesWithRetry(store, batch, `attachment ${attachmentId} (${batch.length} chunks)`);
|
|
3895
6159
|
};
|
|
3896
6160
|
// Helper to store a chunk
|
|
3897
|
-
const
|
|
6161
|
+
const queueChunk = async (chunkData) => {
|
|
3898
6162
|
// Encrypt chunk
|
|
3899
6163
|
const encryptedData = await this.tenant.encryptAttachmentPayload(chunkData, decryptionKeyId);
|
|
3900
6164
|
// Compute content hash
|
|
@@ -3911,6 +6175,7 @@ class BaseMindooDB {
|
|
|
3911
6175
|
docId,
|
|
3912
6176
|
dependencyIds: prevChunkId ? [prevChunkId] : [],
|
|
3913
6177
|
createdAt,
|
|
6178
|
+
attachmentId,
|
|
3914
6179
|
createdByPublicKey: currentUser.userSigningPublicKey,
|
|
3915
6180
|
decryptionKeyId,
|
|
3916
6181
|
signature,
|
|
@@ -3918,42 +6183,61 @@ class BaseMindooDB {
|
|
|
3918
6183
|
encryptedSize: encryptedData.length,
|
|
3919
6184
|
encryptedData,
|
|
3920
6185
|
};
|
|
3921
|
-
|
|
3922
|
-
|
|
6186
|
+
pendingEntries.push(chunkEntry);
|
|
6187
|
+
pendingEncryptedBytes += encryptedData.length;
|
|
3923
6188
|
chunkCount++;
|
|
6189
|
+
await flushPendingEntries();
|
|
3924
6190
|
return chunkId;
|
|
3925
6191
|
};
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
buffer = buffer.slice(this.chunkSizeBytes);
|
|
3934
|
-
lastChunkId = await storeChunk(chunkData);
|
|
6192
|
+
try {
|
|
6193
|
+
await this.createPendingAttachmentUploadLedger(docId, attachmentId, decryptionKeyId, createdAt, currentUser.userSigningPublicKey);
|
|
6194
|
+
ledgerCreated = true;
|
|
6195
|
+
const accumulator = new Uint8Array(this.chunkSizeBytes);
|
|
6196
|
+
let cursor = 0;
|
|
6197
|
+
const writeFilledChunk = async (chunkData) => {
|
|
6198
|
+
lastChunkId = await queueChunk(chunkData);
|
|
3935
6199
|
prevChunkId = lastChunkId;
|
|
3936
6200
|
totalSize += chunkData.length;
|
|
6201
|
+
};
|
|
6202
|
+
// Process incoming data stream without reallocating the pending buffer.
|
|
6203
|
+
for await (const chunk of dataStream) {
|
|
6204
|
+
let offset = 0;
|
|
6205
|
+
while (offset < chunk.byteLength) {
|
|
6206
|
+
const writableBytes = Math.min(this.chunkSizeBytes - cursor, chunk.byteLength - offset);
|
|
6207
|
+
accumulator.set(chunk.subarray(offset, offset + writableBytes), cursor);
|
|
6208
|
+
cursor += writableBytes;
|
|
6209
|
+
offset += writableBytes;
|
|
6210
|
+
if (cursor === this.chunkSizeBytes) {
|
|
6211
|
+
await writeFilledChunk(accumulator.slice());
|
|
6212
|
+
cursor = 0;
|
|
6213
|
+
}
|
|
6214
|
+
}
|
|
3937
6215
|
}
|
|
6216
|
+
// Store remaining data as final chunk (if any)
|
|
6217
|
+
if (cursor > 0) {
|
|
6218
|
+
await writeFilledChunk(accumulator.slice(0, cursor));
|
|
6219
|
+
}
|
|
6220
|
+
await flushPendingEntries(true);
|
|
6221
|
+
this.logger.debug(`Stored ${chunkCount} chunks for streaming attachment ${attachmentId} (${totalSize} bytes)`);
|
|
6222
|
+
// Create attachment reference
|
|
6223
|
+
const ref = {
|
|
6224
|
+
attachmentId,
|
|
6225
|
+
fileName,
|
|
6226
|
+
mimeType,
|
|
6227
|
+
size: totalSize,
|
|
6228
|
+
lastChunkId,
|
|
6229
|
+
decryptionKeyId,
|
|
6230
|
+
createdAt,
|
|
6231
|
+
createdBy: currentUser.userSigningPublicKey,
|
|
6232
|
+
};
|
|
6233
|
+
return ref;
|
|
3938
6234
|
}
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
6235
|
+
catch (error) {
|
|
6236
|
+
if (ledgerCreated || chunkCount > 0) {
|
|
6237
|
+
await this.cleanupIncompleteAttachmentUpload(docId, attachmentId);
|
|
6238
|
+
}
|
|
6239
|
+
throw error;
|
|
3943
6240
|
}
|
|
3944
|
-
this.logger.debug(`Stored ${chunkCount} chunks for streaming attachment ${attachmentId} (${totalSize} bytes)`);
|
|
3945
|
-
// Create attachment reference
|
|
3946
|
-
const ref = {
|
|
3947
|
-
attachmentId,
|
|
3948
|
-
fileName,
|
|
3949
|
-
mimeType,
|
|
3950
|
-
size: totalSize,
|
|
3951
|
-
lastChunkId,
|
|
3952
|
-
decryptionKeyId,
|
|
3953
|
-
createdAt,
|
|
3954
|
-
createdBy: currentUser.userSigningPublicKey,
|
|
3955
|
-
};
|
|
3956
|
-
return ref;
|
|
3957
6241
|
}
|
|
3958
6242
|
/**
|
|
3959
6243
|
* Internal method to append data to an existing attachment.
|
|
@@ -3985,6 +6269,7 @@ class BaseMindooDB {
|
|
|
3985
6269
|
docId,
|
|
3986
6270
|
dependencyIds: [prevChunkId],
|
|
3987
6271
|
createdAt,
|
|
6272
|
+
attachmentId,
|
|
3988
6273
|
createdByPublicKey: currentUser.userSigningPublicKey,
|
|
3989
6274
|
decryptionKeyId,
|
|
3990
6275
|
signature,
|
|
@@ -4082,6 +6367,7 @@ class BaseMindooDB {
|
|
|
4082
6367
|
* @return A promise that resolves with the sync result
|
|
4083
6368
|
*/
|
|
4084
6369
|
async pullChangesFrom(remote, options) {
|
|
6370
|
+
this.assertWritable("pullChangesFrom");
|
|
4085
6371
|
const storeKind = options?.storeKind ?? types_2.StoreKind.docs;
|
|
4086
6372
|
const localStore = this.getStoreForKind(storeKind);
|
|
4087
6373
|
const remoteStore = this.resolveStore(remote, storeKind);
|
|
@@ -4165,6 +6451,7 @@ class BaseMindooDB {
|
|
|
4165
6451
|
* @return A promise that resolves with the sync result
|
|
4166
6452
|
*/
|
|
4167
6453
|
async pushChangesTo(remote, options) {
|
|
6454
|
+
this.assertWritable("pushChangesTo");
|
|
4168
6455
|
const storeKind = options?.storeKind ?? types_2.StoreKind.docs;
|
|
4169
6456
|
const localStore = this.getStoreForKind(storeKind);
|
|
4170
6457
|
const remoteStore = this.resolveStore(remote, storeKind);
|