mindoodb 0.0.17 → 0.0.19

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.
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.BaseMindooDB = void 0;
3
+ exports.BaseMindooDB = exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = void 0;
4
4
  // Import platform-appropriate Automerge implementation
5
5
  // React Native: native Rust (react-native-automerge-generated)
6
6
  // Browser/Node.js: WASM (@automerge/automerge/slim)
7
7
  const automerge_adapter_1 = require("./automerge-adapter");
8
8
  const uuid_1 = require("uuid");
9
- const types_1 = require("./appendonlystores/types");
9
+ const types_1 = require("./types");
10
+ const types_2 = require("./appendonlystores/types");
10
11
  const bloom_1 = require("./appendonlystores/bloom");
11
12
  const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
12
13
  const DocumentDagAnalysis_1 = require("./DocumentDagAnalysis");
@@ -23,6 +24,50 @@ const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
23
24
  const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
24
25
  const DEFAULT_MAX_CACHED_DOCS = 128;
25
26
  const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 0;
27
+ /**
28
+ * Hard-coded initial Automerge change bytes that seed every document created
29
+ * with a caller-provided id (`createDocument({ id })`).
30
+ *
31
+ * Two replicas independently invoking `createDocument({ id: ... })` must
32
+ * produce the same initial Automerge change bytes/hash so the resulting
33
+ * `doc_create` entries converge. We hard-code the bytes (rather than
34
+ * regenerating them at runtime) for two reasons recommended by the Automerge
35
+ * cookbook:
36
+ *
37
+ * 1. Future Automerge releases may change change-byte encoding. Hard-coding
38
+ * pins the wire format we shipped, so old custom-id docs stay mergeable.
39
+ * 2. It prevents accidental schema drift in this file from silently shifting
40
+ * the doc_create entry id of every newly-created custom-id document.
41
+ *
42
+ * To intentionally regenerate these bytes (a breaking change for existing
43
+ * custom-id documents in the field), run
44
+ * `node scripts/regen-custom-id-initial-change.js`
45
+ * paste the new array below, then update the expected hash in
46
+ * `__tests__/CustomIdInitialChange.test.ts`.
47
+ *
48
+ * The encoded change is:
49
+ * actor: 00000000000000c0 (well-known, 8 bytes; only used for this seed)
50
+ * time: 0
51
+ * deps: []
52
+ * ops: set `_attachments` = []
53
+ * hash: b55efb45769e62bff921fd6f4fbb325a446d788ded077ec2a625c32e7631a190
54
+ */
55
+ /** @internal Exported for tests only; do not import from app code. */
56
+ exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES = new Uint8Array([
57
+ 133, 111, 74, 131, 181, 94, 251, 69, 1, 47, 0, 8, 0, 0, 0, 0,
58
+ 0, 0, 0, 192, 1, 1, 0, 0, 0, 5, 21, 14, 52, 1, 66, 2,
59
+ 86, 2, 112, 2, 127, 12, 95, 97, 116, 116, 97, 99, 104, 109, 101, 110,
60
+ 116, 115, 1, 127, 2, 127, 0, 127, 0,
61
+ ]);
62
+ /**
63
+ * Returns the hard-coded initial Automerge change bytes used to seed every
64
+ * custom-id document. Callers must apply these bytes into a fresh
65
+ * `Automerge.init()` so that subsequent edits use the replica's own actor id
66
+ * while still sharing Automerge ancestry with peers.
67
+ */
68
+ function getCustomIdInitialChangeBytes() {
69
+ return exports.CUSTOM_DOC_ID_INITIAL_CHANGE_BYTES;
70
+ }
26
71
  /**
27
72
  * BaseMindooDB is a platform-agnostic implementation of MindooDB
28
73
  * that works in both browser and server environments.
@@ -66,11 +111,11 @@ class BaseMindooDB {
66
111
  this.cachePrefix = null;
67
112
  this.dirtyDocIds = new Set();
68
113
  this.cacheMetaDirty = false;
69
- if (store.getStoreKind() !== types_1.StoreKind.docs) {
70
- throw new Error(`[BaseMindooDB] Expected primary store kind ${types_1.StoreKind.docs} but received ${store.getStoreKind()}`);
114
+ if (store.getStoreKind() !== types_2.StoreKind.docs) {
115
+ throw new Error(`[BaseMindooDB] Expected primary store kind ${types_2.StoreKind.docs} but received ${store.getStoreKind()}`);
71
116
  }
72
- if (attachmentStore.getStoreKind() !== types_1.StoreKind.attachments) {
73
- throw new Error(`[BaseMindooDB] Expected attachment store kind ${types_1.StoreKind.attachments} but received ${attachmentStore.getStoreKind()}`);
117
+ if (attachmentStore.getStoreKind() !== types_2.StoreKind.attachments) {
118
+ throw new Error(`[BaseMindooDB] Expected attachment store kind ${types_2.StoreKind.attachments} but received ${attachmentStore.getStoreKind()}`);
74
119
  }
75
120
  this.tenant = tenant;
76
121
  this.store = store;
@@ -499,6 +544,41 @@ class BaseMindooDB {
499
544
  getSubtle() {
500
545
  return this.tenant.getCryptoAdapter().getSubtle();
501
546
  }
547
+ isDocumentReplayEntry(entry) {
548
+ // Only these entry types participate in Automerge replay and document
549
+ // lifecycle state. Snapshots and attachment entries are derived side data.
550
+ return (entry.entryType === "doc_create" ||
551
+ entry.entryType === "doc_change" ||
552
+ entry.entryType === "doc_delete" ||
553
+ entry.entryType === "doc_undelete");
554
+ }
555
+ findActiveReplayHeadEntryIds(replayEntries) {
556
+ const replayIds = new Set(replayEntries.map((entry) => entry.id));
557
+ const referencedIds = new Set();
558
+ // A replay entry is no longer an active head once another replay entry
559
+ // depends on it. Remaining entries are the branch heads visible in metadata.
560
+ for (const entry of replayEntries) {
561
+ for (const depId of entry.dependencyIds) {
562
+ if (replayIds.has(depId)) {
563
+ referencedIds.add(depId);
564
+ }
565
+ }
566
+ }
567
+ return replayEntries
568
+ .filter((entry) => !referencedIds.has(entry.id))
569
+ .sort((left, right) => left.createdAt !== right.createdAt
570
+ ? left.createdAt - right.createdAt
571
+ : left.id.localeCompare(right.id))
572
+ .map((entry) => entry.id);
573
+ }
574
+ computeIsDeletedFromMetadata(metadata, headEntryIds) {
575
+ // Deletion is intentionally metadata-derived: the latest reachable
576
+ // doc_delete/doc_undelete lifecycle entry wins without materializing the doc.
577
+ const replayEntries = metadata.filter((entry) => this.isDocumentReplayEntry(entry));
578
+ const replayById = new Map(replayEntries.map((entry) => [entry.id, entry]));
579
+ const heads = headEntryIds ?? this.findActiveReplayHeadEntryIds(replayEntries);
580
+ return (0, DocumentDagAnalysis_1.isDeletedFromHeads)(heads, replayById);
581
+ }
502
582
  /**
503
583
  * Initialize the database instance.
504
584
  * If a cache store is available, attempts to restore from cache first,
@@ -526,6 +606,7 @@ class BaseMindooDB {
526
606
  .filter((entry) => entry.entryType === "doc_create"
527
607
  || entry.entryType === "doc_change"
528
608
  || entry.entryType === "doc_delete"
609
+ || entry.entryType === "doc_undelete"
529
610
  || entry.entryType === "doc_snapshot")
530
611
  .map((entry) => entry.docId))).sort((left, right) => left.localeCompare(right));
531
612
  const missingDocIds = lifecycleDocIds.filter((docId) => !this.indexLookup.has(docId));
@@ -620,12 +701,13 @@ class BaseMindooDB {
620
701
  }
621
702
  else {
622
703
  // Metadata-first startup: avoid full materialization for uncached docs.
623
- // Index based on doc-lifecycle entries (create/change/delete) plus
704
+ // Index based on doc-lifecycle entries (create/change/delete/undelete) plus
624
705
  // doc_snapshot which may be the sole entry after a dense sync.
625
706
  // Attachment-only batches should not trigger document index updates.
626
707
  const docLifecycleEntries = entryMetadataList.filter((e) => e.entryType === "doc_create" ||
627
708
  e.entryType === "doc_change" ||
628
709
  e.entryType === "doc_delete" ||
710
+ e.entryType === "doc_undelete" ||
629
711
  e.entryType === "doc_snapshot");
630
712
  if (docLifecycleEntries.length === 0) {
631
713
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — no document lifecycle entries (${entryMetadataList.length} attachment/other entries only)`);
@@ -639,14 +721,13 @@ class BaseMindooDB {
639
721
  this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
640
722
  }
641
723
  else {
642
- const mutationEntries = docLifecycleEntries.filter((e) => e.entryType === "doc_create" ||
643
- e.entryType === "doc_change" ||
644
- e.entryType === "doc_delete");
724
+ const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
725
+ const mutationEntries = allDocMetadata.filter((e) => this.isDocumentReplayEntry(e));
645
726
  // Snapshots compact replay history but should not change the user-visible
646
- // modification time when the original create/change/delete entries still exist.
727
+ // modification time when the original create/change/delete/undelete entries still exist.
647
728
  const entriesForLastModified = mutationEntries.length > 0 ? mutationEntries : docLifecycleEntries;
648
729
  const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
649
- const isDeleted = mutationEntries.some((e) => e.entryType === "doc_delete");
730
+ const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
650
731
  this.updateIndex(docId, lastModified, isDeleted);
651
732
  this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted})`);
652
733
  }
@@ -833,7 +914,7 @@ class BaseMindooDB {
833
914
  if (options?.transferBatchSize && options.transferBatchSize > 0) {
834
915
  return options.transferBatchSize;
835
916
  }
836
- if (options?.storeKind === types_1.StoreKind.attachments) {
917
+ if (options?.storeKind === types_2.StoreKind.attachments) {
837
918
  return 100;
838
919
  }
839
920
  return options?.pageSize ?? 1000;
@@ -1053,9 +1134,9 @@ class BaseMindooDB {
1053
1134
  *
1054
1135
  * Algorithm:
1055
1136
  * 1. Discover all documents on the source via `doc_create` metadata.
1056
- * 2. Also fetch `doc_delete` metadata so deletion markers are transferred.
1137
+ * 2. Also fetch `doc_delete` and `doc_undelete` metadata so lifecycle markers are transferred.
1057
1138
  * 3. Ask the source's batch planner for the optimal replay set per document.
1058
- * 4. Merge all entry IDs: doc_create + doc_delete + snapshot + uncovered changes.
1139
+ * 4. Merge all entry IDs: doc_create + doc_delete + doc_undelete + snapshot + uncovered changes.
1059
1140
  * 5. Filter out IDs the target already has (bloom + exact check).
1060
1141
  * 6. Transfer only the missing entries.
1061
1142
  *
@@ -1076,6 +1157,7 @@ class BaseMindooDB {
1076
1157
  // ── Phase 1: discover documents ──────────────────────────────────
1077
1158
  const docCreateEntries = await sourceStore.findEntries("doc_create", null, null);
1078
1159
  const docDeleteEntries = await sourceStore.findEntries("doc_delete", null, null);
1160
+ const docUndeleteEntries = await sourceStore.findEntries("doc_undelete", null, null);
1079
1161
  const docIds = [...new Set(docCreateEntries.map((e) => e.docId))];
1080
1162
  this.logger.info(`Dense sync: found ${docIds.length} documents on source`);
1081
1163
  if (docIds.length === 0) {
@@ -1100,6 +1182,9 @@ class BaseMindooDB {
1100
1182
  for (const entry of docDeleteEntries) {
1101
1183
  neededIds.add(entry.id);
1102
1184
  }
1185
+ for (const entry of docUndeleteEntries) {
1186
+ neededIds.add(entry.id);
1187
+ }
1103
1188
  for (const plan of batchPlan.plans) {
1104
1189
  if (plan.snapshotEntryId) {
1105
1190
  neededIds.add(plan.snapshotEntryId);
@@ -1156,23 +1241,48 @@ class BaseMindooDB {
1156
1241
  getTenant() {
1157
1242
  return this.tenant;
1158
1243
  }
1159
- async createDocument() {
1160
- return this.createEncryptedDocument("default");
1244
+ async createDocument(options) {
1245
+ return this.createDocumentInternal(options ?? {});
1161
1246
  }
1247
+ /**
1248
+ * @deprecated Use `createDocument({ decryptionKeyId })` instead.
1249
+ */
1162
1250
  async createEncryptedDocument(decryptionKeyId) {
1163
- return this.createDocumentInternal(decryptionKeyId || "default");
1251
+ return this.createDocumentInternal({ decryptionKeyId });
1164
1252
  }
