mindoodb 0.0.4 → 0.0.5

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.
Files changed (67) hide show
  1. package/README-server.md +34 -5
  2. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +9 -1
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +226 -130
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  6. package/dist/appendonlystores/network/HttpTransport.d.ts +12 -1
  7. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  8. package/dist/appendonlystores/network/HttpTransport.js +192 -10
  9. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  10. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +3 -1
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +26 -4
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  14. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +6 -3
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +79 -10
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  18. package/dist/core/BaseMindooDB.d.ts +5 -0
  19. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  20. package/dist/core/BaseMindooDB.js +223 -108
  21. package/dist/core/BaseMindooDB.js.map +1 -1
  22. package/dist/core/BaseMindooTenant.d.ts +1 -0
  23. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  24. package/dist/core/BaseMindooTenant.js +69 -21
  25. package/dist/core/BaseMindooTenant.js.map +1 -1
  26. package/dist/core/MindooDBServerAdmin.d.ts +3 -0
  27. package/dist/core/MindooDBServerAdmin.d.ts.map +1 -1
  28. package/dist/core/MindooDBServerAdmin.js +57 -1
  29. package/dist/core/MindooDBServerAdmin.js.map +1 -1
  30. package/dist/core/appendonlystores/AttachmentReadPlanner.d.ts +22 -0
  31. package/dist/core/appendonlystores/AttachmentReadPlanner.d.ts.map +1 -0
  32. package/dist/core/appendonlystores/AttachmentReadPlanner.js +124 -0
  33. package/dist/core/appendonlystores/AttachmentReadPlanner.js.map +1 -0
  34. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +8 -4
  35. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  36. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +22 -9
  37. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  38. package/dist/core/appendonlystores/MaterializationPlanner.d.ts +35 -0
  39. package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -1
  40. package/dist/core/appendonlystores/MaterializationPlanner.js +1 -0
  41. package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -1
  42. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +19 -1
  43. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  44. package/dist/core/appendonlystores/network/types.d.ts +33 -0
  45. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  46. package/dist/core/appendonlystores/network/types.js +2 -0
  47. package/dist/core/appendonlystores/network/types.js.map +1 -1
  48. package/dist/core/appendonlystores/types.d.ts +91 -3
  49. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  50. package/dist/core/types.d.ts +37 -2
  51. package/dist/core/types.d.ts.map +1 -1
  52. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +28 -7
  53. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  54. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +115 -17
  55. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  56. package/dist/node/server/MindooDBServer.d.ts +7 -0
  57. package/dist/node/server/MindooDBServer.d.ts.map +1 -1
  58. package/dist/node/server/MindooDBServer.js +232 -16
  59. package/dist/node/server/MindooDBServer.js.map +1 -1
  60. package/dist/node/server/TenantManager.d.ts +17 -2
  61. package/dist/node/server/TenantManager.d.ts.map +1 -1
  62. package/dist/node/server/TenantManager.js +142 -20
  63. package/dist/node/server/TenantManager.js.map +1 -1
  64. package/dist/node/server/types.d.ts +9 -4
  65. package/dist/node/server/types.d.ts.map +1 -1
  66. package/dist/node/server/types.js.map +1 -1
  67. package/package.json +2 -1
@@ -8,6 +8,7 @@ const automerge_adapter_1 = require("./automerge-adapter");
8
8
  const uuid_1 = require("uuid");
9
9
  const bloom_1 = require("./appendonlystores/bloom");
10
10
  const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
11
+ const AttachmentReadPlanner_1 = require("./appendonlystores/AttachmentReadPlanner");
11
12
  const idGeneration_1 = require("./utils/idGeneration");
12
13
  const errors_1 = require("./errors");
13
14
  const logging_1 = require("./logging");
@@ -71,6 +72,7 @@ class BaseMindooDB {
71
72
  this.iteratePrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
72
73
  DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS));
73
74
  this.cacheRestoreLimit = Math.max(1, Math.floor(documentCacheConfig?.restoreLimit ?? this.maxCachedDocs));
75
+ this.reconcileRestoredIndexOnInit = documentCacheConfig?.reconcileRestoredIndexOnInit ?? false;
74
76
  this.snapshotMinChanges = Math.max(1, Math.floor(snapshotConfig?.minChanges ?? DEFAULT_SNAPSHOT_MIN_CHANGES));
75
77
  this.snapshotCooldownMs = Math.max(0, Math.floor(snapshotConfig?.cooldownMs ?? DEFAULT_SNAPSHOT_COOLDOWN_MS));
