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.
Files changed (105) hide show
  1. package/LICENSE +200 -0
  2. package/README-server.md +14 -14
  3. package/README.md +12 -10
  4. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +6 -0
  5. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  6. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +519 -373
  7. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  8. package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts +12 -0
  9. package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts.map +1 -1
  10. package/dist/browser/cache/IndexedDBLocalCacheStore.js +152 -65
  11. package/dist/browser/cache/IndexedDBLocalCacheStore.js.map +1 -1
  12. package/dist/browser/index.d.ts +1 -1
  13. package/dist/browser/index.d.ts.map +1 -1
  14. package/dist/browser/index.js +2 -1
  15. package/dist/browser/index.js.map +1 -1
  16. package/dist/browser/index.mjs +14 -0
  17. package/dist/core/BaseMindooDB.d.ts +594 -6
  18. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  19. package/dist/core/BaseMindooDB.js +2478 -191
  20. package/dist/core/BaseMindooDB.js.map +1 -1
  21. package/dist/core/BaseMindooTenant.d.ts +65 -1
  22. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  23. package/dist/core/BaseMindooTenant.js +250 -35
  24. package/dist/core/BaseMindooTenant.js.map +1 -1
  25. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  26. package/dist/core/BaseMindooTenantFactory.js +47 -18
  27. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  28. package/dist/core/DocumentConflictAnalysis.d.ts +22 -0
  29. package/dist/core/DocumentConflictAnalysis.d.ts.map +1 -0
  30. package/dist/core/DocumentConflictAnalysis.js +101 -0
  31. package/dist/core/DocumentConflictAnalysis.js.map +1 -0
  32. package/dist/core/MindooDBServerAdmin.d.ts +26 -1
  33. package/dist/core/MindooDBServerAdmin.d.ts.map +1 -1
  34. package/dist/core/MindooDBServerAdmin.js +19 -3
  35. package/dist/core/MindooDBServerAdmin.js.map +1 -1
  36. package/dist/core/appendonlystores/types.d.ts +9 -0
  37. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  38. package/dist/core/automerge-adapter.d.ts.map +1 -1
  39. package/dist/core/automerge-adapter.js +26 -14
  40. package/dist/core/automerge-adapter.js.map +1 -1
  41. package/dist/core/cache/EncryptedLocalCacheStore.d.ts +51 -1
  42. package/dist/core/cache/EncryptedLocalCacheStore.d.ts.map +1 -1
  43. package/dist/core/cache/EncryptedLocalCacheStore.js +48 -9
  44. package/dist/core/cache/EncryptedLocalCacheStore.js.map +1 -1
  45. package/dist/core/cache/LocalCacheStore.d.ts +34 -0
  46. package/dist/core/cache/LocalCacheStore.d.ts.map +1 -1
  47. package/dist/core/cache/LocalCacheStore.js +16 -0
  48. package/dist/core/cache/LocalCacheStore.js.map +1 -1
  49. package/dist/core/databaseIdValidation.d.ts +13 -0
  50. package/dist/core/databaseIdValidation.d.ts.map +1 -0
  51. package/dist/core/databaseIdValidation.js +38 -0
  52. package/dist/core/databaseIdValidation.js.map +1 -0
  53. package/dist/core/index.d.ts +2 -0
  54. package/dist/core/index.d.ts.map +1 -1
  55. package/dist/core/index.js +2 -0
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  58. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +5 -1
  59. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  60. package/dist/core/indexing/virtualviews/VirtualView.d.ts +14 -0
  61. package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
  62. package/dist/core/indexing/virtualviews/VirtualView.js +38 -6
  63. package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
  64. package/dist/core/indexing/virtualviews/VirtualViewDataChange.d.ts +9 -2
  65. package/dist/core/indexing/virtualviews/VirtualViewDataChange.d.ts.map +1 -1
  66. package/dist/core/indexing/virtualviews/VirtualViewDataChange.js +10 -3
  67. package/dist/core/indexing/virtualviews/VirtualViewDataChange.js.map +1 -1
  68. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +23 -1
  69. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
  70. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +17 -1
  71. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
  72. package/dist/core/indexing/virtualviews/types.d.ts +8 -0
  73. package/dist/core/indexing/virtualviews/types.d.ts.map +1 -1
  74. package/dist/core/indexing/virtualviews/types.js.map +1 -1
  75. package/dist/core/keys/KeyBag.d.ts +244 -7
  76. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  77. package/dist/core/keys/KeyBag.js +299 -82
  78. package/dist/core/keys/KeyBag.js.map +1 -1
  79. package/dist/core/tenantIdValidation.d.ts +19 -0
  80. package/dist/core/tenantIdValidation.d.ts.map +1 -0
  81. package/dist/core/tenantIdValidation.js +43 -0
  82. package/dist/core/tenantIdValidation.js.map +1 -0
  83. package/dist/core/types.d.ts +703 -12
  84. package/dist/core/types.d.ts.map +1 -1
  85. package/dist/core/types.js.map +1 -1
  86. package/dist/node/cache/FileSystemLocalCacheStore.d.ts +2 -1
  87. package/dist/node/cache/FileSystemLocalCacheStore.d.ts.map +1 -1
  88. package/dist/node/cache/FileSystemLocalCacheStore.js +4 -0
  89. package/dist/node/cache/FileSystemLocalCacheStore.js.map +1 -1
  90. package/dist/node/server/ServerSync.d.ts.map +1 -1
  91. package/dist/node/server/ServerSync.js +3 -1
  92. package/dist/node/server/ServerSync.js.map +1 -1
  93. package/dist/node/server/TenantManager.d.ts +1 -0
  94. package/dist/node/server/TenantManager.d.ts.map +1 -1
  95. package/dist/node/server/TenantManager.js +11 -3
  96. package/dist/node/server/TenantManager.js.map +1 -1
  97. package/dist/node/server/validation.d.ts +2 -6
  98. package/dist/node/server/validation.d.ts.map +1 -1
  99. package/dist/node/server/validation.js +14 -24
  100. package/dist/node/server/validation.js.map +1 -1
  101. package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts +1 -0
  102. package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts.map +1 -1
  103. package/dist/reactnative/cache/MMKVLocalCacheStore.js +14 -0
  104. package/dist/reactnative/cache/MMKVLocalCacheStore.js.map +1 -1
  105. 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
