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(internalDoc.doc, { time: now }, (doc) => {
3691
- if (!Array.isArray(doc._attachments)) {
3692
- doc._attachments = [];
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
- doc._lastModified = now;
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
- if (baseHeads && baseHeads.length > 0) {
3824
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads, applyEdits);
3825
- newDoc = result.newDoc;
3826
- }
3827
- else {
3828
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyEdits);
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
- if (patch.baseHeads && patch.baseHeads.length > 0) {
3883
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, patch.baseHeads, applyPatch);
3884
- newDoc = result.newDoc;
3885
- }
3886
- else {
3887
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyPatch);
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(internalDoc.doc, (automergeDoc) => {
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
- const fullEntry = {
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
- return {
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.