mindoodb 0.0.48 → 0.0.49

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 (41) hide show
  1. package/README-server.md +39 -3
  2. package/dist/appendonlystores/network/HttpTransport.d.ts +14 -0
  3. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/HttpTransport.js +58 -39
  5. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  6. package/dist/appendonlystores/network/RequestScheduler.d.ts +65 -0
  7. package/dist/appendonlystores/network/RequestScheduler.d.ts.map +1 -0
  8. package/dist/appendonlystores/network/RequestScheduler.js +147 -0
  9. package/dist/appendonlystores/network/RequestScheduler.js.map +1 -0
  10. package/dist/appendonlystores/network/index.d.ts +1 -0
  11. package/dist/appendonlystores/network/index.d.ts.map +1 -1
  12. package/dist/appendonlystores/network/index.js +6 -1
  13. package/dist/appendonlystores/network/index.js.map +1 -1
  14. package/dist/core/BaseMindooDB.d.ts +127 -0
  15. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  16. package/dist/core/BaseMindooDB.js +514 -51
  17. package/dist/core/BaseMindooDB.js.map +1 -1
  18. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  19. package/dist/core/BaseMindooTenant.js +8 -1
  20. package/dist/core/BaseMindooTenant.js.map +1 -1
  21. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +10 -0
  22. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  23. package/dist/core/types.d.ts +19 -1
  24. package/dist/core/types.d.ts.map +1 -1
  25. package/dist/core/types.js.map +1 -1
  26. package/dist/node/server/MindooDBServer.d.ts +51 -5
  27. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  28. package/dist/node/server/MindooDBServer.js +164 -40
  29. package/dist/node/server/MindooDBServer.js.map +1 -1
  30. package/dist/node/server/config.d.ts.map +1 -1
  31. package/dist/node/server/config.js +4 -0
  32. package/dist/node/server/config.js.map +1 -1
  33. package/dist/node/server/rateLimits.d.ts +46 -0
  34. package/dist/node/server/rateLimits.d.ts.map +1 -0
  35. package/dist/node/server/rateLimits.js +50 -0
  36. package/dist/node/server/rateLimits.js.map +1 -0
  37. package/dist/node/server/types.d.ts +15 -0
  38. package/dist/node/server/types.d.ts.map +1 -1
  39. package/dist/node/server/types.js +9 -0
  40. package/dist/node/server/types.js.map +1 -1
  41. package/package.json +1 -1
@@ -10,6 +10,7 @@ const uuid_1 = require("uuid");
10
10
  const types_1 = require("./types");
11
11
  const types_2 = require("./appendonlystores/types");
12
12
  const bloom_1 = require("./appendonlystores/bloom");
13
+ const types_3 = require("./appendonlystores/network/types");
13
14
  const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
14
15
  const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
15
16
  const DocumentConflictAnalysis_1 = require("./DocumentConflictAnalysis");
@@ -26,10 +27,10 @@ const AccessDeniedError_1 = require("./accesscontrol/AccessDeniedError");
26
27
  const logging_1 = require("./logging");
27
28
  const databaseIdValidation_1 = require("./databaseIdValidation");
28
29
  const DocumentSummaryStore_1 = require("./indexing/summary/DocumentSummaryStore");
29
- const types_3 = require("./indexing/summary/types");
30
+ const types_4 = require("./indexing/summary/types");
30
31
  const DocumentFullTextIndex_1 = require("./indexing/fulltext/DocumentFullTextIndex");
31
- const types_4 = require("./indexing/fulltext/types");
32
- const types_5 = require("./extraction/types");
32
+ const types_5 = require("./indexing/fulltext/types");
33
+ const types_6 = require("./extraction/types");
33
34
  const executeQuery_1 = require("./query/executeQuery");
34
35
  const queryView_1 = require("./query/queryView");
35
36
  const queryLive_1 = require("./query/queryLive");
@@ -43,7 +44,12 @@ const DEFAULT_ATTACHMENT_STREAM_BATCH_SIZE = 4;
43
44
  const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
44
45
  const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
45
46
  const DEFAULT_MAX_CACHED_DOCS = 128;
46
- const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 0;
47
+ /**
48
+ * Documents materialized ahead during changefeed iteration. Kept well below
49
+ * {@link DEFAULT_MAX_CACHED_DOCS} so a window cannot evict itself out of L1
50
+ * before the iteration reaches it.
51
+ */
52
+ const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 32;
47
53
  const DEFAULT_WARMER_BATCH_SIZE = 50;
48
54
  /**
49
55
  * Default number of cached documents fetched per `getMany` batch during
@@ -54,6 +60,62 @@ const DEFAULT_WARMER_BATCH_SIZE = 50;
54
60
  * Tunable via {@link DocumentCacheConfig.restoreBatchSize}.
55
61
  */
56
62
  const DEFAULT_RESTORE_BATCH_SIZE = 256;