76
78
  // Create logger if not provided (for backward compatibility)
@@ -184,7 +186,7 @@ class BaseMindooDB {
184
186
  }
185
187
  exportMetadataCheckpoint() {
186
188
  const checkpoint = {
187
- version: 1,
189
+ version: 2,
188
190
  processedEntryCursor: this.processedEntryCursor,
189
191
  index: this.index,
190
192
  nextChangeSeq: this.nextChangeSeq,
@@ -214,7 +216,7 @@ class BaseMindooDB {
214
216
  return false;
215
217
  }
216
218
  const checkpoint = JSON.parse(new TextDecoder().decode(metaBytes));
217
- if (checkpoint.version !== 1) {
219
+ if (checkpoint.version !== 2) {
218
220
  this.logger.warn(`Unknown cache version ${checkpoint.version}, ignoring cache`);
219
221
  return false;
220
222
  }
@@ -266,6 +268,7 @@ class BaseMindooDB {
266
268
  this.logger.warn(`Failed to restore cached doc ${docId}, will reload from store: ${e}`);
267
269
  }
268
270
  }
271
+ this.logger.info(`Restored ${restoredDocs} cached documents for ${this.store.getId()}`);
269
272
  this.logger.info(`Restored ${restoredDocs} documents from cache for ${prefix}`);
270
273
  return true;
271
274
  }
@@ -460,11 +463,42 @@ class BaseMindooDB {
460
463
  if (restored) {
461
464
  this.logger.info("Restored from cache, processing delta only");
462
465
  await this.syncStoreChanges();
466
+ if (this.reconcileRestoredIndexOnInit) {
467
+ await this.reconcileRestoredIndexWithStore();
468
+ }
463
469
  return;
464
470
  }
465
471
  }
466
472
  await this.syncStoreChanges();
467
473
  }
