mindoodb 0.0.66 → 0.0.69

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 (86) hide show
  1. package/README-server.md +77 -16
  2. package/README.md +41 -58
  3. package/dist/appendonlystores/network/HttpTransport.d.ts +2 -0
  4. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/HttpTransport.js +31 -6
  6. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  7. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +18 -8
  8. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +30 -16
  10. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  11. package/dist/browser/index.mjs +29 -0
  12. package/dist/core/BaseMindooDB.d.ts +168 -8
  13. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  14. package/dist/core/BaseMindooDB.js +763 -74
  15. package/dist/core/BaseMindooDB.js.map +1 -1
  16. package/dist/core/appendonlystores/network/IrohNetworkTransport.d.ts.map +1 -1
  17. package/dist/core/appendonlystores/network/IrohNetworkTransport.js +5 -1
  18. package/dist/core/appendonlystores/network/IrohNetworkTransport.js.map +1 -1
  19. package/dist/core/appendonlystores/network/IrohStreamIO.d.ts +10 -0
  20. package/dist/core/appendonlystores/network/IrohStreamIO.d.ts.map +1 -1
  21. package/dist/core/appendonlystores/network/IrohStreamIO.js.map +1 -1
  22. package/dist/core/appendonlystores/network/types.d.ts +20 -0
  23. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  24. package/dist/core/appendonlystores/network/types.js +33 -0
  25. package/dist/core/appendonlystores/network/types.js.map +1 -1
  26. package/dist/core/appendonlystores/syncStores.d.ts +80 -2
  27. package/dist/core/appendonlystores/syncStores.d.ts.map +1 -1
  28. package/dist/core/appendonlystores/syncStores.js +82 -9
  29. package/dist/core/appendonlystores/syncStores.js.map +1 -1
  30. package/dist/core/appendonlystores/types.d.ts +88 -6
  31. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  32. package/dist/core/appendonlystores/types.js +61 -1
  33. package/dist/core/appendonlystores/types.js.map +1 -1
  34. package/dist/core/builtinDbInvariants.d.ts +8 -5
  35. package/dist/core/builtinDbInvariants.d.ts.map +1 -1
  36. package/dist/core/builtinDbInvariants.js +8 -5
  37. package/dist/core/builtinDbInvariants.js.map +1 -1
  38. package/dist/core/expressions/evaluateExpression.d.ts.map +1 -1
  39. package/dist/core/expressions/evaluateExpression.js +7 -1
  40. package/dist/core/expressions/evaluateExpression.js.map +1 -1
  41. package/dist/core/index.d.ts +8 -0
  42. package/dist/core/index.d.ts.map +1 -1
  43. package/dist/core/index.js +45 -11
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +26 -3
  46. package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -1
  47. package/dist/core/indexing/summary/DocumentSummaryStore.js +100 -15
  48. package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -1
  49. package/dist/core/indexing/summary/extractSummaryFields.d.ts +10 -1
  50. package/dist/core/indexing/summary/extractSummaryFields.d.ts.map +1 -1
  51. package/dist/core/indexing/summary/extractSummaryFields.js +34 -7
  52. package/dist/core/indexing/summary/extractSummaryFields.js.map +1 -1
  53. package/dist/core/indexing/summary/types.d.ts +6 -0
  54. package/dist/core/indexing/summary/types.d.ts.map +1 -1
  55. package/dist/core/indexing/summary/types.js +8 -1
  56. package/dist/core/indexing/summary/types.js.map +1 -1
  57. package/dist/core/quarantine/QuarantineDocument.d.ts +211 -0
  58. package/dist/core/quarantine/QuarantineDocument.d.ts.map +1 -0
  59. package/dist/core/quarantine/QuarantineDocument.js +314 -0
  60. package/dist/core/quarantine/QuarantineDocument.js.map +1 -0
  61. package/dist/core/quarantine/QuarantineView.d.ts +90 -0
  62. package/dist/core/quarantine/QuarantineView.d.ts.map +1 -0
  63. package/dist/core/quarantine/QuarantineView.js +106 -0
  64. package/dist/core/quarantine/QuarantineView.js.map +1 -0
  65. package/dist/core/quarantine/recordPushRejections.d.ts +73 -0
  66. package/dist/core/quarantine/recordPushRejections.d.ts.map +1 -0
  67. package/dist/core/quarantine/recordPushRejections.js +112 -0
  68. package/dist/core/quarantine/recordPushRejections.js.map +1 -0
  69. package/dist/core/quarantine/resubmitQuarantined.d.ts +81 -0
  70. package/dist/core/quarantine/resubmitQuarantined.d.ts.map +1 -0
  71. package/dist/core/quarantine/resubmitQuarantined.js +120 -0
  72. package/dist/core/quarantine/resubmitQuarantined.js.map +1 -0
  73. package/dist/core/types.d.ts +132 -4
  74. package/dist/core/types.d.ts.map +1 -1
  75. package/dist/core/types.js.map +1 -1
  76. package/dist/core/values.d.ts +72 -0
  77. package/dist/core/values.d.ts.map +1 -0
  78. package/dist/core/values.js +107 -0
  79. package/dist/core/values.js.map +1 -0
  80. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  81. package/dist/node/server/MindooDBServer.js +6 -1
  82. package/dist/node/server/MindooDBServer.js.map +1 -1
  83. package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -1
  84. package/dist/node/server/peer/PeerReplicator.js +35 -15
  85. package/dist/node/server/peer/PeerReplicator.js.map +1 -1
  86. package/package.json +1 -1
@@ -5,6 +5,7 @@ exports.BaseMindooDB = exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = void 0;
5
5
  // React Native: native Rust (react-native-automerge-generated)
6
6
  // Browser/Node.js: WASM (@automerge/automerge/slim)
7
7
  const automerge_adapter_1 = require("./automerge-adapter");
8
+ const values_1 = require("./values");
8
9
  const storeEntryTime_1 = require("./storeEntryTime");
9
10
  const uuid_1 = require("uuid");
10
11
  const types_1 = require("./types");