- this.evictCachedDocsIfNeeded();
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
- evictCachedDocsIfNeeded(protectedDocIds) {
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
- const amBinary = automerge_adapter_1.Automerge.save(internal.doc);
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
- for (const id of docIds) {
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
- if (!id.startsWith(docPrefix))
311
- continue;
312
- const docId = id.slice(docPrefix.length);
313
- const docBytes = await store.get("doc", id);
314
- if (!docBytes)
315
- continue;
316
- try {
317
- const internal = this.deserializeDoc(docBytes);
318
- this.storeCachedDocument(internal);
319
- restoredDocs++;
320
- }
321
- catch (e) {
322
- this.logger.warn(`Failed to restore cached doc ${docId}, will reload from store: ${e}`);
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
- return {
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
- * Optimized to only update lookup map for entries that actually moved.
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 deleted
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 = { docId, changeSeq: assignedSeq, lastModified, isDeleted };
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 && existingEntry.isDeleted === isDeleted) {
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
- // Handle missing symmetric key gracefully - skip documents we can't decrypt
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
- // Mark entry IDs as processed so we don't retry them
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; // Skip this document
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
- return store.findNewEntriesForDoc([], filters.docId);
1357
+ entries = await store.findNewEntriesForDoc([], filters.docId);
797
1358
  }
798
- return store.findNewEntries([]);
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, filters);
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
- * Derives human-readable changed field paths for one DAG entry.
2534
+ * Streams conflict-analysis events for one or more documents.
1916
2535
  *
1917
- * This compares the branch-local document state at the selected entry with each
1918
- * reachable parent state, then normalizes array indices so the UI can show stable
1919
- * paths such as `_attachments[].fileName` instead of raw Automerge op keys only.
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 deriveReadableTouchedPaths(docId, entryMetadata, allEntryMetadata, touchedKeys) {
1922
- if (entryMetadata.entryType === "doc_snapshot") {
1923
- return [];
1924
- }
1925
- const currentDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, entryMetadata.id);
1926
- const currentData = this.getReadableDiffValueForDoc(currentDoc);
1927
- const parentEntryIds = entryMetadata.dependencyIds.filter((dependencyId) => allEntryMetadata.some((entry) => entry.id === dependencyId));
1928
- const diffPaths = new Set();
1929
- if (parentEntryIds.length === 0) {
1930
- this.collectReadableDiffPaths(undefined, currentData, "", diffPaths);
1931
- }
1932
- else {
1933
- for (const parentEntryId of parentEntryIds) {
1934
- const parentDoc = await this.materializeBranchInternalDoc(docId, allEntryMetadata, parentEntryId);
1935
- const parentData = this.getReadableDiffValueForDoc(parentDoc);
1936
- this.collectReadableDiffPaths(parentData, currentData, "", diffPaths);
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
- const normalizedPaths = Array.from(diffPaths)
1940
- .map((path) => path.replace(/\[\d+\]/g, "[]"))
1941
- .filter((path, index, allPaths) => path.length > 0 && allPaths.indexOf(path) === index)
1942
- .sort();
1943
- if (normalizedPaths.length === 0) {
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
- return docIds;
2633
+ yield {
2634
+ type: "scanCheckpoint",
2635
+ checkpoint: scanCheckpoint,
2636
+ };
1962
2637
  }
1963
- async getDeletedDocumentIds() {
1964
- const docIds = [];
1965
- for (const entry of this.index) {
1966
- if (entry.isDeleted) {
1967
- docIds.push(entry.docId);
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
- return docIds;
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
- async getAllDocumentIdsAtTimestamp(timestamp) {
1973
- // findEntries() uses an exclusive upper bound (createdAt < creationDateUntil).
1974
- // Query up to timestamp + 1 so entries created exactly at `timestamp` are included
1975
- // for the strict checks below (createTime < timestamp, deleteTime > timestamp).
1976
- const upperBoundExclusive = timestamp === Number.MAX_SAFE_INTEGER ? timestamp : timestamp + 1;
1977
- // We only need lifecycle metadata for this query. Changes and snapshots can
1978
- // affect document contents, but create/delete/undelete determine existence.
1979
- const [creates, deletes, undeletes] = await Promise.all([
1980
- this.store.findEntries("doc_create", null, upperBoundExclusive),
1981
- this.store.findEntries("doc_delete", null, upperBoundExclusive),
1982
- this.store.findEntries("doc_undelete", null, upperBoundExclusive),
1983
- ]);
1984
- const lifecycleEntriesByDocId = new Map();
1985
- const addLifecycleEntry = (entry) => {
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(`===== applyTextPatch called for document ${docId} =====`);
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.validateTextPatch(patch);
3873
+ this.validateJsonPatch(patch);
2182
3874
  const now = Date.now();
2183
3875
  const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
2184
- const baseHeads = patch.baseHeads;
2185
- const applyEdits = (automergeDoc) => {
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, applyEdits);
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, applyEdits);
3887
+ newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyPatch);
2200
3888
  }
2201
- this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
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 text patch for document ${docId}:`, error);
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: "text patched",
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
- await changeFunc(collectingDoc);
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
- await this.persistDocumentChange({
2483
- internalDoc,
2484
- newDoc,
2485
- now,
2486
- headsBeforeChange,
2487
- useCustomKey: Boolean(useCustomKey),
2488
- signingKeyPair,
2489
- signingKeyPassword,
2490
- successMessage: useCustomKey ? "changed with custom signing key" : "changed",
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 docId = indexSnapshot[i].docId;
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
- // Check cache first
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
- // Helper to concatenate Uint8Arrays
3890
- const concatArrays = (a, b) => {
3891
- const result = new Uint8Array(a.length + b.length);
3892
- result.set(a, 0);
3893
- result.set(b, a.length);
3894
- return result;
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 storeChunk = async (chunkData) => {
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
- // Store immediately (streaming - don't buffer chunks in memory)
3922
- await store.putEntries([chunkEntry]);
6186
+ pendingEntries.push(chunkEntry);
6187
+ pendingEncryptedBytes += encryptedData.length;
3923
6188
  chunkCount++;
6189
+ await flushPendingEntries();
3924
6190
  return chunkId;
3925
6191
  };
3926
- // Process incoming data stream
3927
- for await (const chunk of dataStream) {
3928
- // Add incoming data to buffer
3929
- buffer = concatArrays(buffer, chunk);
3930
- // Process complete chunks
3931
- while (buffer.length >= this.chunkSizeBytes) {
3932
- const chunkData = buffer.slice(0, this.chunkSizeBytes);
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
- // Store remaining data as final chunk (if any)
3940
- if (buffer.length > 0) {
3941
- lastChunkId = await storeChunk(buffer);
3942
- totalSize += buffer.length;
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);