474
+ async reconcileRestoredIndexWithStore() {
475
+ const lifecycleMetadata = await this.scanAllMetadata(this.store);
476
+ const lifecycleDocIds = Array.from(new Set(lifecycleMetadata
477
+ .filter((entry) => entry.entryType === "doc_create"
478
+ || entry.entryType === "doc_change"
479
+ || entry.entryType === "doc_delete"
480
+ || entry.entryType === "doc_snapshot")
481
+ .map((entry) => entry.docId))).sort((left, right) => left.localeCompare(right));
482
+ const missingDocIds = lifecycleDocIds.filter((docId) => !this.indexLookup.has(docId));
483
+ if (missingDocIds.length === 0) {
484
+ return;
485
+ }
486
+ this.logger.warn(`Detected stale cache checkpoint for ${this.store.getId()} - ` +
487
+ `index is missing ${missingDocIds.length} document(s): ${missingDocIds.join(", ")}. ` +
488
+ `Rebuilding metadata from local store.`);
489
+ this.index = [];
490
+ this.indexLookup.clear();
491
+ this.docCache.clear();
492
+ this.automergeHashToEntryId.clear();
493
+ this.processedEntryIds = [];
494
+ this.processedEntryCursor = null;
495
+ this.nextChangeSeq = 1;
496
+ this.cacheMetaDirty = true;
497
+ this.cacheManager?.markDirty();
498
+ await this.syncStoreChanges();
499
+ this.logger.info(`Metadata rebuild complete for ${this.store.getId()} - ` +
500
+ `index now has ${this.index.length} document(s).`);
501
+ }
468
502
  /**
469
503
  * Sync changes from the content-addressed store by finding new entries and processing them.
470
504
  * This method can be called multiple times to incrementally sync new entries.
@@ -475,9 +509,10 @@ class BaseMindooDB {
475
509
  this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
476
510
  this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
477
511
  // Find new entries that we haven't processed yet
478
- const newEntryMetadata = await this.getNewEntryMetadataForSync();
512
+ const { entries: newEntryMetadata, nextCursor } = await this.getNewEntryMetadataForSync();
479
513
  this.logger.debug(`Found ${newEntryMetadata.length} new entries`);
480
514
  if (newEntryMetadata.length === 0) {
515
+ this.processedEntryCursor = nextCursor;
481
516
  this.logger.debug(`No new entries to process`);
482
517
  this.performanceCallback?.onSyncOperation?.({
483
518
  operation: "findNewEntries",
@@ -608,6 +643,7 @@ class BaseMindooDB {
608
643
  }
609
644
  // Append new entry IDs to our processed list
610
645
  this.processedEntryIds.push(...newEntryMetadata.map(em => em.id));
646
+ this.processedEntryCursor = nextCursor;
611
647
  this.cacheMetaDirty = true;
612
648
  this.cacheManager?.markDirty();
613
649
  this.logger.debug(`Synced ${newEntryMetadata.length} new entries, index now has ${this.index.length} documents`);
@@ -656,7 +692,10 @@ class BaseMindooDB {
656
692
  processedEntryCount: this.processedEntryIds.length,
657
693
  },
658
694
  });
659
- return result;
695
+ return {
696
+ entries: result,
697
+ nextCursor: this.processedEntryCursor,
698
+ };
660
699
  }
661
700
  const allNew = [];
662
701
  let cursor = this.processedEntryCursor;
@@ -668,7 +707,6 @@ class BaseMindooDB {
668
707
  break;
669
708
  }
670
709
  }
671
- this.processedEntryCursor = cursor;
672
710
  this.performanceCallback?.onSyncOperation?.({
673
711
  operation: "findNewEntries",
674
712
  time: Date.now() - startedAt,
@@ -678,7 +716,10 @@ class BaseMindooDB {
678
716
  cursor,
679
717
  },
680
718
  });
681
- return allNew;
719
+ return {
720
+ entries: allNew,
721
+ nextCursor: cursor,
722
+ };
682
723
  }
683
724
  /**
684
725
  * Fetch the target store's bloom filter summary, returning null if
@@ -746,7 +787,7 @@ class BaseMindooDB {
746
787
  let currentPage = 0;
747
788
  while (true) {
748
789
  if (options?.signal?.aborted) {
749
- return { transferred, cancelled: true };
790
+ return { transferred, scanned, cancelled: true };
750
791
  }
751
792
  const page = await sourceStore.scanEntriesSince(cursor, pageSize);
752
793
  currentPage++;
@@ -773,7 +814,7 @@ class BaseMindooDB {
773
814
  break;
774
815
  }
775
816
  }
776
- return { transferred, cancelled: false };
817
+ return { transferred, scanned, cancelled: false };
777
818
  }
778
819
  onProgress?.({
779
820
  phase: 'preparing',
@@ -784,10 +825,10 @@ class BaseMindooDB {
784
825
  const targetIds = await targetStore.getAllIds();
785
826
  const sourceNewMetadata = await sourceStore.findNewEntries(targetIds);
786
827
  if (sourceNewMetadata.length === 0) {
787
- return { transferred: 0, cancelled: false };
828
+ return { transferred: 0, scanned: 0, cancelled: false };
788
829
  }
789
830
  if (options?.signal?.aborted) {
790
- return { transferred: 0, cancelled: true };
831
+ return { transferred: 0, scanned: 0, cancelled: true };
791
832
  }
792
833
  onProgress?.({
793
834
  phase: 'transferring',
@@ -805,7 +846,11 @@ class BaseMindooDB {
805
846
  scannedEntries: sourceNewMetadata.length,
806
847
  totalSourceEntries: sourceNewMetadata.length,
807
848
  });
808
- return { transferred: sourceEntries.length, cancelled: false };
849
+ return {
850
+ transferred: sourceEntries.length,
851
+ scanned: sourceNewMetadata.length,
852
+ cancelled: false,
853
+ };
809
854
  }
810
855
  /**
811
856
  * Dense sync: transfer only the entries required to reconstruct the latest
@@ -832,7 +877,7 @@ class BaseMindooDB {
832
877
  scannedEntries: 0,
833
878
  });
834
879
  if (options?.signal?.aborted) {
835
- return { transferred: 0, cancelled: true };
880
+ return { transferred: 0, scanned: 0, cancelled: true };
836
881
  }
837
882
  // ── Phase 1: discover documents ──────────────────────────────────
838
883
  const docCreateEntries = await sourceStore.findEntries("doc_create", null, null);
@@ -840,10 +885,10 @@ class BaseMindooDB {
840
885
  const docIds = [...new Set(docCreateEntries.map((e) => e.docId))];
841
886
  this.logger.info(`Dense sync: found ${docIds.length} documents on source`);
842
887
  if (docIds.length === 0) {
843
- return { transferred: 0, cancelled: false };
888
+ return { transferred: 0, scanned: 0, cancelled: false };
844
889
  }
845
890
  if (options?.signal?.aborted) {
846
- return { transferred: 0, cancelled: true };
891
+ return { transferred: 0, scanned: 0, cancelled: true };
847
892
  }
848
893
  // ── Phase 2: batch plan on source ────────────────────────────────
849
894
  onProgress?.({
@@ -872,7 +917,7 @@ class BaseMindooDB {
872
917
  this.logger.info(`Dense sync: planner identified ${neededIds.size} required entries ` +
873
918
  `(from ${docIds.length} documents)`);
874
919
  if (options?.signal?.aborted) {
875
- return { transferred: 0, cancelled: true };
920
+ return { transferred: 0, scanned: neededIds.size, cancelled: true };
876
921
  }
877
922
  // ── Phase 4: filter out entries the target already has ───────────
878
923
  const allNeededArray = Array.from(neededIds);
@@ -881,17 +926,17 @@ class BaseMindooDB {
881
926
  this.logger.info(`Dense sync: ${missingIds.length} entries to transfer ` +
882
927
  `(${allNeededArray.length - missingIds.length} already present)`);
883
928
  if (missingIds.length === 0) {
884
- return { transferred: 0, cancelled: false };
929
+ return { transferred: 0, scanned: allNeededArray.length, cancelled: false };
885
930
  }
886
931
  if (options?.signal?.aborted) {
887
- return { transferred: 0, cancelled: true };
932
+ return { transferred: 0, scanned: allNeededArray.length, cancelled: true };
888
933
  }
889
934
  // ── Phase 5: transfer missing entries in pages ───────────────────
890
935
  const pageSize = options?.pageSize ?? 500;
891
936
  let transferred = 0;
892
937
  for (let offset = 0; offset < missingIds.length; offset += pageSize) {
893
938
  if (options?.signal?.aborted) {
894
- return { transferred, cancelled: true };
939
+ return { transferred, scanned: allNeededArray.length, cancelled: true };
895
940
  }
896
941
  const batch = missingIds.slice(offset, offset + pageSize);
897
942
  const entries = await sourceStore.getEntries(batch);
@@ -906,7 +951,7 @@ class BaseMindooDB {
906
951
  });
907
952
  }
908
953
  this.logger.info(`Dense sync complete: transferred ${transferred} entries`);
909
- return { transferred, cancelled: false };
954
+ return { transferred, scanned: allNeededArray.length, cancelled: false };
910
955
  }
911
956
  getStore() {
912
957
  return this.store;
@@ -1067,9 +1112,15 @@ class BaseMindooDB {
1067
1112
  this.logger.debug(`Getting document ${docId} at timestamp ${timestamp}`);
1068
1113
  // Get all entry metadata for this document
1069
1114
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
1070
- // Filter entries up to the timestamp
1115
+ // Filter to document replay metadata plus snapshots up to the timestamp.
1116
+ // Attachment chunks are not part of the Automerge replay DAG and must not
1117
+ // participate in historical materialization.
1071
1118
  const relevantEntries = allEntryMetadata
1072
- .filter(em => em.createdAt <= timestamp)
1119
+ .filter((em) => em.createdAt <= timestamp
1120
+ && (em.entryType === "doc_create"
1121
+ || em.entryType === "doc_change"
1122
+ || em.entryType === "doc_delete"
1123
+ || em.entryType === "doc_snapshot"))
1073
1124
  .sort((a, b) => a.createdAt - b.createdAt);
1074
1125
  if (relevantEntries.length === 0) {
1075
1126
  this.performanceCallback?.onHistoryOperation?.({
@@ -1081,43 +1132,85 @@ class BaseMindooDB {
1081
1132
  });
1082
1133
  return null; // Document didn't exist at that time
1083
1134
  }
1084
- // Load entries
1085
- const entries = await this.store.getEntries(relevantEntries.map(em => em.id));
1086
- // Apply changes in order
1087
- let doc = automerge_adapter_1.Automerge.init();
1088
- for (const entryData of entries) {
1089
- // Admin-only mode: only accept entries signed by the admin key
1135
+ const metadataById = new Map(relevantEntries.map((meta) => [meta.id, meta]));
1136
+ const materializationPlan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, relevantEntries);
1137
+ let startFromSnapshot = materializationPlan.snapshotEntryId !== null;
1138
+ const snapshotMeta = materializationPlan.snapshotEntryId
1139
+ ? (metadataById.get(materializationPlan.snapshotEntryId) || null)
1140
+ : null;
1141
+ if (startFromSnapshot && !snapshotMeta) {
1142
+ this.logger.warn(`Planner referenced snapshot ${materializationPlan.snapshotEntryId} not found in metadata for ${docId}; falling back to replay without snapshot`);
1143
+ startFromSnapshot = false;
1144
+ }
1145
+ let doc = undefined;
1146
+ if (startFromSnapshot && snapshotMeta) {
1147
+ const snapshotEntries = await this.store.getEntries([snapshotMeta.id]);
1148
+ if (snapshotEntries.length > 0) {
1149
+ const snapshotData = snapshotEntries[0];
1150
+ let isValid = false;
1151
+ if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
1152
+ this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
1153
+ }
1154
+ else {
1155
+ isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
1156
+ }
1157
+ if (!isValid) {
1158
+ this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to replay without snapshot`);
1159
+ startFromSnapshot = false;
1160
+ }
1161
+ else {
1162
+ const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
1163
+ doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
1164
+ const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
1165
+ if (parsed) {
1166
+ this.registerAutomergeHashMapping(docId, parsed.automergeHash, snapshotData.id);
1167
+ }
1168
+ }
1169
+ }
1170
+ }
1171
+ if (!doc) {
1172
+ doc = automerge_adapter_1.Automerge.init();
1173
+ }
1174
+ const entriesToApply = materializationPlan.entryIdsToApply
1175
+ .map((id) => metadataById.get(id))
1176
+ .filter((entry) => entry !== undefined);
1177
+ const loadedEntries = entriesToApply.length > 0
1178
+ ? await this.store.getEntries(entriesToApply.map((entry) => entry.id))
1179
+ : [];
1180
+ const entryById = new Map(loadedEntries.map((entry) => [entry.id, entry]));
1181
+ for (const entryMeta of entriesToApply) {
1182
+ const entryData = entryById.get(entryMeta.id);
1183
+ if (!entryData) {
1184
+ this.logger.warn(`Entry ${entryMeta.id} not found in store, skipping`);
1185
+ continue;
1186
+ }
1090
1187
  if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
1091
1188
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
1092
1189
  continue;
1093
1190
  }
1094
- // Verify signature against the encrypted payload (no decryption needed)
1095
- // We sign the encrypted payload, so anyone can verify signatures without decryption keys
1096
1191
  const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
1097
1192
  if (!isValid) {
1098
1193
  this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
1099
1194
  continue;
1100
1195
  }
1101
- // Decrypt payload (only after signature verification passes)
1102
1196
  const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
1103
- // Apply change using loadIncremental - this is the recommended way for binary change data
1104
1197
  doc = automerge_adapter_1.Automerge.loadIncremental(doc, decryptedPayload);
1105
- // Register the automerge hash -> entry ID mapping for future dependency resolution
1106
1198
  const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
1107
1199
  if (parsed) {
1108
1200
  this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
1109
1201
  }
1110
1202
  }
1111
- // Check if document was deleted at this timestamp
1112
- // Look for a "doc_delete" type entry in the relevant entries
1113
- const deleteEntry = relevantEntries.find(em => em.entryType === "doc_delete" && em.createdAt <= timestamp);
1114
- const isDeleted = deleteEntry !== undefined;
1115
- // Find the first entry to get createdAt and decryptionKeyId
1116
- const firstEntry = relevantEntries.length > 0 ? relevantEntries[0] : null;
1117
- const createdAt = firstEntry ? firstEntry.createdAt : timestamp;
1118
- const decryptionKeyId = firstEntry ? firstEntry.decryptionKeyId : "default";
1119
- // Use delete entry timestamp as lastModified if deleted, otherwise use the requested timestamp
1120
- const lastModified = isDeleted && deleteEntry ? deleteEntry.createdAt : timestamp;
1203
+ const replayEntries = relevantEntries.filter((entry) => entry.entryType === "doc_create"
1204
+ || entry.entryType === "doc_change"
1205
+ || entry.entryType === "doc_delete");
1206
+ const firstReplayEntry = replayEntries.length > 0 ? replayEntries[0] : null;
1207
+ const lastReplayEntry = replayEntries.length > 0 ? replayEntries[replayEntries.length - 1] : null;
1208
+ const createdAt = firstReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? timestamp;
1209
+ const decryptionKeyId = firstReplayEntry?.decryptionKeyId
1210
+ ?? snapshotMeta?.decryptionKeyId
1211
+ ?? "default";
1212
+ const isDeleted = lastReplayEntry?.entryType === "doc_delete";
1213
+ const lastModified = lastReplayEntry?.createdAt ?? snapshotMeta?.createdAt ?? timestamp;
1121
1214
  const internalDoc = {
1122
1215
  id: docId,
1123
1216
  doc,
@@ -1287,6 +1380,15 @@ class BaseMindooDB {
1287
1380
  }
1288
1381
  return docIds;
1289
1382
  }
1383
+ async getDeletedDocumentIds() {
1384
+ const docIds = [];
1385
+ for (const entry of this.index) {
1386
+ if (entry.isDeleted) {
1387
+ docIds.push(entry.docId);
1388
+ }
1389
+ }
1390
+ return docIds;
1391
+ }
1290
1392
  async getAllDocumentIdsAtTimestamp(timestamp) {
1291
1393
  // findEntries() uses an exclusive upper bound (createdAt < creationDateUntil).
1292
1394
  // Query up to timestamp + 1 so entries created exactly at `timestamp` are included
@@ -2012,10 +2114,10 @@ class BaseMindooDB {
2012
2114
  this.logger.debug(`No change entries to apply for document ${docId}`);
2013
2115
  return null; // No changes
2014
2116
  }
2015
- // Sort entries by timestamp
2016
- entriesToApply.sort((a, b) => a.createdAt - b.createdAt);
2117
+ const entriesById = new Map(entriesToApply.map((entry) => [entry.id, entry]));
2118
+ const orderedEntryIds = (0, MaterializationPlanner_1.topologicalByDependencies)(new Set(entriesToApply.map((entry) => entry.id)), entriesById);
2017
2119
  // Load entries from store
2018
- const entries = await this.store.getEntries(entriesToApply.map(em => em.id));
2120
+ const entries = await this.store.getEntries(orderedEntryIds);
2019
2121
  // Filter entries for admin-only mode first
2020
2122
  const validEntries = entries.filter(entryData => {
2021
2123
  if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
@@ -2103,9 +2205,9 @@ class BaseMindooDB {
2103
2205
  this.logger.debug(`Document ${docId} changed: heads before=${JSON.stringify(headsBefore)}, after=${JSON.stringify(headsAfter)}`);
2104
2206
  // Update metadata
2105
2207
  const payload = updatedDoc;
2106
- const lastEntry = entries[entries.length - 1];
2208
+ const lastEntryCreatedAt = entries.reduce((maxCreatedAt, entry) => Math.max(maxCreatedAt, entry.createdAt), cachedDoc.lastModified);
2107
2209
  const lastModified = payload._lastModified ||
2108
- (lastEntry ? lastEntry.createdAt : cachedDoc.lastModified);
2210
+ lastEntryCreatedAt;
2109
2211
  // Check if document was deleted
2110
2212
  const hasDeleteEntry = newEntryMetadata.some(em => em.entryType === "doc_delete");
2111
2213
  const isDeleted = hasDeleteEntry || cachedDoc.isDeleted;
@@ -2556,6 +2658,39 @@ class BaseMindooDB {
2556
2658
  }
2557
2659
  return ref;
2558
2660
  }
2661
+ async planAttachmentRead(store, ref, startByte, endByteExclusive) {
2662
+ if (store.planAttachmentReadByWalkingMetadata) {
2663
+ try {
2664
+ // if the store has a planAttachmentReadByWalkingMetadata method, use it for less overhead through the network
2665
+ return await store.planAttachmentReadByWalkingMetadata(ref.lastChunkId, ref.size, {
2666
+ startByte,
2667
+ endByteExclusive,
2668
+ });
2669
+ }
2670
+ catch (error) {
2671
+ this.logger.debug("Store-level attachment read planner failed, falling back to local metadata walk", { attachmentId: ref.attachmentId, error });
2672
+ }
2673
+ }
2674
+ // fall back to local metadata walk
2675
+ return (0, AttachmentReadPlanner_1.planAttachmentReadByWalkingMetadata)(store, ref.lastChunkId, ref.size, {
2676
+ startByte,
2677
+ endByteExclusive,
2678
+ });
2679
+ }
2680
+ async decryptAttachmentChunk(chunk) {
2681
+ if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
2682
+ throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
2683
+ }
2684
+ const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
2685
+ if (!isValid) {
2686
+ throw new Error(`Invalid signature for chunk ${chunk.id}`);
2687
+ }
2688
+ const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2689
+ if (plaintext.length !== chunk.originalSize) {
2690
+ throw new Error(`Attachment chunk ${chunk.id} decrypted to ${plaintext.length} bytes, expected ${chunk.originalSize}`);
2691
+ }
2692
+ return plaintext;
2693
+ }
2559
2694
  /**
2560
2695
  * Internal method to fetch and concatenate all chunks for an attachment.
2561
2696
  */