1253
+ /**
1254
+ * @deprecated Use `createDocument({ signingKeyPair, signingKeyPassword, decryptionKeyId })` instead.
1255
+ */
1165
1256
  async createDocumentWithSigningKey(signingKeyPair, signingKeyPassword, decryptionKeyId) {
1166
- return this.createDocumentInternal(decryptionKeyId || "default", signingKeyPair, signingKeyPassword);
1257
+ return this.createDocumentInternal({ signingKeyPair, signingKeyPassword, decryptionKeyId });
1167
1258
  }
1168
1259
  /**
1169
1260
  * Internal method to create a new document.
1170
- * Handles both regular document creation (signed by current user) and
1171
- * document creation with a custom signing key (e.g., for directory operations).
1261
+ *
1262
+ * Handles all four creation flavors expressible through `CreateOptions`:
1263
+ * - generated UUID7 ID, current user signs, default tenant key
1264
+ * - generated UUID7 ID, custom signing key (e.g. directory admin operations)
1265
+ * - caller-provided ID, current user signs (idempotent on existing IDs)
1266
+ * - caller-provided ID with custom signing key
1267
+ *
1268
+ * For caller-provided IDs the initial Automerge change is seeded from a
1269
+ * deterministic, hard-coded change so that two replicas creating the same
1270
+ * custom ID converge when synced (see `getCustomIdInitialChangeBytes`).
1172
1271
  */