@@ -150,6 +151,23 @@ const DEFAULT_WARMER_SCHEDULER = {
150
151
  * with a v2 header.
151
152
  */
152
153
  const DOC_CACHE_HEADER_VERSION = 2;
154
+ /**
155
+ * How many quarantine records one document keeps in the audit log.
156
+ *
157
+ * One refused entry usually taints its causal dependents too (the
158
+ * `cascade_dependent` reason), so a single bad entry near the start of a long
159
+ * history can quarantine everything after it. The log exists to say which
160
+ * document is affected and why; a bounded sample of entry ids answers that as
161
+ * well as an unbounded one, and keeps one broken document from crowding every
162
+ * other out of the log — and out of the checkpoint it is written to.
163
+ */
164
+ const MAX_QUARANTINE_RECORDS_PER_DOC = 200;
165
+ /**
166
+ * How many documents the audit log covers at once. Oldest-first eviction: a
167
+ * document that started failing long ago has had its chance to be noticed,
168
+ * whereas dropping the newest would hide the problem the user is looking at.
169
+ */
170
+ const MAX_QUARANTINE_DOCS = 500;
153
171
  /**
154
172
  * Hard-coded initial Automerge change bytes that seed every document created
155
173
  * with a caller-provided id (`createDocument({ id })`).
@@ -216,10 +234,19 @@ class BaseMindooDB {
216
234
  constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback, timeTravelDate = null) {
217
235
  /**
218
236
  * Per-tenant quarantine/audit log of entries rejected during materialization
219
- * (docs/accesscontrol.md §10). In-memory; surfaced via {@link getQuarantineLog}
220
- * for Haven's audit view.
237
+ * (docs/accesscontrol.md §10), grouped by document and persisted in the
238
+ * metadata checkpoint. Surfaced via {@link getQuarantineLog} for Haven's
239
+ * audit view.
240
+ *
241
+ * Grouped by document because that is the unit a verdict is recomputed in: a
242
+ * materialization pass re-evaluates every entry of one document and knows the
243
+ * complete set of quarantined ones when it ends. Replacing that document's
244
+ * group with the pass result is what makes recording idempotent — the log
245
+ * would otherwise gain a duplicate of every record on every re-materialization
246
+ * — and what lets it heal once trust is restored, since an entry that is
247
+ * accepted this time is simply absent from the new group.
221
248
  */
222
- this.quarantineLog = [];
249
+ this.quarantineLog = new Map();
223
250
  /**
224
251
  * Short-TTL cache of "is access control active for this tenant?" so the
225
252
  * materialization fast-path does not re-query the directory on every load.
@@ -446,6 +473,29 @@ class BaseMindooDB {
446
473
  /** Whether the setup document was already probed for FTS auto-activation. */
447
474
  this.fulltextSetupProbed = false;
448
475
  this.dirtyDocIds = new Set();
476
+ /**
477
+ * Per-document counter bumped by {@link updateIndex}, i.e. whenever new
478
+ * entries of a document are indexed. A materialization compares it before
479
+ * caching its result: if entries arrived while it was reading (a sync
480
+ * ingested the next page while a changefeed consumer was materializing),
481
+ * the result reflects an older entry set and must not land in L1 — the
482
+ * index already points past it, so a cached copy would be served (and
483
+ * flushed to L2 under the new changeSeq) as if it were current.
484
+ */
485
+ this.docEntryGeneration = new Map();
486
+ /**
487
+ * Ids of entries this instance wrote itself (local writes) that the next
488
+ * {@link syncStoreChanges} run has not consumed yet. Local writes update
489
+ * the index directly but do not advance the processed-entry cursor, so
490
+ * that run (at the latest on the next open) re-reads them as "new". For a
491
+ * cached doc that is a no-op; for an uncached one the metadata-first path
492
+ * used to force a changeSeq bump, re-emitting unchanged documents to every
493
+ * changefeed consumer (summary, views, full-text) and invalidating their
494
+ * L2 records. Entries listed here are known to be indexed already, so the
495
+ * bump is only taken when the recomputed index state actually differs.
496
+ * Persisted in the metadata checkpoint (the restart case).
497
+ */
498
+ this.locallyIndexedEntryIds = new Set();
449
499
  this.cacheMetaDirty = false;
450
500
  /**
451
501
  * Per-document fingerprint (`changeSeq:automergeHeads`) of the state last
@@ -455,6 +505,15 @@ class BaseMindooDB {
455
505
  * that were merely loaded and cached, not changed).
456
506
  */
457
507
  this.lastFlushedDocState = new Map();
508
+ /**
509
+ * Per-document `changeSeq` of the record known to be in the L2 cache.
510
+ * Unlike {@link lastFlushedDocState} it is persisted in the metadata
511
+ * checkpoint, so after a restart the background warmer can tell which
512
+ * documents need work without reading every L2 record. A missing entry
513
+ * means "unknown" (legacy checkpoint): the record header is read once.
514
+ * Maintained only via {@link rememberL2Record} / {@link forgetL2Record}.
515
+ */
516
+ this.l2RecordChangeSeqs = new Map();
458
517
  /**
459
518
  * Tenant KeyBag fingerprint observed at the time the in-memory index
460
519
  * was last reconciled with the underlying store.
@@ -586,7 +645,7 @@ class BaseMindooDB {
586
645
  usernameHashFromRecord(payload) {
587
646
  if (!payload || typeof payload !== "object")
588
647
  return null;
589
- const value = payload.username_hash;
648
+ const value = this.readAutomergeScalar(payload.username_hash);
590
649
  return typeof value === "string" && value.length > 0 ? value : null;
591
650
  }
592
651
  usernameHashFromInternalDoc(internalDoc) {
@@ -837,7 +896,7 @@ class BaseMindooDB {
837
896
  // flush-before-remove step.
838
897
  this.dirtyDocIds.clear();
839
898
  this.cacheMetaDirty = false;
840
- this.summaryStore?.clearDirty();
899
+ this.summaryStore?.discardDirtyState();
841
900
  this.fulltextIndex?.clearDirty();
842
901
  void cacheManager.deregister(this);
843
902
  if (this.summaryStore) {
@@ -1546,7 +1605,29 @@ class BaseMindooDB {
1546
1605
  value.set(headerBytes, 4);
1547
1606
  value.set(amBinary, 4 + headerBytes.length);
1548
1607
  await store.put("doc", `${prefix}/${docId}`, value);
1608
+ this.rememberL2Record(docId, changeSeq, flushKey);
1609
+ }
1610
+ /**
1611
+ * Record that the L2 cache holds `docId` at `changeSeq` (with the given
1612
+ * `changeSeq:automergeHeads` fingerprint). Marks the metadata checkpoint
1613
+ * dirty when the persisted per-doc changeSeq moved, so the next flush
1614
+ * carries it (flush-before-evict writes records outside a flush).
1615
+ */
1616
+ rememberL2Record(docId, changeSeq, flushKey) {
1549
1617
  this.lastFlushedDocState.set(docId, flushKey);
1618
+ if (this.l2RecordChangeSeqs.get(docId) !== changeSeq) {
1619
+ this.l2RecordChangeSeqs.set(docId, changeSeq);
1620
+ this.cacheMetaDirty = true;
1621
+ this.cacheManager?.markDirty();
1622
+ }
1623
+ }
1624
+ /** Forget everything known about the L2 record of `docId`. */
1625
+ forgetL2Record(docId) {
1626
+ this.lastFlushedDocState.delete(docId);
1627
+ if (this.l2RecordChangeSeqs.delete(docId)) {
1628
+ this.cacheMetaDirty = true;
1629
+ this.cacheManager?.markDirty();
1630
+ }
1550
1631
  }
1551
1632
  exportMetadataCheckpoint() {
1552
1633
  const checkpoint = {
@@ -1570,6 +1651,16 @@ class BaseMindooDB {
1570
1651
  if (!this.supportsCursorScan(this.store)) {
1571
1652
  checkpoint.processedEntryIds = this.processedEntryIds;
1572
1653
  }
1654
+ // Additive field: older checkpoints restore an empty set (the next
1655
+ // syncStoreChanges then re-emits those local writes once, as before).
1656
+ if (this.locallyIndexedEntryIds.size > 0) {
1657
+ checkpoint.locallyIndexedEntryIds = Array.from(this.locallyIndexedEntryIds);
1658
+ }
1659
+ // Additive field: older checkpoints restore an empty map, which makes
1660
+ // the warmer read each record header once (the previous behavior).
1661
+ if (this.l2RecordChangeSeqs.size > 0) {
1662
+ checkpoint.l2RecordChangeSeqs = Object.fromEntries(this.l2RecordChangeSeqs);
1663
+ }
1573
1664
  // Persist the KeyBag fingerprint observed at the last reconciliation
1574
1665
  // so the next restore can decide whether to skip the visibility
1575
1666
  // metadata scan. `null` is acceptable - older checkpoints simply
@@ -1594,6 +1685,14 @@ class BaseMindooDB {
1594
1685
  // the checkpoint as tally-aware, so restore can tell "nothing hidden"
1595
1686
  // apart from "written before this existed, count unknown".
1596
1687
  checkpoint.inaccessibleDocIds = Array.from(this.inaccessibleDocIds);
1688
+ // Persist the materialization quarantine log (additive field, no version
1689
+ // bump: a missing log restores as empty, which is exactly what the log was
1690
+ // before it was persisted at all — and bumping the version would force a
1691
+ // full metadata rebuild on every existing installation to gain nothing but
1692
+ // an audit trail).
1693
+ if (this.quarantineLog.size > 0) {
1694
+ checkpoint.quarantineLog = Object.fromEntries(this.quarantineLog);
1695
+ }
1597
1696
  return new TextEncoder().encode(JSON.stringify(checkpoint));
1598
1697
  }
1599
1698
  /**
@@ -1620,6 +1719,18 @@ class BaseMindooDB {
1620
1719
  if (checkpoint.processedEntryIds) {
1621
1720
  this.processedEntryIds = checkpoint.processedEntryIds;
1622
1721
  }
1722
+ this.locallyIndexedEntryIds = new Set(Array.isArray(checkpoint.locallyIndexedEntryIds)
1723
+ ? checkpoint.locallyIndexedEntryIds.filter((id) => typeof id === "string")
1724
+ : []);
1725
+ this.l2RecordChangeSeqs.clear();
1726
+ if (checkpoint.l2RecordChangeSeqs &&
1727
+ typeof checkpoint.l2RecordChangeSeqs === "object") {
1728
+ for (const [docId, changeSeq] of Object.entries(checkpoint.l2RecordChangeSeqs)) {
1729
+ if (typeof changeSeq === "number") {
1730
+ this.l2RecordChangeSeqs.set(docId, changeSeq);
1731
+ }
1732
+ }
1733
+ }
1623
1734
  this.lastReconciledKeyBagFingerprint =
1624
1735
  typeof checkpoint.keyBagFingerprint === "string"
1625
1736
  ? checkpoint.keyBagFingerprint
@@ -1659,6 +1770,31 @@ class BaseMindooDB {
1659
1770
  else {
1660
1771
  this.legacyCheckpointNeedsVisibilityTally = true;
1661
1772
  }
1773
+ // Materialization quarantine log. Absent in checkpoints written before it
1774
+ // was persisted, and absent whenever nothing is quarantined — both mean
1775
+ // an empty log, and no heal flag is needed: the next materialization of
1776
+ // an affected document re-derives its verdict anyway.
1777
+ this.quarantineLog.clear();
1778
+ if (checkpoint.quarantineLog &&
1779
+ typeof checkpoint.quarantineLog === "object") {
1780
+ for (const [docId, records] of Object.entries(checkpoint.quarantineLog)) {
1781
+ if (!Array.isArray(records))
1782
+ continue;
1783
+ const valid = records
1784
+ .filter((rec) => Boolean(rec &&
1785
+ typeof rec === "object" &&
1786
+ typeof rec.entryId === "string" &&
1787
+ typeof rec.reason === "string"))
1788
+ // Re-apply the caps on read: this file is on disk and may have been
1789
+ // written by a build with different limits.
1790
+ .slice(0, MAX_QUARANTINE_RECORDS_PER_DOC);
1791
+ if (valid.length === 0)
1792
+ continue;
1793
+ if (this.quarantineLog.size >= MAX_QUARANTINE_DOCS)
1794
+ break;
1795
+ this.quarantineLog.set(docId, valid);
1796
+ }
1797
+ }
1662
1798
  this.syncScanCursors.clear();
1663
1799
  if (checkpoint.syncScanCursors &&
1664
1800
  typeof checkpoint.syncScanCursors === "object") {
@@ -1716,6 +1852,17 @@ class BaseMindooDB {
1716
1852
  const docIds = await store.list("doc");
1717
1853
  const docPrefix = `${prefix}/`;
1718
1854
  const allOwnedKeys = docIds.filter((id) => id.startsWith(docPrefix));
1855
+ // The persisted per-doc L2 state must not outlive its record (a
1856
+ // record lost outside this instance would otherwise be skipped by the
1857
+ // warmer forever).
1858
+ if (this.l2RecordChangeSeqs.size > 0) {
1859
+ const ownedDocIds = new Set(allOwnedKeys.map((key) => key.slice(docPrefix.length)));
1860
+ for (const docId of Array.from(this.l2RecordChangeSeqs.keys())) {
1861
+ if (!ownedDocIds.has(docId)) {
1862
+ this.forgetL2Record(docId);
1863
+ }
1864
+ }
1865
+ }
1719
1866
  if (this.restoreToL2) {
1720
1867
  // L2-only restore: leave L1 empty; let lazy reads via
1721
1868
  // tryLoadFromL2 promote individual docs as they are needed. We
@@ -1766,9 +1913,14 @@ class BaseMindooDB {
1766
1913
  const docId = batch[i].slice(docPrefix.length);
1767
1914
  await store.delete("doc", batch[i]);
1768
1915
  this.docCache.delete(docId);
1769
- this.lastFlushedDocState.delete(docId);
1916
+ this.forgetL2Record(docId);
1770
1917
  continue;
1771
1918
  }
1919
+ // Legacy checkpoints lack the per-doc L2 state; the header
1920
+ // just read provides it for free.
1921
+ if (!this.l2RecordChangeSeqs.has(deserialized.internal.id)) {
1922
+ this.rememberL2Record(deserialized.internal.id, deserialized.persistedChangeSeq, `${deserialized.persistedChangeSeq}:${deserialized.persistedHeads.join(",")}`);
1923
+ }
1772
1924
  await this.storeCachedDocument(deserialized.internal);
1773
1925
  restoredDocs++;
1774
1926
  }
@@ -1799,12 +1951,18 @@ class BaseMindooDB {
1799
1951
  * fall back to the normal materialization path when they encounter
1800
1952
  * one. Throws on truly malformed records (caught by callers).
1801
1953
  */
1802
- deserializeDoc(value) {
1954
+ /** Split an L2 record into its JSON header and the Automerge binary. */
1955
+ parseDocCacheRecord(value) {
1803
1956
  const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
1804
1957
  const headerLen = view.getUint32(0, false);
1805
1958
  const headerBytes = value.slice(4, 4 + headerLen);
1806
- const amBinary = value.slice(4 + headerLen);
1807
- const header = JSON.parse(new TextDecoder().decode(headerBytes));
1959
+ return {
1960
+ header: JSON.parse(new TextDecoder().decode(headerBytes)),
1961
+ amBinary: value.slice(4 + headerLen),
1962
+ };
1963
+ }
1964
+ deserializeDoc(value) {
1965
+ const { header, amBinary } = this.parseDocCacheRecord(value);
1808
1966
  if (header.version !== DOC_CACHE_HEADER_VERSION) {
1809
1967
  // v1 records (no `version`) and unknown future versions: skip
1810
1968
  // gracefully so they get re-flushed with the current header on
@@ -2027,6 +2185,7 @@ class BaseMindooDB {
2027
2185
  // Every path that writes entries for a document lands here, so this is
2028
2186
  // where retained metadata for it goes stale (see scannedEntryMetadata).
2029
2187
  this.forgetScannedEntryMetadata(docId);
2188
+ this.docEntryGeneration.set(docId, (this.docEntryGeneration.get(docId) ?? 0) + 1);
2030
2189
  // Same reasoning for the unreadable-document tally: every reveal and every
2031
2190
  // inaccessibility tombstone passes through here, so keeping it in step
2032
2191
  // needs no bookkeeping at the individual call sites. The one case this
@@ -2146,6 +2305,17 @@ class BaseMindooDB {
2146
2305
  }
2147
2306
  this.indexLookupStaleFrom = null;
2148
2307
  }
2308
+ /**
2309
+ * Write entries created by this instance to the docs store. The callers
2310
+ * index the affected documents right away (see
2311
+ * {@link locallyIndexedEntryIds}).
2312
+ */
2313
+ async putLocalEntries(entries) {
2314
+ await this.store.putEntries(entries);
2315
+ for (const entry of entries) {
2316
+ this.locallyIndexedEntryIds.add(entry.id);
2317
+ }
2318
+ }
2149
2319
  /**
2150
2320
  * Register an automerge hash -> entry ID mapping for a document.
2151
2321
  * This is used to resolve Automerge dependency hashes to entry IDs.
@@ -2560,7 +2730,7 @@ class BaseMindooDB {
2560
2730
  async purgeMaterializedDocument(docId) {
2561
2731
  this.docCache.delete(docId);
2562
2732
  this.dirtyDocIds.delete(docId);
2563
- this.lastFlushedDocState.delete(docId);
2733
+ this.forgetL2Record(docId);
2564
2734
  this.automergeHashToEntryId.delete(docId);
2565
2735
  this.forgetScannedEntryMetadata(docId);
2566
2736
  // Plaintext summary values must not outlive the purge either.
@@ -2887,10 +3057,12 @@ class BaseMindooDB {
2887
3057
  this.indexLookupStaleFrom = null;
2888
3058
  this.docCache.clear();
2889
3059
  this.lastFlushedDocState.clear();
3060
+ this.l2RecordChangeSeqs.clear();
2890
3061
  this.automergeHashToEntryId.clear();
2891
3062
  this.clearScannedEntryMetadata();
2892
3063
  this.processedEntryIds = [];
2893
3064
  this.processedEntryCursor = null;
3065
+ this.locallyIndexedEntryIds.clear();
2894
3066
  this.nextChangeSeq = 1;
2895
3067
  this.cacheMetaDirty = true;
2896
3068
  this.cacheManager?.markDirty();
@@ -3076,7 +3248,11 @@ class BaseMindooDB {
3076
3248
  // Force the re-emit only when the batch contains actual replay
3077
3249
  // entries (create/change/delete/undelete): a snapshot-only batch
3078
3250
  // changes nothing observable and may keep the idempotent skip.
3079
- const batchHasReplayEntries = docLifecycleEntries.some((e) => this.isDocumentReplayEntry(e));
3251
+ // Replay entries this instance wrote itself are already indexed
3252
+ // (the local write bumped changeSeq), so they only re-emit when
3253
+ // the recomputed state differs.
3254
+ const batchHasReplayEntries = docLifecycleEntries.some((e) => this.isDocumentReplayEntry(e) &&
3255
+ !this.locallyIndexedEntryIds.has(e.id));
3080
3256
  this.updateIndex(docId, lastModified, isDeleted, representativeEntry.decryptionKeyId, "visible", awaitingWitness, witnessed, batchHasReplayEntries);
3081
3257
  // `allDocMetadata` is this document's complete history in both
3082
3258
  // branches above. Keeping it lets the materialization that
@@ -3184,6 +3360,9 @@ class BaseMindooDB {
3184
3360
  if (!this.supportsCursorScan(this.store)) {
3185
3361
  this.processedEntryIds.push(...newEntryMetadata.map((em) => em.id));
3186
3362
  }
3363
+ for (const entryMeta of newEntryMetadata) {
3364
+ this.locallyIndexedEntryIds.delete(entryMeta.id);
3365
+ }
3187
3366
  this.processedEntryCursor = nextCursor;
3188
3367
  this.cacheMetaDirty = true;
3189
3368
  this.cacheManager?.markDirty();
@@ -3464,7 +3643,7 @@ class BaseMindooDB {
3464
3643
  },
3465
3644
  };
3466
3645
  }
3467
- async syncEntriesFromStore(sourceStore, targetStore, options) {
3646
+ async syncEntriesFromStore(sourceStore, targetStore, options, onPageTransferred) {
3468
3647
  const signal = options?.signal;
3469
3648
  (0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, signal);
3470
3649
  (0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, signal);
@@ -3482,6 +3661,7 @@ class BaseMindooDB {
3482
3661
  logger: this.logger,
3483
3662
  cursors: this.syncCursorStore(),
3484
3663
  rejectionPolicy: "advance",
3664
+ onPageTransferred,
3485
3665
  });
3486
3666
  }
3487
3667
  catch (error) {
@@ -3810,7 +3990,7 @@ class BaseMindooDB {
3810
3990
  allEntries.push(...preparedDoc.entries);
3811
3991
  }
3812
3992
  if (allEntries.length > 0) {
3813
- await this.store.putEntries(allEntries);
3993
+ await this.putLocalEntries(allEntries);
3814
3994
  }
3815
3995
  for (const { index, internalDoc } of prepared) {
3816
3996
  await this.storeCachedDocument(internalDoc);
@@ -3917,8 +4097,12 @@ class BaseMindooDB {
3917
4097
  // starting with "_", e.g. "_attachments") are managed by MindooDB and must
3918
4098
  // not be seeded by callers. `undefined` values are treated as "not set"
3919
4099
  // (Automerge cannot represent undefined).
4100
+ // `MindooValue` typed values become CRDT values here (this also validates
4101
+ // them before anything is written).
3920
4102
  const initialValueEntries = options.initialValues
3921
- ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4103
+ ? Object.entries(options.initialValues)
4104
+ .filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4105
+ .map(([k, v]) => [k, this.hydrateTypedValues(v)])
3922
4106
  : [];
3923
4107
  const hasInitialValues = initialValueEntries.length > 0;
3924
4108
  if (useCustomDocId) {
@@ -4122,7 +4306,7 @@ class BaseMindooDB {
4122
4306
  encryptedData: encryptedPayload,
4123
4307
  };
4124
4308
  // Store entry
4125
- await this.store.putEntries([fullEntry]);
4309
+ await this.putLocalEntries([fullEntry]);
4126
4310
  // Register automerge hash -> entry ID mapping
4127
4311
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
4128
4312
  // A freshly created entry is versioned and un-witnessed, so the document is
@@ -4181,8 +4365,12 @@ class BaseMindooDB {
4181
4365
  // Sanitize caller-provided initial values: internal/reserved fields are
4182
4366
  // managed by MindooDB and must not be seeded by callers. `undefined`
4183
4367
  // values are treated as "not set" (Automerge cannot represent undefined).
4368
+ // `MindooValue` typed values become CRDT values here (this also validates
4369
+ // them before anything is written).
4184
4370
  const initialValueEntries = options.initialValues
4185
- ? Object.entries(options.initialValues).filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4371
+ ? Object.entries(options.initialValues)
4372
+ .filter(([k, v]) => !k.startsWith("_") && v !== undefined)
4373
+ .map(([k, v]) => [k, this.hydrateTypedValues(v)])
4186
4374
  : [];
4187
4375
  const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
4188
4376
  const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
@@ -6710,7 +6898,7 @@ class BaseMindooDB {
6710
6898
  if (prepared.length === 0) {
6711
6899
  return;
6712
6900
  }
6713
- await this.store.putEntries(prepared.map((p) => p.fullEntry));
6901
+ await this.putLocalEntries(prepared.map((p) => p.fullEntry));
6714
6902
  for (const p of prepared) {
6715
6903
  await this.applyLifecycleEntryLocally(p, { markDirty: false });
6716
6904
  }
@@ -6743,7 +6931,7 @@ class BaseMindooDB {
6743
6931
  */
6744
6932
  async writeLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword, bypassPrecheck) {
6745
6933
  const prepared = await this.prepareLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword, bypassPrecheck);
6746
- await this.store.putEntries([prepared.fullEntry]);
6934
+ await this.putLocalEntries([prepared.fullEntry]);
6747
6935
  await this.applyLifecycleEntryLocally(prepared, { markDirty: true });
6748
6936
  }
6749
6937
  /**
@@ -6836,13 +7024,11 @@ class BaseMindooDB {
6836
7024
  ...entryMetadata,
6837
7025
  encryptedData: encryptedPayload,
6838
7026
  };
6839
- // Client-side write-policy precheck (docs/accesscontrol.md §9). `internalDoc.doc`
6840
- // is still the pre-change state here (reassigned in
6841
- // applyLifecycleEntryLocally, after the store write). For deletes the rules
6842
- // default to the "before" state; both states are supplied so either `when`
6843
- // resolves. Throws AccessDeniedError if denied.
7027
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). For deletes
7028
+ // the rules default to the "before" state; both states are supplied so either
7029
+ // `when` resolves. Throws AccessDeniedError if denied.
6844
7030
  {
6845
- const beforeState = internalDoc.doc;
7031
+ const beforeState = this.docAtHeads(internalDoc, newDoc, automergeDepHashes);
6846
7032
  await this.assertWriteAllowed({
6847
7033
  op: entryType,
6848
7034
  signerKey: createdByPublicKey,
@@ -7174,6 +7360,87 @@ class BaseMindooDB {
7174
7360
  spans: this.dehydrateRichTextSpans(spans),
7175
7361
  };
7176
7362
  }
7363
+ async getTextCursors(doc, path, positions, options) {
7364
+ this.validateJsonPath(path, "Text cursor");
7365
+ if (!Array.isArray(positions)) {
7366
+ throw new Error("Text cursor positions must be an array");
7367
+ }
7368
+ const { automergeDoc, heads } = await this.resolveTextCursorTarget(doc, options);
7369
+ if (options?.move !== undefined &&
7370
+ options.move !== "before" &&
7371
+ options.move !== "after") {
7372
+ throw new Error('Text cursor move must be "before" or "after"');
7373
+ }
7374
+ const source = this.textCursorSource(automergeDoc, heads);
7375
+ const text = this.readTextForCursor(source, path);
7376
+ const cursors = positions.map((position) => {
7377
+ let target;
7378
+ if (position === "start" || position === "end") {
7379
+ target = position;
7380
+ }
7381
+ else if (typeof position === "number" && Number.isFinite(position)) {
7382
+ target = this.clampIndex(position, text.length);
7383
+ }
7384
+ else {
7385
+ throw new Error("Text cursor positions must be numbers, \"start\" or \"end\"");
7386
+ }
7387
+ return automerge_adapter_1.Automerge.getCursor(source, path, target, options?.move);
7388
+ });
7389
+ return {
7390
+ path: [...path],
7391
+ heads: heads ?? automerge_adapter_1.Automerge.getHeads(automergeDoc),
7392
+ cursors,
7393
+ };
7394
+ }
7395
+ async resolveTextCursors(doc, path, cursors, options) {
7396
+ this.validateJsonPath(path, "Text cursor");
7397
+ if (!Array.isArray(cursors) ||
7398
+ cursors.some((cursor) => typeof cursor !== "string" || cursor === "")) {
7399
+ throw new Error("Text cursors must be non-empty strings");
7400
+ }
7401
+ const { automergeDoc, heads } = await this.resolveTextCursorTarget(doc, options);
7402
+ const source = this.textCursorSource(automergeDoc, heads);
7403
+ this.readTextForCursor(source, path);
7404
+ const positions = cursors.map((cursor) => automerge_adapter_1.Automerge.getCursorPosition(source, path, cursor));
7405
+ return {
7406
+ path: [...path],
7407
+ heads: heads ?? automerge_adapter_1.Automerge.getHeads(automergeDoc),
7408
+ positions,
7409
+ };
7410
+ }
7411
+ async resolveTextCursorTarget(doc, options) {
7412
+ const docId = doc.getId();
7413
+ let internalDoc = this.wrappedInternalDocs.get(doc) ?? null;
7414
+ if (!internalDoc) {
7415
+ internalDoc = this.getCachedDocument(docId);
7416
+ }
7417
+ if (!internalDoc) {
7418
+ const loadedDoc = await this.loadDocumentInternal(docId);
7419
+ if (!loadedDoc) {
7420
+ throw new errors_1.DocumentNotFoundError(docId);
7421
+ }
7422
+ internalDoc = loadedDoc;
7423
+ }
7424
+ if (internalDoc.isDeleted) {
7425
+ throw new errors_1.DocumentDeletedError(docId);
7426
+ }
7427
+ const heads = options?.heads && options.heads.length > 0
7428
+ ? options.heads
7429
+ : undefined;
7430
+ return { automergeDoc: internalDoc.doc, heads };
7431
+ }
7432
+ /** The document itself, or a read-only view of it at `heads`. */
7433
+ textCursorSource(automergeDoc, heads) {
7434
+ return heads ? automerge_adapter_1.Automerge.view(automergeDoc, heads) : automergeDoc;
7435
+ }
7436
+ /** Reads the text a cursor operates on, rejecting non-text fields early. */
7437
+ readTextForCursor(source, path) {
7438
+ const value = this.readValueAtPath(source, path);
7439
+ if (typeof value !== "string") {
7440
+ throw new Error(`Text cursors require an Automerge text field at '${path.map(String).join(".")}'`);
7441
+ }
7442
+ return value;
7443
+ }
7177
7444
  async exportAutomergeSnapshot(doc) {
7178
7445
  const internalDoc = await this.resolveReadableInternalDoc(doc);
7179
7446
  const binary = automerge_adapter_1.Automerge.save(internalDoc.doc);
@@ -7385,6 +7652,9 @@ class BaseMindooDB {
7385
7652
  const pendingAttachmentAppends = new Map();
7386
7653
  // Map of attachmentId -> extraction result for setAttachmentExtractedText
7387
7654
  const pendingAttachmentTextUpdates = new Map();
7655
+ // incrementCounter() calls, applied in call order after the field
7656
+ // assignments so they can target counters assigned in the same callback.
7657
+ const pendingCounterIncrements = [];
7388
7658
  // Reference to db for closures
7389
7659
  const db = this;
7390
7660
  // Guard flag to prevent operations after callback completes
@@ -7449,7 +7719,9 @@ class BaseMindooDB {
7449
7719
  if (typeof prop === "string" && pendingChanges.has(prop)) {
7450
7720
  return pendingChanges.get(prop);
7451
7721
  }
7452
- return target[prop];
7722
+ // Counters read as numbers and immutable strings as strings here
7723
+ // too; change counters with incrementCounter().
7724
+ return db.readAutomergeScalar(target[prop]);
7453
7725
  },
7454
7726
  has: (target, prop) => {
7455
7727
  // If marked for deletion, it doesn't exist
@@ -7577,6 +7849,14 @@ class BaseMindooDB {
7577
7849
  });
7578
7850
  }
7579
7851
  },
7852
+ // ========== Counter Write Methods ==========
7853
+ incrementCounter: (path, delta) => {
7854
+ throwIfCallbackInactive("incrementCounter");
7855
+ const segments = typeof path === "string" ? [path] : [...path];
7856
+ db.validateJsonPath(segments, "incrementCounter");
7857
+ (0, values_1.assertCounterAmount)(delta, "incrementCounter delta");
7858
+ pendingCounterIncrements.push({ path: segments, delta });
7859
+ },
7580
7860
  // ========== Attachment Read Methods (also work in changeDoc context) ==========
7581
7861
  getAttachments: () => {
7582
7862
  const payload = internalDoc.doc;
@@ -7612,12 +7892,15 @@ class BaseMindooDB {
7612
7892
  newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, (automergeDoc) => {
7613
7893
  // Apply all pending changes (sets/updates)
7614
7894
  for (const [key, value] of pendingChanges) {
7615
- automergeDoc[key] = value;
7895
+ automergeDoc[key] = this.hydrateTypedValues(value);
7616
7896
  }
7617
7897
  // Apply all pending deletions
7618
7898
  for (const key of pendingDeletions) {
7619
7899
  delete automergeDoc[key];
7620
7900
  }
7901
+ for (const { path, delta } of pendingCounterIncrements) {
7902
+ this.incrementCounterAtPath(automergeDoc, path, delta);
7903
+ }
7621
7904
  // Apply pending attachment changes
7622
7905
  if (pendingAttachmentAdditions.length > 0 ||
7623
7906
  pendingAttachmentRemovals.size > 0 ||
@@ -7741,12 +8024,14 @@ class BaseMindooDB {
7741
8024
  (patch.listInsert?.length ?? 0) +
7742
8025
  (patch.textSplice?.length ?? 0) +
7743
8026
  (patch.textMark?.length ?? 0) +
7744
- (patch.textUnmark?.length ?? 0);
8027
+ (patch.textUnmark?.length ?? 0) +
8028
+ (patch.counterIncrement?.length ?? 0);
7745
8029
  if (operationCount === 0) {
7746
8030
  throw new Error("JSON patch must include at least one operation");
7747
8031
  }
7748
8032
  for (const operation of patch.set ?? []) {
7749
8033
  this.validateJsonPath(operation.path, "JSON set");
8034
+ this.hydrateTypedValues(operation.value);
7750
8035
  }
7751
8036
  for (const operation of patch.unset ?? []) {
7752
8037
  this.validateJsonPath(operation.path, "JSON unset");
@@ -7769,6 +8054,7 @@ class BaseMindooDB {
7769
8054
  if (!Array.isArray(operation.values)) {
7770
8055
  throw new Error("JSON listInsert values must be an array");
7771
8056
  }
8057
+ this.hydrateTypedValues(operation.values);
7772
8058
  }
7773
8059
  for (const operation of patch.textSplice ?? []) {
7774
8060
  this.validateJsonPath(operation.path, "JSON textSplice");
@@ -7811,6 +8097,134 @@ class BaseMindooDB {
7811
8097
  throw new Error("JSON textUnmark names must be non-empty strings");
7812
8098
  }
7813
8099
  }
8100
+ for (const operation of patch.counterIncrement ?? []) {
8101
+ this.validateJsonPath(operation.path, "JSON counterIncrement");
8102
+ (0, values_1.assertCounterAmount)(operation.delta, "JSON counterIncrement delta");
8103
+ }
8104
+ }
8105
+ isAutomergeCounter(value) {
8106
+ if (value === null || typeof value !== "object")
8107
+ return false;
8108
+ const automerge = automerge_adapter_1.Automerge;
8109
+ if (typeof automerge.isCounter === "function") {
8110
+ return automerge.isCounter(value);
8111
+ }
8112
+ return (typeof automerge.Counter === "function" &&
8113
+ value instanceof automerge.Counter);
8114
+ }
8115
+ isAutomergeImmutableString(value) {
8116
+ if (value === null || typeof value !== "object")
8117
+ return false;
8118
+ const check = automerge_adapter_1.Automerge
8119
+ .isImmutableString;
8120
+ return typeof check === "function" && check(value);
8121
+ }
8122
+ /**
8123
+ * Plain-JS read view of an Automerge scalar wrapper: counters become numbers
8124
+ * and immutable strings become strings. Everything else is returned as is.
8125
+ */
8126
+ readAutomergeScalar(value) {
8127
+ if (this.isAutomergeCounter(value))
8128
+ return value.value;
8129
+ if (this.isAutomergeImmutableString(value))
8130
+ return String(value);
8131
+ return value;
8132
+ }
8133
+ /**
8134
+ * Turns `MindooValue` typed values (tagged plain objects, see values.ts)
8135
+ * anywhere inside `value` into their CRDT representation, and copies plain
8136
+ * objects and arrays into fresh literals on the way. Values that already are
8137
+ * CRDT scalars, dates or bytes pass through. Throws for malformed tags, so
8138
+ * it doubles as validation.
8139
+ */
8140
+ hydrateTypedValues(value, depth = 0) {
8141
+ if (value === null || typeof value !== "object")
8142
+ return value;
8143
+ if (depth > 100) {
8144
+ throw new Error("Document value is nested too deeply");
8145
+ }
8146
+ if (value instanceof Uint8Array ||
8147
+ value instanceof Date ||
8148
+ this.isAutomergeCounter(value) ||
8149
+ this.isAutomergeImmutableString(value)) {
8150
+ return value;
8151
+ }
8152
+ if (Array.isArray(value)) {
8153
+ return value.map((entry) => this.hydrateTypedValues(entry, depth + 1));
8154
+ }
8155
+ const record = value;
8156
+ if (Object.prototype.hasOwnProperty.call(record, values_1.MINDOO_VALUE_TAG)) {
8157
+ const typed = (0, values_1.parseTypedValue)(record);
8158
+ switch (typed.$mindoo) {
8159
+ case "atomic":
8160
+ return new automerge_adapter_1.Automerge.ImmutableString(typed.value);
8161
+ case "counter":
8162
+ return new automerge_adapter_1.Automerge.Counter(typed.value);
8163
+ case "timestamp":
8164
+ return (0, values_1.toTimestampDate)(typed.value, 'Typed value "timestamp"');
8165
+ }
8166
+ }
8167
+ const result = {};
8168
+ for (const [key, entry] of Object.entries(record)) {
8169
+ if (entry !== undefined) {
8170
+ result[key] = this.hydrateTypedValues(entry, depth + 1);
8171
+ }
8172
+ }
8173
+ return result;
8174
+ }
8175
+ /**
8176
+ * The plain read view of a value that may contain `MindooValue` tags, as
8177
+ * `getData()` would return it once written (used where a precheck needs the
8178
+ * "after" state before the value is in a document).
8179
+ */
8180
+ readTypedValues(value) {
8181
+ return this.normalizeAutomergeScalars(this.hydrateTypedValues(value));
8182
+ }
8183
+ /**
8184
+ * Replaces counters and atomic strings inside a materialized value with
8185
+ * plain numbers and strings, in place where possible. Reads (getData, the
8186
+ * query and summary indexes, write prechecks) then compare them like any
8187
+ * other JSON value.
8188
+ */
8189
+ normalizeAutomergeScalars(value, depth = 0) {
8190
+ if (value === null || typeof value !== "object")
8191
+ return value;
8192
+ if (this.isAutomergeCounter(value) || this.isAutomergeImmutableString(value)) {
8193
+ return this.readAutomergeScalar(value);
8194
+ }
8195
+ if (value instanceof Uint8Array || value instanceof Date || depth > 100) {
8196
+ return value;
8197
+ }
8198
+ if (Array.isArray(value)) {
8199
+ for (let index = 0; index < value.length; index += 1) {
8200
+ value[index] = this.normalizeAutomergeScalars(value[index], depth + 1);
8201
+ }
8202
+ return value;
8203
+ }
8204
+ const record = value;
8205
+ for (const key of Object.keys(record)) {
8206
+ record[key] = this.normalizeAutomergeScalars(record[key], depth + 1);
8207
+ }
8208
+ return record;
8209
+ }
8210
+ /**
8211
+ * Must run inside an Automerge change callback. Initializes a missing field
8212
+ * as a counter holding `delta`; rejects existing non-counter values rather
8213
+ * than silently converting them (a plain number would lose its history and
8214
+ * any concurrent plain-number write would still win).
8215
+ */
8216
+ incrementCounterAtPath(automergeDoc, path, delta) {
8217
+ const parent = this.ensureJsonParentAtPath(automergeDoc, path);
8218
+ const leaf = path[path.length - 1];
8219
+ const existing = parent[leaf];
8220
+ if (existing === undefined || existing === null) {
8221
+ parent[leaf] = new automerge_adapter_1.Automerge.Counter(delta);
8222
+ return;
8223
+ }
8224
+ if (!this.isAutomergeCounter(existing)) {
8225
+ throw new Error(`Cannot increment non-counter value at '${path.map(String).join(".")}'; create it with MindooValue.counter() first`);
8226
+ }
8227
+ existing.increment(delta);
7814
8228
  }
7815
8229
  validateRichTextPatch(patch) {
7816
8230
  this.validateJsonPath(patch.path, "Rich-text patch");
@@ -7975,7 +8389,7 @@ class BaseMindooDB {
7975
8389
  }
7976
8390
  applyJsonPatchOperations(automergeDoc, patch) {
7977
8391
  for (const operation of patch.set ?? []) {
7978
- this.setJsonValueAtPath(automergeDoc, operation.path, structuredClone(operation.value));
8392
+ this.setJsonValueAtPath(automergeDoc, operation.path, this.hydrateTypedValues(structuredClone(operation.value)));
7979
8393
  }
7980
8394
  for (const operation of patch.unset ?? []) {
7981
8395
  this.unsetJsonValueAtPath(automergeDoc, operation.path);
@@ -7986,7 +8400,7 @@ class BaseMindooDB {
7986
8400
  }
7987
8401
  for (const operation of patch.listInsert ?? []) {
7988
8402
  const list = this.ensureJsonListAtPath(automergeDoc, operation.path);
7989
- list.splice(operation.index, 0, ...structuredClone(operation.values));
8403
+ list.splice(operation.index, 0, ...this.hydrateTypedValues(structuredClone(operation.values)));
7990
8404
  }
7991
8405
  for (const operation of patch.textSplice ?? []) {
7992
8406
  this.spliceJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.deleteCount, operation.insert ?? "");
@@ -8005,6 +8419,9 @@ class BaseMindooDB {
8005
8419
  this.unmarkJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.length, name);
8006
8420
  }
8007
8421
  }
8422
+ for (const operation of patch.counterIncrement ?? []) {
8423
+ this.incrementCounterAtPath(automergeDoc, operation.path, operation.delta);
8424
+ }
8008
8425
  }
8009
8426
  spliceJsonTextAtPath(automergeDoc, path, index, deleteCount, insert) {
8010
8427
  const value = this.readValueAtPath(automergeDoc, path);
@@ -8183,6 +8600,38 @@ class BaseMindooDB {
8183
8600
  return apply(internalDoc.doc);
8184
8601
  }
8185
8602
  }
8603
+ /**
8604
+ * The document as it stood at `heads`, for the "before" side of a Tier 2
8605
+ * (`withfields`) write precheck.
8606
+ *
8607
+ * `internalDoc.doc` is normally the pre-change document (it is reassigned to
8608
+ * the new document after the store write), but it is shared mutable state and
8609
+ * is not guaranteed to still sit on the heads a change was computed from: an
8610
+ * out-of-band Automerge operation, the clone recovery in
8611
+ * {@link runChangeWithOutdatedDocRecovery}, or a concurrent op on the same
8612
+ * cached document can move it. A "before" that is really the "after" silently
8613
+ * flips every `when: "before"` verdict, so the caller passes the change's own
8614
+ * dependency hashes, which are fixed at the moment the change was produced,
8615
+ * and only falls back to the cache slot when it has none.
8616
+ *
8617
+ * `Automerge.clone` is load-bearing: a bare `Automerge.view` shares the source
8618
+ * document's WASM handle, and {@link convertAutomergeToJS} materializes through
8619
+ * that handle at ITS heads, not the view's — so materializing a view returns
8620
+ * the post-change state. Cloning gives the snapshot its own handle.
8621
+ */
8622
+ docAtHeads(internalDoc, doc, heads) {
8623
+ if (!heads || heads.length === 0)
8624
+ return internalDoc.doc;
8625
+ try {
8626
+ return automerge_adapter_1.Automerge.clone(automerge_adapter_1.Automerge.view(doc, heads));
8627
+ }
8628
+ catch (error) {
8629
+ // A snapshot we cannot build must not fail the write outright; the cache
8630
+ // slot is the same value this code used before the snapshot existed.
8631
+ this.logger.warn(`Could not snapshot document ${internalDoc.id} at its pre-change heads, using the cached state: ${error}`);
8632
+ return internalDoc.doc;
8633
+ }
8634
+ }
8186
8635
  isOutdatedDocumentError(error) {
8187
8636
  if (!(error instanceof Error))
8188
8637
  return false;
@@ -8261,6 +8710,9 @@ class BaseMindooDB {
8261
8710
  // text patches, merges, ...) carries it consistently on each produced entry.
8262
8711
  const attachmentRefs = this.collectAttachmentRefs(newDoc);
8263
8712
  const entries = [];
8713
+ // The dependencies of the first change ARE the heads the change was computed
8714
+ // from, i.e. the document's "before" state for the write precheck below.
8715
+ let headsBeforeFirstChange;
8264
8716
  for (let index = 0; index < resolvedChangeBytesList.length; index += 1) {
8265
8717
  const changeBytes = resolvedChangeBytesList[index];
8266
8718
  const entry = await this.buildDocChangeStoreEntry({
@@ -8278,27 +8730,27 @@ class BaseMindooDB {
8278
8730
  });
8279
8731
  entries.push(entry);
8280
8732
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
8733
+ if (index === 0) {
8734
+ headsBeforeFirstChange = decodedChange.deps;
8735
+ }
8281
8736
  this.registerAutomergeHashMapping(docId, decodedChange.hash, entry.id);
8282
8737
  }
8283
- // Client-side write-policy precheck (docs/accesscontrol.md §9). `internalDoc.doc`
8284
- // is still the pre-change state here (it is reassigned to `newDoc` below,
8285
- // after the store write), so before/after are both available for Tier 2
8286
- // (`withfields`) evaluation. Throws AccessDeniedError when denied.
8738
+ // Client-side write-policy precheck (docs/accesscontrol.md §9). Throws
8739
+ // AccessDeniedError when denied.
8287
8740
  {
8288
8741
  const signerKey = useCustomKey
8289
8742
  ? signingKeyPair.publicKey
8290
8743
  : (await this.tenant.getCurrentUserId()).userSigningPublicKey;
8291
- const beforeState = internalDoc.doc;
8292
8744
  await this.assertWriteAllowed({
8293
8745
  op: "doc_change",
8294
8746
  signerKey,
8295
8747
  isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
8296
8748
  bypass: bypassPrecheck,
8297
- getBeforeDoc: () => this.convertAutomergeToJS(beforeState),
8749
+ getBeforeDoc: () => this.convertAutomergeToJS(this.docAtHeads(internalDoc, newDoc, headsBeforeFirstChange)),
8298
8750
  getAfterDoc: () => this.convertAutomergeToJS(newDoc),
8299
8751
  });
8300
8752
  }
8301
- await this.store.putEntries(entries);
8753
+ await this.putLocalEntries(entries);
8302
8754
  internalDoc.doc = newDoc;
8303
8755
  internalDoc.lastModified = now;
8304
8756
  // The change entries just written are versioned and un-witnessed, so the
@@ -8464,7 +8916,7 @@ class BaseMindooDB {
8464
8916
  signingKeyPassword: options.signingKeyPassword,
8465
8917
  }
8466
8918
  : undefined);
8467
- await this.store.putEntries([snapshotEntry]);
8919
+ await this.putLocalEntries([snapshotEntry]);
8468
8920
  this.writesSinceSnapshotCheck.set(docId, 0);
8469
8921
  this.logger.debug(`Created snapshot for document ${docId} with ${headHashes.length} heads and ${changesSinceSnapshot} changes since previous snapshot`);
8470
8922
  }
@@ -9393,7 +9845,7 @@ class BaseMindooDB {
9393
9845
  this.logger.warn(`L2 deserialize failed for ${docId}, evicting stale record: ${e}`);
9394
9846
  try {
9395
9847
  await store.delete("doc", key);
9396
- this.lastFlushedDocState.delete(docId);
9848
+ this.forgetL2Record(docId);
9397
9849
  }
9398
9850
  catch (deleteError) {
9399
9851
  this.logger.warn(`Failed to evict corrupt L2 record for ${docId}: ${deleteError}`);
@@ -9420,7 +9872,7 @@ class BaseMindooDB {
9420
9872
  // L2 record is orphaned. Drop and fall through.
9421
9873
  try {
9422
9874
  await store.delete("doc", key);
9423
- this.lastFlushedDocState.delete(docId);
9875
+ this.forgetL2Record(docId);
9424
9876
  }
9425
9877
  catch (deleteError) {
9426
9878
  this.logger.warn(`Failed to evict orphaned L2 record for ${docId}: ${deleteError}`);
@@ -9432,7 +9884,7 @@ class BaseMindooDB {
9432
9884
  if (persistedChangeSeq === currentChangeSeq) {
9433
9885
  // Seed the flush-skip fingerprint with the state just read from L2 so
9434
9886
  // the next periodic flush doesn't rewrite an identical record.
9435
- this.lastFlushedDocState.set(docId, `${persistedChangeSeq}:${automerge_adapter_1.Automerge.getHeads(internal.doc).join(",")}`);
9887
+ this.rememberL2Record(docId, persistedChangeSeq, `${persistedChangeSeq}:${automerge_adapter_1.Automerge.getHeads(internal.doc).join(",")}`);
9436
9888
  await this.storeCachedDocument(internal);
9437
9889
  return internal;
9438
9890
  }
@@ -9474,8 +9926,12 @@ class BaseMindooDB {
9474
9926
  return !persistedHashes.has(parsed.automergeHash);
9475
9927
  });
9476
9928
  if (missingMetadata.length === 0) {
9477
- // No real deltas (changeSeq pointer drift only). Promote and return.
9929
+ // No real deltas (changeSeq pointer drift only). Promote and return,
9930
+ // marked dirty so the record is rewritten under the current changeSeq:
9931
+ // otherwise every later read (and every warmer pass) would repeat
9932
+ // this metadata scan + hash comparison for a record that is current.
9478
9933
  await this.storeCachedDocument(internal);
9934
+ this.markDocDirty(docId);
9479
9935
  return internal;
9480
9936
  }
9481
9937
  let updated;
@@ -9490,8 +9946,9 @@ class BaseMindooDB {
9490
9946
  // applyNewEntriesToCachedDocument returns null when no signed entries
9491
9947
  // produced a head change. Treat the persisted doc as authoritative
9492
9948
  // and put it in L1 ourselves (the helper only caches when it
9493
- // actually applied work).
9949
+ // actually applied work). Dirty for the same reason as above.
9494
9950
  await this.storeCachedDocument(internal);
9951
+ this.markDocDirty(docId);
9495
9952
  return internal;
9496
9953
  }
9497
9954
  return updated;
@@ -9511,9 +9968,15 @@ class BaseMindooDB {
9511
9968
  /**
9512
9969
  * Start the L2 background warmer.
9513
9970
  *
9514
- * Walks a snapshot of the in-memory document index and, for every
9515
- * doc not already in L1, calls {@link tryLoadFromL2}. That path
9516
- * either:
9971
+ * First waits until the changefeed consumers (summary buffer, an
9972
+ * enabled full-text index) have caught up: they materialize every
9973
+ * changed document anyway, so running next to them would do the same
9974
+ * work twice. Then walks the visible documents whose L2 record is not
9975
+ * known to be at the current changeSeq (see {@link l2RecordChangeSeqs},
9976
+ * persisted across restarts, so a pass after a small sync only visits
9977
+ * what changed). It skips every doc that is in L1 or turns out current
9978
+ * (record header check for docs with unknown state, no deserialization).
9979
+ * For the rest it calls {@link tryLoadFromL2}. That path either:
9517
9980
  *
9518
9981
  * - hits L2 fresh and just promotes the doc to L1, or
9519
9982
  * - hits L2 stale and applies missing deltas (re-flushed via the
@@ -9614,16 +10077,13 @@ class BaseMindooDB {
9614
10077
  this.logger.debug("Warmer skipped: no cacheManager attached, no L2 to warm.");
9615
10078
  return;
9616
10079
  }
9617
- // Snapshot the docId list so concurrent index updates don't shift
9618
- // our iteration mid-pass. Iterating the index directly would also
9619
- // work but a snapshot makes the loop's behavior easier to reason
9620
- // about under sync activity.
9621
- const docIds = this.index.map((entry) => entry.docId);
9622
- const total = docIds.length;
9623
- // Progress snapshot is replaced (not mutated in place) so polled
9624
- // readers see consistent values. We initialize even when total=0
9625
- // so the UI can display a "done" terminal state.
9626
- this.warmerProgress = { processed: 0, total, phase: "warming" };
10080
+ // Changefeed consumers (summary buffer, full-text index) materialize
10081
+ // every changed document anyway, and those loads persist to L2 through
10082
+ // flush-before-evict / the periodic flush. Running the warmer next to
10083
+ // them (it is typically started right after a sync, while their
10084
+ // catch-up is still going) materialized the same documents twice. Let
10085
+ // them finish first; the pass below then only has to pick up what they
10086
+ // did not cover.
9627
10087
  const emitProgress = (progress) => {
9628
10088
  this.warmerProgress = progress;
9629
10089
  if (!onProgress)
@@ -9636,8 +10096,40 @@ class BaseMindooDB {
9636
10096
  this.logger.warn(`Warmer onProgress callback threw: ${e}`);
9637
10097
  }
9638
10098
  };
10099
+ this.warmerProgress = {
10100
+ processed: 0,
10101
+ // Unknown until the consumers caught up and the pass is planned.
10102
+ total: 0,
10103
+ phase: "warming",
10104
+ };
10105
+ await this.awaitChangefeedConsumersForWarmer(signal);
10106
+ if (signal.aborted) {
10107
+ emitProgress({ processed: 0, total: 0, phase: "cancelled" });
10108
+ return;
10109
+ }
10110
+ // Snapshot the docs that may need work, so concurrent index updates
10111
+ // don't shift our iteration mid-pass. Docs whose L2 record is known to
10112
+ // be at the current changeSeq are left out up front: after a small sync
10113
+ // the pass (and its progress total) covers only what actually changed
10114
+ // instead of the whole database. Docs held in L1 are left out as well:
10115
+ // loads and changes mark them dirty, so the next flush (or
10116
+ // flush-before-evict) persists them anyway. Unknown docs (legacy
10117
+ // checkpoint) stay in and are checked against their record header below.
10118
+ const docIds = [];
10119
+ for (const entry of this.index) {
10120
+ if (entry.accessState === "visible" &&
10121
+ this.l2RecordChangeSeqs.get(entry.docId) !== entry.changeSeq &&
10122
+ !this.docCache.has(entry.docId)) {
10123
+ docIds.push(entry.docId);
10124
+ }
10125
+ }
10126
+ const total = docIds.length;
10127
+ // Progress snapshot is replaced (not mutated in place) so polled
10128
+ // readers see consistent values. We initialize even when total=0
10129
+ // so the UI can display a "done" terminal state.
10130
+ this.warmerProgress = { processed: 0, total, phase: "warming" };
9639
10131
  if (total === 0) {
9640
- this.logger.debug("Warmer: nothing to warm, index is empty.");
10132
+ this.logger.debug("Warmer: nothing to warm, every L2 record is current.");
9641
10133
  emitProgress({ processed: 0, total: 0, phase: "done" });
9642
10134
  return;
9643
10135
  }
@@ -9645,6 +10137,7 @@ class BaseMindooDB {
9645
10137
  let processed = 0;
9646
10138
  let warmed = 0;
9647
10139
  let skippedAlreadyHot = 0;
10140
+ let skippedFreshInL2 = 0;
9648
10141
  let errored = 0;
9649
10142
  try {
9650
10143
  for (const docId of docIds) {
@@ -9657,6 +10150,11 @@ class BaseMindooDB {
9657
10150
  if (this.docCache.has(docId)) {
9658
10151
  skippedAlreadyHot++;
9659
10152
  }
10153
+ else if (await this.isL2RecordCurrent(docId)) {
10154
+ // Nothing to do: loading it would only deserialize the doc into
10155
+ // L1 and evict it again (L1 is far smaller than a large DB).
10156
+ skippedFreshInL2++;
10157
+ }
9660
10158
  else {
9661
10159
  try {
9662
10160
  const fromL2 = await this.tryLoadFromL2(docId);
@@ -9682,7 +10180,7 @@ class BaseMindooDB {
9682
10180
  }
9683
10181
  }
9684
10182
  this.logger.info(`Warmer finished in ${Date.now() - startedAt}ms: processed=${processed}, ` +
9685
- `warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, errored=${errored}`);
10183
+ `warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, freshInL2=${skippedFreshInL2}, errored=${errored}`);
9686
10184
  emitProgress({ processed, total, phase: "done" });
9687
10185
  }
9688
10186
  catch (e) {
@@ -9693,6 +10191,84 @@ class BaseMindooDB {
9693
10191
  emitProgress({ processed, total, phase: "cancelled" });
9694
10192
  }
9695
10193
  }
10194
+ /**
10195
+ * Wait until the summary buffer and an enabled full-text index have
10196
+ * consumed the changefeed (joining a running auto-follow run or starting
10197
+ * a catch-up). Failures are logged: the warmer then simply does the
10198
+ * remaining work itself.
10199
+ */
10200
+ async awaitChangefeedConsumersForWarmer(signal) {
10201
+ const startedAt = Date.now();
10202
+ const catchUp = (async () => {
10203
+ await this.summaryStore?.update({ signal });
10204
+ if (!signal.aborted && this.fulltextIndex?.isEnabled()) {
10205
+ await this.fulltextIndex.update({ signal });
10206
+ }
10207
+ })();
10208
+ // Joining an already running auto-follow run does not honor our signal,
10209
+ // so a stop must not wait for it: the run just continues on its own.
10210
+ let onAbort = () => { };
10211
+ const aborted = new Promise((resolve) => {
10212
+ onAbort = () => resolve();
10213
+ if (signal.aborted)
10214
+ resolve();
10215
+ else
10216
+ signal.addEventListener("abort", onAbort, { once: true });
10217
+ });
10218
+ try {
10219
+ await Promise.race([catchUp, aborted]);
10220
+ }
10221
+ catch (e) {
10222
+ this.logger.warn(`Warmer: changefeed consumer catch-up failed: ${e}`);
10223
+ }
10224
+ finally {
10225
+ signal.removeEventListener("abort", onAbort);
10226
+ }
10227
+ catchUp.catch(() => { });
10228
+ this.logger.debug(`Warmer: changefeed consumers caught up in ${Date.now() - startedAt}ms`);
10229
+ }
10230
+ /**
10231
+ * Whether L2 already holds this document at its current changeSeq, so
10232
+ * the warmer can skip it without deserializing. Answered from the
10233
+ * persisted {@link l2RecordChangeSeqs}; only when that has no entry for
10234
+ * the doc (legacy checkpoint) from the record header (no Automerge load).
10235
+ */
10236
+ async isL2RecordCurrent(docId) {
10237
+ const indexEntryIdx = this.getDocIndexPosition(docId);
10238
+ const store = this.cacheManager?.getStore() ?? null;
10239
+ if (indexEntryIdx === undefined || !store) {
10240
+ return false;
10241
+ }
10242
+ const entry = this.index[indexEntryIdx];
10243
+ if (entry.accessState !== "visible") {
10244
+ return false;
10245
+ }
10246
+ const currentChangeSeq = entry.changeSeq;
10247
+ const knownChangeSeq = this.l2RecordChangeSeqs.get(docId);
10248
+ if (knownChangeSeq !== undefined) {
10249
+ return knownChangeSeq === currentChangeSeq;
10250
+ }
10251
+ // Unknown (checkpoint from before the per-doc state was persisted):
10252
+ // read the record header once and remember the answer.
10253
+ try {
10254
+ const bytes = await store.get("doc", `${this.getCachePrefix()}/${docId}`);
10255
+ if (!bytes) {
10256
+ return false;
10257
+ }
10258
+ const { header } = this.parseDocCacheRecord(bytes);
10259
+ if (header.version !== DOC_CACHE_HEADER_VERSION ||
10260
+ header.changeSeq !== currentChangeSeq ||
10261
+ !Array.isArray(header.automergeHeads)) {
10262
+ return false;
10263
+ }
10264
+ this.rememberL2Record(docId, currentChangeSeq, `${currentChangeSeq}:${header.automergeHeads.join(",")}`);
10265
+ return true;
10266
+ }
10267
+ catch {
10268
+ // Unreadable record: let the regular L2 path evict/rebuild it.
10269
+ return false;
10270
+ }
10271
+ }
9696
10272
  /**
9697
10273
  * The per-tenant quarantine/audit log: entries rejected during
9698
10274
  * materialization by the access-control layer (docs/accesscontrol.md §10).
@@ -9700,7 +10276,11 @@ class BaseMindooDB {
9700
10276
  * store but are excluded from materialized state and queries.
9701
10277
  */
9702
10278
  getQuarantineLog() {
9703
- return this.quarantineLog;
10279
+ const flat = [];
10280
+ for (const records of this.quarantineLog.values()) {
10281
+ flat.push(...records);
10282
+ }
10283
+ return flat;
9704
10284
  }
9705
10285
  /**
9706
10286
  * Whether access control is active for this tenant and should gate
@@ -9900,8 +10480,9 @@ class BaseMindooDB {
9900
10480
  const afterValues = { _attachments: [] };
9901
10481
  if (options.initialValues) {
9902
10482
  for (const [k, v] of Object.entries(options.initialValues)) {
9903
- if (!k.startsWith("_"))
9904
- afterValues[k] = v;
10483
+ if (!k.startsWith("_") && v !== undefined) {
10484
+ afterValues[k] = this.readTypedValues(v);
10485
+ }
9905
10486
  }
9906
10487
  }
9907
10488
  const decision = await this.evaluateClientWriteAccess({
@@ -10224,10 +10805,52 @@ class BaseMindooDB {
10224
10805
  const info = await this.resolveCreatorInfo(docId);
10225
10806
  return info?.signingKey ?? null;
10226
10807
  }
10227
- /** Append a record to the quarantine/audit log (and log it). */
10228
- recordQuarantine(rec) {
10229
- this.quarantineLog.push(rec);
10230
- console.log(`[MindooDB][ACL] Quarantined entry ${rec.entryId} (doc ${rec.docId}): ${rec.reason} — ${rec.detail}`);
10808
+ /**
10809
+ * Replace one document's quarantine records with the verdict of the pass that
10810
+ * just ended.
10811
+ *
10812
+ * Whole-group replacement rather than appending, because a pass re-derives the
10813
+ * complete verdict for the document: appending would duplicate every record
10814
+ * on every re-materialization, and would never drop the record of an entry
10815
+ * that has since become acceptable.
10816
+ *
10817
+ * Writes nothing when the verdict is unchanged. That is not just thrift — the
10818
+ * checkpoint is rewritten whenever this marks it dirty, and a document is
10819
+ * re-materialized often, so marking dirty on an unchanged verdict would turn
10820
+ * every read of a quarantined document into a checkpoint write.
10821
+ */
10822
+ commitQuarantineForDoc(docId, records) {
10823
+ const previous = this.quarantineLog.get(docId);
10824
+ if (records.length === 0) {
10825
+ if (previous) {
10826
+ this.quarantineLog.delete(docId);
10827
+ this.cacheMetaDirty = true;
10828
+ }
10829
+ return;
10830
+ }
10831
+ const kept = records.length > MAX_QUARANTINE_RECORDS_PER_DOC
10832
+ ? records.slice(0, MAX_QUARANTINE_RECORDS_PER_DOC)
10833
+ : records;
10834
+ // Compare the verdict, not the record objects: `recordedAt` is a wall-clock
10835
+ // stamp that differs on every pass and would make every verdict look new.
10836
+ const verdictOf = (list) => list.map((rec) => `${rec.entryId}\u0000${rec.reason}`).join("\u0001");
10837
+ if (previous && verdictOf(previous) === verdictOf(kept)) {
10838
+ return;
10839
+ }
10840
+ this.quarantineLog.set(docId, kept);
10841
+ if (!previous) {
10842
+ while (this.quarantineLog.size > MAX_QUARANTINE_DOCS) {
10843
+ // Map iteration is insertion-ordered, so this is the oldest document.
10844
+ const oldest = this.quarantineLog.keys().next();
10845
+ if (oldest.done || oldest.value === docId)
10846
+ break;
10847
+ this.quarantineLog.delete(oldest.value);
10848
+ }
10849
+ }
10850
+ this.cacheMetaDirty = true;
10851
+ for (const rec of kept) {
10852
+ console.log(`[MindooDB][ACL] Quarantined entry ${rec.entryId} (doc ${rec.docId}): ${rec.reason} — ${rec.detail}`);
10853
+ }
10231
10854
  }
10232
10855
  /**
10233
10856
  * Materialize a document through the access-control (Tier 2) path
@@ -10284,8 +10907,12 @@ class BaseMindooDB {
10284
10907
  .sort((a, b) => (a.receivedAt ?? a.createdAt) === (b.receivedAt ?? b.createdAt)
10285
10908
  ? a.id.localeCompare(b.id)
10286
10909
  : (a.receivedAt ?? a.createdAt) - (b.receivedAt ?? b.createdAt));
10287
- if (dataEntries.length === 0)
10910
+ if (dataEntries.length === 0) {
10911
+ // Nothing left to judge (e.g. the history was purged), so no verdict can
10912
+ // stand either.
10913
+ this.commitQuarantineForDoc(docId, []);
10288
10914
  return { doc: null, cacheable };
10915
+ }
10289
10916
  const createMeta = dataEntries.find((m) => m.entryType === "doc_create");
10290
10917
  const creatorKey = createMeta?.createdByPublicKey ?? null;
10291
10918
  const creatorTrustedTime = createMeta
@@ -10299,9 +10926,13 @@ class BaseMindooDB {
10299
10926
  let decryptionKeyId = "default";
10300
10927
  const acceptedMeta = [];
10301
10928
  const quarantined = new Set();
10929
+ // Collected for the whole pass and committed once at the end, so the log
10930
+ // holds this document's current verdict rather than the union of every
10931
+ // verdict ever reached for it. See commitQuarantineForDoc.
10932
+ const quarantineRecords = [];
10302
10933
  const quarantine = (meta, reason, detail) => {
10303
10934
  quarantined.add(meta.id);
10304
- this.recordQuarantine({
10935
+ quarantineRecords.push({
10305
10936
  entryId: meta.id,
10306
10937
  docId,
10307
10938
  dbid,
@@ -10458,6 +11089,9 @@ class BaseMindooDB {
10458
11089
  this.registerAutomergeHashMapping(docId, parsed.automergeHash, entry.id);
10459
11090
  }
10460
11091
  }
11092
+ // The pass is over and `quarantineRecords` is this document's full verdict,
11093
+ // including the empty verdict that retires an earlier one.
11094
+ this.commitQuarantineForDoc(docId, quarantineRecords);
10461
11095
  if (currentDoc === null) {
10462
11096
  // The create was quarantined or absent: the document is logically absent.
10463
11097
  return { doc: null, cacheable };
@@ -10485,6 +11119,22 @@ class BaseMindooDB {
10485
11119
  /**
10486
11120
  * Internal method to load a document from the content-addressed store
10487
11121
  */
11122
+ /** Whether no new entries of `docId` were indexed since `generation` was read. */
11123
+ isEntryGenerationCurrent(docId, generation) {
11124
+ return (this.docEntryGeneration.get(docId) ?? 0) === generation;
11125
+ }
11126
+ /**
11127
+ * The L2 path caches inside {@link tryLoadFromL2}; when entries were
11128
+ * indexed while it ran, take the (outdated) result back out of L1.
11129
+ */
11130
+ dropCachedLoadIfSuperseded(docId, generation, loaded) {
11131
+ if (this.isEntryGenerationCurrent(docId, generation) ||
11132
+ this.docCache.get(docId) !== loaded) {
11133
+ return;
11134
+ }
11135
+ this.docCache.delete(docId);
11136
+ this.dirtyDocIds.delete(docId);
11137
+ }
10488
11138
  async loadDocumentInternal(docId) {
10489
11139
  const startedAt = Date.now();
10490
11140
  const cacheCheckStartedAt = Date.now();
@@ -10528,8 +11178,10 @@ class BaseMindooDB {
10528
11178
  // the full signature-verify + decrypt + Automerge-replay pipeline.
10529
11179
  // The L2 path either returns a ready-to-use InternalDoc (already
10530
11180
  // promoted into L1) or null - in which case we proceed below.
11181
+ const entryGeneration = this.docEntryGeneration.get(docId) ?? 0;
10531
11182
  const l2Doc = await this.tryLoadFromL2(docId);
10532
11183
  if (l2Doc !== null) {
11184
+ this.dropCachedLoadIfSuperseded(docId, entryGeneration, l2Doc);
10533
11185
  this.performanceCallback?.onDocumentLoad?.({
10534
11186
  docId,
10535
11187
  cacheHit: true,
@@ -10613,7 +11265,9 @@ class BaseMindooDB {
10613
11265
  const aclDoc = aclResult.doc;
10614
11266
  // Only cache a fully-validated result. A transient validation failure
10615
11267
  // (audit finding #2) leaves `cacheable` false so the next load retries.
10616
- if (aclDoc && aclResult.cacheable) {
11268
+ if (aclDoc &&
11269
+ aclResult.cacheable &&
11270
+ this.isEntryGenerationCurrent(docId, entryGeneration)) {
10617
11271
  await this.storeCachedDocument(aclDoc);
10618
11272
  this.markDocDirty(docId);
10619
11273
  }
@@ -10886,10 +11540,16 @@ class BaseMindooDB {
10886
11540
  witnessed: witnessState.witnessed,
10887
11541
  sealedRecipients: (0, recipients_1.newestRecipientBlock)(allEntryMetadata),
10888
11542
  };
10889
- // Update cache
10890
- await this.storeCachedDocument(internalDoc);
10891
- this.markDocDirty(docId);
10892
- this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
11543
+ // Update cache (unless newer entries were indexed meanwhile: then this
11544
+ // result is already outdated and the next read materializes again)
11545
+ if (this.isEntryGenerationCurrent(docId, entryGeneration)) {
11546
+ await this.storeCachedDocument(internalDoc);
11547
+ this.markDocDirty(docId);
11548
+ this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
11549
+ }
11550
+ else {
11551
+ this.logger.debug(`===== Loaded document ${docId}; not cached, newer entries were indexed during the load =====`);
11552
+ }
10893
11553
  this.performanceCallback?.onDocumentLoad?.({
10894
11554
  docId,
10895
11555
  cacheHit: false,
@@ -10920,8 +11580,10 @@ class BaseMindooDB {
10920
11580
  if (meta && meta.handle && typeof meta.handle.materialize === "function") {
10921
11581
  // Use native materialize() which properly converts Text objects to strings
10922
11582
  try {
11583
+ // materialize() returns fresh objects, so the scalar wrappers can be
11584
+ // replaced in place.
10923
11585
  const materialized = meta.handle.materialize("/");
10924
- return materialized;
11586
+ return this.normalizeAutomergeScalars(materialized);
10925
11587
  }
10926
11588
  catch (error) {
10927
11589
  console.error("[MindooDB] Failed to materialize document:", error);
@@ -10942,6 +11604,11 @@ class BaseMindooDB {
10942
11604
  return value;
10943
11605
  if (value instanceof Uint8Array)
10944
11606
  return value;
11607
+ if (value instanceof Date)
11608
+ return new Date(value.getTime());
11609
+ if (this.isAutomergeCounter(value) || this.isAutomergeImmutableString(value)) {
11610
+ return this.readAutomergeScalar(value);
11611
+ }
10945
11612
  if (depth > 40)
10946
11613
  return null;
10947
11614
  const automerge = automerge_adapter_1.Automerge;
@@ -10987,6 +11654,18 @@ class BaseMindooDB {
10987
11654
  },
10988
11655
  get: (target, prop) => {
10989
11656
  const value = target[prop];
11657
+ // Counters read as plain numbers and immutable strings as strings;
11658
+ // their wrappers would otherwise surface as `{ value }` / `{ val }`
11659
+ // after structured cloning.
11660
+ if (db.isAutomergeCounter(value) ||
11661
+ db.isAutomergeImmutableString(value)) {
11662
+ return db.readAutomergeScalar(value);
11663
+ }
11664
+ // Hand out a copy: a Proxy around a Date breaks every Date method
11665
+ // ("this is not a Date object").
11666
+ if (value instanceof Date) {
11667
+ return new Date(value.getTime());
11668
+ }
10990
11669
  // Recursively wrap nested objects (but not arrays or special types)
10991
11670
  if (value !== null &&
10992
11671
  typeof value === "object" &&
@@ -11044,6 +11723,9 @@ class BaseMindooDB {
11044
11723
  setAttachmentExtractedText: () => {
11045
11724
  throw new Error("setAttachmentExtractedText() can only be called within changeDoc() callback");
11046
11725
  },
11726
+ incrementCounter: () => {
11727
+ throw new Error("incrementCounter() can only be called within changeDoc() callback; use applyJsonPatch() with counterIncrement outside of it");
11728
+ },
11047
11729
  // ========== Attachment Read Methods ==========
11048
11730
  // These work in the read-only wrapper
11049
11731
  getAttachments: () => {
@@ -11751,7 +12433,7 @@ class BaseMindooDB {
11751
12433
  signingKeyPassword: signing?.signingKeyPassword,
11752
12434
  attachmentRefs: this.collectAttachmentRefs(newDoc),
11753
12435
  });
11754
- await this.store.putEntries([entry]);
12436
+ await this.putLocalEntries([entry]);
11755
12437
  this.registerAutomergeHashMapping(docId, automerge_adapter_1.Automerge.decodeChange(changeBytes).hash, entry.id);
11756
12438
  internalDoc.doc = newDoc;
11757
12439
  internalDoc.lastModified = now;
@@ -11851,7 +12533,14 @@ class BaseMindooDB {
11851
12533
  this.logger.info(`Pulling entries from remote store ${remoteStore.getId()}/${remoteStore.getStoreKind()}`);
11852
12534
  const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
11853
12535
  try {
11854
- const syncResult = await this.syncEntriesFromStore(remoteStore, localStore, options);
12536
+ // Docs pulls process each transferred scan page right away: the local
12537
+ // index advances chunk by chunk, and the change event it emits lets the
12538
+ // summary buffer / full-text index materialize and extract documents
12539
+ // while the next page is still on the wire, instead of all at once
12540
+ // after the transfer (see docs/adhoc-queries.md, "Cold start").
12541
+ const syncResult = await this.syncEntriesFromStore(remoteStore, localStore, options, storeKind === types_2.StoreKind.docs
12542
+ ? () => this.syncStoreChanges()
12543
+ : undefined);
11855
12544
  const transferredBytes = syncResult.transferredBytes ?? 0;
11856
12545
  this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
11857
12546
  if (syncResult.cancelled) {