63
+ /**
64
+ * Cap for {@link BaseMindooDB.scannedEntryMetadata}, counted in retained
65
+ * metadata entries (not documents). Metadata is small (~300 bytes), so this
66
+ * is a few tens of MB at the ceiling; past it, materialization falls back to
67
+ * the per-document scan it would have done anyway.
68
+ */
69
+ const MAX_SCANNED_ENTRY_METADATA_ENTRIES = 200000;
70
+ /**
71
+ * Encrypted payload bytes per batched entry fetch
72
+ * ({@link BaseMindooDB.prefetchEntriesForDocuments}). Bounds response size
73
+ * and peak memory; the count limit below stays far under the server's
74
+ * per-request id cap, so bytes are the binding constraint in practice.
75
+ */
76
+ const MATERIALIZATION_PREFETCH_PAGE_BYTES = 8 * 1024 * 1024;
77
+ const MATERIALIZATION_PREFETCH_MAX_IDS = 5000;
78
+ /**
79
+ * Documents in a prefetch window without retained metadata that justify one
80
+ * bulk metadata scan of the whole store
81
+ * ({@link BaseMindooDB.warmScannedEntryMetadataFromStore}). A store-wide scan
82
+ * costs one request per 1000 entries, so it repays itself after a handful of
83
+ * per-document scans.
84
+ */
85
+ const METADATA_WARMUP_MIN_UNCOVERED_DOCS = 8;
86
+ /**
87
+ * Backoff before retrying a rate-limited prefetch request when the server
88
+ * sends no `Retry-After`. The number of entries also caps the retry count.
89
+ */
90
+ const PREFETCH_RATE_LIMIT_DELAYS_MS = [250, 1000, 3000];
91
+ /** Upper bound for an honoured `Retry-After`, so a prefetch cannot hang. */
92
+ const PREFETCH_MAX_RATE_LIMIT_DELAY_MS = 10000;
93
+ /**
94
+ * Read the type off a transport error by shape rather than `instanceof`:
95
+ * the error crosses module and bundle boundaries, where prototype identity is
96
+ * not reliable. Returns null for anything that is not a `NetworkError`.
97
+ */
98
+ function networkErrorType(error) {
99
+ const candidate = error;
100
+ if (!candidate || candidate.name !== "NetworkError" || typeof candidate.type !== "string") {
101
+ return null;
102
+ }
103
+ return candidate.type;
104
+ }
105
+ /**
106
+ * How long to wait before retrying a rate-limited request, or null when the
107
+ * error is not a rate limit or the retries are exhausted. Honours the
108
+ * server's `Retry-After` when it asks for a longer wait than our backoff.
109
+ */
110
+ function rateLimitRetryDelayMs(error, attempt) {
111
+ if (networkErrorType(error) !== types_3.NetworkErrorType.RATE_LIMITED ||
112
+ attempt >= PREFETCH_RATE_LIMIT_DELAYS_MS.length) {
113
+ return null;
114
+ }
115
+ const retryAfterMs = error.retryAfterMs;
116
+ const requested = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) ? retryAfterMs : 0;
117
+ return Math.min(Math.max(requested, PREFETCH_RATE_LIMIT_DELAYS_MS[attempt]), PREFETCH_MAX_RATE_LIMIT_DELAY_MS);
118
+ }
57
119
  /**
58
120
  * Default {@link WarmerScheduler}: a simple `setTimeout(0)` yield that
59
121
  * works in Node, browsers, and React Native without pulling in any
@@ -190,6 +252,39 @@ class BaseMindooDB {
190
252
  * below {@link snapshotMinChanges}. Absent key = unknown (scan once).
191
253
  */
192
254
  this.writesSinceSnapshotCheck = new Map();
255
+ /**
256
+ * Full entry metadata per document, kept from the sync walk that already
257
+ * read it, so the following materialization does not scan the store for
258
+ * the same document again. On a network-backed store that scan is an HTTP
259
+ * request per document — the dominant cost of a cold open.
260
+ *
261
+ * Only ever holds a document's *complete* history: the sync path fills it
262
+ * from the unfiltered cold-sync batch or from an explicit per-doc scan,
263
+ * both of which are complete by construction. A partial history here would
264
+ * materialize a document from a truncated DAG.
265
+ *
266
+ * Invalidated by {@link updateIndex} (every path that writes entries for a
267
+ * document goes through it) and consumed on first read, since the
268
+ * materialized document then lives in L1/L2.
269
+ */
270
+ this.scannedEntryMetadata = new Map();
271
+ /** Retained entry count across {@link scannedEntryMetadata}. */
272
+ this.scannedEntryMetadataSize = 0;
273
+ /**
274
+ * One bulk metadata scan per instance
275
+ * ({@link warmScannedEntryMetadataFromStore}); set before the scan runs so
276
+ * a failed or oversized attempt is not repeated on every window.
277
+ */
278
+ this.scannedEntryMetadataWarmed = false;
279
+ /**
280
+ * Entries fetched ahead for a batch of documents about to be materialized
281
+ * (see {@link prefetchEntriesForDocuments}). Read through
282
+ * {@link fetchEntries}, which falls back to the store for anything the
283
+ * buffer does not hold — a partial buffer is always safe.
284
+ *
285
+ * Non-null only for the duration of one prefetch batch.
286
+ */
287
+ this.materializationEntryBuffer = null;
193
288
  // Track which entry IDs we've already processed
194
289
  this.processedEntryIds = [];
195
290
  this.processedEntryCursor = null;
@@ -278,6 +373,15 @@ class BaseMindooDB {
278
373
  // common choke point of all mutation paths, so it only records pending
279
374
  // docIds; emission is coalesced per sync batch / event-loop turn.
280
375
  // ---------------------------------------------------------------------------
376
+ /**
377
+ * Single-flight for {@link syncStoreChanges}. `processedEntryCursor`
378
+ * only advances once a run finishes, so overlapping callers would
379
+ * otherwise walk the identical cursor sequence — on a remote store that
380
+ * is one duplicated `scanEntriesSince` request per caller and page.
381
+ */
382
+ this.syncStoreChangesInFlight = null;
383
+ /** Shared follow-up run for callers that arrive mid-sync. */
384
+ this.syncStoreChangesQueued = null;
281
385
  this.changeListeners = new Set();
282
386
  this.pendingChangeNotifications = new Map();
283
387
  this.changeNotifyTimer = null;
@@ -373,8 +477,16 @@ class BaseMindooDB {
373
477
  this._isAdminOnlyDb = adminOnlyDb;
374
478
  this.timeTravelDate = timeTravelDate;
375
479
  this.maxCachedDocs = Math.max(1, Math.floor(documentCacheConfig?.maxEntries ?? DEFAULT_MAX_CACHED_DOCS));
376
- this.iteratePrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
480
+ const requestedPrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
377
481
  DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS));