@@ -2572,17 +2707,7 @@ class BaseMindooDB {
2572
2707
  const plaintextChunks = [];
2573
2708
  let totalSize = 0;
2574
2709
  for (const chunk of chunks) {
2575
- // Admin-only mode: only accept chunks signed by the admin key
2576
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
2577
- throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
2578
- }
2579
- // Verify signature
2580
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
2581
- if (!isValid) {
2582
- throw new Error(`Invalid signature for chunk ${chunk.id}`);
2583
- }
2584
- // Decrypt
2585
- const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2710
+ const plaintext = await this.decryptAttachmentChunk(chunk);
2586
2711
  plaintextChunks.push(plaintext);
2587
2712
  totalSize += plaintext.length;
2588
2713
  }
@@ -2605,37 +2730,16 @@ class BaseMindooDB {
2605
2730
  throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
2606
2731
  }
2607
2732
  const ref = this.getAttachmentRefInternal(docId, attachmentId);
2608
- if (endByte > ref.size) {
2609
- throw new Error(`End byte ${endByte} exceeds attachment size ${ref.size}`);
2610
- }
2611
2733
  const store = this.getEffectiveAttachmentStore();
2612
- const chunkSize = this.chunkSizeBytes;
2613
- // Calculate which chunks we need
2614
- const startChunkIndex = Math.floor(startByte / chunkSize);
2615
- const endChunkIndex = Math.floor((endByte - 1) / chunkSize);
2616
- // Resolve dependency chain to get all chunk IDs
2617
- const allChunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
2618
- // Get only the chunks we need
2619
- const neededChunkIds = allChunkIds.slice(startChunkIndex, endChunkIndex + 1);
2734
+ const readPlan = await this.planAttachmentRead(store, ref, startByte, endByte);
2735
+ const neededChunkIds = readPlan.chunkPlans.map((chunkPlan) => chunkPlan.id);
2620
2736
  const chunks = await store.getEntries(neededChunkIds);