1173
- async createDocumentInternal(decryptionKeyId, signingKeyPair, signingKeyPassword) {
1174
- const keyId = decryptionKeyId;
1272
+ async createDocumentInternal(options) {
1273
+ const { signingKeyPair, signingKeyPassword } = options;
1274
+ if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
1275
+ throw new Error("createDocument: signingKeyPair and signingKeyPassword must be provided together");
1276
+ }
1175
1277
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
1278
+ const keyId = options.decryptionKeyId ?? "default";
1279
+ const useCustomDocId = options.id !== undefined;
1280
+ if (useCustomDocId) {
1281
+ if (!types_1.CUSTOM_DOC_ID_REGEX.test(options.id)) {
1282
+ throw new Error(`createDocument: invalid document id "${options.id}". ` +
1283
+ `Custom document IDs must match ${types_1.CUSTOM_DOC_ID_REGEX.source}.`);
1284
+ }
1285
+ }
1176
1286
  // Admin-only validation: only admin key can modify data in admin-only databases
1177
1287
  if (this._isAdminOnlyDb) {
1178
1288
  const adminPublicKey = this.getAdminPublicKey();
@@ -1183,32 +1293,70 @@ class BaseMindooDB {
1183
1293
  throw new Error("Admin-only database: only the admin key can modify data");
1184
1294
  }
1185
1295
  }
1186
- // Generate UUID7 for document ID
1187
- const docId = (0, uuid_1.v7)();
1188
- this.logger.debug(`Creating document ${docId} with key ${keyId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
1189
- // Create initial Automerge document
1190
- const initialDoc = automerge_adapter_1.Automerge.init();
1191
- // Create the first change
1296
+ const docId = useCustomDocId ? options.id : (0, uuid_1.v7)();
1297
+ // Idempotent create: when a caller-provided id already exists locally,
1298
+ // return the existing document instead of producing a duplicate doc_create.
1299
+ if (useCustomDocId) {
1300
+ const existing = await this.loadDocumentInternal(docId);
1301
+ if (existing) {
1302
+ if (existing.isDeleted) {
1303
+ this.logger.debug(`Document ${docId} exists as a tombstone; undeleting existing custom-id document`);
1304
+ await this.undeleteDocInternal(docId, signingKeyPair, signingKeyPassword);
1305
+ const undeleted = await this.loadDocumentInternal(docId);
1306
+ if (!undeleted || undeleted.isDeleted) {
1307
+ throw new Error(`Document ${docId} could not be undeleted`);
1308
+ }
1309
+ return this.wrapDocument(undeleted);
1310
+ }
1311
+ this.logger.debug(`Document ${docId} already exists locally; returning existing document`);
1312
+ return this.wrapDocument(existing);
1313
+ }
1314
+ }
1315
+ this.logger.debug(`Creating document ${docId} with key ${keyId}${useCustomSigningKey ? ' using custom signing key' : ''}${useCustomDocId ? ' with caller-provided id' : ''}`);
1316
+ // Build the initial Automerge document and its first change bytes.
1317
+ //
1318
+ // For UUID7 documents we use the historical path (Automerge.init + change).
1319
+ // For custom-ID documents we apply a hard-coded initial change so that
1320
+ // independent replicas using the same id produce the same Automerge hash
1321
+ // and `doc_create` entry id, allowing later changes to merge.
1192
1322
  const now = Date.now();
1193
- this.logger.debug(`Creating initial Automerge change for document ${docId}`);
1194
1323
  let newDoc;
1195
- try {
1196
- newDoc = automerge_adapter_1.Automerge.change(initialDoc, (doc) => {
1197
- // Store metadata in the document payload
1198
- // We need to modify the document to ensure a change is created
1199
- doc._attachments = [];
1200
- });
1201
- this.logger.debug(`Successfully created Automerge change, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
1202
- }
1203
- catch (error) {
1204
- this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
1205
- throw error;
1324
+ let changeBytes;
1325
+ if (useCustomDocId) {
1326
+ this.logger.debug(`Seeding custom-id document ${docId} with hard-coded initial Automerge change`);
1327
+ changeBytes = getCustomIdInitialChangeBytes();
1328
+ try {
1329
+ const fresh = automerge_adapter_1.Automerge.init();
1330
+ const [appliedDoc] = automerge_adapter_1.Automerge.applyChanges(fresh, [changeBytes]);
1331
+ newDoc = appliedDoc;
1332
+ this.logger.debug(`Applied hard-coded initial change, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
1333
+ }
1334
+ catch (error) {
1335
+ this.logger.error(`Error applying hard-coded initial change for document ${docId}:`, error);
1336
+ throw error;
1337
+ }
1206
1338
  }
1207
- // Get the change bytes from the document
1208
- this.logger.debug(`Getting change bytes from document ${docId}`);
1209
- const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
1210
- if (!changeBytes) {
1211
- throw new Error("Failed to get change bytes from Automerge document");
1339
+ else {
1340
+ this.logger.debug(`Creating initial Automerge change for document ${docId}`);
1341
+ const initialDoc = automerge_adapter_1.Automerge.init();
1342
+ try {
1343
+ newDoc = automerge_adapter_1.Automerge.change(initialDoc, (doc) => {
1344
+ // Store metadata in the document payload
1345
+ // We need to modify the document to ensure a change is created
1346
+ doc._attachments = [];
1347
+ });
1348
+ this.logger.debug(`Successfully created Automerge change, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
1349
+ }
1350
+ catch (error) {
1351
+ this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
1352
+ throw error;
1353
+ }
1354
+ this.logger.debug(`Getting change bytes from document ${docId}`);
1355
+ const localChange = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
1356
+ if (!localChange) {
1357
+ throw new Error("Failed to get change bytes from Automerge document");
1358
+ }
1359
+ changeBytes = localChange;
1212
1360
  }
1213
1361
  this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
1214
1362
  // Decode the change to get hash and dependencies
@@ -1314,6 +1462,7 @@ class BaseMindooDB {
1314
1462
  && (em.entryType === "doc_create"
1315
1463
  || em.entryType === "doc_change"
1316
1464
  || em.entryType === "doc_delete"
1465
+ || em.entryType === "doc_undelete"
1317
1466
  || em.entryType === "doc_snapshot"))
1318
1467
  .sort((a, b) => a.createdAt - b.createdAt);
1319
1468
  if (relevantEntries.length === 0) {
@@ -1394,16 +1543,14 @@ class BaseMindooDB {
1394
1543
  this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
1395
1544
  }
1396
1545
  }
1397
- const replayEntries = relevantEntries.filter((entry) => entry.entryType === "doc_create"
1398
- || entry.entryType === "doc_change"
1399
- || entry.entryType === "doc_delete");
1546
+ const replayEntries = relevantEntries.filter((entry) => this.isDocumentReplayEntry(entry));
1400
1547
  const firstReplayEntry = replayEntries.length > 0 ? replayEntries[0] : null;
1401
1548
  const lastReplayEntry = replayEntries.length > 0 ? replayEntries[replayEntries.length - 1] : null;
1402
1549
  const createdAt = firstReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? timestamp;
1403
1550
  const decryptionKeyId = firstReplayEntry?.decryptionKeyId
1404
1551
  ?? snapshotMeta?.decryptionKeyId
1405
1552
  ?? "default";
1406
- const isDeleted = lastReplayEntry?.entryType === "doc_delete";
1553
+ const isDeleted = this.computeIsDeletedFromMetadata(relevantEntries);
1407
1554
  const lastModified = lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? timestamp;
1408
1555
  const internalDoc = {
1409
1556
  id: docId,
@@ -1427,9 +1574,9 @@ class BaseMindooDB {
1427
1574
  this.logger.debug(`Iterating document history for ${docId}`);
1428
1575
  // Get all entry metadata for this document
1429
1576
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
1430
- // Filter to only doc_create, doc_change, and doc_delete entries (exclude snapshots)
1577
+ // Filter to document replay entries (exclude snapshots)
1431
1578
  const relevantEntries = allEntryMetadata
1432
- .filter(em => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete")
1579
+ .filter((em) => this.isDocumentReplayEntry(em))
1433
1580
  .sort((a, b) => a.createdAt - b.createdAt);
1434
1581
  if (relevantEntries.length === 0) {
1435
1582
  return; // Document has no history
@@ -1481,10 +1628,10 @@ class BaseMindooDB {
1481
1628
  currentDoc = automerge_adapter_1.Automerge.loadIncremental(currentDoc, decryptedPayload);
1482
1629
  // Check if document actually changed by comparing heads
1483
1630
  // For first entry (doc_create), always yield since it's the initial creation
1484
- // For delete entries, always yield
1631
+ // For lifecycle terminal entries, always yield
1485
1632
  // For other entries, only yield if heads changed
1486
1633
  let shouldYield = false;
1487
- if (isFirstEntry || entryMetadata.entryType === "doc_delete") {
1634
+ if (isFirstEntry || entryMetadata.entryType === "doc_delete" || entryMetadata.entryType === "doc_undelete") {
1488
1635
  shouldYield = true;
1489
1636
  }
1490
1637
  else {
@@ -1508,20 +1655,18 @@ class BaseMindooDB {
1508
1655
  createdAt: createdAt,
1509
1656
  lastModified: entryMetadata.createdAt,
1510
1657
  decryptionKeyId,
1511
- isDeleted: entryMetadata.entryType === "doc_delete",
1658
+ isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((entry) => entry.createdAt < entryMetadata.createdAt ||
1659
+ (entry.createdAt === entryMetadata.createdAt && entry.id <= entryMetadata.id))),
1512
1660
  };
1513
- // Wrap and yield (including delete entries)
1661
+ // Wrap and yield (including lifecycle terminal entries)
1514
1662
  const wrappedDoc = this.wrapDocument(internalDoc);
1515
1663
  yield {
1664
+ changeEntryId: entryMetadata.id,
1516
1665
  doc: wrappedDoc,
1517
1666
  changeCreatedAt: entryMetadata.createdAt,
1518
1667
  changeCreatedByPublicKey: entryMetadata.createdByPublicKey,
1519
1668
  };
1520
1669
  }
1521
- // Stop after delete entry (document is deleted, no more changes)
1522
- if (entryMetadata.entryType === "doc_delete") {
1523
- break;
1524
- }
1525
1670
  }
1526
1671
  }
1527
1672
  async getDocumentHistoryPage(docId, options) {
@@ -1534,7 +1679,8 @@ class BaseMindooDB {
1534
1679
  const relevantEntries = allEntryMetadata
1535
1680
  .filter((em) => em.entryType === "doc_create" ||
1536
1681
  em.entryType === "doc_change" ||
1537
- em.entryType === "doc_delete")
1682
+ em.entryType === "doc_delete" ||
1683
+ em.entryType === "doc_undelete")
1538
1684
  .sort((a, b) => a.createdAt - b.createdAt);
1539
1685
  // Cursor paging is offset-based because the result is a bounded timeline view
1540
1686
  // over the doc's sorted change metadata, not a resumable store scan cursor.
@@ -1545,7 +1691,8 @@ class BaseMindooDB {
1545
1691
  changeCreatedAt: entry.createdAt,
1546
1692
  changeCreatedByPublicKey: entry.createdByPublicKey,
1547
1693
  dependencyIds: [...entry.dependencyIds],
1548
- isDeleted: entry.entryType === "doc_delete",
1694
+ isDeleted: this.computeIsDeletedFromMetadata(relevantEntries.filter((candidate) => candidate.createdAt < entry.createdAt ||
1695
+ (candidate.createdAt === entry.createdAt && candidate.id <= entry.id))),
1549
1696
  }));
1550
1697
  const nextOffset = offset + entries.length;
1551
1698
  const hasMore = nextOffset < relevantEntries.length;
@@ -1827,63 +1974,65 @@ class BaseMindooDB {
1827
1974
  // Query up to timestamp + 1 so entries created exactly at `timestamp` are included
1828
1975
  // for the strict checks below (createTime < timestamp, deleteTime > timestamp).
1829
1976
  const upperBoundExclusive = timestamp === Number.MAX_SAFE_INTEGER ? timestamp : timestamp + 1;
1830
- // Efficiently query for doc_create and doc_delete entries before the timestamp
1831
- const [creates, deletes] = await Promise.all([
1977
+ // We only need lifecycle metadata for this query. Changes and snapshots can
1978
+ // affect document contents, but create/delete/undelete determine existence.
1979
+ const [creates, deletes, undeletes] = await Promise.all([
1832
1980
  this.store.findEntries("doc_create", null, upperBoundExclusive),
1833
- this.store.findEntries("doc_delete", null, upperBoundExclusive)
1981
+ this.store.findEntries("doc_delete", null, upperBoundExclusive),
1982
+ this.store.findEntries("doc_undelete", null, upperBoundExclusive),
1834
1983
  ]);
1835
- // Build sets of docIds for efficient lookup
1836
- const createdDocIds = new Set();
1837
- const deletedDocIds = new Set();
1838
- // Track earliest create time for each doc
1839
- const createTimes = new Map();
1984
+ const lifecycleEntriesByDocId = new Map();
1985
+ const addLifecycleEntry = (entry) => {
1986
+ // Group entries by document so each document's lifecycle can be evaluated
1987
+ // independently without materializing its Automerge state.
1988
+ const entries = lifecycleEntriesByDocId.get(entry.docId) ?? [];
1989
+ entries.push(entry);
1990
+ lifecycleEntriesByDocId.set(entry.docId, entries);
1991
+ };
1840
1992
  for (const entry of creates) {
1841
- const existingTime = createTimes.get(entry.docId);
1842
- if (!existingTime || entry.createdAt < existingTime) {
1843
- createTimes.set(entry.docId, entry.createdAt);
1844
- createdDocIds.add(entry.docId);
1845
- }
1993
+ addLifecycleEntry(entry);
1846
1994
  }
1847
- // Track earliest delete time for each doc
1848
- const deleteTimes = new Map();
1849
1995
  for (const entry of deletes) {
1850
- const existingTime = deleteTimes.get(entry.docId);
1851
- if (!existingTime || entry.createdAt < existingTime) {
1852
- deleteTimes.set(entry.docId, entry.createdAt);
1853
- deletedDocIds.add(entry.docId);
1854
- }
1996
+ addLifecycleEntry(entry);
1997
+ }
1998
+ for (const entry of undeletes) {
1999
+ addLifecycleEntry(entry);
1855
2000
  }
1856
- // Find documents that existed at the timestamp
1857
2001
  const docIds = [];
1858
- for (const docId of createdDocIds) {
1859
- const createTime = createTimes.get(docId);
1860
- const deleteTime = deleteTimes.get(docId);
1861
- // Document exists at timestamp if:
1862
- // 1. It was created at or before the timestamp
1863
- // 2. Either it was never deleted, or it was deleted after the timestamp
1864
- if (createTime <= timestamp) {
1865
- if (!deleteTime || deleteTime > timestamp) {
1866
- docIds.push(docId);
1867
- }
2002
+ for (const [docId, lifecycleEntries] of lifecycleEntriesByDocId.entries()) {
2003
+ // Sort deterministically so same-timestamp lifecycle entries resolve the
2004
+ // same way on every replica.
2005
+ const ordered = lifecycleEntries.sort((a, b) => a.createdAt !== b.createdAt ? a.createdAt - b.createdAt : a.id.localeCompare(b.id));
2006
+ // A document cannot exist before its create entry, even if malformed or
2007
+ // partial metadata somehow contains later lifecycle entries.
2008
+ if (!ordered.some((entry) => entry.entryType === "doc_create" && entry.createdAt <= timestamp)) {
2009
+ continue;
2010
+ }
2011
+ // Delete and undelete are terminal lifecycle markers. The latest one at
2012
+ // the timestamp decides whether the document existed then.
2013
+ const latestTerminal = ordered
2014
+ .filter((entry) => entry.entryType === "doc_delete" || entry.entryType === "doc_undelete")
2015
+ .at(-1);
2016
+ if (!latestTerminal || latestTerminal.entryType === "doc_undelete") {
2017
+ docIds.push(docId);
1868
2018
  }
1869
2019
  }
1870
2020
  return docIds;
1871
2021
  }
1872
- async deleteDocument(docId) {
1873
- return this.deleteDocInternal(docId);
2022
+ async deleteDocument(docId, options = {}) {
2023
+ return this.deleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
1874
2024
  }
1875
2025
  async deleteDocumentWithSigningKey(docId, signingKeyPair, signingKeyPassword) {
1876
- return this.deleteDocInternal(docId, signingKeyPair, signingKeyPassword);
2026
+ return this.deleteDocument(docId, { signingKeyPair, signingKeyPassword });
1877
2027
  }
1878
- /**
1879
- * Internal method to delete a document.
1880
- * Handles both regular deletion (signed by current user) and
1881
- * deletion with a custom signing key (e.g., for directory operations).
1882
- */
1883
- async deleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
2028
+ async undeleteDocument(docId, options = {}) {
2029
+ return this.undeleteDocInternal(docId, options.signingKeyPair, options.signingKeyPassword);
2030
+ }
2031
+ async assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword) {
1884
2032
  const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
1885
- this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
1886
- // Admin-only validation: only admin key can modify data in admin-only databases
2033
+ if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
2034
+ throw new Error("Lifecycle mutation requires both signingKeyPair and signingKeyPassword");
2035
+ }
1887
2036
  if (this._isAdminOnlyDb) {
1888
2037
  const adminPublicKey = this.getAdminPublicKey();
1889
2038
  const signerPublicKey = useCustomSigningKey
@@ -1893,58 +2042,57 @@ class BaseMindooDB {
1893
2042
  throw new Error("Admin-only database: only the admin key can modify data");
1894
2043
  }
1895
2044
  }
1896
- // Get current document
1897
- const internalDoc = await this.loadDocumentInternal(docId);
1898
- if (!internalDoc || internalDoc.isDeleted) {
1899
- throw new Error(`Document ${docId} not found or already deleted`);
1900
- }
2045
+ }
2046
+ /**
2047
+ * Write a delete or undelete lifecycle entry for an existing document.
2048
+ *
2049
+ * The document body is preserved; this method only writes a small Automerge
2050
+ * change that advances `_lastModified`, wraps it in the requested lifecycle
2051
+ * StoreEntry type, signs/encrypts it, and updates local caches/indexes.
2052
+ */
2053
+ async writeLifecycleEntry(internalDoc, entryType, signingKeyPair, signingKeyPassword) {
2054
+ // Delete and undelete are encoded as normal Automerge changes so the DAG
2055
+ // keeps causal ancestry, while the StoreEntry type carries lifecycle intent.
2056
+ const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
1901
2057
  const now = Date.now();
1902
- // Create deletion change by clearing all fields from the document
1903
- // This ensures Automerge produces actual change bytes
1904
- // The deletion is also tracked via the "doc_delete" type entry in the append-only store
1905
- const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, (doc) => {
1906
- // Remove all fields from the document
1907
- for (const key of Object.keys(doc)) {
1908
- delete doc[key];
1909
- }
1910
- // Mark as deleted for clarity
1911
- doc._deleted = true;
2058
+ const docId = internalDoc.id;
2059
+ // Lifecycle changes are intentionally non-destructive: they only bump
2060
+ // `_lastModified` and repair missing legacy attachment arrays. The document
2061
+ // body remains available for history and future undeletion.
2062
+ const newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, { time: now }, (doc) => {
2063
+ if (!Array.isArray(doc._attachments)) {
2064
+ doc._attachments = [];
2065
+ }
1912
2066
  doc._lastModified = now;
1913
2067
  });
1914
- // Get the change bytes from the document
1915
2068
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
1916
2069
  if (!changeBytes) {
1917
2070
  throw new Error("Failed to get change bytes from Automerge document");
1918
2071
  }
1919
- // Decode the change to get hash and dependencies
1920
2072
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
1921
2073
  const automergeHash = decodedChange.hash;
1922
- const automergeDepHashes = decodedChange.deps || []; // Dependencies from the decoded change
1923
- // Encrypt the change payload first
2074
+ const automergeDepHashes = decodedChange.deps || [];
1924
2075
  const encryptedPayload = await this.tenant.encryptPayload(changeBytes, internalDoc.decryptionKeyId);
1925
- // Compute content hash from encrypted data
1926
2076
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
1927
- // Generate entry ID with blockchain-like chaining
1928
2077
  const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, automergeHash, automergeDepHashes, this.getSubtle());
1929
- // Resolve Automerge dependency hashes to entry IDs
2078
+ // Convert Automerge dependency hashes back into store entry IDs so metadata
2079
+ // consumers can traverse the lifecycle DAG without decrypting payloads.
1930
2080
  const dependencyIds = await this.ensureAutomergeDepsResolved(docId, automergeDepHashes);
1931
- // Sign the encrypted payload - either with custom key or current user's key
1932
2081
  let signature;
1933
2082
  let createdByPublicKey;
1934
2083
  if (useCustomSigningKey) {
1935
- this.logger.debug(`Signing deletion for document ${docId} with provided key`);
1936
2084
  signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
1937
2085
  createdByPublicKey = signingKeyPair.publicKey;
1938
2086
  }
1939
2087
  else {
1940
- this.logger.debug(`Signing deletion for document ${docId} with current user's key`);
1941
2088
  const currentUser = await this.tenant.getCurrentUserId();
1942
2089
  signature = await this.tenant.signPayload(encryptedPayload);
1943
2090
  createdByPublicKey = currentUser.userSigningPublicKey;
1944
2091
  }
1945
- // Create entry metadata with type "doc_delete" to mark this as a deletion entry in the store
2092
+ // The encrypted Automerge change is the payload; the metadata classifies it
2093
+ // as a delete or undelete lifecycle entry for fast history/view queries.
1946
2094
  const entryMetadata = {
1947
- entryType: "doc_delete",
2095
+ entryType,
1948
2096
  id: entryId,
1949
2097
  contentHash,
1950
2098
  docId,
@@ -1956,32 +2104,127 @@ class BaseMindooDB {
1956
2104
  originalSize: changeBytes.length,
1957
2105
  encryptedSize: encryptedPayload.length,
1958
2106
  };
1959
- // Create full entry object
1960
2107
  const fullEntry = {
1961
2108
  ...entryMetadata,
1962
2109
  encryptedData: encryptedPayload,
1963
2110
  };
1964
- // Store entry
1965
2111
  await this.store.putEntries([fullEntry]);
1966
- // Register automerge hash -> entry ID mapping
1967
2112
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
1968
- // Update cache and index
1969
- internalDoc.isDeleted = true;
2113
+ // Keep the loaded document, cache, index, and sync dirty tracking in step
2114
+ // with the append-only store entry we just wrote.
2115
+ internalDoc.doc = newDoc;
2116
+ internalDoc.isDeleted = entryType === "doc_delete";
1970
2117
  internalDoc.lastModified = now;
1971
2118
  this.storeCachedDocument(internalDoc);
1972
- this.updateIndex(docId, internalDoc.lastModified, true);
2119
+ this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
1973
2120
  this.markDocDirty(docId);
2121
+ }
2122
+ /**
2123
+ * Internal method to delete a document.
2124
+ * Handles both regular deletion (signed by current user) and
2125
+ * deletion with a custom signing key (e.g., for directory operations).
2126
+ */
2127
+ async deleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
2128
+ const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
2129
+ this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
2130
+ await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
2131
+ // Get current document
2132
+ const internalDoc = await this.loadDocumentInternal(docId);
2133
+ if (!internalDoc || internalDoc.isDeleted) {
2134
+ throw new Error(`Document ${docId} not found or already deleted`);
2135
+ }
2136
+ await this.writeLifecycleEntry(internalDoc, "doc_delete", signingKeyPair, signingKeyPassword);
1974
2137
  this.logger.info(`Document ${docId} deleted successfully`);
1975
2138
  }
1976
- async changeDoc(doc, changeFunc) {
1977
- return this.changeDocInternal(doc, changeFunc);
2139
+ async undeleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
2140
+ const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
2141
+ this.logger.debug(`Undeleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
2142
+ await this.assertLifecycleMutationAllowed(signingKeyPair, signingKeyPassword);
2143
+ const internalDoc = await this.loadDocumentInternal(docId);
2144
+ if (!internalDoc) {
2145
+ throw new Error(`Document ${docId} not found`);
2146
+ }
2147
+ if (!internalDoc.isDeleted) {
2148
+ this.logger.debug(`Document ${docId} is already alive; undelete is a no-op`);
2149
+ return;
2150
+ }
2151
+ await this.writeLifecycleEntry(internalDoc, "doc_undelete", signingKeyPair, signingKeyPassword);
2152
+ this.logger.info(`Document ${docId} undeleted successfully`);
2153
+ }
2154
+ async changeDoc(doc, changeFunc, options = {}) {
2155
+ return this.changeDocInternal(doc, changeFunc, options.signingKeyPair, options.signingKeyPassword);
1978
2156
  }
1979
2157
  async changeDocWithSigningKey(doc, changeFunc, signingKeyPair, signingKeyPassword) {
1980
- return this.changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword);
2158
+ return this.changeDoc(doc, changeFunc, { signingKeyPair, signingKeyPassword });
2159
+ }
2160
+ async applyTextPatch(doc, patch) {
2161
+ const docId = doc.getId();
2162
+ this.logger.debug(`===== applyTextPatch called for document ${docId} =====`);
2163
+ if (this._isAdminOnlyDb) {
2164
+ const adminPublicKey = this.getAdminPublicKey();
2165
+ const currentUser = await this.tenant.getCurrentUserId();
2166
+ if (currentUser.userSigningPublicKey !== adminPublicKey) {
2167
+ throw new Error("Admin-only database: only the admin key can modify data");
2168
+ }
2169
+ }
2170
+ let internalDoc = this.getCachedDocument(docId);
2171
+ if (!internalDoc) {
2172
+ const loadedDoc = await this.loadDocumentInternal(docId);
2173
+ if (!loadedDoc) {
2174
+ throw new Error(`Document ${docId} not found`);
2175
+ }
2176
+ internalDoc = loadedDoc;
2177
+ }
2178
+ if (internalDoc.isDeleted) {
2179
+ throw new Error(`Document ${docId} has been deleted`);
2180
+ }
2181
+ this.validateTextPatch(patch);
2182
+ const now = Date.now();
2183
+ const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
2184
+ const baseHeads = patch.baseHeads;
2185
+ const applyEdits = (automergeDoc) => {
2186
+ this.ensureTextPath(automergeDoc, patch.path);
2187
+ for (const edit of patch.edits) {
2188
+ automerge_adapter_1.Automerge.splice(automergeDoc, patch.path, edit.index, edit.deleteCount, edit.insert ?? "");
2189
+ }
2190
+ automergeDoc._lastModified = now;
2191
+ };
2192
+ let newDoc;
2193
+ try {
2194
+ if (baseHeads && baseHeads.length > 0) {
2195
+ const result = automerge_adapter_1.Automerge.changeAt(internalDoc.doc, baseHeads, applyEdits);
2196
+ newDoc = result.newDoc;
2197
+ }
2198
+ else {
2199
+ newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, applyEdits);
2200
+ }
2201
+ this.logger.debug(`Successfully applied text patch, new document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(newDoc))}`);
2202
+ }
2203
+ catch (error) {
2204
+ this.logger.error(`Error applying text patch for document ${docId}:`, error);
2205
+ throw error;
2206
+ }
2207
+ await this.persistDocumentChange({
2208
+ internalDoc,
2209
+ newDoc,
2210
+ now,
2211
+ headsBeforeChange,
2212
+ useCustomKey: false,
2213
+ successMessage: "text patched",
2214
+ });
2215
+ const wrapped = this.wrapDocument(internalDoc);
2216
+ return {
2217
+ doc: wrapped,
2218
+ heads: wrapped.getHeads(),
2219
+ data: wrapped.getData(),
2220
+ };
1981
2221
  }
1982
2222
  async changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword) {
1983
2223
  const docId = doc.getId();
1984
- const useCustomKey = signingKeyPair && signingKeyPassword;
2224
+ if ((signingKeyPair !== undefined) !== (signingKeyPassword !== undefined)) {
2225
+ throw new Error("changeDoc: signingKeyPair and signingKeyPassword must be provided together");
2226
+ }
2227
+ const useCustomKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
1985
2228
  this.logger.debug(`===== ${useCustomKey ? 'changeDocWithSigningKey' : 'changeDoc'} called for document ${docId} =====`);
1986
2229
  // Admin-only validation: only admin key can modify data in admin-only databases
1987
2230
  if (this._isAdminOnlyDb) {
@@ -2046,6 +2289,7 @@ class BaseMindooDB {
2046
2289
  getLastModified: () => internalDoc.lastModified,
2047
2290
  getDecryptionKeyId: () => internalDoc.decryptionKeyId,
2048
2291
  isDeleted: () => false,
2292
+ getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
2049
2293
  getData: () => {
2050
2294
  // Return a proxy that collects property assignments and deletions
2051
2295
  const currentData = internalDoc.doc;
@@ -2187,6 +2431,7 @@ class BaseMindooDB {
2187
2431
  // Deactivate the callback guard - no more changes can be made via collectingDoc
2188
2432
  isCallbackActive = false;
2189
2433
  // Now apply the collected changes synchronously in Automerge.change()
2434
+ const headsBeforeChange = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
2190
2435
  let newDoc;
2191
2436
  try {
2192
2437
  newDoc = automerge_adapter_1.Automerge.change(internalDoc.doc, (automergeDoc) => {
@@ -2234,31 +2479,93 @@ class BaseMindooDB {
2234
2479
  this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
2235
2480
  throw error;
2236
2481
  }
2237
- // Get the change bytes from the document
2482
+ await this.persistDocumentChange({
2483
+ internalDoc,
2484
+ newDoc,
2485
+ now,
2486
+ headsBeforeChange,
2487
+ useCustomKey: Boolean(useCustomKey),
2488
+ signingKeyPair,
2489
+ signingKeyPassword,
2490
+ successMessage: useCustomKey ? "changed with custom signing key" : "changed",
2491
+ });
2492
+ }
2493
+ validateTextPatch(patch) {
2494
+ if (!Array.isArray(patch.path) || patch.path.length === 0) {
2495
+ throw new Error("Text patch path must contain at least one segment");
2496
+ }
2497
+ for (const segment of patch.path) {
2498
+ if (typeof segment !== "string" && typeof segment !== "number") {
2499
+ throw new Error("Text patch path segments must be strings or numbers");
2500
+ }
2501
+ }
2502
+ if (!Array.isArray(patch.edits) || patch.edits.length === 0) {
2503
+ throw new Error("Text patch must include at least one edit");
2504
+ }
2505
+ for (const edit of patch.edits) {
2506
+ if (!Number.isInteger(edit.index) || edit.index < 0) {
2507
+ throw new Error("Text edit index must be a non-negative integer");
2508
+ }
2509
+ if (!Number.isInteger(edit.deleteCount) || edit.deleteCount < 0) {
2510
+ throw new Error("Text edit deleteCount must be a non-negative integer");
2511
+ }
2512
+ if (edit.insert !== undefined && typeof edit.insert !== "string") {
2513
+ throw new Error("Text edit insert value must be a string when provided");
2514
+ }
2515
+ }
2516
+ }
2517
+ ensureTextPath(automergeDoc, path) {
2518
+ let target = automergeDoc;
2519
+ for (let i = 0; i < path.length - 1; i++) {
2520
+ const segment = path[i];
2521
+ const nextSegment = path[i + 1];
2522
+ if (target[segment] === undefined || target[segment] === null) {
2523
+ target[segment] = typeof nextSegment === "number" ? [] : {};
2524
+ }
2525
+ target = target[segment];
2526
+ if (target === null || typeof target !== "object") {
2527
+ throw new Error(`Cannot apply text patch through non-object path segment '${String(segment)}'`);
2528
+ }
2529
+ }
2530
+ const leaf = path[path.length - 1];
2531
+ const currentValue = target[leaf];
2532
+ if (currentValue === undefined || currentValue === null) {
2533
+ target[leaf] = "";
2534
+ return;
2535
+ }
2536
+ if (typeof currentValue !== "string") {
2537
+ throw new Error(`Cannot apply text patch to non-string value at '${path.map(String).join(".")}'`);
2538
+ }
2539
+ }
2540
+ async persistDocumentChange(options) {
2541
+ const { internalDoc, newDoc, now, headsBeforeChange, useCustomKey, signingKeyPair, signingKeyPassword, successMessage } = options;
2542
+ const docId = internalDoc.id;
2238
2543
  this.logger.debug(`Getting change bytes from document ${docId}`);
2239
- const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
2544
+ const changesSincePreviousHeads = headsBeforeChange
2545
+ ? automerge_adapter_1.Automerge.getChangesSince(newDoc, headsBeforeChange)
2546
+ : [];
2547
+ if (changesSincePreviousHeads.length > 1) {
2548
+ throw new Error(`Expected one Automerge change, got ${changesSincePreviousHeads.length}`);
2549
+ }
2550
+ const changeBytes = changesSincePreviousHeads[0] ?? automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
2240
2551
  if (!changeBytes) {
2241
- //TODO decide if we just exit here or throw an error
2242
2552
  throw new Error("Failed to get change bytes from Automerge document");
2243
2553
  }
2244
2554
  this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
2245
- // Decode the change to get hash and dependencies
2246
2555
  const decodedChange = automerge_adapter_1.Automerge.decodeChange(changeBytes);
2247
2556
  const automergeHash = decodedChange.hash;
2248
- const automergeDepHashes = decodedChange.deps || []; // Dependencies from the decoded change
2249
- // Encrypt the change payload first
2557
+ const automergeDepHashes = decodedChange.deps || [];
2250
2558
  const encryptedPayload = await this.tenant.encryptPayload(changeBytes, internalDoc.decryptionKeyId);
2251
- // Compute content hash from encrypted data
2252
2559
  const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
2253
- // Generate entry ID with blockchain-like chaining
2254
2560
  const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, automergeHash, automergeDepHashes, this.getSubtle());
2255
2561
  this.logger.debug(`Generated entry ID for change: ${entryId}`);
2256
- // Resolve Automerge dependency hashes to entry IDs
2257
2562
  const dependencyIds = await this.ensureAutomergeDepsResolved(docId, automergeDepHashes);
2258
- // Sign the encrypted payload - use custom key if provided, otherwise current user's key
2259
2563
  let signature;
2260
2564
  let createdByPublicKey;
2261
2565
  if (useCustomKey) {
2566
+ if (!signingKeyPair || !signingKeyPassword) {
2567
+ throw new Error("Custom signing requires both signingKeyPair and signingKeyPassword");
2568
+ }
2262
2569
  signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
2263
2570
  createdByPublicKey = signingKeyPair.publicKey;
2264
2571
  }
@@ -2267,7 +2574,6 @@ class BaseMindooDB {
2267
2574
  signature = await this.tenant.signPayload(encryptedPayload);
2268
2575
  createdByPublicKey = currentUser.userSigningPublicKey;
2269
2576
  }
2270
- // Create entry metadata
2271
2577
  const entryMetadata = {
2272
2578
  entryType: "doc_change",
2273
2579
  id: entryId,
@@ -2281,23 +2587,18 @@ class BaseMindooDB {
2281
2587
  originalSize: changeBytes.length,
2282
2588
  encryptedSize: encryptedPayload.length,
2283
2589
  };
2284
- // Create full entry object
2285
2590
  const fullEntry = {
2286
2591
  ...entryMetadata,
2287
2592
  encryptedData: encryptedPayload,
2288
2593
  };
2289
- // Store entry
2290
2594
  await this.store.putEntries([fullEntry]);
2291
- // Register automerge hash -> entry ID mapping
2292
2595
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
2293
- // Update cache and index
2294
2596
  internalDoc.doc = newDoc;
2295
2597
  internalDoc.lastModified = now;
2296
2598
  this.storeCachedDocument(internalDoc);
2297
2599
  this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
2298
2600
  this.markDocDirty(docId);
2299
- this.logger.info(`Document ${docId} ${useCustomKey ? 'changed with custom signing key' : 'changed'} successfully`);
2300
- // Snapshot creation runs as a best-effort background optimization.
2601
+ this.logger.info(`Document ${docId} ${successMessage} successfully`);
2301
2602
  await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
2302
2603
  ? { signingKeyPair, signingKeyPassword, createdByPublicKey }
2303
2604
  : { createdByPublicKey });
@@ -2311,7 +2612,7 @@ class BaseMindooDB {
2311
2612
  const docId = internalDoc.id;
2312
2613
  try {
2313
2614
  const allMetadata = await this.scanAllMetadata(this.store, { docId });
2314
- const replayEntries = allMetadata.filter((em) => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
2615
+ const replayEntries = allMetadata.filter((em) => this.isDocumentReplayEntry(em));
2315
2616
  const snapshots = allMetadata
2316
2617
  .filter((em) => em.entryType === "doc_snapshot")
2317
2618
  .sort((a, b) => b.createdAt - a.createdAt);
@@ -2674,7 +2975,7 @@ class BaseMindooDB {
2674
2975
  createdAt: firstReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
2675
2976
  lastModified: lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? fallbackTimestamp,
2676
2977
  decryptionKeyId: firstReplayEntry?.decryptionKeyId ?? snapshotMeta?.decryptionKeyId ?? "default",
2677
- isDeleted: lastReplayEntry?.entryType === "doc_delete",
2978
+ isDeleted: this.computeIsDeletedFromMetadata(replayEntriesForState),
2678
2979
  };
2679
2980
  }
2680
2981
  async *iterateChangeMetadataSince(cursor) {
@@ -2809,7 +3110,7 @@ class BaseMindooDB {
2809
3110
  // Get current document heads to check if it changes
2810
3111
  const headsBefore = automerge_adapter_1.Automerge.getHeads(cachedDoc.doc);
2811
3112
  // Filter entries to only include change entries (exclude snapshots)
2812
- const entriesToApply = newEntryMetadata.filter(em => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
3113
+ const entriesToApply = newEntryMetadata.filter((em) => this.isDocumentReplayEntry(em));
2813
3114
  if (entriesToApply.length === 0) {
2814
3115
  this.logger.debug(`No change entries to apply for document ${docId}`);
2815
3116
  return null; // No changes
@@ -2908,9 +3209,8 @@ class BaseMindooDB {
2908
3209
  const lastEntryCreatedAt = entries.reduce((maxCreatedAt, entry) => Math.max(maxCreatedAt, entry.createdAt), cachedDoc.lastModified);
2909
3210
  const lastModified = payload._lastModified ||
2910
3211
  lastEntryCreatedAt;
2911
- // Check if document was deleted
2912
- const hasDeleteEntry = newEntryMetadata.some(em => em.entryType === "doc_delete");
2913
- const isDeleted = hasDeleteEntry || cachedDoc.isDeleted;
3212
+ const allDocMetadata = await this.scanAllMetadata(this.store, { docId });
3213
+ const isDeleted = this.computeIsDeletedFromMetadata(allDocMetadata);
2914
3214
  const updatedInternalDoc = {
2915
3215
  id: docId,
2916
3216
  doc: updatedDoc,
@@ -3186,9 +3486,7 @@ class BaseMindooDB {
3186
3486
  this.logger.debug(`All entries applied successfully for document ${docId}`);
3187
3487
  this.logger.debug(`Final document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
3188
3488
  const payload = doc;
3189
- // Check if document was deleted by looking for a "doc_delete" type entry
3190
- const hasDeleteEntry = allEntryMetadata.some(em => em.entryType === "doc_delete");
3191
- const isDeleted = hasDeleteEntry;
3489
+ const isDeleted = this.computeIsDeletedFromMetadata(allEntryMetadata);
3192
3490
  this.logger.debug(`Document ${docId} isDeleted: ${isDeleted}`);
3193
3491
  // The authoritative decryptionKeyId comes from the doc_create entry's metadata,
3194
3492
  // not from the Automerge payload (which does not store encryption metadata).
@@ -3307,6 +3605,7 @@ class BaseMindooDB {
3307
3605
  getLastModified: () => internalDoc.lastModified,
3308
3606
  getDecryptionKeyId: () => internalDoc.decryptionKeyId,
3309
3607
  isDeleted: () => internalDoc.isDeleted,
3608
+ getHeads: () => automerge_adapter_1.Automerge.getHeads(internalDoc.doc),
3310
3609
  getData: () => {
3311
3610
  // Convert Automerge document to plain JS object, converting Text objects to strings
3312
3611
  const jsDoc = this.convertAutomergeToJS(internalDoc.doc);
@@ -3333,13 +3632,13 @@ class BaseMindooDB {
3333
3632
  return payload._attachments || [];
3334
3633
  },
3335
3634
  getAttachment: async (attachmentId) => {
3336
- return db.getAttachmentInternal(docId, attachmentId);
3635
+ return db.getAttachmentForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId));
3337
3636
  },
3338
3637
  getAttachmentRange: async (attachmentId, startByte, endByte) => {
3339
- return db.getAttachmentRangeInternal(docId, attachmentId, startByte, endByte);
3638
+ return db.getAttachmentRangeForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId), startByte, endByte);
3340
3639
  },
3341
3640
  streamAttachment: (attachmentId, startOffset = 0) => {
3342
- return db.streamAttachmentInternal(docId, attachmentId, startOffset);
3641
+ return db.streamAttachmentForRefInternal(db.getAttachmentRefFromDoc(internalDoc, attachmentId), startOffset);
3343
3642
  },
3344
3643
  };
3345
3644
  }
@@ -3351,11 +3650,14 @@ class BaseMindooDB {
3351
3650
  if (!internalDoc) {
3352
3651
  throw new Error(`Document ${docId} not found while reading attachment ${attachmentId}`);
3353
3652
  }
3653
+ return this.getAttachmentRefFromDoc(internalDoc, attachmentId);
3654
+ }
3655
+ getAttachmentRefFromDoc(internalDoc, attachmentId) {
3354
3656
  const payload = internalDoc.doc;
3355
3657
  const attachments = payload._attachments || [];
3356
3658
  const ref = attachments.find(a => a.attachmentId === attachmentId);
3357
3659
  if (!ref) {
3358
- throw new Error(`Attachment ${attachmentId} not found in document ${docId}`);
3660
+ throw new Error(`Attachment ${attachmentId} not found in document ${internalDoc.id}`);
3359
3661
  }
3360
3662
  return ref;
3361
3663
  }
@@ -3398,10 +3700,13 @@ class BaseMindooDB {
3398
3700
  async getAttachmentInternal(docId, attachmentId) {
3399
3701
  this.logger.debug(`Getting attachment ${attachmentId} from document ${docId}`);
3400
3702
  const ref = await this.getAttachmentRefInternal(docId, attachmentId);
3703
+ return await this.getAttachmentForRefInternal(ref);
3704
+ }
3705
+ async getAttachmentForRefInternal(ref) {
3401
3706
  const store = this.getEffectiveAttachmentStore();
3402
3707
  // Resolve dependency chain to get all chunk IDs in order (oldest first)
3403
3708
  const chunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
3404
- this.logger.debug(`Resolved ${chunkIds.length} chunks for attachment ${attachmentId}`);
3709
+ this.logger.debug(`Resolved ${chunkIds.length} chunks for attachment ${ref.attachmentId}`);
3405
3710
  // Fetch all chunks
3406
3711
  const chunks = await store.getEntries(chunkIds);
3407
3712
  // Verify signatures, decrypt, and collect plaintext chunks
@@ -3419,7 +3724,7 @@ class BaseMindooDB {
3419
3724
  result.set(chunk, offset);
3420
3725
  offset += chunk.length;
3421
3726
  }
3422
- this.logger.debug(`Retrieved attachment ${attachmentId}: ${result.length} bytes`);
3727
+ this.logger.debug(`Retrieved attachment ${ref.attachmentId}: ${result.length} bytes`);
3423
3728
  return result;
3424
3729
  }
3425
3730
  /**
@@ -3431,6 +3736,12 @@ class BaseMindooDB {
3431
3736
  throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
3432
3737
  }
3433
3738
  const ref = await this.getAttachmentRefInternal(docId, attachmentId);
3739
+ return await this.getAttachmentRangeForRefInternal(ref, startByte, endByte);
3740
+ }
3741
+ async getAttachmentRangeForRefInternal(ref, startByte, endByte) {
3742
+ if (startByte < 0 || endByte <= startByte) {
3743
+ throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
3744
+ }
3434
3745
  const store = this.getEffectiveAttachmentStore();
3435
3746
  const readPlan = await this.planAttachmentRead(store, ref, startByte, endByte);
3436
3747
  const neededChunkIds = readPlan.chunkPlans.map((chunkPlan) => chunkPlan.id);
@@ -3454,7 +3765,7 @@ class BaseMindooDB {
3454
3765
  resultOffset += bytesToCopy;
3455
3766
  bytesRemaining -= bytesToCopy;
3456
3767
  }
3457
- this.logger.debug(`Retrieved attachment ${attachmentId} range: ${result.length} bytes`);
3768
+ this.logger.debug(`Retrieved attachment ${ref.attachmentId} range: ${result.length} bytes`);
3458
3769
  return result;
3459
3770
  }
3460
3771
  /**
@@ -3463,6 +3774,9 @@ class BaseMindooDB {
3463
3774
  async *streamAttachmentInternal(docId, attachmentId, startOffset) {
3464
3775
  this.logger.debug(`Streaming attachment ${attachmentId} from offset ${startOffset}`);
3465
3776
  const ref = await this.getAttachmentRefInternal(docId, attachmentId);
3777
+ yield* this.streamAttachmentForRefInternal(ref, startOffset);
3778
+ }
3779
+ async *streamAttachmentForRefInternal(ref, startOffset) {
3466
3780
  if (startOffset < 0 || startOffset > ref.size) {
3467
3781
  throw new Error(`Invalid stream offset ${startOffset} for attachment size ${ref.size}`);
3468
3782
  }
@@ -3492,7 +3806,7 @@ class BaseMindooDB {
3492
3806
  }
3493
3807
  }
3494
3808
  }
3495
- this.logger.debug(`Finished streaming attachment ${attachmentId}`);
3809
+ this.logger.debug(`Finished streaming attachment ${ref.attachmentId}`);
3496
3810
  }
3497
3811
  /**
3498
3812
  * Get the attachment store for this database.
@@ -3693,12 +4007,12 @@ class BaseMindooDB {
3693
4007
  * Resolve a sync target to a ContentAddressedStore.
3694
4008
  * Accepts either a raw store or a MindooDB instance (calls getStore()).
3695
4009
  */
3696
- getStoreForKind(storeKind = types_1.StoreKind.docs) {
3697
- return storeKind === types_1.StoreKind.attachments ? this.attachmentStore : this.store;
4010
+ getStoreForKind(storeKind = types_2.StoreKind.docs) {
4011
+ return storeKind === types_2.StoreKind.attachments ? this.attachmentStore : this.store;
3698
4012
  }
3699
- resolveStore(remote, storeKind = types_1.StoreKind.docs) {
4013
+ resolveStore(remote, storeKind = types_2.StoreKind.docs) {
3700
4014
  if ('getStore' in remote && typeof remote.getStore === 'function') {
3701
- return storeKind === types_1.StoreKind.attachments
4015
+ return storeKind === types_2.StoreKind.attachments
3702
4016
  ? remote.getAttachmentStore()
3703
4017
  : remote.getStore();
3704
4018
  }
@@ -3768,7 +4082,7 @@ class BaseMindooDB {
3768
4082
  * @return A promise that resolves with the sync result
3769
4083
  */
3770
4084
  async pullChangesFrom(remote, options) {
3771
- const storeKind = options?.storeKind ?? types_1.StoreKind.docs;
4085
+ const storeKind = options?.storeKind ?? types_2.StoreKind.docs;
3772
4086
  const localStore = this.getStoreForKind(storeKind);
3773
4087
  const remoteStore = this.resolveStore(remote, storeKind);
3774
4088
  if (localStore.getId() !== remoteStore.getId() || localStore.getStoreKind() !== remoteStore.getStoreKind()) {
@@ -3807,7 +4121,7 @@ class BaseMindooDB {
3807
4121
  cancelled: true,
3808
4122
  };
3809
4123
  }
3810
- if (storeKind === types_1.StoreKind.docs) {
4124
+ if (storeKind === types_2.StoreKind.docs) {
3811
4125
  await this.syncStoreChanges();
3812
4126
  }
3813
4127
  if (options?.signal?.aborted) {
@@ -3851,7 +4165,7 @@ class BaseMindooDB {
3851
4165
  * @return A promise that resolves with the sync result
3852
4166
  */
3853
4167
  async pushChangesTo(remote, options) {
3854
- const storeKind = options?.storeKind ?? types_1.StoreKind.docs;
4168
+ const storeKind = options?.storeKind ?? types_2.StoreKind.docs;
3855
4169
  const localStore = this.getStoreForKind(storeKind);
3856
4170
  const remoteStore = this.resolveStore(remote, storeKind);
3857
4171
  if (localStore.getId() !== remoteStore.getId() || localStore.getStoreKind() !== remoteStore.getStoreKind()) {