mindoodb 0.0.23 → 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.
|
@@ -779,6 +779,22 @@ class BaseMindooDB {
|
|
|
779
779
|
}
|
|
780
780
|
this.automergeHashToEntryId.get(docId).set(automergeHash, entryId);
|
|
781
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
|
+
}
|
|
782
798
|
/**
|
|
783
799
|
* Get the entry ID for an automerge hash within a document.
|
|
784
800
|
* Returns null if not found.
|
|
@@ -823,6 +839,9 @@ class BaseMindooDB {
|
|
|
823
839
|
}
|
|
824
840
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, metadata.id);
|
|
825
841
|
}
|
|
842
|
+
for (const metadata of allMetadata) {
|
|
843
|
+
this.registerSnapshotHeadHashMappings(metadata);
|
|
844
|
+
}
|
|
826
845
|
}
|
|
827
846
|
/**
|
|
828
847
|
* Resolve Automerge dependency hashes to entry IDs without silently dropping parents.
|
|
@@ -2128,6 +2147,7 @@ class BaseMindooDB {
|
|
|
2128
2147
|
if (parsed) {
|
|
2129
2148
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
2130
2149
|
}
|
|
2150
|
+
this.registerSnapshotHeadHashMappings(snapshotData);
|
|
2131
2151
|
}
|
|
2132
2152
|
}
|
|
2133
2153
|
}
|
|
@@ -3688,12 +3708,12 @@ class BaseMindooDB {
|
|
|
3688
3708
|
// Lifecycle changes are intentionally non-destructive: they only bump
|
|
3689
3709
|
// `_lastModified` and repair missing legacy attachment arrays. The document
|
|
3690
3710
|
// body remains available for history and future undeletion.
|
|
3691
|
-
const newDoc = automerge_adapter_1.Automerge.change(
|
|
3692
|
-
if (!Array.isArray(
|
|
3693
|
-
|
|
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 = [];
|
|
3694
3714
|
}
|
|
3695
|
-
|
|
3696
|
-
});
|
|
3715
|
+
mutableDoc._lastModified = now;
|
|
3716
|
+
}));
|
|
3697
3717
|
const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
|
|
3698
3718
|
if (!changeBytes) {
|
|
3699
3719
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
@@ -3821,13 +3841,13 @@ class BaseMindooDB {
|
|
|
3821
3841
|
};
|
|
3822
3842
|
let newDoc;
|
|
3823
3843
|
try {
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
}
|
|
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
|
+
});
|
|
3831
3851
|
this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3832
3852
|
}
|
|
3833
3853
|
catch (error) {
|
|
@@ -3874,20 +3894,34 @@ class BaseMindooDB {
|
|
|
3874
3894
|
this.validateRichTextPatch(patch);
|
|
3875
3895
|
const now = Date.now();
|
|
3876
3896
|
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
3897
|
+
const spansSequence = patch.spansSequence ?? (patch.spans ? [patch.spans] : []);
|
|
3877
3898
|
const applySpans = (automergeDoc) => {
|
|
3878
3899
|
this.ensureRichTextPath(automergeDoc, patch.path);
|
|
3879
|
-
|
|
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
|
+
}
|
|
3880
3914
|
automergeDoc._lastModified = now;
|
|
3881
3915
|
};
|
|
3882
3916
|
let newDoc;
|
|
3883
3917
|
try {
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
}
|
|
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
|
+
});
|
|
3891
3925
|
this.logger.debug(`Successfully applied rich-text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3892
3926
|
}
|
|
3893
3927
|
catch (error) {
|
|
@@ -3909,6 +3943,92 @@ class BaseMindooDB {
|
|
|
3909
3943
|
data: wrapped.getData(),
|
|
3910
3944
|
};
|
|
3911
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
|
+
}
|
|
3912
4032
|
async getRichTextSnapshot(doc, path) {
|
|
3913
4033
|
const docId = doc.getId();
|
|
3914
4034
|
this.validateJsonPath(path, "Rich-text snapshot");
|
|
@@ -3933,6 +4053,122 @@ class BaseMindooDB {
|
|
|
3933
4053
|
spans: this.dehydrateRichTextSpans(spans),
|
|
3934
4054
|
};
|
|
3935
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
|
+
}
|
|
3936
4172
|
async applyJsonPatch(doc, patch) {
|
|
3937
4173
|
this.assertWritable("applyJsonPatch");
|
|
3938
4174
|
const docId = doc.getId();
|
|
@@ -3964,13 +4200,13 @@ class BaseMindooDB {
|
|
|
3964
4200
|
};
|
|
3965
4201
|
let newDoc;
|
|
3966
4202
|
try {
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
}
|
|
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
|
+
});
|
|
3974
4210
|
this.logger.debug(`Successfully applied JSON patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
3975
4211
|
}
|
|
3976
4212
|
catch (error) {
|
|
@@ -4215,7 +4451,7 @@ class BaseMindooDB {
|
|
|
4215
4451
|
const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
|
|
4216
4452
|
let newDoc;
|
|
4217
4453
|
try {
|
|
4218
|
-
newDoc = automerge_adapter_1.Automerge.change(
|
|
4454
|
+
newDoc = this.runChangeWithOutdatedDocRecovery(internalDoc, (doc) => automerge_adapter_1.Automerge.change(doc, (automergeDoc) => {
|
|
4219
4455
|
// Apply all pending changes (sets/updates)
|
|
4220
4456
|
for (const [key, value] of pendingChanges) {
|
|
4221
4457
|
automergeDoc[key] = value;
|
|
@@ -4253,7 +4489,7 @@ class BaseMindooDB {
|
|
|
4253
4489
|
}
|
|
4254
4490
|
// Update lastModified timestamp
|
|
4255
4491
|
automergeDoc._lastModified = now;
|
|
4256
|
-
});
|
|
4492
|
+
}));
|
|
4257
4493
|
this.logger.debug(`Successfully applied change function, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
|
|
4258
4494
|
}
|
|
4259
4495
|
catch (error) {
|
|
@@ -4308,7 +4544,10 @@ class BaseMindooDB {
|
|
|
4308
4544
|
const operationCount = (patch.set?.length ?? 0)
|
|
4309
4545
|
+ (patch.unset?.length ?? 0)
|
|
4310
4546
|
+ (patch.listDelete?.length ?? 0)
|
|
4311
|
-
+ (patch.listInsert?.length ?? 0)
|
|
4547
|
+
+ (patch.listInsert?.length ?? 0)
|
|
4548
|
+
+ (patch.textSplice?.length ?? 0)
|
|
4549
|
+
+ (patch.textMark?.length ?? 0)
|
|
4550
|
+
+ (patch.textUnmark?.length ?? 0);
|
|
4312
4551
|
if (operationCount === 0) {
|
|
4313
4552
|
throw new Error("JSON patch must include at least one operation");
|
|
4314
4553
|
}
|
|
@@ -4336,13 +4575,69 @@ class BaseMindooDB {
|
|
|
4336
4575
|
throw new Error("JSON listInsert values must be an array");
|
|
4337
4576
|
}
|
|
4338
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
|
+
}
|
|
4339
4614
|
}
|
|
4340
4615
|
validateRichTextPatch(patch) {
|
|
4341
4616
|
this.validateJsonPath(patch.path, "Rich-text patch");
|
|
4342
|
-
|
|
4343
|
-
|
|
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;
|
|
4344
4628
|
}
|
|
4345
|
-
|
|
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) {
|
|
4346
4641
|
if (!span || typeof span !== "object") {
|
|
4347
4642
|
throw new Error("Rich-text span must be an object");
|
|
4348
4643
|
}
|
|
@@ -4361,6 +4656,40 @@ class BaseMindooDB {
|
|
|
4361
4656
|
throw new Error("Rich-text span type must be 'text' or 'block'");
|
|
4362
4657
|
}
|
|
4363
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
|
+
}
|
|
4692
|
+
}
|
|
4364
4693
|
validateJsonPath(path, label) {
|
|
4365
4694
|
if (!Array.isArray(path) || path.length === 0) {
|
|
4366
4695
|
throw new Error(`${label} path must contain at least one segment`);
|
|
@@ -4454,6 +4783,53 @@ class BaseMindooDB {
|
|
|
4454
4783
|
const list = this.ensureJsonListAtPath(automergeDoc, operation.path);
|
|
4455
4784
|
list.splice(operation.index, 0, ...structuredClone(operation.values));
|
|
4456
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));
|
|
4457
4833
|
}
|
|
4458
4834
|
setJsonValueAtPath(target, path, value) {
|
|
4459
4835
|
const parent = this.ensureJsonParentAtPath(target, path);
|
|
@@ -4463,6 +4839,13 @@ class BaseMindooDB {
|
|
|
4463
4839
|
const parent = this.readJsonParentAtPath(target, path);
|
|
4464
4840
|
delete parent[path[path.length - 1]];
|
|
4465
4841
|
}
|
|
4842
|
+
readValueAtPath(target, path) {
|
|
4843
|
+
let value = target;
|
|
4844
|
+
for (const segment of path) {
|
|
4845
|
+
value = value?.[segment];
|
|
4846
|
+
}
|
|
4847
|
+
return value;
|
|
4848
|
+
}
|
|
4466
4849
|
readJsonListAtPath(target, path) {
|
|
4467
4850
|
let value = target;
|
|
4468
4851
|
for (const segment of path) {
|
|
@@ -4555,6 +4938,46 @@ class BaseMindooDB {
|
|
|
4555
4938
|
throw new Error(`Cannot apply text patch to non-string value at '${path.map(String).join(".")}'`);
|
|
4556
4939
|
}
|
|
4557
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
|
+
}
|
|
4558
4981
|
ensureRichTextPath(automergeDoc, path) {
|
|
4559
4982
|
let target = automergeDoc;
|
|
4560
4983
|
for (let i = 0; i < path.length - 1; i++) {
|
|
@@ -4592,6 +5015,60 @@ class BaseMindooDB {
|
|
|
4592
5015
|
if (!changeBytes) {
|
|
4593
5016
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
4594
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;
|
|
4595
5072
|
this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
|
|
4596
5073
|
const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
|
|
4597
5074
|
const automergeHash = decodedChange.hash;
|
|
@@ -4629,21 +5106,10 @@ class BaseMindooDB {
|
|
|
4629
5106
|
encryptedSize: encryptedPayload.length,
|
|
4630
5107
|
attachmentIds: attachmentIds && attachmentIds.length > 0 ? attachmentIds : undefined,
|
|
4631
5108
|
};
|
|
4632
|
-
|
|
5109
|
+
return {
|
|
4633
5110
|
...entryMetadata,
|
|
4634
5111
|
encryptedData: encryptedPayload,
|
|
4635
5112
|
};
|
|
4636
|
-
await this.store.putEntries([fullEntry]);
|
|
4637
|
-
this.registerAutomergeHashMapping(docId, automergeHash, entryId);
|
|
4638
|
-
internalDoc.doc = newDoc;
|
|
4639
|
-
internalDoc.lastModified = now;
|
|
4640
|
-
await this.storeCachedDocument(internalDoc);
|
|
4641
|
-
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted, internalDoc.decryptionKeyId, "visible");
|
|
4642
|
-
this.markDocDirty(docId);
|
|
4643
|
-
this.logger.info(`Document ${docId} ${successMessage} successfully`);
|
|
4644
|
-
await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
|
|
4645
|
-
? { signingKeyPair, signingKeyPassword, createdByPublicKey }
|
|
4646
|
-
: { createdByPublicKey });
|
|
4647
5113
|
}
|
|
4648
5114
|
/**
|
|
4649
5115
|
* Best-effort snapshot scheduling and writing.
|
|
@@ -4977,6 +5443,7 @@ class BaseMindooDB {
|
|
|
4977
5443
|
if (parsed) {
|
|
4978
5444
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
4979
5445
|
}
|
|
5446
|
+
this.registerSnapshotHeadHashMappings(snapshotData);
|
|
4980
5447
|
}
|
|
4981
5448
|
}
|
|
4982
5449
|
}
|
|
@@ -5799,6 +6266,7 @@ class BaseMindooDB {
|
|
|
5799
6266
|
if (parsed) {
|
|
5800
6267
|
this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
|
|
5801
6268
|
}
|
|
6269
|
+
this.registerSnapshotHeadHashMappings(snapshotData);
|
|
5802
6270
|
}
|
|
5803
6271
|
}
|
|
5804
6272
|
}
|