482
+ // A window refills with up to half a window of runway left, so it can
483
+ // reach 1.5x its size ahead of the cursor. Keep that under the L1 cache,
484
+ // or the prefetch evicts the documents it just materialized and iteration
485
+ // fetches them a second time.
486
+ this.iteratePrefetchWindowDocs =
487
+ requestedPrefetchWindowDocs > 0
488
+ ? Math.max(1, Math.min(requestedPrefetchWindowDocs, Math.floor(this.maxCachedDocs / 2)))
489
+ : 0;
378
490
  this.cacheRestoreLimit = Math.max(1, Math.floor(documentCacheConfig?.restoreLimit ?? this.maxCachedDocs));
379
491
  this.cacheRestoreBatchSize = Math.max(1, Math.floor(documentCacheConfig?.restoreBatchSize ?? DEFAULT_RESTORE_BATCH_SIZE));
380
492
  this.reconcileRestoredIndexOnInit = documentCacheConfig?.reconcileRestoredIndexOnInit ?? false;
@@ -535,8 +647,8 @@ class BaseMindooDB {
535
647
  */
536
648
  async getSummarySetup() {
537
649
  try {
538
- const doc = await this.getDocument(types_3.DB_SETUP_DOC_ID);
539
- return (0, types_3.sanitizeSummaryConfig)(doc.getData()[types_3.SUMMARY_SETUP_FIELD]) ?? null;
650
+ const doc = await this.getDocument(types_4.DB_SETUP_DOC_ID);
651
+ return (0, types_4.sanitizeSummaryConfig)(doc.getData()[types_4.SUMMARY_SETUP_FIELD]) ?? null;
540
652
  }
541
653
  catch (error) {
542
654
  if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
@@ -557,14 +669,14 @@ class BaseMindooDB {
557
669
  * backfill — locally and, after sync, on every other replica.
558
670
  */
559
671
  async setSummarySetup(config) {
560
- const doc = await this.createDocument({ id: types_3.DB_SETUP_DOC_ID });
672
+ const doc = await this.createDocument({ id: types_4.DB_SETUP_DOC_ID });
561
673
  await this.changeDoc(doc, (d) => {
562
674
  const data = d.getData();
563
675
  if (config === null) {
564
- delete data[types_3.SUMMARY_SETUP_FIELD];
676
+ delete data[types_4.SUMMARY_SETUP_FIELD];
565
677
  }
566
678
  else {
567
- data[types_3.SUMMARY_SETUP_FIELD] = (0, types_3.sanitizeSummaryConfig)(config) ?? {};
679
+ data[types_4.SUMMARY_SETUP_FIELD] = (0, types_4.sanitizeSummaryConfig)(config) ?? {};
568
680
  }
569
681
  });
570
682
  }
@@ -606,7 +718,7 @@ class BaseMindooDB {
606
718
  if (this._isAdminOnlyDb || this.store.getId() === "directory") {
607
719
  return;
608
720
  }
609
- const hasSetupChange = changes.some((change) => change.docId === types_3.DB_SETUP_DOC_ID);
721
+ const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
610
722
  if (!hasSetupChange && this.summarySetupProbed) {
611
723
  return;
612
724
  }
@@ -693,8 +805,8 @@ class BaseMindooDB {
693
805
  */
694
806
  async getFulltextSetup() {
695
807
  try {
696
- const doc = await this.getDocument(types_3.DB_SETUP_DOC_ID);
697
- return (0, types_4.sanitizeFulltextConfig)(doc.getData()[types_4.FULLTEXT_SETUP_FIELD]) ?? null;
808
+ const doc = await this.getDocument(types_4.DB_SETUP_DOC_ID);
809
+ return (0, types_5.sanitizeFulltextConfig)(doc.getData()[types_5.FULLTEXT_SETUP_FIELD]) ?? null;
698
810
  }
699
811
  catch (error) {
700
812
  if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
@@ -717,19 +829,19 @@ class BaseMindooDB {
717
829
  * resumable backfill — locally and, after sync, on every other replica.
718
830
  */
719
831
  async setFulltextSetup(config) {
720
- const sanitized = config === null ? null : (0, types_4.sanitizeFulltextConfig)(config) ?? {};
832
+ const sanitized = config === null ? null : (0, types_5.sanitizeFulltextConfig)(config) ?? {};
721
833
  const current = await this.getFulltextSetup();
722
834
  if (JSON.stringify(current) === JSON.stringify(sanitized)) {
723
835
  return;
724
836
  }
725
- const doc = await this.createDocument({ id: types_3.DB_SETUP_DOC_ID });
837
+ const doc = await this.createDocument({ id: types_4.DB_SETUP_DOC_ID });
726
838
  await this.changeDoc(doc, (d) => {
727
839
  const data = d.getData();
728
840
  if (sanitized === null) {
729
- delete data[types_4.FULLTEXT_SETUP_FIELD];
841
+ delete data[types_5.FULLTEXT_SETUP_FIELD];
730
842
  }
731
843
  else {
732
- data[types_4.FULLTEXT_SETUP_FIELD] = sanitized;
844
+ data[types_5.FULLTEXT_SETUP_FIELD] = sanitized;
733
845
  }
734
846
  });
735
847
  }
@@ -743,8 +855,8 @@ class BaseMindooDB {
743
855
  */
744
856
  async getExtractionSetup() {
745
857
  try {
746
- const doc = await this.getDocument(types_3.DB_SETUP_DOC_ID);
747
- return (0, types_5.sanitizeExtractionConfig)(doc.getData()[types_5.EXTRACTION_SETUP_FIELD]) ?? null;
858
+ const doc = await this.getDocument(types_4.DB_SETUP_DOC_ID);
859
+ return (0, types_6.sanitizeExtractionConfig)(doc.getData()[types_6.EXTRACTION_SETUP_FIELD]) ?? null;
748
860
  }
749
861
  catch (error) {
750
862
  if (error instanceof errors_1.DocumentNotFoundError || error instanceof errors_1.DocumentDeletedError) {
@@ -763,19 +875,19 @@ class BaseMindooDB {
763
875
  * without producing changefeed/sync churn.
764
876
  */
765
877
  async setExtractionSetup(config) {
766
- const sanitized = config === null ? null : (0, types_5.sanitizeExtractionConfig)(config) ?? {};
878
+ const sanitized = config === null ? null : (0, types_6.sanitizeExtractionConfig)(config) ?? {};
767
879
  const current = await this.getExtractionSetup();
768
880
  if (JSON.stringify(current) === JSON.stringify(sanitized)) {
769
881
  return;
770
882
  }
771
- const doc = await this.createDocument({ id: types_3.DB_SETUP_DOC_ID });
883
+ const doc = await this.createDocument({ id: types_4.DB_SETUP_DOC_ID });
772
884
  await this.changeDoc(doc, (d) => {
773
885
  const data = d.getData();
774
886
  if (sanitized === null) {
775
- delete data[types_5.EXTRACTION_SETUP_FIELD];
887
+ delete data[types_6.EXTRACTION_SETUP_FIELD];
776
888
  }
777
889
  else {
778
- data[types_5.EXTRACTION_SETUP_FIELD] = sanitized;
890
+ data[types_6.EXTRACTION_SETUP_FIELD] = sanitized;
779
891
  }
780
892
  });
781
893
  }
@@ -841,7 +953,7 @@ class BaseMindooDB {
841
953
  if (this._isAdminOnlyDb || this.store.getId() === "directory") {
842
954
  return;
843
955
  }
844
- const hasSetupChange = changes.some((change) => change.docId === types_3.DB_SETUP_DOC_ID);
956
+ const hasSetupChange = changes.some((change) => change.docId === types_4.DB_SETUP_DOC_ID);
845
957
  if (!hasSetupChange && this.fulltextSetupProbed) {
846
958
  return;
847
959
  }
@@ -1621,6 +1733,9 @@ class BaseMindooDB {
1621
1733
  */
1622
1734
  updateIndex(docId, lastModified, isDeleted, decryptionKeyId = "default", accessState = "visible", awaitingWitness = false, witnessed = false, forceChangeSeqBump = false) {
1623
1735
  const startedAt = Date.now();
1736
+ // Every path that writes entries for a document lands here, so this is
1737
+ // where retained metadata for it goes stale (see scannedEntryMetadata).
1738
+ this.forgetScannedEntryMetadata(docId);
1624
1739
  const assignedSeq = this.nextChangeSeq;
1625
1740
  const newEntry = {
1626
1741
  docId,
@@ -2065,6 +2180,7 @@ class BaseMindooDB {
2065
2180
  this.dirtyDocIds.delete(docId);
2066
2181
  this.lastFlushedDocState.delete(docId);
2067
2182
  this.automergeHashToEntryId.delete(docId);
2183
+ this.forgetScannedEntryMetadata(docId);
2068
2184
  // Plaintext summary values must not outlive the purge either.
2069
2185
  this.summaryStore?.removeDocument(docId);
2070
2186
  // Nor extracted plaintext tokens in the full-text index.
@@ -2384,6 +2500,7 @@ class BaseMindooDB {
2384
2500
  this.docCache.clear();
2385
2501
  this.lastFlushedDocState.clear();
2386
2502
  this.automergeHashToEntryId.clear();
2503
+ this.clearScannedEntryMetadata();
2387
2504
  this.processedEntryIds = [];
2388
2505
  this.processedEntryCursor = null;
2389
2506
  this.nextChangeSeq = 1;
@@ -2403,22 +2520,50 @@ class BaseMindooDB {
2403
2520
  * sync page) must call {@link startBackgroundWarmer} explicitly so
2404
2521
  * casual sync calls stay cheap and the warmer cost is paid only when
2405
2522
  * the workload is about to benefit from it.
2523
+ *
2524
+ * Overlapping callers are coalesced: at most one run is in flight and
2525
+ * at most one follow-up run is queued behind it (see
2526
+ * {@link syncStoreChangesInFlight}).
2406
2527
  */
2407
2528
  async syncStoreChanges() {
2408
- // Hold change-notification emission for the whole batch so listeners
2409
- // receive one coalesced event per sync run instead of one per document.
2410
- this.beginChangeNotificationHold();
2411
- try {
2412
- await this.syncStoreChangesInternal();
2413
- }
2414
- finally {
2415
- this.endChangeNotificationHold();
2529
+ if (this.syncStoreChangesInFlight) {
2530
+ if (!this.syncStoreChangesQueued) {
2531
+ // The running scan may have started before the entries this caller
2532
+ // is waiting for were written, so it gets a follow-up run rather
2533
+ // than the in-flight one. The follow-up starts regardless of how
2534
+ // the current run ended, so a failed run does not strand it.
2535
+ this.syncStoreChangesQueued = this.syncStoreChangesInFlight
2536
+ .then(() => undefined, () => undefined)
2537
+ .then(() => {
2538
+ this.syncStoreChangesQueued = null;
2539
+ return this.syncStoreChanges();
2540
+ });
2541
+ }
2542
+ return this.syncStoreChangesQueued;
2416
2543
  }
2544
+ const run = (async () => {
2545
+ // Hold change-notification emission for the whole batch so listeners
2546
+ // receive one coalesced event per sync run instead of one per document.
2547
+ this.beginChangeNotificationHold();
2548
+ try {
2549
+ await this.syncStoreChangesInternal();
2550
+ }
2551
+ finally {
2552
+ this.endChangeNotificationHold();
2553
+ }
2554
+ })();
2555
+ this.syncStoreChangesInFlight = run.finally(() => {
2556
+ this.syncStoreChangesInFlight = null;
2557
+ });
2558
+ return this.syncStoreChangesInFlight;
2417
2559
  }
2418
2560
  async syncStoreChangesInternal() {
2419
2561
  const syncStartedAt = Date.now();
2420
2562
  this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
2421
2563
  this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
2564
+ // Cold sync already fetched every entry via the unfiltered scan; reusing that
2565
+ // per-doc batch avoids N× scanEntriesSince({ docId }) (HTTP storms on remote).
2566
+ const coldSync = this.processedEntryCursor == null && this.processedEntryIds.length === 0;
2422
2567
  // Find new entries that we haven't processed yet
2423
2568
  const { entries: newEntryMetadata, nextCursor } = await this.getNewEntryMetadataForSync();
2424
2569
  this.logger.debug(`Found ${newEntryMetadata.length} new entries`);
@@ -2506,7 +2651,12 @@ class BaseMindooDB {
2506
2651
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
2507
2652
  }
2508
2653
  else {
2509
- const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
2654
+ // Cold sync: entryMetadataList is already the full doc history from
2655
+ // the unfiltered scan. Incremental sync of an uncached doc may only
2656
+ // have recent entries, so re-scan that doc for prior lifecycle state.
2657
+ const allDocMetadata = coldSync
2658
+ ? entryMetadataList
2659
+ : await this.scanAllMetadata(this.store, { docId });
2510
2660
  const mutationEntries = allDocMetadata.filter((e) => this.isDocumentReplayEntry(e));
2511
2661
  // Snapshots compact replay history but should not change the user-visible
2512
2662
  // modification time when the original create/change/delete/undelete entries still exist.
@@ -2519,6 +2669,12 @@ class BaseMindooDB {
2519
2669
  // changes nothing observable and may keep the idempotent skip.
2520
2670
  const batchHasReplayEntries = docLifecycleEntries.some((e) => this.isDocumentReplayEntry(e));
2521
2671
  this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed, batchHasReplayEntries);
2672
+ // `allDocMetadata` is this document's complete history in both
2673
+ // branches above. Keeping it lets the materialization that
2674
+ // follows (summary, full-text, a UI read) skip its own per-doc
2675
+ // scan — one HTTP request each on a network-backed store.
2676
+ // Must come after updateIndex, which invalidates the entry.
2677
+ this.rememberScannedEntryMetadata(docId, allDocMetadata);
2522
2678
  this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted}, awaitingWitness: ${awaitingWitness})`);
2523
2679
  }
2524
2680
  }
@@ -2636,6 +2792,143 @@ class BaseMindooDB {
2636
2792
  supportsCursorScan(store) {
2637
2793
  return typeof store.scanEntriesSince === "function";
2638
2794
  }
2795
+ /**
2796
+ * Retain a document's complete entry metadata for the materialization that
2797
+ * usually follows. Callers must pass the full history — see
2798
+ * {@link scannedEntryMetadata}.
2799
+ *
2800
+ * Skipped for time-travel instances: their scans are cutoff-scoped, and an
2801
+ * ad-hoc historical open does not repay the extra invariant.
2802
+ */
2803
+ rememberScannedEntryMetadata(docId, metadata) {
2804
+ if (this.timeTravelDate != null || metadata.length === 0) {
2805
+ return;
2806
+ }
2807
+ // A complete history starts at a create entry, or at a snapshot that
2808
+ // compacted one away. Anything else means the scan this came from was
2809
+ // truncated, and materializing a document from a truncated DAG yields a
2810
+ // wrong document rather than a slow one — drop it and let the
2811
+ // per-document scan do the work.
2812
+ const hasHistoryBase = metadata.some((entry) => entry.entryType === "doc_create" || entry.entryType === "doc_snapshot");
2813
+ if (!hasHistoryBase) {
2814
+ this.logger.warn(`Not retaining entry metadata for ${docId}: no doc_create or doc_snapshot among ${metadata.length} entries`);
2815
+ return;
2816
+ }
2817
+ this.forgetScannedEntryMetadata(docId);
2818
+ if (this.scannedEntryMetadataSize + metadata.length >
2819
+ MAX_SCANNED_ENTRY_METADATA_ENTRIES) {
2820
+ return;
2821
+ }
2822
+ this.scannedEntryMetadata.set(docId, metadata);
2823
+ this.scannedEntryMetadataSize += metadata.length;
2824
+ }
2825
+ /** Read the retained metadata without consuming it (batch planning). */
2826
+ peekScannedEntryMetadata(docId) {
2827
+ return this.scannedEntryMetadata.get(docId) ?? null;
2828
+ }
2829
+ /** Consume the retained metadata for a document, if any is held. */
2830
+ takeScannedEntryMetadata(docId) {
2831
+ const metadata = this.scannedEntryMetadata.get(docId);
2832
+ if (!metadata) {
2833
+ return null;
2834
+ }
2835
+ this.forgetScannedEntryMetadata(docId);
2836
+ return metadata;
2837
+ }
2838
+ forgetScannedEntryMetadata(docId) {
2839
+ const existing = this.scannedEntryMetadata.get(docId);
2840
+ if (!existing) {
2841
+ return;
2842
+ }
2843
+ this.scannedEntryMetadata.delete(docId);
2844
+ this.scannedEntryMetadataSize -= existing.length;
2845
+ }
2846
+ clearScannedEntryMetadata() {
2847
+ this.scannedEntryMetadata.clear();
2848
+ this.scannedEntryMetadataSize = 0;
2849
+ }
2850
+ /**
2851
+ * Fill {@link scannedEntryMetadata} for the whole database with a single
2852
+ * paged scan.
2853
+ *
2854
+ * The sync walk only retains metadata for the documents it processed, so an
2855
+ * open that restored its index from the cache checkpoint starts with an
2856
+ * empty map — and a pass over the changefeed then scans once per document.
2857
+ * One store-wide scan replaces all of those: it costs a request per 1000
2858
+ * entries instead of a request per document.
2859
+ *
2860
+ * All-or-nothing by necessity. The scan is ordered by receipt order, not by
2861
+ * document, so a run stopped halfway would leave arbitrary documents with a
2862
+ * partial history — and a partial history materializes a wrong document.
2863
+ * If the store holds more entries than the cap allows, nothing is retained
2864
+ * and the per-document path stays in charge.
2865
+ */
2866
+ async warmScannedEntryMetadataFromStore() {
2867
+ if (this.scannedEntryMetadataWarmed ||
2868
+ this.timeTravelDate != null ||
2869
+ !this.supportsCursorScan(this.store)) {
2870
+ return;
2871
+ }
2872
+ this.scannedEntryMetadataWarmed = true;
2873
+ const startedAt = Date.now();
2874
+ const byDoc = new Map();
2875
+ let scannedEntries = 0;
2876
+ let cursor = null;
2877
+ const filters = this.mergeTimeTravelScanFilters(undefined);
2878
+ while (true) {
2879
+ const page = await this.withRateLimitBackoff("metadata warm-up scan", () => this.store.scanEntriesSince(cursor, 1000, filters));
2880
+ for (const entry of page.entries) {
2881
+ const existing = byDoc.get(entry.docId);
2882
+ if (existing) {
2883
+ existing.push(entry);
2884
+ }
2885
+ else {
2886
+ byDoc.set(entry.docId, [entry]);
2887
+ }
2888
+ }
2889
+ scannedEntries += page.entries.length;
2890
+ if (scannedEntries > MAX_SCANNED_ENTRY_METADATA_ENTRIES) {
2891
+ this.logger.debug(`Metadata warm-up skipped: store holds more than ${MAX_SCANNED_ENTRY_METADATA_ENTRIES} entries`);
2892
+ return;
2893
+ }
2894
+ if (!page.hasMore || !page.nextCursor) {
2895
+ break;
2896
+ }
2897
+ cursor = page.nextCursor;
2898
+ }
2899
+ for (const [docId, metadata] of byDoc) {
2900
+ // Cached documents will not be materialized from this metadata.
2901
+ if (this.docCache.has(docId)) {
2902
+ continue;
2903
+ }
2904
+ this.rememberScannedEntryMetadata(docId, metadata);
2905
+ }
2906
+ // Logged at info: the entries-per-document ratio here is the quickest
2907
+ // answer to "does this database have the history I expect?".
2908
+ this.logger.info(`Metadata warm-up scanned ${scannedEntries} entries and retained ` +
2909
+ `${this.scannedEntryMetadataSize} for ${this.scannedEntryMetadata.size} ` +
2910
+ `document(s) in ${Date.now() - startedAt}ms`);
2911
+ }
2912
+ /**
2913
+ * Run a prefetch request, waiting out a rate limit instead of giving up on
2914
+ * it. Every other failure propagates to the caller, which treats a failed
2915
+ * prefetch as a miss and falls back to the per-document path.
2916
+ */
2917
+ async withRateLimitBackoff(description, run) {
2918
+ for (let attempt = 0;; attempt++) {
2919
+ try {
2920
+ return await run();
2921
+ }
2922
+ catch (error) {
2923
+ const delayMs = rateLimitRetryDelayMs(error, attempt);
2924
+ if (delayMs == null) {
2925
+ throw error;
2926
+ }
2927
+ this.logger.debug(`${description} rate limited; retrying in ${delayMs}ms (attempt ${attempt + 1})`);
2928
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
2929
+ }
2930
+ }
2931
+ }
2639
2932
  async scanAllMetadata(store, filters) {
2640
2933
  const effectiveFilters = this.mergeTimeTravelScanFilters(filters);
2641
2934
  if (!this.supportsCursorScan(store)) {
@@ -7975,15 +8268,146 @@ class BaseMindooDB {
7975
8268
  this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
7976
8269
  }
7977
8270
  }
7978
- async prefetchIterationWindow(indexSnapshot, startIndex, idPrefix) {
7979
- if (this.iteratePrefetchWindowDocs <= 0) {
7980
- return 0;
8271
+ /**
8272
+ * Load entries for a materialization, serving whatever
8273
+ * {@link materializationEntryBuffer} already holds and fetching the rest.
8274
+ * Results keep the requested id order.
8275
+ */
8276
+ async fetchEntries(ids) {
8277
+ const buffer = this.materializationEntryBuffer;
8278
+ if (!buffer || buffer.size === 0 || ids.length === 0) {
8279
+ return this.store.getEntries(ids);
8280
+ }
8281
+ const missingIds = ids.filter((id) => !buffer.has(id));
8282
+ const fetched = missingIds.length > 0 ? await this.store.getEntries(missingIds) : [];
8283
+ const fetchedById = new Map(fetched.map((entry) => [entry.id, entry]));
8284
+ const result = [];
8285
+ for (const id of ids) {
8286
+ const entry = buffer.get(id) ?? fetchedById.get(id);
8287
+ if (entry) {
8288
+ result.push(entry);
8289
+ }
8290
+ }
8291
+ return result;
8292
+ }
8293
+ /**
8294
+ * Fetch one page of a batched prefetch: waits out a rate limit, and splits
8295
+ * the page when the server rejects it as too large. The estimate that built
8296
+ * the page comes from `encryptedSize` metadata, so it can be wrong about
8297
+ * the framing overhead — halving is cheaper than guessing conservatively.
8298
+ */
8299
+ async fetchEntryPage(ids) {
8300
+ try {
8301
+ return await this.withRateLimitBackoff("entry prefetch", () => this.store.getEntries(ids));
8302
+ }
8303
+ catch (error) {
8304
+ if (networkErrorType(error) !== types_3.NetworkErrorType.PAYLOAD_TOO_LARGE ||
8305
+ ids.length <= 1) {
8306
+ throw error;
8307
+ }
8308
+ const half = Math.ceil(ids.length / 2);
8309
+ this.logger.debug(`Entry prefetch page of ${ids.length} rejected as too large; splitting`);
8310
+ return [
8311
+ ...(await this.fetchEntryPage(ids.slice(0, half))),
8312
+ ...(await this.fetchEntryPage(ids.slice(half))),
8313
+ ];
8314
+ }
8315
+ }
8316
+ /**
8317
+ * Fetch the entries a window of documents will need in a few large calls
8318
+ * instead of two or three per document. On a network-backed store that is
8319
+ * the difference between tens of requests and tens of thousands for a cold
8320
+ * pass over the changefeed.
8321
+ *
8322
+ * Plans locally from the metadata the sync walk retained
8323
+ * ({@link scannedEntryMetadata}); documents without retained metadata are
8324
+ * left out of the batch and materialize on their own. Pages are bounded by
8325
+ * encrypted payload bytes so peak memory stays predictable.
8326
+ *
8327
+ * Returns the buffer instead of installing it, so the caller controls its
8328
+ * lifetime.
8329
+ */
8330
+ async prefetchEntriesForDocuments(docIds) {
8331
+ const wantedSizes = new Map();
8332
+ for (const docId of docIds) {
8333
+ const metadata = this.peekScannedEntryMetadata(docId);
8334
+ if (!metadata) {
8335
+ continue;
8336
+ }
8337
+ const plan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, metadata);
8338
+ const sizeById = new Map(metadata.map((entry) => [entry.id, entry.encryptedSize ?? 0]));
8339
+ const planned = plan.snapshotEntryId
8340
+ ? [plan.snapshotEntryId, ...plan.entryIdsToApply]
8341
+ : plan.entryIdsToApply;
8342
+ for (const id of planned) {
8343
+ if (!wantedSizes.has(id)) {
8344
+ wantedSizes.set(id, sizeById.get(id) ?? 0);
8345
+ }
8346
+ }
8347
+ }
8348
+ if (wantedSizes.size === 0) {
8349
+ return null;
8350
+ }
8351
+ const buffer = new Map();
8352
+ let page = [];
8353
+ let pageBytes = 0;
8354
+ const flushPage = async () => {
8355
+ if (page.length === 0) {
8356
+ return;
8357
+ }
8358
+ for (const entry of await this.fetchEntryPage(page)) {
8359
+ buffer.set(entry.id, entry);
8360
+ }
8361
+ page = [];
8362
+ pageBytes = 0;
8363
+ };
8364
+ for (const [id, size] of wantedSizes) {
8365
+ // A single oversized entry still gets its own page: the budget bounds
8366
+ // a batch, never one document.
8367
+ if (page.length > 0 &&
8368
+ (pageBytes + size > MATERIALIZATION_PREFETCH_PAGE_BYTES ||
8369
+ page.length >= MATERIALIZATION_PREFETCH_MAX_IDS)) {
8370
+ await flushPage();
8371
+ }
8372
+ page.push(id);
8373
+ pageBytes += size;
8374
+ }
8375
+ await flushPage();
8376
+ return buffer;
8377
+ }
8378
+ /**
8379
+ * Materialize a window of upcoming documents so iteration reads them from
8380
+ * the cache instead of the store.
8381
+ *
8382
+ * Called after every yielded document, so it must be cheap when the runway
8383
+ * ahead is still full: `prefetchedThrough` is the index position the
8384
+ * previous window reached, and a call that still has half a window of
8385
+ * runway left returns without touching the store. Without that check the
8386
+ * pass issues a fetch per yielded document, and — because each call would
8387
+ * scan past the cached documents to find a full window of uncached ones —
8388
+ * it also runs unboundedly ahead of the cursor, materializing the whole
8389
+ * changefeed tail and evicting it from L1 before iteration arrives.
8390
+ *
8391
+ * Returns the position prefetched through, which the caller threads back in.
8392
+ */
8393
+ async prefetchIterationWindow(indexSnapshot, startIndex, prefetchedThrough, idPrefix) {
8394
+ const windowSize = this.iteratePrefetchWindowDocs;
8395
+ if (windowSize <= 0) {
8396
+ return { prefetchedThrough: startIndex, prefetchedDocuments: 0 };
8397
+ }
8398
+ const from = Math.max(startIndex, prefetchedThrough);
8399
+ if (from - startIndex >= Math.ceil(windowSize / 2)) {
8400
+ // Enough runway left; refilling now would trade one batched fetch per
8401
+ // window for one per document.
8402
+ return { prefetchedThrough, prefetchedDocuments: 0 };
7981
8403
  }
7982
8404
  const docIds = [];
7983
8405
  const seen = new Set();
7984
- // Only look ahead a bounded number of uncached docs so iteration does not
8406
+ // Look ahead a bounded number of uncached docs so iteration does not
7985
8407
  // materialize the entire remaining changefeed tail up front.
7986
- for (let i = startIndex; i < indexSnapshot.length && docIds.length < this.iteratePrefetchWindowDocs; i++) {
8408
+ let scannedThrough = from;
8409
+ for (let i = from; i < indexSnapshot.length && docIds.length < windowSize; i++) {
8410
+ scannedThrough = i + 1;
7987
8411
  const entry = indexSnapshot[i];
7988
8412
  // Skip docs that iterateChangesSince will emit as lightweight
7989
8413
  // tombstones anyway: inaccessible docs are never materialized, and
@@ -8003,15 +8427,42 @@ class BaseMindooDB {
8003
8427
  docIds.push(docId);
8004
8428
  }
8005
8429
  if (docIds.length === 0) {
8006
- return 0;
8430
+ return { prefetchedThrough: scannedThrough, prefetchedDocuments: 0 };
8007
8431
  }
8008
8432
  // Prefetch is opportunistic: iteration can still fall back to on-demand
8009
8433
  // loads, so individual prefetch failures are logged and ignored here.
8010
- await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId).catch((err) => {
8011
- this.logger.warn(`Failed to prefetch document ${docId}:`, err);
8012
- return null;
8013
- })));
8014
- return docIds.length;
8434
+ // The same holds for the batched entry fetch — a failure there just means
8435
+ // the loads below go to the store themselves.
8436
+ try {
8437
+ // Documents the sync walk did not process have no retained metadata, so
8438
+ // they would each scan the store on their own and the batch below would
8439
+ // have nothing to plan from. A window full of them means a bulk pass is
8440
+ // under way (a warm open with a cold document cache); one store-wide
8441
+ // scan covers all of it.
8442
+ const uncoveredDocs = docIds.filter((docId) => !this.peekScannedEntryMetadata(docId)).length;
8443
+ if (uncoveredDocs >= METADATA_WARMUP_MIN_UNCOVERED_DOCS) {
8444
+ await this.warmScannedEntryMetadataFromStore();
8445
+ }
8446
+ this.materializationEntryBuffer =
8447
+ await this.prefetchEntriesForDocuments(docIds);
8448
+ }
8449
+ catch (err) {
8450
+ this.logger.warn(`Failed to batch-fetch entries for prefetch window:`, err);
8451
+ this.materializationEntryBuffer = null;
8452
+ }
8453
+ try {
8454
+ await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId).catch((err) => {
8455
+ this.logger.warn(`Failed to prefetch document ${docId}:`, err);
8456
+ return null;
8457
+ })));
8458
+ }
8459
+ finally {
8460
+ this.materializationEntryBuffer = null;
8461
+ }
8462
+ return {
8463
+ prefetchedThrough: scannedThrough,
8464
+ prefetchedDocuments: docIds.length,
8465
+ };
8015
8466
  }
8016
8467
  getStartIndexForCursor(indexSnapshot, actualCursor) {
8017
8468
  let startIndex = 0;
@@ -8403,7 +8854,16 @@ class BaseMindooDB {
8403
8854
  // Find starting position using binary search
8404
8855
  // We want to find the first entry that is greater than the cursor.
8405
8856
  const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
8406
- let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex, idPrefix);
8857
+ let prefetchedDocuments = 0;
8858
+ // How far ahead the prefetch windows have reached. Kept per generator run
8859
+ // so concurrent iterations do not share a runway.
8860
+ let prefetchedThrough = startIndex;
8861
+ const prefetchFrom = async (from) => {
8862
+ const result = await this.prefetchIterationWindow(indexSnapshot, from, prefetchedThrough, idPrefix);
8863
+ prefetchedThrough = result.prefetchedThrough;
8864
+ prefetchedDocuments += result.prefetchedDocuments;
8865
+ };
8866
+ await prefetchFrom(startIndex);
8407
8867
  let yieldedDocuments = 0;
8408
8868
  let loadedDocuments = 0;
8409
8869
  try {
@@ -8428,7 +8888,7 @@ class BaseMindooDB {
8428
8888
  };
8429
8889
  yieldedDocuments++;
8430
8890
  yield { doc: this.buildInaccessibleDoc(entry), cursor: inaccessibleCursor };
8431
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
8891
+ await prefetchFrom(i + 1);
8432
8892
  continue;
8433
8893
  }
8434
8894
  if (entry.isDeleted) {
@@ -8445,7 +8905,7 @@ class BaseMindooDB {
8445
8905
  };
8446
8906
  yieldedDocuments++;
8447
8907
  yield { doc: this.buildDeletedDoc(entry), cursor: deletedCursor };
8448
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
8908
+ await prefetchFrom(i + 1);
8449
8909
  continue;
8450
8910
  }
8451
8911
  try {
@@ -8475,7 +8935,7 @@ class BaseMindooDB {
8475
8935
  // Deleted documents are included so external indexes can handle deletions
8476
8936
  yieldedDocuments++;
8477
8937
  yield { doc, cursor: currentCursor };
8478
- prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1, idPrefix);
8938
+ await prefetchFrom(i + 1);
8479
8939
  }
8480
8940
  catch (error) {
8481
8941
  this.logger.error(`Error processing document ${entry.docId}:`, error);
@@ -9567,7 +10027,10 @@ class BaseMindooDB {
9567
10027
  // TODO: Implement loading from last snapshot if available
9568
10028
  this.logger.debug(`Getting all entry hashes for document ${docId}`);
9569
10029
  const storeQueryStartedAt = Date.now();
9570
- const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
10030
+ // The sync walk may already have read this document's full metadata; on a
10031
+ // network-backed store that saves one request per document.
10032
+ const allEntryMetadata = this.takeScannedEntryMetadata(docId) ??
10033
+ (await this.scanAllMetadata(this.store, { docId }));
9571
10034
  storeQueryTime += Date.now() - storeQueryStartedAt;
9572
10035
  this.logger.debug(`Found ${allEntryMetadata.length} total entry hashes for document ${docId}`);
9573
10036
  if (allEntryMetadata.length === 0) {
@@ -9661,7 +10124,7 @@ class BaseMindooDB {
9661
10124
  if (startFromSnapshot && snapshotMeta) {
9662
10125
  this.logger.debug(`Loading snapshot for document ${docId}`);
9663
10126
  const snapshotLoadStartedAt = Date.now();
9664
- const snapshotEntries = await this.store.getEntries([snapshotMeta.id]);
10127
+ const snapshotEntries = await this.fetchEntries([snapshotMeta.id]);
9665
10128
  entryLoadTime += Date.now() - snapshotLoadStartedAt;
9666
10129
  this.logger.debug(`Retrieved ${snapshotEntries.length} snapshot entry(s) from store`);
9667
10130
  if (snapshotEntries.length > 0) {
@@ -9729,7 +10192,7 @@ class BaseMindooDB {
9729
10192
  // Load and apply all entries
9730
10193
  this.logger.debug(`Fetching ${entriesToLoad.length} entries from store for document ${docId}`);
9731
10194
  const entryLoadStartedAt = Date.now();
9732
- const entries = await this.store.getEntries(entriesToLoad.map(em => em.id));
10195
+ const entries = await this.fetchEntries(entriesToLoad.map(em => em.id));
9733
10196
  entryLoadTime += Date.now() - entryLoadStartedAt;
9734
10197
  this.logger.debug(`Retrieved ${entries.length} entries from store for document ${docId}`);
9735
10198
  this.logger.debug(`Loading document ${docId}: found ${entries.length} entries to apply (${startFromSnapshot ? 'starting from snapshot' : 'starting from scratch'})`);