mindoodb 0.0.22 → 0.0.24
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.
|
@@ -137,6 +137,7 @@ class BaseMindooDB {
|
|
|
137
137
|
this.nextChangeSeq = 1;
|
|
138
138
|
// Cache of loaded documents: Map<docId, InternalDoc>
|
|
139
139
|
this.docCache = new Map();
|
|
140
|
+
this.wrappedInternalDocs = new WeakMap();
|
|
140
141
|
// Track which entry IDs we've already processed
|
|
141
142
|
this.processedEntryIds = [];
|
|
142
143
|
this.processedEntryCursor = null;
|
|
@@ -778,6 +779,22 @@ class BaseMindooDB {
|
|
|
778
779
|
}
|
|
779
780
|
this.automergeHashToEntryId.get(docId).set(automergeHash, entryId);
|
|
780
781
|
}
|
|
782
|
+
registerAutomergeHashMappingIfAbsent(docId, automergeHash, entryId) {
|
|
783
|
+
if (this.getEntryIdForAutomergeHash(docId, automergeHash)) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
this.registerAutomergeHashMapping(docId, automergeHash, entryId);
|
|
787
|
+
}
|
|
788
|
+
registerSnapshotHeadHashMappings(metadata) {
|
|
789
|
+
if (metadata.entryType !== "doc_snapshot") {
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
for (const headHash of metadata.snapshotHeadHashes ?? []) {
|
|
793
|
+
// A dense sync may keep only a snapshot for covered heads. In that case
|
|
794
|
+
// the snapshot entry is the local causal parent for future changes.
|
|
795
|
+
this.registerAutomergeHashMappingIfAbsent(metadata.docId, headHash, metadata.id);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
781
798
|
/**
|
|
782
799
|
* Get the entry ID for an automerge hash within a document.
|
|
783
800
|
* Returns null if not found.
|
|
@@ -822,6 +839,9 @@ class BaseMindooDB {
|
|
|
822
839
|
}
|
|
823
840
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, metadata.id);
|
|
824
841
|
}
|
|
842
|
+
for (const metadata of allMetadata) {
|
|
843
|
+
this.registerSnapshotHeadHashMappings(metadata);
|
|
844
|
+
}
|
|
825
845
|
}
|
|
826
846
|
/**
|
|
827
847
|
* Resolve Automerge dependency hashes to entry IDs without silently dropping parents.
|
|
@@ -2127,6 +2147,7 @@ class BaseMindooDB {
|
|
|
2127
2147
|
if (parsed) {
|
|
2128
2148
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
2129
2149
|
}
|
|
2150
|
+
this.registerSnapshotHeadHashMappings(snapshotData);
|
|
2130
2151
|
}
|
|
2131
2152
|
}
|
|
2132
2153
|
}
|
|
@@ -3687,12 +3708,12 @@ class BaseMindooDB {
|
|
|
3687
3708
|
// Lifecycle changes are intentionally non-destructive: they only bump
|
|
3688
3709
|
// `_lastModified` and repair missing legacy attachment arrays. The document
|
|
3689
3710
|
// body remains available for history and future undeletion.
|
|
3690
|
-
const newDoc = automerge_adapter_1.Automerge.change(
|
|
3691
|
-
if (!Array.isArray(
|
|
3692
|
-
|
|
3711
|
+
const newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, { time: now }, (mutableDoc) => {
|
|
3712
|
+
if (!Array.isArray(mutableDoc._attachments)) {
|
|
3713
|
+
mutableDoc._attachments = [];
|
|
3693
3714
|
}
|
|
3694
|
-
|
|
3695
|
-
});
|
|
3715
|
+
mutableDoc._lastModified = now;
|
|
3716
|
+
}));
|
|
3696
3717
|
const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
|
|
3697
3718
|
if (!changeBytes) {
|
|
3698
3719
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
@@ -3820,13 +3841,13 @@ class BaseMindooDB {
|
|
|
3820
3841
|
};
|
|
3821
3842
|
let newDoc;
|
|
3822
3843
|
try {
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
}
|
|
3844
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
|
|
3845
|
+
if (baseHeads && baseHeads.length > 0) {
|
|
3846
|
+
const result = automerge_adapter_1.Automerge.changeAt(doc, baseHeads, applyEdits);
|
|
3847
|
+
return result.newDoc;
|
|
3848
|
+
}
|
|
3849
|
+
return automerge_adapter_1.Automerge.change(doc, applyEdits);
|
|
3850
|
+
});
|
|
3830
3851
|
this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3831
3852
|
}
|
|
3832
3853
|
catch (error) {
|
|
@@ -3848,6 +3869,306 @@ class BaseMindooDB {
|
|
|
3848
3869
|
data: wrapped.getData(),
|
|
3849
3870
|
};
|
|
3850
3871
|
}
|
|
3872
|
+
async applyRichTextPatch(doc, patch) {
|
|
3873
|
+
this.assertWritable("applyRichTextPatch");
|
|
3874
|
+
const docId = doc.getId();
|
|
3875
|
+
this.logger.debug(`===== applyRichTextPatch called for document ${docId} =====`);
|
|
3876
|
+
if (this._isAdminOnlyDb) {
|
|
3877
|
+
const adminPublicKey = this.getAdminPublicKey();
|
|
3878
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
3879
|
+
if (currentUser.userSigningPublicKey !== adminPublicKey) {
|
|
3880
|
+
throw new Error("Admin-only database: only the admin key can modify data");
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
let internalDoc = this.getCachedDocument(docId);
|
|
3884
|
+
if (!internalDoc) {
|
|
3885
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
3886
|
+
if (!loadedDoc) {
|
|
3887
|
+
throw new Error(`Document ${docId} not found`);
|
|
3888
|
+
}
|
|
3889
|
+
internalDoc = loadedDoc;
|
|
3890
|
+
}
|
|
3891
|
+
if (internalDoc.isDeleted) {
|
|
3892
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
3893
|
+
}
|
|
3894
|
+
this.validateRichTextPatch(patch);
|
|
3895
|
+
const now = Date.now();
|
|
3896
|
+
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
3897
|
+
const spansSequence = patch.spansSequence ?? (patch.spans ? [patch.spans] : []);
|
|
3898
|
+
const applySpans = (automergeDoc) => {
|
|
3899
|
+
this.ensureRichTextPath(automergeDoc, patch.path);
|
|
3900
|
+
for (const spans of spansSequence) {
|
|
3901
|
+
const revivedSpans = this.reviveRichTextSpans(spans);
|
|
3902
|
+
try {
|
|
3903
|
+
automerge_adapter_1.Automerge.updateSpans(automergeDoc, patch.path, revivedSpans, patch.updateSpansConfig);
|
|
3904
|
+
}
|
|
3905
|
+
catch (error) {
|
|
3906
|
+
if (!this.isRichTextUpdateSpansBoundsError(error)) {
|
|
3907
|
+
throw error;
|
|
3908
|
+
}
|
|
3909
|
+
this.logger.warn(`updateSpans failed for document ${docId} at '${patch.path.map(String).join(".")}', resetting rich-text field and applying snapshot from scratch`, error);
|
|
3910
|
+
this.setJsonValueAtPath(automergeDoc, patch.path, "");
|
|
3911
|
+
automerge_adapter_1.Automerge.updateSpans(automergeDoc, patch.path, revivedSpans, patch.updateSpansConfig);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
automergeDoc._lastModified = now;
|
|
3915
|
+
};
|
|
3916
|
+
let newDoc;
|
|
3917
|
+
try {
|
|
3918
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
|
|
3919
|
+
if (patch.baseHeads && patch.baseHeads.length > 0) {
|
|
3920
|
+
const result = automerge_adapter_1.Automerge.changeAt(doc, patch.baseHeads, applySpans);
|
|
3921
|
+
return result.newDoc;
|
|
3922
|
+
}
|
|
3923
|
+
return automerge_adapter_1.Automerge.change(doc, applySpans);
|
|
3924
|
+
});
|
|
3925
|
+
this.logger.debug(`Successfully applied rich-text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3926
|
+
}
|
|
3927
|
+
catch (error) {
|
|
3928
|
+
this.logger.error(`Error applying rich-text patch for document ${docId}:`, error);
|
|
3929
|
+
throw error;
|
|
3930
|
+
}
|
|
3931
|
+
await this.persistDocumentChange({
|
|
3932
|
+
internalDoc,
|
|
3933
|
+
newDoc,
|
|
3934
|
+
now,
|
|
3935
|
+
headsBeforeChange,
|
|
3936
|
+
useCustomKey: false,
|
|
3937
|
+
successMessage: "rich text patched",
|
|
3938
|
+
});
|
|
3939
|
+
const wrapped = this.wrapDocument(internalDoc);
|
|
3940
|
+
return {
|
|
3941
|
+
doc: wrapped,
|
|
3942
|
+
heads: wrapped.getHeads(),
|
|
3943
|
+
data: wrapped.getData(),
|
|
3944
|
+
};
|
|
3945
|
+
}
|
|
3946
|
+
async applyRichTextStepsPatch(doc, patch) {
|
|
3947
|
+
this.assertWritable("applyRichTextStepsPatch");
|
|
3948
|
+
const docId = doc.getId();
|
|
3949
|
+
this.logger.debug(`===== applyRichTextStepsPatch called for document ${docId} =====`);
|
|
3950
|
+
if (this._isAdminOnlyDb) {
|
|
3951
|
+
const adminPublicKey = this.getAdminPublicKey();
|
|
3952
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
3953
|
+
if (currentUser.userSigningPublicKey !== adminPublicKey) {
|
|
3954
|
+
throw new Error("Admin-only database: only the admin key can modify data");
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
let internalDoc = this.getCachedDocument(docId);
|
|
3958
|
+
if (!internalDoc) {
|
|
3959
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
3960
|
+
if (!loadedDoc) {
|
|
3961
|
+
throw new Error(`Document ${docId} not found`);
|
|
3962
|
+
}
|
|
3963
|
+
internalDoc = loadedDoc;
|
|
3964
|
+
}
|
|
3965
|
+
if (internalDoc.isDeleted) {
|
|
3966
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
3967
|
+
}
|
|
3968
|
+
this.validateRichTextStepPatch(patch);
|
|
3969
|
+
const now = Date.now();
|
|
3970
|
+
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
3971
|
+
const applySteps = (automergeDoc) => {
|
|
3972
|
+
this.ensureRichTextPath(automergeDoc, patch.path);
|
|
3973
|
+
const beforeValue = this.readValueAtPath(automergeDoc, patch.path);
|
|
3974
|
+
const beforeLength = typeof beforeValue === "string" ? beforeValue.length : null;
|
|
3975
|
+
this.logger.info("[RichTextSteps] Applying positional steps", {
|
|
3976
|
+
docId,
|
|
3977
|
+
path: patch.path,
|
|
3978
|
+
baseHeads: patch.baseHeads,
|
|
3979
|
+
stepCount: patch.steps.length,
|
|
3980
|
+
firstStep: patch.steps[0],
|
|
3981
|
+
beforeLength,
|
|
3982
|
+
});
|
|
3983
|
+
for (const step of patch.steps) {
|
|
3984
|
+
automerge_adapter_1.Automerge.splice(automergeDoc, patch.path, step.index, step.deleteCount, step.insert ?? "");
|
|
3985
|
+
for (const markRange of step.marks ?? []) {
|
|
3986
|
+
for (const [name, value] of Object.entries(markRange.marks)) {
|
|
3987
|
+
automerge_adapter_1.Automerge.mark(automergeDoc, patch.path, {
|
|
3988
|
+
start: markRange.index,
|
|
3989
|
+
end: markRange.index + markRange.length,
|
|
3990
|
+
expand: "none",
|
|
3991
|
+
}, name, this.reviveRichTextValue(value));
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
const afterValue = this.readValueAtPath(automergeDoc, patch.path);
|
|
3996
|
+
this.logger.info("[RichTextSteps] Applied positional steps", {
|
|
3997
|
+
docId,
|
|
3998
|
+
afterLength: typeof afterValue === "string" ? afterValue.length : null,
|
|
3999
|
+
});
|
|
4000
|
+
automergeDoc._lastModified = now;
|
|
4001
|
+
};
|
|
4002
|
+
let newDoc;
|
|
4003
|
+
try {
|
|
4004
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
|
|
4005
|
+
if (patch.baseHeads && patch.baseHeads.length > 0) {
|
|
4006
|
+
const result = automerge_adapter_1.Automerge.changeAt(doc, patch.baseHeads, applySteps);
|
|
4007
|
+
return result.newDoc;
|
|
4008
|
+
}
|
|
4009
|
+
return automerge_adapter_1.Automerge.change(doc, applySteps);
|
|
4010
|
+
});
|
|
4011
|
+
this.logger.debug(`Successfully applied rich-text steps, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
4012
|
+
}
|
|
4013
|
+
catch (error) {
|
|
4014
|
+
this.logger.error(`Error applying rich-text steps for document ${docId}:`, error);
|
|
4015
|
+
throw error;
|
|
4016
|
+
}
|
|
4017
|
+
await this.persistDocumentChange({
|
|
4018
|
+
internalDoc,
|
|
4019
|
+
newDoc,
|
|
4020
|
+
now,
|
|
4021
|
+
headsBeforeChange,
|
|
4022
|
+
useCustomKey: false,
|
|
4023
|
+
successMessage: "rich text steps patched",
|
|
4024
|
+
});
|
|
4025
|
+
const wrapped = this.wrapDocument(internalDoc);
|
|
4026
|
+
return {
|
|
4027
|
+
doc: wrapped,
|
|
4028
|
+
heads: wrapped.getHeads(),
|
|
4029
|
+
data: wrapped.getData(),
|
|
4030
|
+
};
|
|
4031
|
+
}
|
|
4032
|
+
async getRichTextSnapshot(doc, path) {
|
|
4033
|
+
const docId = doc.getId();
|
|
4034
|
+
this.validateJsonPath(path, "Rich-text snapshot");
|
|
4035
|
+
let internalDoc = this.wrappedInternalDocs.get(doc) ?? null;
|
|
4036
|
+
if (!internalDoc) {
|
|
4037
|
+
internalDoc = this.getCachedDocument(docId);
|
|
4038
|
+
}
|
|
4039
|
+
if (!internalDoc) {
|
|
4040
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
4041
|
+
if (!loadedDoc) {
|
|
4042
|
+
throw new Error(`Document ${docId} not found`);
|
|
4043
|
+
}
|
|
4044
|
+
internalDoc = loadedDoc;
|
|
4045
|
+
}
|
|
4046
|
+
if (internalDoc.isDeleted) {
|
|
4047
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
4048
|
+
}
|
|
4049
|
+
const spans = automerge_adapter_1.Automerge.spans(internalDoc.doc, path);
|
|
4050
|
+
return {
|
|
4051
|
+
path: [...path],
|
|
4052
|
+
heads: automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
|
|
4053
|
+
spans: this.dehydrateRichTextSpans(spans),
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
4056
|
+
async exportAutomergeSnapshot(doc) {
|
|
4057
|
+
const internalDoc = await this.resolveReadableInternalDoc(doc);
|
|
4058
|
+
const binary = automerge_adapter_1.Automerge.save(internalDoc.doc);
|
|
4059
|
+
return {
|
|
4060
|
+
binary: new Uint8Array(binary),
|
|
4061
|
+
heads: automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
|
|
4062
|
+
};
|
|
4063
|
+
}
|
|
4064
|
+
async applyAutomergeChanges(doc, patch) {
|
|
4065
|
+
this.assertWritable("applyAutomergeChanges");
|
|
4066
|
+
const docId = doc.getId();
|
|
4067
|
+
this.logger.debug(`===== applyAutomergeChanges called for document ${docId} =====`);
|
|
4068
|
+
if (this._isAdminOnlyDb) {
|
|
4069
|
+
const adminPublicKey = this.getAdminPublicKey();
|
|
4070
|
+
const currentUser = await this.tenant.getCurrentUserId();
|
|
4071
|
+
if (currentUser.userSigningPublicKey !== adminPublicKey) {
|
|
4072
|
+
throw new Error("Admin-only database: only the admin key can modify data");
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
4075
|
+
if (!Array.isArray(patch.changes) || patch.changes.length === 0) {
|
|
4076
|
+
throw new Error("Automerge changes patch must include at least one change byte sequence");
|
|
4077
|
+
}
|
|
4078
|
+
let internalDoc = this.getCachedDocument(docId);
|
|
4079
|
+
if (!internalDoc) {
|
|
4080
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
4081
|
+
if (!loadedDoc) {
|
|
4082
|
+
throw new Error(`Document ${docId} not found`);
|
|
4083
|
+
}
|
|
4084
|
+
internalDoc = loadedDoc;
|
|
4085
|
+
}
|
|
4086
|
+
if (internalDoc.isDeleted) {
|
|
4087
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
4088
|
+
}
|
|
4089
|
+
await this.hydrateAutomergeHashMappingsFromStore(docId);
|
|
4090
|
+
const now = Date.now();
|
|
4091
|
+
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
4092
|
+
if (patch.baseHeads?.length) {
|
|
4093
|
+
const baseHeadsKey = [...patch.baseHeads].sort().join("|");
|
|
4094
|
+
const currentHeadsKey = [...headsBeforeChange].sort().join("|");
|
|
4095
|
+
if (baseHeadsKey !== currentHeadsKey) {
|
|
4096
|
+
this.logger.debug(`Applying Automerge changes for document ${docId} with stale baseHeads; merging into current heads`);
|
|
4097
|
+
}
|
|
4098
|
+
}
|
|
4099
|
+
let newDoc;
|
|
4100
|
+
try {
|
|
4101
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (currentDoc) => {
|
|
4102
|
+
const [mergedDoc] = automerge_adapter_1.Automerge.applyChanges(currentDoc, patch.changes.map((change) => new Uint8Array(change)));
|
|
4103
|
+
return mergedDoc;
|
|
4104
|
+
});
|
|
4105
|
+
this.logger.debug(`Successfully applied Automerge changes, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
4106
|
+
}
|
|
4107
|
+
catch (error) {
|
|
4108
|
+
this.logger.error(`Error applying Automerge changes for document ${docId}:`, error);
|
|
4109
|
+
throw error;
|
|
4110
|
+
}
|
|
4111
|
+
const incomingChanges = patch.changes.map((change) => new Uint8Array(change));
|
|
4112
|
+
const changesToPersist = incomingChanges.filter((changeBytes) => {
|
|
4113
|
+
const hash = automerge_adapter_1.Automerge.decodeChange(changeBytes).hash;
|
|
4114
|
+
return this.getEntryIdForAutomergeHash(docId, hash) === null;
|
|
4115
|
+
});
|
|
4116
|
+
if (changesToPersist.length === 0) {
|
|
4117
|
+
internalDoc.doc = newDoc;
|
|
4118
|
+
internalDoc.lastModified = now;
|
|
4119
|
+
await this.storeCachedDocument(internalDoc);
|
|
4120
|
+
this.markDocDirty(docId);
|
|
4121
|
+
}
|
|
4122
|
+
else {
|
|
4123
|
+
await this.persistAutomergeDocumentChanges({
|
|
4124
|
+
internalDoc,
|
|
4125
|
+
newDoc,
|
|
4126
|
+
now,
|
|
4127
|
+
changeBytesList: changesToPersist,
|
|
4128
|
+
useCustomKey: false,
|
|
4129
|
+
successMessage: "Automerge changes applied",
|
|
4130
|
+
});
|
|
4131
|
+
}
|
|
4132
|
+
const wrapped = this.wrapDocument(internalDoc);
|
|
4133
|
+
const mergedHeads = wrapped.getHeads();
|
|
4134
|
+
let changesSince;
|
|
4135
|
+
if (patch.replicaHeads?.length) {
|
|
4136
|
+
try {
|
|
4137
|
+
const missingChanges = automerge_adapter_1.Automerge.getChangesSince(newDoc, patch.replicaHeads);
|
|
4138
|
+
changesSince = {
|
|
4139
|
+
sinceHeads: [...patch.replicaHeads],
|
|
4140
|
+
changes: missingChanges.map((change) => new Uint8Array(change)),
|
|
4141
|
+
};
|
|
4142
|
+
}
|
|
4143
|
+
catch (error) {
|
|
4144
|
+
this.logger.debug(`Could not compute Automerge changesSince for document ${docId}:`, error);
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
return {
|
|
4148
|
+
doc: wrapped,
|
|
4149
|
+
heads: mergedHeads,
|
|
4150
|
+
data: wrapped.getData(),
|
|
4151
|
+
changesSince,
|
|
4152
|
+
};
|
|
4153
|
+
}
|
|
4154
|
+
async resolveReadableInternalDoc(doc) {
|
|
4155
|
+
const docId = doc.getId();
|
|
4156
|
+
let internalDoc = this.wrappedInternalDocs.get(doc) ?? null;
|
|
4157
|
+
if (!internalDoc) {
|
|
4158
|
+
internalDoc = this.getCachedDocument(docId);
|
|
4159
|
+
}
|
|
4160
|
+
if (!internalDoc) {
|
|
4161
|
+
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
4162
|
+
if (!loadedDoc) {
|
|
4163
|
+
throw new Error(`Document ${docId} not found`);
|
|
4164
|
+
}
|
|
4165
|
+
internalDoc = loadedDoc;
|
|
4166
|
+
}
|
|
4167
|
+
if (internalDoc.isDeleted) {
|
|
4168
|
+
throw new Error(`Document ${docId} has been deleted`);
|
|
4169
|
+
}
|
|
4170
|
+
return internalDoc;
|
|
4171
|
+
}
|
|
3851
4172
|
async applyJsonPatch(doc, patch) {
|
|
3852
4173
|
this.assertWritable("applyJsonPatch");
|
|
3853
4174
|
const docId = doc.getId();
|
|
@@ -3879,13 +4200,13 @@ class BaseMindooDB {
|
|
|
3879
4200
|
};
|
|
3880
4201
|
let newDoc;
|
|
3881
4202
|
try {
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
}
|
|
4203
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => {
|
|
4204
|
+
if (patch.baseHeads && patch.baseHeads.length > 0) {
|
|
4205
|
+
const result = automerge_adapter_1.Automerge.changeAt(doc, patch.baseHeads, applyPatch);
|
|
4206
|
+
return result.newDoc;
|
|
4207
|
+
}
|
|
4208
|
+
return automerge_adapter_1.Automerge.change(doc, applyPatch);
|
|
4209
|
+
});
|
|
3889
4210
|
this.logger.debug(`Successfully applied JSON patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3890
4211
|
}
|
|
3891
4212
|
catch (error) {
|
|
@@ -4130,7 +4451,7 @@ class BaseMindooDB {
|
|
|
4130
4451
|
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
4131
4452
|
let newDoc;
|
|
4132
4453
|
try {
|
|
4133
|
-
newDoc = automerge_adapter_1.Automerge.change(
|
|
4454
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, (automergeDoc) => {
|
|
4134
4455
|
// Apply all pending changes (sets/updates)
|
|
4135
4456
|
for (const [key, value] of pendingChanges) {
|
|
4136
4457
|
automergeDoc[key] = value;
|
|
@@ -4168,7 +4489,7 @@ class BaseMindooDB {
|
|
|
4168
4489
|
}
|
|
4169
4490
|
// Update lastModified timestamp
|
|
4170
4491
|
automergeDoc._lastModified = now;
|
|
4171
|
-
});
|
|
4492
|
+
}));
|
|
4172
4493
|
this.logger.debug(`Successfully applied change function, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
4173
4494
|
}
|
|
4174
4495
|
catch (error) {
|
|
@@ -4223,7 +4544,10 @@ class BaseMindooDB {
|
|
|
4223
4544
|
const operationCount = (patch.set?.length ?? 0)
|
|
4224
4545
|
+ (patch.unset?.length ?? 0)
|
|
4225
4546
|
+ (patch.listDelete?.length ?? 0)
|
|
4226
|
-
+ (patch.listInsert?.length ?? 0)
|
|
4547
|
+
+ (patch.listInsert?.length ?? 0)
|
|
4548
|
+
+ (patch.textSplice?.length ?? 0)
|
|
4549
|
+
+ (patch.textMark?.length ?? 0)
|
|
4550
|
+
+ (patch.textUnmark?.length ?? 0);
|
|
4227
4551
|
if (operationCount === 0) {
|
|
4228
4552
|
throw new Error("JSON patch must include at least one operation");
|
|
4229
4553
|
}
|
|
@@ -4251,6 +4575,120 @@ class BaseMindooDB {
|
|
|
4251
4575
|
throw new Error("JSON listInsert values must be an array");
|
|
4252
4576
|
}
|
|
4253
4577
|
}
|
|
4578
|
+
for (const operation of patch.textSplice ?? []) {
|
|
4579
|
+
this.validateJsonPath(operation.path, "JSON textSplice");
|
|
4580
|
+
if (!Number.isInteger(operation.index) || operation.index < 0) {
|
|
4581
|
+
throw new Error("JSON textSplice index must be a non-negative integer");
|
|
4582
|
+
}
|
|
4583
|
+
if (!Number.isInteger(operation.deleteCount) || operation.deleteCount < 0) {
|
|
4584
|
+
throw new Error("JSON textSplice deleteCount must be a non-negative integer");
|
|
4585
|
+
}
|
|
4586
|
+
if (operation.insert !== undefined && typeof operation.insert !== "string") {
|
|
4587
|
+
throw new Error("JSON textSplice insert must be a string");
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
for (const operation of patch.textMark ?? []) {
|
|
4591
|
+
this.validateJsonPath(operation.path, "JSON textMark");
|
|
4592
|
+
if (!Number.isInteger(operation.index) || operation.index < 0) {
|
|
4593
|
+
throw new Error("JSON textMark index must be a non-negative integer");
|
|
4594
|
+
}
|
|
4595
|
+
if (!Number.isInteger(operation.length) || operation.length <= 0) {
|
|
4596
|
+
throw new Error("JSON textMark length must be a positive integer");
|
|
4597
|
+
}
|
|
4598
|
+
if (!operation.marks || typeof operation.marks !== "object" || Array.isArray(operation.marks)) {
|
|
4599
|
+
throw new Error("JSON textMark marks must be an object");
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
for (const operation of patch.textUnmark ?? []) {
|
|
4603
|
+
this.validateJsonPath(operation.path, "JSON textUnmark");
|
|
4604
|
+
if (!Number.isInteger(operation.index) || operation.index < 0) {
|
|
4605
|
+
throw new Error("JSON textUnmark index must be a non-negative integer");
|
|
4606
|
+
}
|
|
4607
|
+
if (!Number.isInteger(operation.length) || operation.length <= 0) {
|
|
4608
|
+
throw new Error("JSON textUnmark length must be a positive integer");
|
|
4609
|
+
}
|
|
4610
|
+
if (!Array.isArray(operation.names) || operation.names.some((name) => typeof name !== "string" || !name)) {
|
|
4611
|
+
throw new Error("JSON textUnmark names must be non-empty strings");
|
|
4612
|
+
}
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
validateRichTextPatch(patch) {
|
|
4616
|
+
this.validateJsonPath(patch.path, "Rich-text patch");
|
|
4617
|
+
const hasSpans = patch.spans !== undefined;
|
|
4618
|
+
const hasSpansSequence = patch.spansSequence !== undefined;
|
|
4619
|
+
if (hasSpans === hasSpansSequence) {
|
|
4620
|
+
throw new Error("Rich-text patch must include exactly one of spans or spansSequence");
|
|
4621
|
+
}
|
|
4622
|
+
if (hasSpans) {
|
|
4623
|
+
if (!Array.isArray(patch.spans)) {
|
|
4624
|
+
throw new Error("Rich-text patch spans must be an array");
|
|
4625
|
+
}
|
|
4626
|
+
this.validateRichTextSpans(patch.spans);
|
|
4627
|
+
return;
|
|
4628
|
+
}
|
|
4629
|
+
if (!Array.isArray(patch.spansSequence) || patch.spansSequence.length === 0) {
|
|
4630
|
+
throw new Error("Rich-text patch spansSequence must be a non-empty array");
|
|
4631
|
+
}
|
|
4632
|
+
for (const spans of patch.spansSequence) {
|
|
4633
|
+
if (!Array.isArray(spans)) {
|
|
4634
|
+
throw new Error("Rich-text patch spansSequence entries must be arrays");
|
|
4635
|
+
}
|
|
4636
|
+
this.validateRichTextSpans(spans);
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
validateRichTextSpans(spans) {
|
|
4640
|
+
for (const span of spans) {
|
|
4641
|
+
if (!span || typeof span !== "object") {
|
|
4642
|
+
throw new Error("Rich-text span must be an object");
|
|
4643
|
+
}
|
|
4644
|
+
if (span.type === "text") {
|
|
4645
|
+
if (typeof span.value !== "string") {
|
|
4646
|
+
throw new Error("Rich-text text span value must be a string");
|
|
4647
|
+
}
|
|
4648
|
+
continue;
|
|
4649
|
+
}
|
|
4650
|
+
if (span.type === "block") {
|
|
4651
|
+
if (!span.value || typeof span.value !== "object" || Array.isArray(span.value)) {
|
|
4652
|
+
throw new Error("Rich-text block span value must be an object");
|
|
4653
|
+
}
|
|
4654
|
+
continue;
|
|
4655
|
+
}
|
|
4656
|
+
throw new Error("Rich-text span type must be 'text' or 'block'");
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
validateRichTextStepPatch(patch) {
|
|
4660
|
+
this.validateJsonPath(patch.path, "Rich-text steps patch");
|
|
4661
|
+
if (!Array.isArray(patch.steps) || patch.steps.length === 0) {
|
|
4662
|
+
throw new Error("Rich-text steps patch steps must be a non-empty array");
|
|
4663
|
+
}
|
|
4664
|
+
for (const step of patch.steps) {
|
|
4665
|
+
if (!step || typeof step !== "object") {
|
|
4666
|
+
throw new Error("Rich-text step must be an object");
|
|
4667
|
+
}
|
|
4668
|
+
if (step.type !== "splice") {
|
|
4669
|
+
throw new Error("Rich-text step type must be 'splice'");
|
|
4670
|
+
}
|
|
4671
|
+
if (!Number.isInteger(step.index) || step.index < 0) {
|
|
4672
|
+
throw new Error("Rich-text splice step index must be a non-negative integer");
|
|
4673
|
+
}
|
|
4674
|
+
if (!Number.isInteger(step.deleteCount) || step.deleteCount < 0) {
|
|
4675
|
+
throw new Error("Rich-text splice step deleteCount must be a non-negative integer");
|
|
4676
|
+
}
|
|
4677
|
+
if (step.insert !== undefined && typeof step.insert !== "string") {
|
|
4678
|
+
throw new Error("Rich-text splice step insert must be a string");
|
|
4679
|
+
}
|
|
4680
|
+
for (const markRange of step.marks ?? []) {
|
|
4681
|
+
if (!Number.isInteger(markRange.index) || markRange.index < 0) {
|
|
4682
|
+
throw new Error("Rich-text mark range index must be a non-negative integer");
|
|
4683
|
+
}
|
|
4684
|
+
if (!Number.isInteger(markRange.length) || markRange.length <= 0) {
|
|
4685
|
+
throw new Error("Rich-text mark range length must be a positive integer");
|
|
4686
|
+
}
|
|
4687
|
+
if (!markRange.marks || typeof markRange.marks !== "object" || Array.isArray(markRange.marks)) {
|
|
4688
|
+
throw new Error("Rich-text mark range marks must be an object");
|
|
4689
|
+
}
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4254
4692
|
}
|
|
4255
4693
|
validateJsonPath(path, label) {
|
|
4256
4694
|
if (!Array.isArray(path) || path.length === 0) {
|
|
@@ -4262,6 +4700,74 @@ class BaseMindooDB {
|
|
|
4262
4700
|
}
|
|
4263
4701
|
}
|
|
4264
4702
|
}
|
|
4703
|
+
reviveRichTextSpans(spans) {
|
|
4704
|
+
return spans.map((span) => {
|
|
4705
|
+
if (span.type === "text") {
|
|
4706
|
+
return {
|
|
4707
|
+
type: "text",
|
|
4708
|
+
value: span.value,
|
|
4709
|
+
marks: span.marks ? this.reviveRichTextValue(span.marks) : undefined,
|
|
4710
|
+
};
|
|
4711
|
+
}
|
|
4712
|
+
return {
|
|
4713
|
+
type: "block",
|
|
4714
|
+
value: this.reviveRichTextValue(span.value),
|
|
4715
|
+
};
|
|
4716
|
+
});
|
|
4717
|
+
}
|
|
4718
|
+
reviveRichTextValue(value) {
|
|
4719
|
+
if (Array.isArray(value)) {
|
|
4720
|
+
return value.map((entry) => this.reviveRichTextValue(entry));
|
|
4721
|
+
}
|
|
4722
|
+
if (!value || typeof value !== "object") {
|
|
4723
|
+
return value;
|
|
4724
|
+
}
|
|
4725
|
+
const record = value;
|
|
4726
|
+
if (record.type === "immutableString" && typeof record.value === "string") {
|
|
4727
|
+
return new automerge_adapter_1.Automerge.ImmutableString(record.value);
|
|
4728
|
+
}
|
|
4729
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [
|
|
4730
|
+
key,
|
|
4731
|
+
this.reviveRichTextValue(entry),
|
|
4732
|
+
]));
|
|
4733
|
+
}
|
|
4734
|
+
dehydrateRichTextSpans(spans) {
|
|
4735
|
+
return spans.map((span) => {
|
|
4736
|
+
const record = span;
|
|
4737
|
+
if (record.type === "text") {
|
|
4738
|
+
return {
|
|
4739
|
+
type: "text",
|
|
4740
|
+
value: typeof record.value === "string" ? record.value : "",
|
|
4741
|
+
marks: record.marks
|
|
4742
|
+
? this.dehydrateRichTextValue(record.marks)
|
|
4743
|
+
: undefined,
|
|
4744
|
+
};
|
|
4745
|
+
}
|
|
4746
|
+
return {
|
|
4747
|
+
type: "block",
|
|
4748
|
+
value: this.dehydrateRichTextValue(record.value),
|
|
4749
|
+
};
|
|
4750
|
+
});
|
|
4751
|
+
}
|
|
4752
|
+
dehydrateRichTextValue(value) {
|
|
4753
|
+
if (Array.isArray(value)) {
|
|
4754
|
+
return value.map((entry) => this.dehydrateRichTextValue(entry));
|
|
4755
|
+
}
|
|
4756
|
+
if (!value || typeof value !== "object") {
|
|
4757
|
+
return value;
|
|
4758
|
+
}
|
|
4759
|
+
if (automerge_adapter_1.Automerge.isImmutableString?.(value)) {
|
|
4760
|
+
return {
|
|
4761
|
+
type: "immutableString",
|
|
4762
|
+
value: String(value),
|
|
4763
|
+
};
|
|
4764
|
+
}
|
|
4765
|
+
const record = value;
|
|
4766
|
+
return Object.fromEntries(Object.entries(record).map(([key, entry]) => [
|
|
4767
|
+
key,
|
|
4768
|
+
this.dehydrateRichTextValue(entry),
|
|
4769
|
+
]));
|
|
4770
|
+
}
|
|
4265
4771
|
applyJsonPatchOperations(automergeDoc, patch) {
|
|
4266
4772
|
for (const operation of patch.set ?? []) {
|
|
4267
4773
|
this.setJsonValueAtPath(automergeDoc, operation.path, structuredClone(operation.value));
|
|
@@ -4277,6 +4783,53 @@ class BaseMindooDB {
|
|
|
4277
4783
|
const list = this.ensureJsonListAtPath(automergeDoc, operation.path);
|
|
4278
4784
|
list.splice(operation.index, 0, ...structuredClone(operation.values));
|
|
4279
4785
|
}
|
|
4786
|
+
for (const operation of patch.textSplice ?? []) {
|
|
4787
|
+
this.spliceJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.deleteCount, operation.insert ?? "");
|
|
4788
|
+
}
|
|
4789
|
+
for (const operation of patch.textMark ?? []) {
|
|
4790
|
+
for (const [name, value] of Object.entries(operation.marks)) {
|
|
4791
|
+
automerge_adapter_1.Automerge.mark(automergeDoc, operation.path, {
|
|
4792
|
+
start: operation.index,
|
|
4793
|
+
end: operation.index + operation.length,
|
|
4794
|
+
expand: "none",
|
|
4795
|
+
}, name, this.reviveRichTextValue(value));
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
for (const operation of patch.textUnmark ?? []) {
|
|
4799
|
+
for (const name of operation.names) {
|
|
4800
|
+
this.unmarkJsonTextAtPath(automergeDoc, operation.path, operation.index, operation.length, name);
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
spliceJsonTextAtPath(automergeDoc, path, index, deleteCount, insert) {
|
|
4805
|
+
const value = this.readValueAtPath(automergeDoc, path);
|
|
4806
|
+
if (value === undefined || value === null) {
|
|
4807
|
+
this.setJsonValueAtPath(automergeDoc, path, "");
|
|
4808
|
+
}
|
|
4809
|
+
else if (typeof value !== "string") {
|
|
4810
|
+
throw new Error(`Cannot apply JSON textSplice to non-string value at '${path.map(String).join(".")}'`);
|
|
4811
|
+
}
|
|
4812
|
+
const text = this.readValueAtPath(automergeDoc, path);
|
|
4813
|
+
const length = typeof text === "string" ? text.length : 0;
|
|
4814
|
+
automerge_adapter_1.Automerge.splice(automergeDoc, path, this.clampIndex(index, length), Math.max(0, Math.min(deleteCount, length - this.clampIndex(index, length))), insert);
|
|
4815
|
+
}
|
|
4816
|
+
unmarkJsonTextAtPath(automergeDoc, path, index, length, name) {
|
|
4817
|
+
const range = {
|
|
4818
|
+
start: index,
|
|
4819
|
+
end: index + length,
|
|
4820
|
+
expand: "none",
|
|
4821
|
+
};
|
|
4822
|
+
if (typeof automerge_adapter_1.Automerge.unmark === "function") {
|
|
4823
|
+
automerge_adapter_1.Automerge.unmark(automergeDoc, path, range, name);
|
|
4824
|
+
return;
|
|
4825
|
+
}
|
|
4826
|
+
automerge_adapter_1.Automerge.mark(automergeDoc, path, range, name, null);
|
|
4827
|
+
}
|
|
4828
|
+
clampIndex(index, length) {
|
|
4829
|
+
if (!Number.isFinite(index)) {
|
|
4830
|
+
return length;
|
|
4831
|
+
}
|
|
4832
|
+
return Math.max(0, Math.min(Math.trunc(index), length));
|
|
4280
4833
|
}
|
|
4281
4834
|
setJsonValueAtPath(target, path, value) {
|
|
4282
4835
|
const parent = this.ensureJsonParentAtPath(target, path);
|
|
@@ -4286,6 +4839,13 @@ class BaseMindooDB {
|
|
|
4286
4839
|
const parent = this.readJsonParentAtPath(target, path);
|
|
4287
4840
|
delete parent[path[path.length - 1]];
|
|
4288
4841
|
}
|
|
4842
|
+
readValueAtPath(target, path) {
|
|
4843
|
+
let value = target;
|
|
4844
|
+
for (const segment of path) {
|
|
4845
|
+
value = value?.[segment];
|
|
4846
|
+
}
|
|
4847
|
+
return value;
|
|
4848
|
+
}
|
|
4289
4849
|
readJsonListAtPath(target, path) {
|
|
4290
4850
|
let value = target;
|
|
4291
4851
|
for (const segment of path) {
|
|
@@ -4378,6 +4938,69 @@ class BaseMindooDB {
|
|
|
4378
4938
|
throw new Error(`Cannot apply text patch to non-string value at '${path.map(String).join(".")}'`);
|
|
4379
4939
|
}
|
|
4380
4940
|
}
|
|
4941
|
+
/**
|
|
4942
|
+
* Run an Automerge change operation against `internalDoc.doc`, recovering
|
|
4943
|
+
* from the "outdated document" error by cloning the cached doc and
|
|
4944
|
+
* retrying once.
|
|
4945
|
+
*
|
|
4946
|
+
* Automerge marks a document as outdated whenever the JS reference has
|
|
4947
|
+
* already been passed to `Automerge.change` / `Automerge.applyChanges` /
|
|
4948
|
+
* `Automerge.changeAt` (the WASM handle's `state.heads` is set to a
|
|
4949
|
+
* non-null value, putting the doc into "view" mode). The mindoodb cache
|
|
4950
|
+
* normally avoids this by reassigning `internalDoc.doc = newDoc` after
|
|
4951
|
+
* every change, but a stale reference (left over from an aborted or
|
|
4952
|
+
* out-of-band Automerge operation) can still leak in. When that happens
|
|
4953
|
+
* the doc is still semantically up to date - it just can't be mutated -
|
|
4954
|
+
* and `Automerge.clone(...)` produces a fresh, writable copy at the same
|
|
4955
|
+
* heads. The same workaround already lives in
|
|
4956
|
+
* {@link applyNewEntriesToCachedDocument} (line ~6606) for the sync
|
|
4957
|
+
* pipeline; this helper centralizes it for the patch / changeDoc paths.
|
|
4958
|
+
*/
|
|
4959
|
+
runChangeWithOutdatedDocRecovery(internalDoc, apply) {
|
|
4960
|
+
try {
|
|
4961
|
+
return apply(internalDoc.doc);
|
|
4962
|
+
}
|
|
4963
|
+
catch (error) {
|
|
4964
|
+
if (!this.isOutdatedDocumentError(error)) {
|
|
4965
|
+
throw error;
|
|
4966
|
+
}
|
|
4967
|
+
this.logger.warn(`Cached document ${internalDoc.id} was outdated for Automerge; cloning to recover and retrying change`);
|
|
4968
|
+
internalDoc.doc = automerge_adapter_1.Automerge.clone(internalDoc.doc);
|
|
4969
|
+
return apply(internalDoc.doc);
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
isOutdatedDocumentError(error) {
|
|
4973
|
+
if (!(error instanceof Error))
|
|
4974
|
+
return false;
|
|
4975
|
+
return /outdated document/i.test(error.message);
|
|
4976
|
+
}
|
|
4977
|
+
isRichTextUpdateSpansBoundsError(error) {
|
|
4978
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4979
|
+
return /Cannot updateSpans|out of bounds/i.test(message);
|
|
4980
|
+
}
|
|
4981
|
+
ensureRichTextPath(automergeDoc, path) {
|
|
4982
|
+
let target = automergeDoc;
|
|
4983
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
4984
|
+
const segment = path[i];
|
|
4985
|
+
const nextSegment = path[i + 1];
|
|
4986
|
+
if (target[segment] === undefined || target[segment] === null) {
|
|
4987
|
+
target[segment] = typeof nextSegment === "number" ? [] : {};
|
|
4988
|
+
}
|
|
4989
|
+
target = target[segment];
|
|
4990
|
+
if (target === null || typeof target !== "object") {
|
|
4991
|
+
throw new Error(`Cannot apply rich-text patch through non-object path segment '${String(segment)}'`);
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
const leaf = path[path.length - 1];
|
|
4995
|
+
const currentValue = target[leaf];
|
|
4996
|
+
if (currentValue === undefined || currentValue === null) {
|
|
4997
|
+
target[leaf] = "";
|
|
4998
|
+
return;
|
|
4999
|
+
}
|
|
5000
|
+
if (typeof currentValue !== "string") {
|
|
5001
|
+
throw new Error(`Cannot apply rich-text patch to non-string value at '${path.map(String).join(".")}'`);
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
4381
5004
|
async persistDocumentChange(options) {
|
|
4382
5005
|
const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds } = options;
|
|
4383
5006
|
const docId = internalDoc.id;
|
|
@@ -4392,6 +5015,60 @@ class BaseMindooDB {
|
|
|
4392
5015
|
if (!changeBytes) {
|
|
4393
5016
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
4394
5017
|
}
|
|
5018
|
+
await this.persistAutomergeDocumentChanges({
|
|
5019
|
+
internalDoc,
|
|
5020
|
+
newDoc,
|
|
5021
|
+
now,
|
|
5022
|
+
changeBytesList: [changeBytes],
|
|
5023
|
+
useCustomKey,
|
|
5024
|
+
signingKeyPair,
|
|
5025
|
+
signingKeyPassword,
|
|
5026
|
+
successMessage,
|
|
5027
|
+
attachmentIds,
|
|
5028
|
+
});
|
|
5029
|
+
}
|
|
5030
|
+
async persistAutomergeDocumentChanges(options) {
|
|
5031
|
+
const { internalDoc, newDoc, now, changeBytesList, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage, attachmentIds, } = options;
|
|
5032
|
+
const docId = internalDoc.id;
|
|
5033
|
+
const resolvedChangeBytesList = changeBytesList ?? (headsBeforeChange
|
|
5034
|
+
? automerge_adapter_1.Automerge.getChangesSince(newDoc, headsBeforeChange)
|
|
5035
|
+
: []);
|
|
5036
|
+
if (resolvedChangeBytesList.length === 0) {
|
|
5037
|
+
throw new Error("No Automerge changes to persist");
|
|
5038
|
+
}
|
|
5039
|
+
const entries = [];
|
|
5040
|
+
for (let index = 0; index < resolvedChangeBytesList.length; index += 1) {
|
|
5041
|
+
const changeBytes = resolvedChangeBytesList[index];
|
|
5042
|
+
const entry = await this.buildDocChangeStoreEntry({
|
|
5043
|
+
internalDoc,
|
|
5044
|
+
changeBytes,
|
|
5045
|
+
now,
|
|
5046
|
+
useCustomKey,
|
|
5047
|
+
signingKeyPair,
|
|
5048
|
+
signingKeyPassword,
|
|
5049
|
+
attachmentIds: index === 0 ? attachmentIds : undefined,
|
|
5050
|
+
});
|
|
5051
|
+
entries.push(entry);
|
|
5052
|
+
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
5053
|
+
this.registerAutomergeHashMapping(docId, decodedChange.hash, entry.id);
|
|
5054
|
+
}
|
|
5055
|
+
await this.store.putEntries(entries);
|
|
5056
|
+
internalDoc.doc = newDoc;
|
|
5057
|
+
internalDoc.lastModified = now;
|
|
5058
|
+
await this.storeCachedDocument(internalDoc);
|
|
5059
|
+
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
|
|
5060
|
+
this.markDocDirty(docId);
|
|
5061
|
+
this.logger.info(`Document ${docId} ${successMessage} successfully`);
|
|
5062
|
+
const createdByPublicKey = useCustomKey
|
|
5063
|
+
? signingKeyPair?.publicKey
|
|
5064
|
+
: (await this.tenant.getCurrentUserId()).userSigningPublicKey;
|
|
5065
|
+
await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
|
|
5066
|
+
? { signingKeyPair, signingKeyPassword, createdByPublicKey: createdByPublicKey ?? "" }
|
|
5067
|
+
: { createdByPublicKey: createdByPublicKey ?? "" });
|
|
5068
|
+
}
|
|
5069
|
+
async buildDocChangeStoreEntry(options) {
|
|
5070
|
+
const { internalDoc, changeBytes, now, useCustomKey, signingKeyPair, signingKeyPassword, attachmentIds } = options;
|
|
5071
|
+
const docId = internalDoc.id;
|
|
4395
5072
|
this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
|
|
4396
5073
|
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
4397
5074
|
const automergeHash = decodedChange.hash;
|
|
@@ -4429,21 +5106,10 @@ class BaseMindooDB {
|
|
|
4429
5106
|
encryptedSize: encryptedPayload.length,
|
|
4430
5107
|
attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined,
|
|
4431
5108
|
};
|
|
4432
|
-
|
|
5109
|
+
return {
|
|
4433
5110
|
...entryMetadata,
|
|
4434
5111
|
encryptedData: encryptedPayload,
|
|
4435
5112
|
};
|
|
4436
|
-
await this.store.putEntries([fullEntry]);
|
|
4437
|
-
this.registerAutomergeHashMapping(docId, automergeHash, entryId);
|
|
4438
|
-
internalDoc.doc = newDoc;
|
|
4439
|
-
internalDoc.lastModified = now;
|
|
4440
|
-
await this.storeCachedDocument(internalDoc);
|
|
4441
|
-
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
|
|
4442
|
-
this.markDocDirty(docId);
|
|
4443
|
-
this.logger.info(`Document ${docId} ${successMessage} successfully`);
|
|
4444
|
-
await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
|
|
4445
|
-
? { signingKeyPair, signingKeyPassword, createdByPublicKey }
|
|
4446
|
-
: { createdByPublicKey });
|
|
4447
5113
|
}
|
|
4448
5114
|
/**
|
|
4449
5115
|
* Best-effort snapshot scheduling and writing.
|
|
@@ -4777,6 +5443,7 @@ class BaseMindooDB {
|
|
|
4777
5443
|
if (parsed) {
|
|
4778
5444
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
4779
5445
|
}
|
|
5446
|
+
this.registerSnapshotHeadHashMappings(snapshotData);
|
|
4780
5447
|
}
|
|
4781
5448
|
}
|
|
4782
5449
|
}
|
|
@@ -5599,6 +6266,7 @@ class BaseMindooDB {
|
|
|
5599
6266
|
if (parsed) {
|
|
5600
6267
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
5601
6268
|
}
|
|
6269
|
+
this.registerSnapshotHeadHashMappings(snapshotData);
|
|
5602
6270
|
}
|
|
5603
6271
|
}
|
|
5604
6272
|
}
|
|
@@ -5830,7 +6498,7 @@ class BaseMindooDB {
|
|
|
5830
6498
|
},
|
|
5831
6499
|
});
|
|
5832
6500
|
};
|
|
5833
|
-
|
|
6501
|
+
const wrapped = {
|
|
5834
6502
|
getDatabase: () => db,
|
|
5835
6503
|
getId: () => docId,
|
|
5836
6504
|
getCreatedAt: () => internalDoc.createdAt,
|
|
@@ -5873,6 +6541,8 @@ class BaseMindooDB {
|
|
|
5873
6541
|
return db.streamAttachmentForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId), startOffset);
|
|
5874
6542
|
},
|
|
5875
6543
|
};
|
|
6544
|
+
this.wrappedInternalDocs.set(wrapped, internalDoc);
|
|
6545
|
+
return wrapped;
|
|
5876
6546
|
}
|
|
5877
6547
|
/**
|
|
5878
6548
|
* Get an attachment reference by ID from a document's _attachments array.
|