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(internalDoc.doc, { time: now }, (doc) => {
3692
- if (!Array.isArray(doc._attachments)) {
3693
- 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 = [];
3694
3714
  }
3695
- doc._lastModified = now;
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
- if (baseHeads && baseHeads.length > 0) {
3825
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads, applyEdits);
3826
- newDoc = result.newDoc;
3827
- }
3828
- else {
3829
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyEdits);
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
- automerge_adapter_1.Automerge.updateSpans(automergeDoc, patch.path, this.reviveRichTextSpans(patch.spans), patch.updateSpansConfig);
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
- if (patch.baseHeads && patch.baseHeads.length > 0) {
3885
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, patch.baseHeads, applySpans);
3886
- newDoc = result.newDoc;
3887
- }
3888
- else {
3889
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applySpans);
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
- if (patch.baseHeads && patch.baseHeads.length > 0) {
3968
- const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, patch.baseHeads, applyPatch);
3969
- newDoc = result.newDoc;
3970
- }
3971
- else {
3972
- newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyPatch);
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(internalDoc.doc, (automergeDoc) => {
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
- if (!Array.isArray(patch.spans)) {
4343
- throw new Error("Rich-text patch spans must be an array");
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
- for (const span of patch.spans) {
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
- const fullEntry = {
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
  }