mindoodb 0.0.65 → 0.0.67
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 +129 -8
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +631 -60
- 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/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/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/userkeys/UserKeyManager.d.ts.map +1 -1
- package/dist/core/userkeys/UserKeyManager.js +11 -1
- package/dist/core/userkeys/UserKeyManager.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,16 @@ 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();
|
|
449
486
|
this.cacheMetaDirty = false;
|
|
450
487
|
/**
|
|
451
488
|
* Per-document fingerprint (`changeSeq:automergeHeads`) of the state last
|
|
@@ -586,7 +623,7 @@ class BaseMindooDB {
|
|
|
586
623
|
usernameHashFromRecord(payload) {
|
|
587
624
|
if (!payload || typeof payload !== "object")
|
|
588
625
|
return null;
|
|
589
|
-
const value = payload.username_hash;
|
|
626
|
+
const value = this.readAutomergeScalar(payload.username_hash);
|
|
590
627
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
591
628
|
}
|
|
592
629
|
usernameHashFromInternalDoc(internalDoc) {
|
|
@@ -837,7 +874,7 @@ class BaseMindooDB {
|
|
|
837
874
|
// flush-before-remove step.
|
|
838
875
|
this.dirtyDocIds.clear();
|
|
839
876
|
this.cacheMetaDirty = false;
|
|
840
|
-
this.summaryStore?.
|
|
877
|
+
this.summaryStore?.discardDirtyState();
|
|
841
878
|
this.fulltextIndex?.clearDirty();
|
|
842
879
|
void cacheManager.deregister(this);
|
|
843
880
|
if (this.summaryStore) {
|
|
@@ -1594,6 +1631,14 @@ class BaseMindooDB {
|
|
|
1594
1631
|
// the checkpoint as tally-aware, so restore can tell "nothing hidden"
|
|
1595
1632
|
// apart from "written before this existed, count unknown".
|
|
1596
1633
|
checkpoint.inaccessibleDocIds = Array.from(this.inaccessibleDocIds);
|
|
1634
|
+
// Persist the materialization quarantine log (additive field, no version
|
|
1635
|
+
// bump: a missing log restores as empty, which is exactly what the log was
|
|
1636
|
+
// before it was persisted at all — and bumping the version would force a
|
|
1637
|
+
// full metadata rebuild on every existing installation to gain nothing but
|
|
1638
|
+
// an audit trail).
|
|
1639
|
+
if (this.quarantineLog.size > 0) {
|
|
1640
|
+
checkpoint.quarantineLog = Object.fromEntries(this.quarantineLog);
|
|
1641
|
+
}
|
|
1597
1642
|
return new TextEncoder().encode(JSON.stringify(checkpoint));
|
|
1598
1643
|
}
|
|
1599
1644
|
/**
|
|
@@ -1659,6 +1704,31 @@ class BaseMindooDB {
|
|
|
1659
1704
|
else {
|
|
1660
1705
|
this.legacyCheckpointNeedsVisibilityTally = true;
|
|
1661
1706
|
}
|
|
1707
|
+
// Materialization quarantine log. Absent in checkpoints written before it
|
|
1708
|
+
// was persisted, and absent whenever nothing is quarantined — both mean
|
|
1709
|
+
// an empty log, and no heal flag is needed: the next materialization of
|
|
1710
|
+
// an affected document re-derives its verdict anyway.
|
|
1711
|
+
this.quarantineLog.clear();
|
|
1712
|
+
if (checkpoint.quarantineLog &&
|
|
1713
|
+
typeof checkpoint.quarantineLog === "object") {
|
|
1714
|
+
for (const [docId, records] of Object.entries(checkpoint.quarantineLog)) {
|
|
1715
|
+
if (!Array.isArray(records))
|
|
1716
|
+
continue;
|
|
1717
|
+
const valid = records
|
|
1718
|
+
.filter((rec) => Boolean(rec &&
|
|
1719
|
+
typeof rec === "object" &&
|
|
1720
|
+
typeof rec.entryId === "string" &&
|
|
1721
|
+
typeof rec.reason === "string"))
|
|
1722
|
+
// Re-apply the caps on read: this file is on disk and may have been
|
|
1723
|
+
// written by a build with different limits.
|
|
1724
|
+
.slice(0, MAX_QUARANTINE_RECORDS_PER_DOC);
|
|
1725
|
+
if (valid.length === 0)
|
|
1726
|
+
continue;
|
|
1727
|
+
if (this.quarantineLog.size >= MAX_QUARANTINE_DOCS)
|
|
1728
|
+
break;
|
|
1729
|
+
this.quarantineLog.set(docId, valid);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1662
1732
|
this.syncScanCursors.clear();
|
|
1663
1733
|
if (checkpoint.syncScanCursors &&
|
|
1664
1734
|
typeof checkpoint.syncScanCursors === "object") {
|
|
@@ -1799,12 +1869,18 @@ class BaseMindooDB {
|
|
|
1799
1869
|
* fall back to the normal materialization path when they encounter
|
|
1800
1870
|
* one. Throws on truly malformed records (caught by callers).
|
|
1801
1871
|
*/
|
|
1802
|
-
|
|
1872
|
+
/** Split an L2 record into its JSON header and the Automerge binary. */
|
|
1873
|
+
parseDocCacheRecord(value) {
|
|
1803
1874
|
const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
|
|
1804
1875
|
const headerLen = view.getUint32(0, false);
|
|
1805
1876
|
const headerBytes = value.slice(4, 4 + headerLen);
|
|
1806
|
-
|
|
1807
|
-
|
|
1877
|
+
return {
|
|
1878
|
+
header: JSON.parse(new TextDecoder().decode(headerBytes)),
|
|
1879
|
+
amBinary: value.slice(4 + headerLen),
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
deserializeDoc(value) {
|
|
1883
|
+
const { header, amBinary } = this.parseDocCacheRecord(value);
|
|
1808
1884
|
if (header.version !== DOC_CACHE_HEADER_VERSION) {
|
|
1809
1885
|
// v1 records (no `version`) and unknown future versions: skip
|
|
1810
1886
|
// gracefully so they get re-flushed with the current header on
|
|
@@ -2027,6 +2103,7 @@ class BaseMindooDB {
|
|
|
2027
2103
|
// Every path that writes entries for a document lands here, so this is
|
|
2028
2104
|
// where retained metadata for it goes stale (see scannedEntryMetadata).
|
|
2029
2105
|
this.forgetScannedEntryMetadata(docId);
|
|
2106
|
+
this.docEntryGeneration.set(docId, (this.docEntryGeneration.get(docId) ?? 0) + 1);
|
|
2030
2107
|
// Same reasoning for the unreadable-document tally: every reveal and every
|
|
2031
2108
|
// inaccessibility tombstone passes through here, so keeping it in step
|
|
2032
2109
|
// needs no bookkeeping at the individual call sites. The one case this
|
|
@@ -3464,7 +3541,7 @@ class BaseMindooDB {
|
|
|
3464
3541
|
},
|
|
3465
3542
|
};
|
|
3466
3543
|
}
|
|
3467
|
-
async syncEntriesFromStore(sourceStore, targetStore, options) {
|
|
3544
|
+
async syncEntriesFromStore(sourceStore, targetStore, options, onPageTransferred) {
|
|
3468
3545
|
const signal = options?.signal;
|
|
3469
3546
|
(0, syncStores_1.setSyncAbortSignalOnStore)(sourceStore, signal);
|
|
3470
3547
|
(0, syncStores_1.setSyncAbortSignalOnStore)(targetStore, signal);
|
|
@@ -3482,6 +3559,7 @@ class BaseMindooDB {
|
|
|
3482
3559
|
logger: this.logger,
|
|
3483
3560
|
cursors: this.syncCursorStore(),
|
|
3484
3561
|
rejectionPolicy: "advance",
|
|
3562
|
+
onPageTransferred,
|
|
3485
3563
|
});
|
|
3486
3564
|
}
|
|
3487
3565
|
catch (error) {
|
|
@@ -3917,8 +3995,12 @@ class BaseMindooDB {
|
|
|
3917
3995
|
// starting with "_", e.g. "_attachments") are managed by MindooDB and must
|
|
3918
3996
|
// not be seeded by callers. `undefined` values are treated as "not set"
|
|
3919
3997
|
// (Automerge cannot represent undefined).
|
|
3998
|
+
// `MindooValue` typed values become CRDT values here (this also validates
|
|
3999
|
+
// them before anything is written).
|
|
3920
4000
|
const initialValueEntries = options.initialValues
|
|
3921
|
-
? Object.entries(options.initialValues)
|
|
4001
|
+
? Object.entries(options.initialValues)
|
|
4002
|
+
.filter(([k, v]) => !k.startsWith("_") && v !== undefined)
|
|
4003
|
+
.map(([k, v]) => [k, this.hydrateTypedValues(v)])
|
|
3922
4004
|
: [];
|
|
3923
4005
|
const hasInitialValues = initialValueEntries.length > 0;
|
|
3924
4006
|
if (useCustomDocId) {
|
|
@@ -4181,8 +4263,12 @@ class BaseMindooDB {
|
|
|
4181
4263
|
// Sanitize caller-provided initial values: internal/reserved fields are
|
|
4182
4264
|
// managed by MindooDB and must not be seeded by callers. `undefined`
|
|
4183
4265
|
// values are treated as "not set" (Automerge cannot represent undefined).
|
|
4266
|
+
// `MindooValue` typed values become CRDT values here (this also validates
|
|
4267
|
+
// them before anything is written).
|
|
4184
4268
|
const initialValueEntries = options.initialValues
|
|
4185
|
-
? Object.entries(options.initialValues)
|
|
4269
|
+
? Object.entries(options.initialValues)
|
|
4270
|
+
.filter(([k, v]) => !k.startsWith("_") && v !== undefined)
|
|
4271
|
+
.map(([k, v]) => [k, this.hydrateTypedValues(v)])
|
|
4186
4272
|
: [];
|
|
4187
4273
|
const signerPublicKey = await this.resolveSignerPublicKey(useCustomSigningKey ? signingKeyPair : undefined);
|
|
4188
4274
|
const createUsernameHash = this.usernameHashFromRecord(Object.fromEntries(initialValueEntries));
|
|
@@ -6836,13 +6922,11 @@ class BaseMindooDB {
|
|
|
6836
6922
|
...entryMetadata,
|
|
6837
6923
|
encryptedData: encryptedPayload,
|
|
6838
6924
|
};
|
|
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.
|
|
6925
|
+
// Client-side write-policy precheck (docs/accesscontrol.md §9). For deletes
|
|
6926
|
+
// the rules default to the "before" state; both states are supplied so either
|
|
6927
|
+
// `when` resolves. Throws AccessDeniedError if denied.
|
|
6844
6928
|
{
|
|
6845
|
-
const beforeState = internalDoc
|
|
6929
|
+
const beforeState = this.docAtHeads(internalDoc, newDoc, automergeDepHashes);
|
|
6846
6930
|
await this.assertWriteAllowed({
|
|
6847
6931
|
op: entryType,
|
|
6848
6932
|
signerKey: createdByPublicKey,
|
|
@@ -7174,6 +7258,87 @@ class BaseMindooDB {
|
|
|
7174
7258
|
spans: this.dehydrateRichTextSpans(spans),
|
|
7175
7259
|
};
|
|
7176
7260
|
}
|
|
7261
|
+
async getTextCursors(doc, path, positions, options) {
|
|
7262
|
+
this.validateJsonPath(path, "Text cursor");
|
|
7263
|
+
if (!Array.isArray(positions)) {
|
|
7264
|
+
throw new Error("Text cursor positions must be an array");
|
|
7265
|
+
}
|
|
7266
|
+
const { automergeDoc, heads } = await this.resolveTextCursorTarget(doc, options);
|
|
7267
|
+
if (options?.move !== undefined &&
|
|
7268
|
+
options.move !== "before" &&
|
|
7269
|
+
options.move !== "after") {
|
|
7270
|
+
throw new Error('Text cursor move must be "before" or "after"');
|
|
7271
|
+
}
|
|
7272
|
+
const source = this.textCursorSource(automergeDoc, heads);
|
|
7273
|
+
const text = this.readTextForCursor(source, path);
|
|
7274
|
+
const cursors = positions.map((position) => {
|
|
7275
|
+
let target;
|
|
7276
|
+
if (position === "start" || position === "end") {
|
|
7277
|
+
target = position;
|
|
7278
|
+
}
|
|
7279
|
+
else if (typeof position === "number" && Number.isFinite(position)) {
|
|
7280
|
+
target = this.clampIndex(position, text.length);
|
|
7281
|
+
}
|
|
7282
|
+
else {
|
|
7283
|
+
throw new Error("Text cursor positions must be numbers, \"start\" or \"end\"");
|
|
7284
|
+
}
|
|
7285
|
+
return automerge_adapter_1.Automerge.getCursor(source, path, target, options?.move);
|
|
7286
|
+
});
|
|
7287
|
+
return {
|
|
7288
|
+
path: [...path],
|
|
7289
|
+
heads: heads ?? automerge_adapter_1.Automerge.getHeads(automergeDoc),
|
|
7290
|
+
cursors,
|
|
7291
|
+
};
|
|
7292
|
+
}
|
|
7293
|
+
async resolveTextCursors(doc, path, cursors, options) {
|
|
7294
|
+
this.validateJsonPath(path, "Text cursor");
|
|
7295
|
+
if (!Array.isArray(cursors) ||
|
|
7296
|
+
cursors.some((cursor) => typeof cursor !== "string" || cursor === "")) {
|
|
7297
|
+
throw new Error("Text cursors must be non-empty strings");
|
|
7298
|
+
}
|
|
7299
|
+
const { automergeDoc, heads } = await this.resolveTextCursorTarget(doc, options);
|
|
7300
|
+
const source = this.textCursorSource(automergeDoc, heads);
|
|
7301
|
+
this.readTextForCursor(source, path);
|
|
7302
|
+
const positions = cursors.map((cursor) => automerge_adapter_1.Automerge.getCursorPosition(source, path, cursor));
|
|
7303
|
+
return {
|
|
7304
|
+
path: [...path],
|
|
7305
|
+
heads: heads ?? automerge_adapter_1.Automerge.getHeads(automergeDoc),
|
|
7306
|
+
positions,
|
|
7307
|
+
};
|
|
7308
|
+
}
|
|
7309
|
+
async resolveTextCursorTarget(doc, options) {
|
|
7310
|
+
const docId = doc.getId();
|
|
7311
|
+
let internalDoc = this.wrappedInternalDocs.get(doc) ?? null;
|
|
7312
|
+
if (!internalDoc) {
|
|
7313
|
+
internalDoc = this.getCachedDocument(docId);
|
|
7314
|
+
}
|
|
7315
|
+
if (!internalDoc) {
|
|
7316
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
7317
|
+
if (!loadedDoc) {
|
|
7318
|
+
throw new errors_1.DocumentNotFoundError(docId);
|
|
7319
|
+
}
|
|
7320
|
+
internalDoc = loadedDoc;
|
|
7321
|
+
}
|
|
7322
|
+
if (internalDoc.isDeleted) {
|
|
7323
|
+
throw new errors_1.DocumentDeletedError(docId);
|
|
7324
|
+
}
|
|
7325
|
+
const heads = options?.heads && options.heads.length > 0
|
|
7326
|
+
? options.heads
|
|
7327
|
+
: undefined;
|
|
7328
|
+
return { automergeDoc: internalDoc.doc, heads };
|
|
7329
|
+
}
|
|
7330
|
+
/** The document itself, or a read-only view of it at `heads`. */
|
|
7331
|
+
textCursorSource(automergeDoc, heads) {
|
|
7332
|
+
return heads ? automerge_adapter_1.Automerge.view(automergeDoc, heads) : automergeDoc;
|
|
7333
|
+
}
|
|
7334
|
+
/** Reads the text a cursor operates on, rejecting non-text fields early. */
|
|
7335
|
+
readTextForCursor(source, path) {
|
|
7336
|
+
const value = this.readValueAtPath(source, path);
|
|
7337
|
+
if (typeof value !== "string") {
|
|
7338
|
+
throw new Error(`Text cursors require an Automerge text field at '${path.map(String).join(".")}'`);
|
|
7339
|
+
}
|
|
7340
|
+
return value;
|
|
7341
|
+
}
|
|
7177
7342
|
async exportAutomergeSnapshot(doc) {
|
|
7178
7343
|
const internalDoc = await this.resolveReadableInternalDoc(doc);
|
|
7179
7344
|
const binary = automerge_adapter_1.Automerge.save(internalDoc.doc);
|
|
@@ -7385,6 +7550,9 @@ class BaseMindooDB {
|
|
|
7385
7550
|
const pendingAttachmentAppends = new Map();
|
|
7386
7551
|
// Map of attachmentId -> extraction result for setAttachmentExtractedText
|
|
7387
7552
|
const pendingAttachmentTextUpdates = new Map();
|
|
7553
|
+
// incrementCounter() calls, applied in call order after the field
|
|
7554
|
+
// assignments so they can target counters assigned in the same callback.
|
|
7555
|
+
const pendingCounterIncrements = [];
|
|
7388
7556
|
// Reference to db for closures
|
|
7389
7557
|
const db = this;
|
|
7390
7558
|
// Guard flag to prevent operations after callback completes
|
|
@@ -7449,7 +7617,9 @@ class BaseMindooDB {
|
|
|
7449
7617
|
if (typeof prop === "string" && pendingChanges.has(prop)) {
|
|
7450
7618
|
return pendingChanges.get(prop);
|
|
7451
7619
|
}
|
|
7452
|
-
|
|
7620
|
+
// Counters read as numbers and immutable strings as strings here
|
|
7621
|
+
// too; change counters with incrementCounter().
|
|
7622
|
+
return db.readAutomergeScalar(target[prop]);
|
|
7453
7623
|
},
|
|
7454
7624
|
has: (target, prop) => {
|
|
7455
7625
|
// If marked for deletion, it doesn't exist
|
|
@@ -7577,6 +7747,14 @@ class BaseMindooDB {
|
|
|
7577
7747
|
});
|
|
7578
7748
|
}
|
|
7579
7749
|
},
|
|
7750
|
+
// ========== Counter Write Methods ==========
|
|
7751
|
+
incrementCounter: (path, delta) => {
|
|
7752
|
+
throwIfCallbackInactive("incrementCounter");
|
|
7753
|
+
const segments = typeof path === "string" ? [path] : [...path];
|
|
7754
|
+
db.validateJsonPath(segments, "incrementCounter");
|
|
7755
|
+
(0, values_1.assertCounterAmount)(delta, "incrementCounter delta");
|
|
7756
|
+
pendingCounterIncrements.push({ path: segments, delta });
|
|
7757
|
+
},
|
|
7580
7758
|
// ========== Attachment Read Methods (also work in changeDoc context) ==========
|
|
7581
7759
|
getAttachments: () => {
|
|
7582
7760
|
const payload = internalDoc.doc;
|
|
@@ -7612,12 +7790,15 @@ class BaseMindooDB {
|
|
|
7612
7790
|
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, (automergeDoc) => {
|
|
7613
7791
|
// Apply all pending changes (sets/updates)
|
|
7614
7792
|
for (const [key, value] of pendingChanges) {
|
|
7615
|
-
automergeDoc[key] = value;
|
|
7793
|
+
automergeDoc[key] = this.hydrateTypedValues(value);
|
|
7616
7794
|
}
|
|
7617
7795
|
// Apply all pending deletions
|
|
7618
7796
|
for (const key of pendingDeletions) {
|
|
7619
7797
|
delete automergeDoc[key];
|
|
7620
7798
|
}
|
|
7799
|
+
for (const { path, delta } of pendingCounterIncrements) {
|
|
7800
|
+
this.incrementCounterAtPath(automergeDoc, path, delta);
|
|
7801
|
+
}
|
|
7621
7802
|
// Apply pending attachment changes
|
|
7622
7803
|
if (pendingAttachmentAdditions.length > 0 ||
|
|
7623
7804
|
pendingAttachmentRemovals.size > 0 ||
|
|
@@ -7741,12 +7922,14 @@ class BaseMindooDB {
|
|
|
7741
7922
|
(patch.listInsert?.length ?? 0) +
|
|
7742
7923
|
(patch.textSplice?.length ?? 0) +
|
|
7743
7924
|
(patch.textMark?.length ?? 0) +
|
|
7744
|
-
(patch.textUnmark?.length ?? 0)
|
|
7925
|
+
(patch.textUnmark?.length ?? 0) +
|
|
7926
|
+
(patch.counterIncrement?.length ?? 0);
|
|
7745
7927
|
if (operationCount === 0) {
|
|
7746
7928
|
throw new Error("JSON patch must include at least one operation");
|
|
7747
7929
|
}
|
|
7748
7930
|
for (const operation of patch.set ?? []) {
|
|
7749
7931
|
this.validateJsonPath(operation.path, "JSON set");
|
|
7932
|
+
this.hydrateTypedValues(operation.value);
|
|
7750
7933
|
}
|
|
7751
7934
|
for (const operation of patch.unset ?? []) {
|
|
7752
7935
|
this.validateJsonPath(operation.path, "JSON unset");
|
|
@@ -7769,6 +7952,7 @@ class BaseMindooDB {
|
|
|
7769
7952
|
if (!Array.isArray(operation.values)) {
|
|
7770
7953
|
throw new Error("JSON listInsert values must be an array");
|
|
7771
7954
|
}
|
|
7955
|
+
this.hydrateTypedValues(operation.values);
|
|
7772
7956
|
}
|
|
7773
7957
|
for (const operation of patch.textSplice ?? []) {
|
|
7774
7958
|
this.validateJsonPath(operation.path, "JSON textSplice");
|
|
@@ -7811,6 +7995,134 @@ class BaseMindooDB {
|
|
|
7811
7995
|
throw new Error("JSON textUnmark names must be non-empty strings");
|
|
7812
7996
|
}
|
|
7813
7997
|
}
|
|
7998
|
+
for (const operation of patch.counterIncrement ?? []) {
|
|
7999
|
+
this.validateJsonPath(operation.path, "JSON counterIncrement");
|
|
8000
|
+
(0, values_1.assertCounterAmount)(operation.delta, "JSON counterIncrement delta");
|
|
8001
|
+
}
|
|
8002
|
+
}
|
|
8003
|
+
isAutomergeCounter(value) {
|
|
8004
|
+
if (value === null || typeof value !== "object")
|
|
8005
|
+
return false;
|
|
8006
|
+
const automerge = automerge_adapter_1.Automerge;
|
|
8007
|
+
if (typeof automerge.isCounter === "function") {
|
|
8008
|
+
return automerge.isCounter(value);
|
|
8009
|
+
}
|
|
8010
|
+
return (typeof automerge.Counter === "function" &&
|
|
8011
|
+
value instanceof automerge.Counter);
|
|
8012
|
+
}
|
|
8013
|
+
isAutomergeImmutableString(value) {
|
|
8014
|
+
if (value === null || typeof value !== "object")
|
|
8015
|
+
return false;
|
|
8016
|
+
const check = automerge_adapter_1.Automerge
|
|
8017
|
+
.isImmutableString;
|
|
8018
|
+
return typeof check === "function" && check(value);
|
|
8019
|
+
}
|
|
8020
|
+
/**
|
|
8021
|
+
* Plain-JS read view of an Automerge scalar wrapper: counters become numbers
|
|
8022
|
+
* and immutable strings become strings. Everything else is returned as is.
|
|
8023
|
+
*/
|
|
8024
|
+
readAutomergeScalar(value) {
|
|
8025
|
+
if (this.isAutomergeCounter(value))
|
|
8026
|
+
return value.value;
|
|
8027
|
+
if (this.isAutomergeImmutableString(value))
|
|
8028
|
+
return String(value);
|
|
8029
|
+
return value;
|
|
8030
|
+
}
|
|
8031
|
+
/**
|
|
8032
|
+
* Turns `MindooValue` typed values (tagged plain objects, see values.ts)
|
|
8033
|
+
* anywhere inside `value` into their CRDT representation, and copies plain
|
|
8034
|
+
* objects and arrays into fresh literals on the way. Values that already are
|
|
8035
|
+
* CRDT scalars, dates or bytes pass through. Throws for malformed tags, so
|
|
8036
|
+
* it doubles as validation.
|
|
8037
|
+
*/
|
|
8038
|
+
hydrateTypedValues(value, depth = 0) {
|
|
8039
|
+
if (value === null || typeof value !== "object")
|
|
8040
|
+
return value;
|
|
8041
|
+
if (depth > 100) {
|
|
8042
|
+
throw new Error("Document value is nested too deeply");
|
|
8043
|
+
}
|
|
8044
|
+
if (value instanceof Uint8Array ||
|
|
8045
|
+
value instanceof Date ||
|
|
8046
|
+
this.isAutomergeCounter(value) ||
|
|
8047
|
+
this.isAutomergeImmutableString(value)) {
|
|
8048
|
+
return value;
|
|
8049
|
+
}
|
|
8050
|
+
if (Array.isArray(value)) {
|
|
8051
|
+
return value.map((entry) => this.hydrateTypedValues(entry, depth + 1));
|
|
8052
|
+
}
|
|
8053
|
+
const record = value;
|
|
8054
|
+
if (Object.prototype.hasOwnProperty.call(record, values_1.MINDOO_VALUE_TAG)) {
|
|
8055
|
+
const typed = (0, values_1.parseTypedValue)(record);
|
|
8056
|
+
switch (typed.$mindoo) {
|
|
8057
|
+
case "atomic":
|
|
8058
|
+
return new automerge_adapter_1.Automerge.ImmutableString(typed.value);
|
|
8059
|
+
case "counter":
|
|
8060
|
+
return new automerge_adapter_1.Automerge.Counter(typed.value);
|
|
8061
|
+
case "timestamp":
|
|
8062
|
+
return (0, values_1.toTimestampDate)(typed.value, 'Typed value "timestamp"');
|
|
8063
|
+
}
|
|
8064
|
+
}
|
|
8065
|
+
const result = {};
|
|
8066
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
8067
|
+
if (entry !== undefined) {
|
|
8068
|
+
result[key] = this.hydrateTypedValues(entry, depth + 1);
|
|
8069
|
+
}
|
|
8070
|
+
}
|
|
8071
|
+
return result;
|
|
8072
|
+
}
|
|
8073
|
+
/**
|
|
8074
|
+
* The plain read view of a value that may contain `MindooValue` tags, as
|
|
8075
|
+
* `getData()` would return it once written (used where a precheck needs the
|
|
8076
|
+
* "after" state before the value is in a document).
|
|
8077
|
+
*/
|
|
8078
|
+
readTypedValues(value) {
|
|
8079
|
+
return this.normalizeAutomergeScalars(this.hydrateTypedValues(value));
|
|
8080
|
+
}
|
|
8081
|
+
/**
|
|
8082
|
+
* Replaces counters and atomic strings inside a materialized value with
|
|
8083
|
+
* plain numbers and strings, in place where possible. Reads (getData, the
|
|
8084
|
+
* query and summary indexes, write prechecks) then compare them like any
|
|
8085
|
+
* other JSON value.
|
|
8086
|
+
*/
|
|
8087
|
+
normalizeAutomergeScalars(value, depth = 0) {
|
|
8088
|
+
if (value === null || typeof value !== "object")
|
|
8089
|
+
return value;
|
|
8090
|
+
if (this.isAutomergeCounter(value) || this.isAutomergeImmutableString(value)) {
|
|
8091
|
+
return this.readAutomergeScalar(value);
|
|
8092
|
+
}
|
|
8093
|
+
if (value instanceof Uint8Array || value instanceof Date || depth > 100) {
|
|
8094
|
+
return value;
|
|
8095
|
+
}
|
|
8096
|
+
if (Array.isArray(value)) {
|
|
8097
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
8098
|
+
value[index] = this.normalizeAutomergeScalars(value[index], depth + 1);
|
|
8099
|
+
}
|
|
8100
|
+
return value;
|
|
8101
|
+
}
|
|
8102
|
+
const record = value;
|
|
8103
|
+
for (const key of Object.keys(record)) {
|
|
8104
|
+
record[key] = this.normalizeAutomergeScalars(record[key], depth + 1);
|
|
8105
|
+
}
|
|
8106
|
+
return record;
|
|
8107
|
+
}
|
|
8108
|
+
/**
|
|
8109
|
+
* Must run inside an Automerge change callback. Initializes a missing field
|
|
8110
|
+
* as a counter holding `delta`; rejects existing non-counter values rather
|
|
8111
|
+
* than silently converting them (a plain number would lose its history and
|
|
8112
|
+
* any concurrent plain-number write would still win).
|
|
8113
|
+
*/
|
|
8114
|
+
incrementCounterAtPath(automergeDoc, path, delta) {
|
|
8115
|
+
const parent = this.ensureJsonParentAtPath(automergeDoc, path);
|
|
8116
|
+
const leaf = path[path.length - 1];
|
|
8117
|
+
const existing = parent[leaf];
|
|
8118
|
+
if (existing === undefined || existing === null) {
|
|
8119
|
+
parent[leaf] = new automerge_adapter_1.Automerge.Counter(delta);
|
|
8120
|
+
return;
|
|
8121
|
+
}
|
|
8122
|
+
if (!this.isAutomergeCounter(existing)) {
|
|
8123
|
+
throw new Error(`Cannot increment non-counter value at '${path.map(String).join(".")}'; create it with MindooValue.counter() first`);
|
|
8124
|
+
}
|
|
8125
|
+
existing.increment(delta);
|
|
7814
8126
|
}
|
|
7815
8127
|
validateRichTextPatch(patch) {
|
|
7816
8128
|
this.validateJsonPath(patch.path, "Rich-text patch");
|
|
@@ -7975,7 +8287,7 @@ class BaseMindooDB {
|
|
|
7975
8287
|
}
|
|
7976
8288
|
applyJsonPatchOperations(automergeDoc, patch) {
|
|
7977
8289
|
for (const operation of patch.set ?? []) {
|
|
7978
|
-
this.setJsonValueAtPath(automergeDoc, operation.path, structuredClone(operation.value));
|
|
8290
|
+
this.setJsonValueAtPath(automergeDoc, operation.path, this.hydrateTypedValues(structuredClone(operation.value)));
|
|
7979
8291
|
}
|
|
7980
8292
|
for (const operation of patch.unset ?? []) {
|
|
7981
8293
|
this.unsetJsonValueAtPath(automergeDoc, operation.path);
|
|
@@ -7986,7 +8298,7 @@ class BaseMindooDB {
|
|
|
7986
8298
|
}
|
|
7987
8299
|
for (const operation of patch.listInsert ?? []) {
|
|
7988
8300
|
const list = this.ensureJsonListAtPath(automergeDoc, operation.path);
|
|
7989
|
-
list.splice(operation.index, 0, ...structuredClone(operation.values));
|
|
8301
|
+
list.splice(operation.index, 0, ...this.hydrateTypedValues(structuredClone(operation.values)));
|
|
7990
8302
|
}
|
|
7991
8303
|
for (const operation of patch.textSplice ?? []) {
|
|
7992
8304
|
this.spliceJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.deleteCount, operation.insert ?? "");
|
|
@@ -8005,6 +8317,9 @@ class BaseMindooDB {
|
|
|
8005
8317
|
this.unmarkJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.length, name);
|
|
8006
8318
|
}
|
|
8007
8319
|
}
|
|
8320
|
+
for (const operation of patch.counterIncrement ?? []) {
|
|
8321
|
+
this.incrementCounterAtPath(automergeDoc, operation.path, operation.delta);
|
|
8322
|
+
}
|
|
8008
8323
|
}
|
|
8009
8324
|
spliceJsonTextAtPath(automergeDoc, path, index, deleteCount, insert) {
|
|
8010
8325
|
const value = this.readValueAtPath(automergeDoc, path);
|
|
@@ -8183,6 +8498,38 @@ class BaseMindooDB {
|
|
|
8183
8498
|
return apply(internalDoc.doc);
|
|
8184
8499
|
}
|
|
8185
8500
|
}
|
|
8501
|
+
/**
|
|
8502
|
+
* The document as it stood at `heads`, for the "before" side of a Tier 2
|
|
8503
|
+
* (`withfields`) write precheck.
|
|
8504
|
+
*
|
|
8505
|
+
* `internalDoc.doc` is normally the pre-change document (it is reassigned to
|
|
8506
|
+
* the new document after the store write), but it is shared mutable state and
|
|
8507
|
+
* is not guaranteed to still sit on the heads a change was computed from: an
|
|
8508
|
+
* out-of-band Automerge operation, the clone recovery in
|
|
8509
|
+
* {@link runChangeWithOutdatedDocRecovery}, or a concurrent op on the same
|
|
8510
|
+
* cached document can move it. A "before" that is really the "after" silently
|
|
8511
|
+
* flips every `when: "before"` verdict, so the caller passes the change's own
|
|
8512
|
+
* dependency hashes, which are fixed at the moment the change was produced,
|
|
8513
|
+
* and only falls back to the cache slot when it has none.
|
|
8514
|
+
*
|
|
8515
|
+
* `Automerge.clone` is load-bearing: a bare `Automerge.view` shares the source
|
|
8516
|
+
* document's WASM handle, and {@link convertAutomergeToJS} materializes through
|
|
8517
|
+
* that handle at ITS heads, not the view's — so materializing a view returns
|
|
8518
|
+
* the post-change state. Cloning gives the snapshot its own handle.
|
|
8519
|
+
*/
|
|
8520
|
+
docAtHeads(internalDoc, doc, heads) {
|
|
8521
|
+
if (!heads || heads.length === 0)
|
|
8522
|
+
return internalDoc.doc;
|
|
8523
|
+
try {
|
|
8524
|
+
return automerge_adapter_1.Automerge.clone(automerge_adapter_1.Automerge.view(doc, heads));
|
|
8525
|
+
}
|
|
8526
|
+
catch (error) {
|
|
8527
|
+
// A snapshot we cannot build must not fail the write outright; the cache
|
|
8528
|
+
// slot is the same value this code used before the snapshot existed.
|
|
8529
|
+
this.logger.warn(`Could not snapshot document ${internalDoc.id} at its pre-change heads, using the cached state: ${error}`);
|
|
8530
|
+
return internalDoc.doc;
|
|
8531
|
+
}
|
|
8532
|
+
}
|
|
8186
8533
|
isOutdatedDocumentError(error) {
|
|
8187
8534
|
if (!(error instanceof Error))
|
|
8188
8535
|
return false;
|
|
@@ -8261,6 +8608,9 @@ class BaseMindooDB {
|
|
|
8261
8608
|
// text patches, merges, ...) carries it consistently on each produced entry.
|
|
8262
8609
|
const attachmentRefs = this.collectAttachmentRefs(newDoc);
|
|
8263
8610
|
const entries = [];
|
|
8611
|
+
// The dependencies of the first change ARE the heads the change was computed
|
|
8612
|
+
// from, i.e. the document's "before" state for the write precheck below.
|
|
8613
|
+
let headsBeforeFirstChange;
|
|
8264
8614
|
for (let index = 0; index < resolvedChangeBytesList.length; index += 1) {
|
|
8265
8615
|
const changeBytes = resolvedChangeBytesList[index];
|
|
8266
8616
|
const entry = await this.buildDocChangeStoreEntry({
|
|
@@ -8278,23 +8628,23 @@ class BaseMindooDB {
|
|
|
8278
8628
|
});
|
|
8279
8629
|
entries.push(entry);
|
|
8280
8630
|
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
8631
|
+
if (index === 0) {
|
|
8632
|
+
headsBeforeFirstChange = decodedChange.deps;
|
|
8633
|
+
}
|
|
8281
8634
|
this.registerAutomergeHashMapping(docId, decodedChange.hash, entry.id);
|
|
8282
8635
|
}
|
|
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.
|
|
8636
|
+
// Client-side write-policy precheck (docs/accesscontrol.md §9). Throws
|
|
8637
|
+
// AccessDeniedError when denied.
|
|
8287
8638
|
{
|
|
8288
8639
|
const signerKey = useCustomKey
|
|
8289
8640
|
? signingKeyPair.publicKey
|
|
8290
8641
|
: (await this.tenant.getCurrentUserId()).userSigningPublicKey;
|
|
8291
|
-
const beforeState = internalDoc.doc;
|
|
8292
8642
|
await this.assertWriteAllowed({
|
|
8293
8643
|
op: "doc_change",
|
|
8294
8644
|
signerKey,
|
|
8295
8645
|
isAuthor: await this.resolveIsAuthor(docId, signerKey, Number.MAX_SAFE_INTEGER),
|
|
8296
8646
|
bypass: bypassPrecheck,
|
|
8297
|
-
getBeforeDoc: () => this.convertAutomergeToJS(
|
|
8647
|
+
getBeforeDoc: () => this.convertAutomergeToJS(this.docAtHeads(internalDoc, newDoc, headsBeforeFirstChange)),
|
|
8298
8648
|
getAfterDoc: () => this.convertAutomergeToJS(newDoc),
|
|
8299
8649
|
});
|
|
8300
8650
|
}
|
|
@@ -9474,8 +9824,12 @@ class BaseMindooDB {
|
|
|
9474
9824
|
return !persistedHashes.has(parsed.automergeHash);
|
|
9475
9825
|
});
|
|
9476
9826
|
if (missingMetadata.length === 0) {
|
|
9477
|
-
// No real deltas (changeSeq pointer drift only). Promote and return
|
|
9827
|
+
// No real deltas (changeSeq pointer drift only). Promote and return,
|
|
9828
|
+
// marked dirty so the record is rewritten under the current changeSeq:
|
|
9829
|
+
// otherwise every later read (and every warmer pass) would repeat
|
|
9830
|
+
// this metadata scan + hash comparison for a record that is current.
|
|
9478
9831
|
await this.storeCachedDocument(internal);
|
|
9832
|
+
this.markDocDirty(docId);
|
|
9479
9833
|
return internal;
|
|
9480
9834
|
}
|
|
9481
9835
|
let updated;
|
|
@@ -9490,8 +9844,9 @@ class BaseMindooDB {
|
|
|
9490
9844
|
// applyNewEntriesToCachedDocument returns null when no signed entries
|
|
9491
9845
|
// produced a head change. Treat the persisted doc as authoritative
|
|
9492
9846
|
// and put it in L1 ourselves (the helper only caches when it
|
|
9493
|
-
// actually applied work).
|
|
9847
|
+
// actually applied work). Dirty for the same reason as above.
|
|
9494
9848
|
await this.storeCachedDocument(internal);
|
|
9849
|
+
this.markDocDirty(docId);
|
|
9495
9850
|
return internal;
|
|
9496
9851
|
}
|
|
9497
9852
|
return updated;
|
|
@@ -9511,9 +9866,13 @@ class BaseMindooDB {
|
|
|
9511
9866
|
/**
|
|
9512
9867
|
* Start the L2 background warmer.
|
|
9513
9868
|
*
|
|
9514
|
-
*
|
|
9515
|
-
*
|
|
9516
|
-
*
|
|
9869
|
+
* First waits until the changefeed consumers (summary buffer, an
|
|
9870
|
+
* enabled full-text index) have caught up: they materialize every
|
|
9871
|
+
* changed document anyway, so running next to them would do the same
|
|
9872
|
+
* work twice. Then walks a snapshot of the in-memory document index and
|
|
9873
|
+
* skips every doc that is in L1 or whose L2 record is already at the
|
|
9874
|
+
* current changeSeq (checked from the record header, no deserialization).
|
|
9875
|
+
* For the rest it calls {@link tryLoadFromL2}. That path either:
|
|
9517
9876
|
*
|
|
9518
9877
|
* - hits L2 fresh and just promotes the doc to L1, or
|
|
9519
9878
|
* - hits L2 stale and applies missing deltas (re-flushed via the
|
|
@@ -9614,16 +9973,13 @@ class BaseMindooDB {
|
|
|
9614
9973
|
this.logger.debug("Warmer skipped: no cacheManager attached, no L2 to warm.");
|
|
9615
9974
|
return;
|
|
9616
9975
|
}
|
|
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" };
|
|
9976
|
+
// Changefeed consumers (summary buffer, full-text index) materialize
|
|
9977
|
+
// every changed document anyway, and those loads persist to L2 through
|
|
9978
|
+
// flush-before-evict / the periodic flush. Running the warmer next to
|
|
9979
|
+
// them (it is typically started right after a sync, while their
|
|
9980
|
+
// catch-up is still going) materialized the same documents twice. Let
|
|
9981
|
+
// them finish first; the pass below then only has to pick up what they
|
|
9982
|
+
// did not cover.
|
|
9627
9983
|
const emitProgress = (progress) => {
|
|
9628
9984
|
this.warmerProgress = progress;
|
|
9629
9985
|
if (!onProgress)
|
|
@@ -9636,6 +9992,26 @@ class BaseMindooDB {
|
|
|
9636
9992
|
this.logger.warn(`Warmer onProgress callback threw: ${e}`);
|
|
9637
9993
|
}
|
|
9638
9994
|
};
|
|
9995
|
+
this.warmerProgress = {
|
|
9996
|
+
processed: 0,
|
|
9997
|
+
total: this.index.length,
|
|
9998
|
+
phase: "warming",
|
|
9999
|
+
};
|
|
10000
|
+
await this.awaitChangefeedConsumersForWarmer(signal);
|
|
10001
|
+
if (signal.aborted) {
|
|
10002
|
+
emitProgress({ processed: 0, total: this.index.length, phase: "cancelled" });
|
|
10003
|
+
return;
|
|
10004
|
+
}
|
|
10005
|
+
// Snapshot the docId list so concurrent index updates don't shift
|
|
10006
|
+
// our iteration mid-pass. Iterating the index directly would also
|
|
10007
|
+
// work but a snapshot makes the loop's behavior easier to reason
|
|
10008
|
+
// about under sync activity.
|
|
10009
|
+
const docIds = this.index.map((entry) => entry.docId);
|
|
10010
|
+
const total = docIds.length;
|
|
10011
|
+
// Progress snapshot is replaced (not mutated in place) so polled
|
|
10012
|
+
// readers see consistent values. We initialize even when total=0
|
|
10013
|
+
// so the UI can display a "done" terminal state.
|
|
10014
|
+
this.warmerProgress = { processed: 0, total, phase: "warming" };
|
|
9639
10015
|
if (total === 0) {
|
|
9640
10016
|
this.logger.debug("Warmer: nothing to warm, index is empty.");
|
|
9641
10017
|
emitProgress({ processed: 0, total: 0, phase: "done" });
|
|
@@ -9645,6 +10021,7 @@ class BaseMindooDB {
|
|
|
9645
10021
|
let processed = 0;
|
|
9646
10022
|
let warmed = 0;
|
|
9647
10023
|
let skippedAlreadyHot = 0;
|
|
10024
|
+
let skippedFreshInL2 = 0;
|
|
9648
10025
|
let errored = 0;
|
|
9649
10026
|
try {
|
|
9650
10027
|
for (const docId of docIds) {
|
|
@@ -9657,6 +10034,11 @@ class BaseMindooDB {
|
|
|
9657
10034
|
if (this.docCache.has(docId)) {
|
|
9658
10035
|
skippedAlreadyHot++;
|
|
9659
10036
|
}
|
|
10037
|
+
else if (await this.isL2RecordCurrent(docId)) {
|
|
10038
|
+
// Nothing to do: loading it would only deserialize the doc into
|
|
10039
|
+
// L1 and evict it again (L1 is far smaller than a large DB).
|
|
10040
|
+
skippedFreshInL2++;
|
|
10041
|
+
}
|
|
9660
10042
|
else {
|
|
9661
10043
|
try {
|
|
9662
10044
|
const fromL2 = await this.tryLoadFromL2(docId);
|
|
@@ -9682,7 +10064,7 @@ class BaseMindooDB {
|
|
|
9682
10064
|
}
|
|
9683
10065
|
}
|
|
9684
10066
|
this.logger.info(`Warmer finished in ${Date.now() - startedAt}ms: processed=${processed}, ` +
|
|
9685
|
-
`warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, errored=${errored}`);
|
|
10067
|
+
`warmed=${warmed}, alreadyHot=${skippedAlreadyHot}, freshInL2=${skippedFreshInL2}, errored=${errored}`);
|
|
9686
10068
|
emitProgress({ processed, total, phase: "done" });
|
|
9687
10069
|
}
|
|
9688
10070
|
catch (e) {
|
|
@@ -9693,6 +10075,82 @@ class BaseMindooDB {
|
|
|
9693
10075
|
emitProgress({ processed, total, phase: "cancelled" });
|
|
9694
10076
|
}
|
|
9695
10077
|
}
|
|
10078
|
+
/**
|
|
10079
|
+
* Wait until the summary buffer and an enabled full-text index have
|
|
10080
|
+
* consumed the changefeed (joining a running auto-follow run or starting
|
|
10081
|
+
* a catch-up). Failures are logged: the warmer then simply does the
|
|
10082
|
+
* remaining work itself.
|
|
10083
|
+
*/
|
|
10084
|
+
async awaitChangefeedConsumersForWarmer(signal) {
|
|
10085
|
+
const startedAt = Date.now();
|
|
10086
|
+
const catchUp = (async () => {
|
|
10087
|
+
await this.summaryStore?.update({ signal });
|
|
10088
|
+
if (!signal.aborted && this.fulltextIndex?.isEnabled()) {
|
|
10089
|
+
await this.fulltextIndex.update({ signal });
|
|
10090
|
+
}
|
|
10091
|
+
})();
|
|
10092
|
+
// Joining an already running auto-follow run does not honor our signal,
|
|
10093
|
+
// so a stop must not wait for it: the run just continues on its own.
|
|
10094
|
+
let onAbort = () => { };
|
|
10095
|
+
const aborted = new Promise((resolve) => {
|
|
10096
|
+
onAbort = () => resolve();
|
|
10097
|
+
if (signal.aborted)
|
|
10098
|
+
resolve();
|
|
10099
|
+
else
|
|
10100
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
10101
|
+
});
|
|
10102
|
+
try {
|
|
10103
|
+
await Promise.race([catchUp, aborted]);
|
|
10104
|
+
}
|
|
10105
|
+
catch (e) {
|
|
10106
|
+
this.logger.warn(`Warmer: changefeed consumer catch-up failed: ${e}`);
|
|
10107
|
+
}
|
|
10108
|
+
finally {
|
|
10109
|
+
signal.removeEventListener("abort", onAbort);
|
|
10110
|
+
}
|
|
10111
|
+
catchUp.catch(() => { });
|
|
10112
|
+
this.logger.debug(`Warmer: changefeed consumers caught up in ${Date.now() - startedAt}ms`);
|
|
10113
|
+
}
|
|
10114
|
+
/**
|
|
10115
|
+
* Whether L2 already holds this document at its current changeSeq, so
|
|
10116
|
+
* the warmer can skip it without deserializing. Answered from
|
|
10117
|
+
* {@link lastFlushedDocState} for records written in this session,
|
|
10118
|
+
* otherwise from the record header (no Automerge load).
|
|
10119
|
+
*/
|
|
10120
|
+
async isL2RecordCurrent(docId) {
|
|
10121
|
+
const indexEntryIdx = this.getDocIndexPosition(docId);
|
|
10122
|
+
const store = this.cacheManager?.getStore() ?? null;
|
|
10123
|
+
if (indexEntryIdx === undefined || !store) {
|
|
10124
|
+
return false;
|
|
10125
|
+
}
|
|
10126
|
+
const entry = this.index[indexEntryIdx];
|
|
10127
|
+
if (entry.accessState !== "visible") {
|
|
10128
|
+
return false;
|
|
10129
|
+
}
|
|
10130
|
+
const currentChangeSeq = entry.changeSeq;
|
|
10131
|
+
const flushed = this.lastFlushedDocState.get(docId);
|
|
10132
|
+
if (flushed !== undefined) {
|
|
10133
|
+
return flushed.startsWith(`${currentChangeSeq}:`);
|
|
10134
|
+
}
|
|
10135
|
+
try {
|
|
10136
|
+
const bytes = await store.get("doc", `${this.getCachePrefix()}/${docId}`);
|
|
10137
|
+
if (!bytes) {
|
|
10138
|
+
return false;
|
|
10139
|
+
}
|
|
10140
|
+
const { header } = this.parseDocCacheRecord(bytes);
|
|
10141
|
+
if (header.version !== DOC_CACHE_HEADER_VERSION ||
|
|
10142
|
+
header.changeSeq !== currentChangeSeq ||
|
|
10143
|
+
!Array.isArray(header.automergeHeads)) {
|
|
10144
|
+
return false;
|
|
10145
|
+
}
|
|
10146
|
+
this.lastFlushedDocState.set(docId, `${currentChangeSeq}:${header.automergeHeads.join(",")}`);
|
|
10147
|
+
return true;
|
|
10148
|
+
}
|
|
10149
|
+
catch {
|
|
10150
|
+
// Unreadable record: let the regular L2 path evict/rebuild it.
|
|
10151
|
+
return false;
|
|
10152
|
+
}
|
|
10153
|
+
}
|
|
9696
10154
|
/**
|
|
9697
10155
|
* The per-tenant quarantine/audit log: entries rejected during
|
|
9698
10156
|
* materialization by the access-control layer (docs/accesscontrol.md §10).
|
|
@@ -9700,7 +10158,11 @@ class BaseMindooDB {
|
|
|
9700
10158
|
* store but are excluded from materialized state and queries.
|
|
9701
10159
|
*/
|
|
9702
10160
|
getQuarantineLog() {
|
|
9703
|
-
|
|
10161
|
+
const flat = [];
|
|
10162
|
+
for (const records of this.quarantineLog.values()) {
|
|
10163
|
+
flat.push(...records);
|
|
10164
|
+
}
|
|
10165
|
+
return flat;
|
|
9704
10166
|
}
|
|
9705
10167
|
/**
|
|
9706
10168
|
* Whether access control is active for this tenant and should gate
|
|
@@ -9900,8 +10362,9 @@ class BaseMindooDB {
|
|
|
9900
10362
|
const afterValues = { _attachments: [] };
|
|
9901
10363
|
if (options.initialValues) {
|
|
9902
10364
|
for (const [k, v] of Object.entries(options.initialValues)) {
|
|
9903
|
-
if (!k.startsWith("_"))
|
|
9904
|
-
afterValues[k] = v;
|
|
10365
|
+
if (!k.startsWith("_") && v !== undefined) {
|
|
10366
|
+
afterValues[k] = this.readTypedValues(v);
|
|
10367
|
+
}
|
|
9905
10368
|
}
|
|
9906
10369
|
}
|
|
9907
10370
|
const decision = await this.evaluateClientWriteAccess({
|
|
@@ -10224,10 +10687,52 @@ class BaseMindooDB {
|
|
|
10224
10687
|
const info = await this.resolveCreatorInfo(docId);
|
|
10225
10688
|
return info?.signingKey ?? null;
|
|
10226
10689
|
}
|
|
10227
|
-
/**
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
|
|
10690
|
+
/**
|
|
10691
|
+
* Replace one document's quarantine records with the verdict of the pass that
|
|
10692
|
+
* just ended.
|
|
10693
|
+
*
|
|
10694
|
+
* Whole-group replacement rather than appending, because a pass re-derives the
|
|
10695
|
+
* complete verdict for the document: appending would duplicate every record
|
|
10696
|
+
* on every re-materialization, and would never drop the record of an entry
|
|
10697
|
+
* that has since become acceptable.
|
|
10698
|
+
*
|
|
10699
|
+
* Writes nothing when the verdict is unchanged. That is not just thrift — the
|
|
10700
|
+
* checkpoint is rewritten whenever this marks it dirty, and a document is
|
|
10701
|
+
* re-materialized often, so marking dirty on an unchanged verdict would turn
|
|
10702
|
+
* every read of a quarantined document into a checkpoint write.
|
|
10703
|
+
*/
|
|
10704
|
+
commitQuarantineForDoc(docId, records) {
|
|
10705
|
+
const previous = this.quarantineLog.get(docId);
|
|
10706
|
+
if (records.length === 0) {
|
|
10707
|
+
if (previous) {
|
|
10708
|
+
this.quarantineLog.delete(docId);
|
|
10709
|
+
this.cacheMetaDirty = true;
|
|
10710
|
+
}
|
|
10711
|
+
return;
|
|
10712
|
+
}
|
|
10713
|
+
const kept = records.length > MAX_QUARANTINE_RECORDS_PER_DOC
|
|
10714
|
+
? records.slice(0, MAX_QUARANTINE_RECORDS_PER_DOC)
|
|
10715
|
+
: records;
|
|
10716
|
+
// Compare the verdict, not the record objects: `recordedAt` is a wall-clock
|
|
10717
|
+
// stamp that differs on every pass and would make every verdict look new.
|
|
10718
|
+
const verdictOf = (list) => list.map((rec) => `${rec.entryId}\u0000${rec.reason}`).join("\u0001");
|
|
10719
|
+
if (previous && verdictOf(previous) === verdictOf(kept)) {
|
|
10720
|
+
return;
|
|
10721
|
+
}
|
|
10722
|
+
this.quarantineLog.set(docId, kept);
|
|
10723
|
+
if (!previous) {
|
|
10724
|
+
while (this.quarantineLog.size > MAX_QUARANTINE_DOCS) {
|
|
10725
|
+
// Map iteration is insertion-ordered, so this is the oldest document.
|
|
10726
|
+
const oldest = this.quarantineLog.keys().next();
|
|
10727
|
+
if (oldest.done || oldest.value === docId)
|
|
10728
|
+
break;
|
|
10729
|
+
this.quarantineLog.delete(oldest.value);
|
|
10730
|
+
}
|
|
10731
|
+
}
|
|
10732
|
+
this.cacheMetaDirty = true;
|
|
10733
|
+
for (const rec of kept) {
|
|
10734
|
+
console.log(`[MindooDB][ACL] Quarantined entry ${rec.entryId} (doc ${rec.docId}): ${rec.reason} — ${rec.detail}`);
|
|
10735
|
+
}
|
|
10231
10736
|
}
|
|
10232
10737
|
/**
|
|
10233
10738
|
* Materialize a document through the access-control (Tier 2) path
|
|
@@ -10284,8 +10789,12 @@ class BaseMindooDB {
|
|
|
10284
10789
|
.sort((a, b) => (a.receivedAt ?? a.createdAt) === (b.receivedAt ?? b.createdAt)
|
|
10285
10790
|
? a.id.localeCompare(b.id)
|
|
10286
10791
|
: (a.receivedAt ?? a.createdAt) - (b.receivedAt ?? b.createdAt));
|
|
10287
|
-
if (dataEntries.length === 0)
|
|
10792
|
+
if (dataEntries.length === 0) {
|
|
10793
|
+
// Nothing left to judge (e.g. the history was purged), so no verdict can
|
|
10794
|
+
// stand either.
|
|
10795
|
+
this.commitQuarantineForDoc(docId, []);
|
|
10288
10796
|
return { doc: null, cacheable };
|
|
10797
|
+
}
|
|
10289
10798
|
const createMeta = dataEntries.find((m) => m.entryType === "doc_create");
|
|
10290
10799
|
const creatorKey = createMeta?.createdByPublicKey ?? null;
|
|
10291
10800
|
const creatorTrustedTime = createMeta
|
|
@@ -10299,9 +10808,13 @@ class BaseMindooDB {
|
|
|
10299
10808
|
let decryptionKeyId = "default";
|
|
10300
10809
|
const acceptedMeta = [];
|
|
10301
10810
|
const quarantined = new Set();
|
|
10811
|
+
// Collected for the whole pass and committed once at the end, so the log
|
|
10812
|
+
// holds this document's current verdict rather than the union of every
|
|
10813
|
+
// verdict ever reached for it. See commitQuarantineForDoc.
|
|
10814
|
+
const quarantineRecords = [];
|
|
10302
10815
|
const quarantine = (meta, reason, detail) => {
|
|
10303
10816
|
quarantined.add(meta.id);
|
|
10304
|
-
|
|
10817
|
+
quarantineRecords.push({
|
|
10305
10818
|
entryId: meta.id,
|
|
10306
10819
|
docId,
|
|
10307
10820
|
dbid,
|
|
@@ -10458,6 +10971,9 @@ class BaseMindooDB {
|
|
|
10458
10971
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entry.id);
|
|
10459
10972
|
}
|
|
10460
10973
|
}
|
|
10974
|
+
// The pass is over and `quarantineRecords` is this document's full verdict,
|
|
10975
|
+
// including the empty verdict that retires an earlier one.
|
|
10976
|
+
this.commitQuarantineForDoc(docId, quarantineRecords);
|
|
10461
10977
|
if (currentDoc === null) {
|
|
10462
10978
|
// The create was quarantined or absent: the document is logically absent.
|
|
10463
10979
|
return { doc: null, cacheable };
|
|
@@ -10485,6 +11001,22 @@ class BaseMindooDB {
|
|
|
10485
11001
|
/**
|
|
10486
11002
|
* Internal method to load a document from the content-addressed store
|
|
10487
11003
|
*/
|
|
11004
|
+
/** Whether no new entries of `docId` were indexed since `generation` was read. */
|
|
11005
|
+
isEntryGenerationCurrent(docId, generation) {
|
|
11006
|
+
return (this.docEntryGeneration.get(docId) ?? 0) === generation;
|
|
11007
|
+
}
|
|
11008
|
+
/**
|
|
11009
|
+
* The L2 path caches inside {@link tryLoadFromL2}; when entries were
|
|
11010
|
+
* indexed while it ran, take the (outdated) result back out of L1.
|
|
11011
|
+
*/
|
|
11012
|
+
dropCachedLoadIfSuperseded(docId, generation, loaded) {
|
|
11013
|
+
if (this.isEntryGenerationCurrent(docId, generation) ||
|
|
11014
|
+
this.docCache.get(docId) !== loaded) {
|
|
11015
|
+
return;
|
|
11016
|
+
}
|
|
11017
|
+
this.docCache.delete(docId);
|
|
11018
|
+
this.dirtyDocIds.delete(docId);
|
|
11019
|
+
}
|
|
10488
11020
|
async loadDocumentInternal(docId) {
|
|
10489
11021
|
const startedAt = Date.now();
|
|
10490
11022
|
const cacheCheckStartedAt = Date.now();
|
|
@@ -10528,8 +11060,10 @@ class BaseMindooDB {
|
|
|
10528
11060
|
// the full signature-verify + decrypt + Automerge-replay pipeline.
|
|
10529
11061
|
// The L2 path either returns a ready-to-use InternalDoc (already
|
|
10530
11062
|
// promoted into L1) or null - in which case we proceed below.
|
|
11063
|
+
const entryGeneration = this.docEntryGeneration.get(docId) ?? 0;
|
|
10531
11064
|
const l2Doc = await this.tryLoadFromL2(docId);
|
|
10532
11065
|
if (l2Doc !== null) {
|
|
11066
|
+
this.dropCachedLoadIfSuperseded(docId, entryGeneration, l2Doc);
|
|
10533
11067
|
this.performanceCallback?.onDocumentLoad?.({
|
|
10534
11068
|
docId,
|
|
10535
11069
|
cacheHit: true,
|
|
@@ -10613,7 +11147,9 @@ class BaseMindooDB {
|
|
|
10613
11147
|
const aclDoc = aclResult.doc;
|
|
10614
11148
|
// Only cache a fully-validated result. A transient validation failure
|
|
10615
11149
|
// (audit finding #2) leaves `cacheable` false so the next load retries.
|
|
10616
|
-
if (aclDoc &&
|
|
11150
|
+
if (aclDoc &&
|
|
11151
|
+
aclResult.cacheable &&
|
|
11152
|
+
this.isEntryGenerationCurrent(docId, entryGeneration)) {
|
|
10617
11153
|
await this.storeCachedDocument(aclDoc);
|
|
10618
11154
|
this.markDocDirty(docId);
|
|
10619
11155
|
}
|
|
@@ -10886,10 +11422,16 @@ class BaseMindooDB {
|
|
|
10886
11422
|
witnessed: witnessState.witnessed,
|
|
10887
11423
|
sealedRecipients: (0, recipients_1.newestRecipientBlock)(allEntryMetadata),
|
|
10888
11424
|
};
|
|
10889
|
-
// Update cache
|
|
10890
|
-
|
|
10891
|
-
this.
|
|
10892
|
-
|
|
11425
|
+
// Update cache (unless newer entries were indexed meanwhile: then this
|
|
11426
|
+
// result is already outdated and the next read materializes again)
|
|
11427
|
+
if (this.isEntryGenerationCurrent(docId, entryGeneration)) {
|
|
11428
|
+
await this.storeCachedDocument(internalDoc);
|
|
11429
|
+
this.markDocDirty(docId);
|
|
11430
|
+
this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
|
|
11431
|
+
}
|
|
11432
|
+
else {
|
|
11433
|
+
this.logger.debug(`===== Loaded document ${docId}; not cached, newer entries were indexed during the load =====`);
|
|
11434
|
+
}
|
|
10893
11435
|
this.performanceCallback?.onDocumentLoad?.({
|
|
10894
11436
|
docId,
|
|
10895
11437
|
cacheHit: false,
|
|
@@ -10920,8 +11462,10 @@ class BaseMindooDB {
|
|
|
10920
11462
|
if (meta && meta.handle && typeof meta.handle.materialize === "function") {
|
|
10921
11463
|
// Use native materialize() which properly converts Text objects to strings
|
|
10922
11464
|
try {
|
|
11465
|
+
// materialize() returns fresh objects, so the scalar wrappers can be
|
|
11466
|
+
// replaced in place.
|
|
10923
11467
|
const materialized = meta.handle.materialize("/");
|
|
10924
|
-
return materialized;
|
|
11468
|
+
return this.normalizeAutomergeScalars(materialized);
|
|
10925
11469
|
}
|
|
10926
11470
|
catch (error) {
|
|
10927
11471
|
console.error("[MindooDB] Failed to materialize document:", error);
|
|
@@ -10942,6 +11486,11 @@ class BaseMindooDB {
|
|
|
10942
11486
|
return value;
|
|
10943
11487
|
if (value instanceof Uint8Array)
|
|
10944
11488
|
return value;
|
|
11489
|
+
if (value instanceof Date)
|
|
11490
|
+
return new Date(value.getTime());
|
|
11491
|
+
if (this.isAutomergeCounter(value) || this.isAutomergeImmutableString(value)) {
|
|
11492
|
+
return this.readAutomergeScalar(value);
|
|
11493
|
+
}
|
|
10945
11494
|
if (depth > 40)
|
|
10946
11495
|
return null;
|
|
10947
11496
|
const automerge = automerge_adapter_1.Automerge;
|
|
@@ -10987,6 +11536,18 @@ class BaseMindooDB {
|
|
|
10987
11536
|
},
|
|
10988
11537
|
get: (target, prop) => {
|
|
10989
11538
|
const value = target[prop];
|
|
11539
|
+
// Counters read as plain numbers and immutable strings as strings;
|
|
11540
|
+
// their wrappers would otherwise surface as `{ value }` / `{ val }`
|
|
11541
|
+
// after structured cloning.
|
|
11542
|
+
if (db.isAutomergeCounter(value) ||
|
|
11543
|
+
db.isAutomergeImmutableString(value)) {
|
|
11544
|
+
return db.readAutomergeScalar(value);
|
|
11545
|
+
}
|
|
11546
|
+
// Hand out a copy: a Proxy around a Date breaks every Date method
|
|
11547
|
+
// ("this is not a Date object").
|
|
11548
|
+
if (value instanceof Date) {
|
|
11549
|
+
return new Date(value.getTime());
|
|
11550
|
+
}
|
|
10990
11551
|
// Recursively wrap nested objects (but not arrays or special types)
|
|
10991
11552
|
if (value !== null &&
|
|
10992
11553
|
typeof value === "object" &&
|
|
@@ -11044,6 +11605,9 @@ class BaseMindooDB {
|
|
|
11044
11605
|
setAttachmentExtractedText: () => {
|
|
11045
11606
|
throw new Error("setAttachmentExtractedText() can only be called within changeDoc() callback");
|
|
11046
11607
|
},
|
|
11608
|
+
incrementCounter: () => {
|
|
11609
|
+
throw new Error("incrementCounter() can only be called within changeDoc() callback; use applyJsonPatch() with counterIncrement outside of it");
|
|
11610
|
+
},
|
|
11047
11611
|
// ========== Attachment Read Methods ==========
|
|
11048
11612
|
// These work in the read-only wrapper
|
|
11049
11613
|
getAttachments: () => {
|
|
@@ -11851,7 +12415,14 @@ class BaseMindooDB {
|
|
|
11851
12415
|
this.logger.info(`Pulling entries from remote store ${remoteStore.getId()}/${remoteStore.getStoreKind()}`);
|
|
11852
12416
|
const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
|
|
11853
12417
|
try {
|
|
11854
|
-
|
|
12418
|
+
// Docs pulls process each transferred scan page right away: the local
|
|
12419
|
+
// index advances chunk by chunk, and the change event it emits lets the
|
|
12420
|
+
// summary buffer / full-text index materialize and extract documents
|
|
12421
|
+
// while the next page is still on the wire, instead of all at once
|
|
12422
|
+
// after the transfer (see docs/adhoc-queries.md, "Cold start").
|
|
12423
|
+
const syncResult = await this.syncEntriesFromStore(remoteStore, localStore, options, storeKind === types_2.StoreKind.docs
|
|
12424
|
+
? () => this.syncStoreChanges()
|
|
12425
|
+
: undefined);
|
|
11855
12426
|
const transferredBytes = syncResult.transferredBytes ?? 0;
|
|
11856
12427
|
this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
|
|
11857
12428
|
if (syncResult.cancelled) {
|