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.
- package/README-server.md +77 -16
- package/README.md +41 -58
- package/dist/appendonlystores/network/HttpTransport.d.ts +2 -0
- package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.js +31 -6
- package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +18 -8
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +30 -16
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/index.mjs +29 -0
- package/dist/core/BaseMindooDB.d.ts +168 -8
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +763 -74
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/appendonlystores/network/IrohNetworkTransport.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/IrohNetworkTransport.js +5 -1
- package/dist/core/appendonlystores/network/IrohNetworkTransport.js.map +1 -1
- package/dist/core/appendonlystores/network/IrohStreamIO.d.ts +10 -0
- package/dist/core/appendonlystores/network/IrohStreamIO.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/IrohStreamIO.js.map +1 -1
- package/dist/core/appendonlystores/network/types.d.ts +20 -0
- package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/types.js +33 -0
- package/dist/core/appendonlystores/network/types.js.map +1 -1
- package/dist/core/appendonlystores/syncStores.d.ts +80 -2
- package/dist/core/appendonlystores/syncStores.d.ts.map +1 -1
- package/dist/core/appendonlystores/syncStores.js +82 -9
- package/dist/core/appendonlystores/syncStores.js.map +1 -1
- package/dist/core/appendonlystores/types.d.ts +88 -6
- package/dist/core/appendonlystores/types.d.ts.map +1 -1
- package/dist/core/appendonlystores/types.js +61 -1
- package/dist/core/appendonlystores/types.js.map +1 -1
- package/dist/core/builtinDbInvariants.d.ts +8 -5
- package/dist/core/builtinDbInvariants.d.ts.map +1 -1
- package/dist/core/builtinDbInvariants.js +8 -5
- package/dist/core/builtinDbInvariants.js.map +1 -1
- package/dist/core/expressions/evaluateExpression.d.ts.map +1 -1
- package/dist/core/expressions/evaluateExpression.js +7 -1
- package/dist/core/expressions/evaluateExpression.js.map +1 -1
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +45 -11
- package/dist/core/index.js.map +1 -1
- package/dist/core/indexing/summary/DocumentSummaryStore.d.ts +26 -3
- package/dist/core/indexing/summary/DocumentSummaryStore.d.ts.map +1 -1
- package/dist/core/indexing/summary/DocumentSummaryStore.js +100 -15
- package/dist/core/indexing/summary/DocumentSummaryStore.js.map +1 -1
- package/dist/core/indexing/summary/extractSummaryFields.d.ts +10 -1
- package/dist/core/indexing/summary/extractSummaryFields.d.ts.map +1 -1
- package/dist/core/indexing/summary/extractSummaryFields.js +34 -7
- package/dist/core/indexing/summary/extractSummaryFields.js.map +1 -1
- package/dist/core/indexing/summary/types.d.ts +6 -0
- package/dist/core/indexing/summary/types.d.ts.map +1 -1
- package/dist/core/indexing/summary/types.js +8 -1
- package/dist/core/indexing/summary/types.js.map +1 -1
- package/dist/core/quarantine/QuarantineDocument.d.ts +211 -0
- package/dist/core/quarantine/QuarantineDocument.d.ts.map +1 -0
- package/dist/core/quarantine/QuarantineDocument.js +314 -0
- package/dist/core/quarantine/QuarantineDocument.js.map +1 -0
- package/dist/core/quarantine/QuarantineView.d.ts +90 -0
- package/dist/core/quarantine/QuarantineView.d.ts.map +1 -0
- package/dist/core/quarantine/QuarantineView.js +106 -0
- package/dist/core/quarantine/QuarantineView.js.map +1 -0
- package/dist/core/quarantine/recordPushRejections.d.ts +73 -0
- package/dist/core/quarantine/recordPushRejections.d.ts.map +1 -0
- package/dist/core/quarantine/recordPushRejections.js +112 -0
- package/dist/core/quarantine/recordPushRejections.js.map +1 -0
- package/dist/core/quarantine/resubmitQuarantined.d.ts +81 -0
- package/dist/core/quarantine/resubmitQuarantined.d.ts.map +1 -0
- package/dist/core/quarantine/resubmitQuarantined.js +120 -0
- package/dist/core/quarantine/resubmitQuarantined.js.map +1 -0
- package/dist/core/types.d.ts +132 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/values.d.ts +72 -0
- package/dist/core/values.d.ts.map +1 -0
- package/dist/core/values.js +107 -0
- package/dist/core/values.js.map +1 -0
- package/dist/node/server/MindooDBServer.d.ts.map +1 -1
- package/dist/node/server/MindooDBServer.js +6 -1
- package/dist/node/server/MindooDBServer.js.map +1 -1
- package/dist/node/server/peer/PeerReplicator.d.ts.map +1 -1
- package/dist/node/server/peer/PeerReplicator.js +35 -15
- package/dist/node/server/peer/PeerReplicator.js.map +1 -1
- 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)
|
|
220
|
-
* for Haven's
|
|
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?.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1807
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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.
|
|
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)
|
|
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.
|
|
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.
|
|
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).
|
|
6840
|
-
//
|
|
6841
|
-
//
|
|
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
|
|
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
|
-
|
|
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).
|
|
8284
|
-
//
|
|
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(
|
|
8749
|
+
getBeforeDoc: () => this.convertAutomergeToJS(this.docAtHeads(internalDoc, newDoc, headsBeforeFirstChange)),
|
|
8298
8750
|
getAfterDoc: () => this.convertAutomergeToJS(newDoc),
|
|
8299
8751
|
});
|
|
8300
8752
|
}
|
|
8301
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
9515
|
-
*
|
|
9516
|
-
*
|
|
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
|
-
//
|
|
9618
|
-
//
|
|
9619
|
-
//
|
|
9620
|
-
//
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
10891
|
-
this.
|
|
10892
|
-
|
|
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.
|
|
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
|
-
|
|
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) {
|