2621
2737
  // Decrypt needed chunks
2622
2738
  const plaintextChunks = [];
2623
2739
  for (const chunk of chunks) {
2624
- // Admin-only mode: only accept chunks signed by the admin key
2625
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
2626
- throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
2627
- }
2628
- // Verify signature
2629
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
2630
- if (!isValid) {
2631
- throw new Error(`Invalid signature for chunk ${chunk.id}`);
2632
- }
2633
- // Decrypt
2634
- const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2740
+ const plaintext = await this.decryptAttachmentChunk(chunk);
2635
2741
  plaintextChunks.push(plaintext);
2636
2742
  }
2637
- // Calculate offsets within the fetched chunks
2638
- const offsetInFirstChunk = startByte - (startChunkIndex * chunkSize);
2639
2743
  const totalNeededBytes = endByte - startByte;
2640
2744
  // Extract the requested range
2641
2745
  const result = new Uint8Array(totalNeededBytes);
@@ -2643,7 +2747,7 @@ class BaseMindooDB {
2643
2747
  let bytesRemaining = totalNeededBytes;
2644
2748
  for (let i = 0; i < plaintextChunks.length && bytesRemaining > 0; i++) {
2645
2749
  const chunk = plaintextChunks[i];
2646
- const chunkStart = i === 0 ? offsetInFirstChunk : 0;
2750
+ const chunkStart = i === 0 ? readPlan.offsetInFirstChunk : 0;
2647
2751
  const bytesToCopy = Math.min(chunk.length - chunkStart, bytesRemaining);
2648
2752
  result.set(chunk.slice(chunkStart, chunkStart + bytesToCopy), resultOffset);
2649
2753
  resultOffset += bytesToCopy;
@@ -2658,30 +2762,25 @@ class BaseMindooDB {
2658
2762
  async *streamAttachmentInternal(docId, attachmentId, startOffset) {
2659
2763
  this.logger.debug(`Streaming attachment ${attachmentId} from offset ${startOffset}`);
2660
2764
  const ref = this.getAttachmentRefInternal(docId, attachmentId);
2765
+ if (startOffset < 0 || startOffset > ref.size) {
2766
+ throw new Error(`Invalid stream offset ${startOffset} for attachment size ${ref.size}`);
2767
+ }
2768
+ if (ref.size === 0 || startOffset === ref.size) {
2769
+ return;
2770
+ }
2661
2771
  const store = this.getEffectiveAttachmentStore();
2662
- const chunkSize = this.chunkSizeBytes;
2663
- // Calculate starting chunk
2664
- const startChunkIndex = Math.floor(startOffset / chunkSize);
2665
- const offsetInStartChunk = startOffset % chunkSize;
2666
- // Resolve dependency chain to get all chunk IDs
2667
- const allChunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
2772
+ const readPlan = await this.planAttachmentRead(store, ref, startOffset, ref.size);
2668
2773
  // Stream chunks starting from startChunkIndex
2669
- for (let i = startChunkIndex; i < allChunkIds.length; i++) {
2670
- const [chunk] = await store.getEntries([allChunkIds[i]]);
2671
- // Admin-only mode: only accept chunks signed by the admin key
2672
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
2673
- throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
2674
- }
2675
- // Verify signature
2676
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
2677
- if (!isValid) {
2678
- throw new Error(`Invalid signature for chunk ${chunk.id}`);
2774
+ for (let i = 0; i < readPlan.chunkPlans.length; i++) {
2775
+ const chunkPlan = readPlan.chunkPlans[i];
2776
+ const [chunk] = await store.getEntries([chunkPlan.id]);
2777
+ if (!chunk) {
2778
+ throw new Error(`Attachment chunk ${chunkPlan.id} not found in store`);
2679
2779
  }
2680
- // Decrypt
2681
- const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2780
+ const plaintext = await this.decryptAttachmentChunk(chunk);
2682
2781
  // For first chunk, skip bytes before startOffset
2683
- if (i === startChunkIndex && offsetInStartChunk > 0) {
2684
- yield plaintext.slice(offsetInStartChunk);
2782
+ if (i === 0 && readPlan.offsetInFirstChunk > 0) {
2783
+ yield plaintext.slice(readPlan.offsetInFirstChunk);
2685
2784
  }
2686
2785
  else {
2687
2786
  yield plaintext;
@@ -2966,11 +3065,15 @@ class BaseMindooDB {
2966
3065
  this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
2967
3066
  if (syncResult.cancelled) {
2968
3067
  this.logger.info(`Pull cancelled after transferring ${syncResult.transferred} entries`);
2969
- return { transferredEntries: syncResult.transferred, cancelled: true };
3068
+ return {
3069
+ transferredEntries: syncResult.transferred,
3070
+ scannedEntries: syncResult.scanned,
3071
+ cancelled: true,
3072
+ };
2970
3073
  }
2971
3074
  if (syncResult.transferred === 0) {
2972
3075
  this.logger.debug(`No new entries to pull`);
2973
- return { transferredEntries: 0, cancelled: false };
3076
+ return { transferredEntries: 0, scannedEntries: syncResult.scanned, cancelled: false };
2974
3077
  }
2975
3078
  options?.onProgress?.({
2976
3079
  phase: 'processing',
@@ -2988,7 +3091,11 @@ class BaseMindooDB {
2988
3091
  transferredEntries: syncResult.transferred,
2989
3092
  scannedEntries: syncResult.transferred,
2990
3093
  });
2991
- return { transferredEntries: syncResult.transferred, cancelled: false };
3094
+ return {
3095
+ transferredEntries: syncResult.transferred,
3096
+ scannedEntries: syncResult.scanned,
3097
+ cancelled: false,
3098
+ };
2992
3099
  }
2993
3100
  finally {
2994
3101
  restoreAuthOverride();
@@ -3018,7 +3125,11 @@ class BaseMindooDB {
3018
3125
  this.logger.debug(`Transferred ${syncResult.transferred} entries to remote store`);
3019
3126
  if (syncResult.cancelled) {
3020
3127
  this.logger.info(`Push cancelled after transferring ${syncResult.transferred} entries`);
3021
- return { transferredEntries: syncResult.transferred, cancelled: true };
3128
+ return {
3129
+ transferredEntries: syncResult.transferred,
3130
+ scannedEntries: syncResult.scanned,
3131
+ cancelled: true,
3132
+ };
3022
3133
  }
3023
3134
  if (syncResult.transferred === 0) {
3024
3135
  this.logger.debug(`No new entries to push`);
@@ -3032,7 +3143,11 @@ class BaseMindooDB {
3032
3143
  transferredEntries: syncResult.transferred,
3033
3144
  scannedEntries: syncResult.transferred,
3034
3145
  });
3035
- return { transferredEntries: syncResult.transferred, cancelled: false };
3146
+ return {
3147
+ transferredEntries: syncResult.transferred,
3148
+ scannedEntries: syncResult.scanned,
3149
+ cancelled: false,
3150
+ };
3036
3151
  }
3037
3152
  finally {
3038
3153
  restoreAuthOverride();