mindoodb 0.0.3 → 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 (214) hide show
  1. package/README-server.md +1030 -0
  2. package/README.md +14 -26
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +27 -0
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +270 -121
  6. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  7. package/dist/appendonlystores/network/HttpTransport.d.ts +15 -0
  8. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/HttpTransport.js +235 -10
  10. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +5 -0
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +40 -4
  14. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +10 -3
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +138 -13
  18. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
  19. package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts +21 -0
  20. package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts.map +1 -0
  21. package/dist/browser/cache/IndexedDBLocalCacheStore.js +106 -0
  22. package/dist/browser/cache/IndexedDBLocalCacheStore.js.map +1 -0
  23. package/dist/browser/index.d.ts +3 -0
  24. package/dist/browser/index.d.ts.map +1 -1
  25. package/dist/browser/index.js +9 -1
  26. package/dist/browser/index.js.map +1 -1
  27. package/dist/core/BaseMindooDB.d.ts +108 -13
  28. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  29. package/dist/core/BaseMindooDB.js +1268 -292
  30. package/dist/core/BaseMindooDB.js.map +1 -1
  31. package/dist/core/BaseMindooTenant.d.ts +31 -1
  32. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  33. package/dist/core/BaseMindooTenant.js +212 -129
  34. package/dist/core/BaseMindooTenant.js.map +1 -1
  35. package/dist/core/BaseMindooTenantDirectory.d.ts +13 -10
  36. package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
  37. package/dist/core/BaseMindooTenantDirectory.js +134 -27
  38. package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
  39. package/dist/core/BaseMindooTenantFactory.d.ts +5 -1
  40. package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
  41. package/dist/core/BaseMindooTenantFactory.js +46 -69
  42. package/dist/core/BaseMindooTenantFactory.js.map +1 -1
  43. package/dist/core/MindooDBServerAdmin.d.ts +241 -0
  44. package/dist/core/MindooDBServerAdmin.d.ts.map +1 -0
  45. package/dist/core/MindooDBServerAdmin.js +401 -0
  46. package/dist/core/MindooDBServerAdmin.js.map +1 -0
  47. package/dist/core/appendonlystores/AttachmentReadPlanner.d.ts +22 -0
  48. package/dist/core/appendonlystores/AttachmentReadPlanner.d.ts.map +1 -0
  49. package/dist/core/appendonlystores/AttachmentReadPlanner.js +124 -0
  50. package/dist/core/appendonlystores/AttachmentReadPlanner.js.map +1 -0
  51. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +10 -4
  52. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  53. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +44 -9
  54. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  55. package/dist/core/appendonlystores/MaterializationPlanner.d.ts +84 -0
  56. package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -0
  57. package/dist/core/appendonlystores/MaterializationPlanner.js +326 -0
  58. package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -0
  59. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +33 -0
  60. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  61. package/dist/core/appendonlystores/network/types.d.ts +35 -0
  62. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  63. package/dist/core/appendonlystores/network/types.js +2 -0
  64. package/dist/core/appendonlystores/network/types.js.map +1 -1
  65. package/dist/core/appendonlystores/types.d.ts +174 -3
  66. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  67. package/dist/core/cache/CacheManager.d.ts +75 -0
  68. package/dist/core/cache/CacheManager.d.ts.map +1 -0
  69. package/dist/core/cache/CacheManager.js +101 -0
  70. package/dist/core/cache/CacheManager.js.map +1 -0
  71. package/dist/core/cache/EncryptedLocalCacheStore.d.ts +41 -0
  72. package/dist/core/cache/EncryptedLocalCacheStore.d.ts.map +1 -0
  73. package/dist/core/cache/EncryptedLocalCacheStore.js +120 -0
  74. package/dist/core/cache/EncryptedLocalCacheStore.js.map +1 -0
  75. package/dist/core/cache/LocalCacheStore.d.ts +50 -0
  76. package/dist/core/cache/LocalCacheStore.d.ts.map +1 -0
  77. package/dist/core/cache/LocalCacheStore.js +39 -0
  78. package/dist/core/cache/LocalCacheStore.js.map +1 -0
  79. package/dist/core/crypto/privateKeyEncryption.d.ts +5 -0
  80. package/dist/core/crypto/privateKeyEncryption.d.ts.map +1 -0
  81. package/dist/core/crypto/privateKeyEncryption.js +87 -0
  82. package/dist/core/crypto/privateKeyEncryption.js.map +1 -0
  83. package/dist/core/index.d.ts +13 -6
  84. package/dist/core/index.d.ts.map +1 -1
  85. package/dist/core/index.js +13 -1
  86. package/dist/core/index.js.map +1 -1
  87. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +4 -1
  88. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  89. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +39 -11
  90. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  91. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.d.ts +1 -0
  92. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.d.ts.map +1 -1
  93. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.js +3 -0
  94. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.js.map +1 -1
  95. package/dist/core/indexing/virtualviews/VirtualView.d.ts +43 -0
  96. package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
  97. package/dist/core/indexing/virtualviews/VirtualView.js +173 -0
  98. package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
  99. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +7 -0
  100. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
  101. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +28 -0
  102. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
  103. package/dist/core/keys/KeyBag.d.ts +38 -14
  104. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  105. package/dist/core/keys/KeyBag.js +102 -76
  106. package/dist/core/keys/KeyBag.js.map +1 -1
  107. package/dist/core/keys/KeyContext.d.ts +3 -3
  108. package/dist/core/keys/KeyContext.d.ts.map +1 -1
  109. package/dist/core/keys/KeyContext.js +10 -4
  110. package/dist/core/keys/KeyContext.js.map +1 -1
  111. package/dist/core/types.d.ts +341 -22
  112. package/dist/core/types.d.ts.map +1 -1
  113. package/dist/core/types.js +7 -1
  114. package/dist/core/types.js.map +1 -1
  115. package/dist/core/utils/idGeneration.d.ts.map +1 -1
  116. package/dist/core/utils/idGeneration.js +37 -3
  117. package/dist/core/utils/idGeneration.js.map +1 -1
  118. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +31 -7
  119. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  120. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +139 -17
  121. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  122. package/dist/node/attachments/writeAttachmentToFile.d.ts +15 -0
  123. package/dist/node/attachments/writeAttachmentToFile.d.ts.map +1 -0
  124. package/dist/node/attachments/writeAttachmentToFile.js +73 -0
  125. package/dist/node/attachments/writeAttachmentToFile.js.map +1 -0
  126. package/dist/node/cache/FileSystemLocalCacheStore.d.ts +17 -0
  127. package/dist/node/cache/FileSystemLocalCacheStore.d.ts.map +1 -0
  128. package/dist/node/cache/FileSystemLocalCacheStore.js +128 -0
  129. package/dist/node/cache/FileSystemLocalCacheStore.js.map +1 -0
  130. package/dist/node/cli/change-identity-password.d.ts +7 -0
  131. package/dist/node/cli/change-identity-password.d.ts.map +1 -0
  132. package/dist/node/cli/change-identity-password.js +67 -0
  133. package/dist/node/cli/change-identity-password.js.map +1 -0
  134. package/dist/node/cli/cli-utils.d.ts +16 -0
  135. package/dist/node/cli/cli-utils.d.ts.map +1 -0
  136. package/dist/node/cli/cli-utils.js +113 -0
  137. package/dist/node/cli/cli-utils.js.map +1 -0
  138. package/dist/node/cli/identity-export-public.d.ts +4 -0
  139. package/dist/node/cli/identity-export-public.d.ts.map +1 -0
  140. package/dist/node/cli/identity-export-public.js +55 -0
  141. package/dist/node/cli/identity-export-public.js.map +1 -0
  142. package/dist/node/cli/identity-info.d.ts +4 -0
  143. package/dist/node/cli/identity-info.d.ts.map +1 -0
  144. package/dist/node/cli/identity-info.js +57 -0
  145. package/dist/node/cli/identity-info.js.map +1 -0
  146. package/dist/node/index.d.ts +1 -0
  147. package/dist/node/index.d.ts.map +1 -1
  148. package/dist/node/index.js +4 -1
  149. package/dist/node/index.js.map +1 -1
  150. package/dist/node/server/CapabilityMatcher.d.ts +45 -0
  151. package/dist/node/server/CapabilityMatcher.d.ts.map +1 -0
  152. package/dist/node/server/CapabilityMatcher.js +117 -0
  153. package/dist/node/server/CapabilityMatcher.js.map +1 -0
  154. package/dist/node/server/MindooDBServer.d.ts +94 -0
  155. package/dist/node/server/MindooDBServer.d.ts.map +1 -0
  156. package/dist/node/server/MindooDBServer.js +1256 -0
  157. package/dist/node/server/MindooDBServer.js.map +1 -0
  158. package/dist/node/server/ServerSync.d.ts +41 -0
  159. package/dist/node/server/ServerSync.d.ts.map +1 -0
  160. package/dist/node/server/ServerSync.js +187 -0
  161. package/dist/node/server/ServerSync.js.map +1 -0
  162. package/dist/node/server/StoreFactory.d.ts +45 -0
  163. package/dist/node/server/StoreFactory.d.ts.map +1 -0
  164. package/dist/node/server/StoreFactory.js +89 -0
  165. package/dist/node/server/StoreFactory.js.map +1 -0
  166. package/dist/node/server/SystemAdminAuth.d.ts +65 -0
  167. package/dist/node/server/SystemAdminAuth.d.ts.map +1 -0
  168. package/dist/node/server/SystemAdminAuth.js +200 -0
  169. package/dist/node/server/SystemAdminAuth.js.map +1 -0
  170. package/dist/node/server/SystemIpAllowlist.d.ts +21 -0
  171. package/dist/node/server/SystemIpAllowlist.d.ts.map +1 -0
  172. package/dist/node/server/SystemIpAllowlist.js +210 -0
  173. package/dist/node/server/SystemIpAllowlist.js.map +1 -0
  174. package/dist/node/server/TenantManager.d.ts +100 -0
  175. package/dist/node/server/TenantManager.d.ts.map +1 -0
  176. package/dist/node/server/TenantManager.js +615 -0
  177. package/dist/node/server/TenantManager.js.map +1 -0
  178. package/dist/node/server/add-to-network.d.ts +20 -0
  179. package/dist/node/server/add-to-network.d.ts.map +1 -0
  180. package/dist/node/server/add-to-network.js +275 -0
  181. package/dist/node/server/add-to-network.js.map +1 -0
  182. package/dist/node/server/config.d.ts +51 -0
  183. package/dist/node/server/config.d.ts.map +1 -0
  184. package/dist/node/server/config.js +218 -0
  185. package/dist/node/server/config.js.map +1 -0
  186. package/dist/node/server/resolveServerPassword.d.ts +7 -0
  187. package/dist/node/server/resolveServerPassword.d.ts.map +1 -0
  188. package/dist/node/server/resolveServerPassword.js +25 -0
  189. package/dist/node/server/resolveServerPassword.js.map +1 -0
  190. package/dist/node/server/server.d.ts +22 -0
  191. package/dist/node/server/server.d.ts.map +1 -0
  192. package/dist/node/server/server.js +225 -0
  193. package/dist/node/server/server.js.map +1 -0
  194. package/dist/node/server/serverinit.d.ts +21 -0
  195. package/dist/node/server/serverinit.d.ts.map +1 -0
  196. package/dist/node/server/serverinit.js +317 -0
  197. package/dist/node/server/serverinit.js.map +1 -0
  198. package/dist/node/server/types.d.ts +167 -0
  199. package/dist/node/server/types.d.ts.map +1 -0
  200. package/dist/node/server/types.js +24 -0
  201. package/dist/node/server/types.js.map +1 -0
  202. package/dist/node/server/validation.d.ts +47 -0
  203. package/dist/node/server/validation.d.ts.map +1 -0
  204. package/dist/node/server/validation.js +114 -0
  205. package/dist/node/server/validation.js.map +1 -0
  206. package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts +48 -0
  207. package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts.map +1 -0
  208. package/dist/reactnative/cache/MMKVLocalCacheStore.js +113 -0
  209. package/dist/reactnative/cache/MMKVLocalCacheStore.js.map +1 -0
  210. package/dist/reactnative/index.d.ts +1 -0
  211. package/dist/reactnative/index.d.ts.map +1 -1
  212. package/dist/reactnative/index.js +4 -1
  213. package/dist/reactnative/index.js.map +1 -1
  214. package/package.json +15 -3
@@ -7,6 +7,8 @@ exports.BaseMindooDB = void 0;
7
7
  const automerge_adapter_1 = require("./automerge-adapter");
8
8
  const uuid_1 = require("uuid");
9
9
  const bloom_1 = require("./appendonlystores/bloom");
10
+ const MaterializationPlanner_1 = require("./appendonlystores/MaterializationPlanner");
11
+ const AttachmentReadPlanner_1 = require("./appendonlystores/AttachmentReadPlanner");
10
12
  const idGeneration_1 = require("./utils/idGeneration");
11
13
  const errors_1 = require("./errors");
12
14
  const logging_1 = require("./logging");
@@ -14,6 +16,10 @@ const logging_1 = require("./logging");
14
16
  * Default chunk size for attachments: 256KB
15
17
  */
16
18
  const DEFAULT_CHUNK_SIZE_BYTES = 256 * 1024;
19
+ const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
20
+ const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
21
+ const DEFAULT_MAX_CACHED_DOCS = 128;
22
+ const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 0;
17
23
  /**
18
24
  * BaseMindooDB is a platform-agnostic implementation of MindooDB
19
25
  * that works in both browser and server environments.
@@ -34,12 +40,13 @@ const DEFAULT_CHUNK_SIZE_BYTES = 256 * 1024;
34
40
  * - Automerge.applyChanges(doc, changes) - verify method signature
35
41
  */
36
42
  class BaseMindooDB {
37
- constructor(tenant, store, attachmentStore, attachmentConfig, adminOnlyDb = false, logger, performanceCallback) {
38
- // Internal index: sorted array of document entries, maintained in order by (lastModified, docId)
39
- // This allows efficient incremental processing without sorting on each call
43
+ constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback) {
44
+ // Internal changefeed index: sorted by (changeSeq, docId) for deterministic iteration.
45
+ // lastModified remains available for UX metadata but is not the primary cursor key.
40
46
  this.index = [];
41
47
  // Lookup map for O(1) access to index entries by docId
42
48
  this.indexLookup = new Map(); // Map<docId, arrayIndex>
49
+ this.nextChangeSeq = 1;
43
50
  // Cache of loaded documents: Map<docId, InternalDoc>
44
51
  this.docCache = new Map();
45
52
  // Track which entry IDs we've already processed
@@ -51,11 +58,23 @@ class BaseMindooDB {
51
58
  // Cache for imported public keys (CryptoKey objects) to avoid re-importing the same key
52
59
  // Map<publicKeyPEM, CryptoKey>
53
60
  this.publicKeyCache = new Map();
61
+ // Local cache support
62
+ this.cacheManager = null;
63
+ this.cachePrefix = null;
64
+ this.dirtyDocIds = new Set();
65
+ this.cacheMetaDirty = false;
54
66
  this.tenant = tenant;
55
67
  this.store = store;
56
68
  this.attachmentStore = attachmentStore;
57
69
  this.chunkSizeBytes = attachmentConfig?.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES;
58
70
  this._isAdminOnlyDb = adminOnlyDb;
71
+ this.maxCachedDocs = Math.max(1, Math.floor(documentCacheConfig?.maxEntries ?? DEFAULT_MAX_CACHED_DOCS));
72
+ this.iteratePrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
73
+ DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS));
74
+ this.cacheRestoreLimit = Math.max(1, Math.floor(documentCacheConfig?.restoreLimit ?? this.maxCachedDocs));
75
+ this.reconcileRestoredIndexOnInit = documentCacheConfig?.reconcileRestoredIndexOnInit ?? false;
76
+ this.snapshotMinChanges = Math.max(1, Math.floor(snapshotConfig?.minChanges ?? DEFAULT_SNAPSHOT_MIN_CHANGES));
77
+ this.snapshotCooldownMs = Math.max(0, Math.floor(snapshotConfig?.cooldownMs ?? DEFAULT_SNAPSHOT_COOLDOWN_MS));
59
78
  // Create logger if not provided (for backward compatibility)
60
79
  this.logger =
61
80
  logger ||
@@ -72,6 +91,208 @@ class BaseMindooDB {
72
91
  isAdminOnlyDb() {
73
92
  return this._isAdminOnlyDb;
74
93
  }
94
+ // ---------------------------------------------------------------------------
95
+ // Local cache support (ICacheable)
96
+ // ---------------------------------------------------------------------------
97
+ /**
98
+ * Attach a CacheManager so this DB participates in periodic cache flushing.
99
+ */
100
+ setCacheManager(cacheManager) {
101
+ this.cacheManager = cacheManager;
102
+ const cacheIdentity = this.store.getCacheIdentity?.() ?? this.store.getId();
103
+ this.cachePrefix = `${this.tenant.getId()}/${cacheIdentity}`;
104
+ cacheManager.register(this);
105
+ }
106
+ getCachePrefix() {
107
+ return this.cachePrefix ?? `${this.tenant.getId()}/${this.store.getId()}`;
108
+ }
109
+ hasDirtyState() {
110
+ return this.dirtyDocIds.size > 0 || this.cacheMetaDirty;
111
+ }
112
+ clearDirty() {
113
+ this.dirtyDocIds.clear();
114
+ this.cacheMetaDirty = false;
115
+ this.evictCachedDocsIfNeeded();
116
+ }
117
+ markDocDirty(docId) {
118
+ this.dirtyDocIds.add(docId);
119
+ this.cacheMetaDirty = true;
120
+ this.cacheManager?.markDirty();
121
+ }
122
+ getCachedDocument(docId) {
123
+ const cached = this.docCache.get(docId) ?? null;
124
+ if (!cached) {
125
+ return null;
126
+ }
127
+ this.touchCachedDocument(docId, cached);
128
+ return cached;
129
+ }
130
+ touchCachedDocument(docId, internalDoc) {
131
+ this.docCache.delete(docId);
132
+ this.docCache.set(docId, internalDoc);
133
+ }
134
+ storeCachedDocument(internalDoc) {
135
+ this.touchCachedDocument(internalDoc.id, internalDoc);
136
+ this.evictCachedDocsIfNeeded(new Set([internalDoc.id]));
137
+ }
138
+ evictCachedDocsIfNeeded(protectedDocIds) {
139
+ if (this.docCache.size <= this.maxCachedDocs) {
140
+ return;
141
+ }
142
+ for (const [docId] of this.docCache) {
143
+ if (this.docCache.size <= this.maxCachedDocs) {
144
+ break;
145
+ }
146
+ if (protectedDocIds?.has(docId)) {
147
+ continue;
148
+ }
149
+ this.docCache.delete(docId);
150
+ this.dirtyDocIds.delete(docId);
151
+ }
152
+ }
153
+ /**
154
+ * Flush dirty documents and metadata to the cache store.
155
+ */
156
+ async flushToCache(store) {
157
+ const prefix = this.getCachePrefix();
158
+ let written = 0;
159
+ for (const docId of this.dirtyDocIds) {
160
+ const internal = this.docCache.get(docId);
161
+ if (!internal)
162
+ continue;
163
+ const amBinary = automerge_adapter_1.Automerge.save(internal.doc);
164
+ const header = JSON.stringify({
165
+ id: internal.id,
166
+ createdAt: internal.createdAt,
167
+ lastModified: internal.lastModified,
168
+ decryptionKeyId: internal.decryptionKeyId,
169
+ isDeleted: internal.isDeleted,
170
+ });
171
+ const headerBytes = new TextEncoder().encode(header);
172
+ // Format: 4-byte header length (big-endian) + header JSON + Automerge binary
173
+ const value = new Uint8Array(4 + headerBytes.length + amBinary.length);
174
+ const view = new DataView(value.buffer);
175
+ view.setUint32(0, headerBytes.length, false);
176
+ value.set(headerBytes, 4);
177
+ value.set(amBinary, 4 + headerBytes.length);
178
+ await store.put("doc", `${prefix}/${docId}`, value);
179
+ written++;
180
+ }
181
+ // Write metadata checkpoint
182
+ const meta = this.exportMetadataCheckpoint();
183
+ await store.put("db-meta", prefix, meta);
184
+ written++;
185
+ return written;
186
+ }
187
+ exportMetadataCheckpoint() {
188
+ const checkpoint = {
189
+ version: 2,
190
+ processedEntryCursor: this.processedEntryCursor,
191
+ index: this.index,
192
+ nextChangeSeq: this.nextChangeSeq,
193
+ };
194
+ // Serialize automergeHashToEntryId: Map<string, Map<string,string>> -> nested object
195
+ const hashMap = {};
196
+ for (const [docId, inner] of this.automergeHashToEntryId) {
197
+ hashMap[docId] = Object.fromEntries(inner);
198
+ }
199
+ checkpoint.automergeHashToEntryId = hashMap;
200
+ // For stores without cursor scan, include processedEntryIds
201
+ if (!this.supportsCursorScan(this.store)) {
202
+ checkpoint.processedEntryIds = this.processedEntryIds;
203
+ }
204
+ return new TextEncoder().encode(JSON.stringify(checkpoint));
205
+ }
206
+ /**
207
+ * Attempt to restore state from cache. Returns true on success.
208
+ */
209
+ async restoreFromCache(store) {
210
+ const prefix = this.getCachePrefix();
211
+ try {
212
+ // 1. Load metadata checkpoint
213
+ const metaBytes = await store.get("db-meta", prefix);
214
+ if (!metaBytes) {
215
+ this.logger.debug("No cache metadata found, will do full rebuild");
216
+ return false;
217
+ }
218
+ const checkpoint = JSON.parse(new TextDecoder().decode(metaBytes));
219
+ if (checkpoint.version !== 2) {
220
+ this.logger.warn(`Unknown cache version ${checkpoint.version}, ignoring cache`);
221
+ return false;
222
+ }
223
+ // 2. Restore metadata
224
+ this.processedEntryCursor = checkpoint.processedEntryCursor ?? null;
225
+ this.index = checkpoint.index ?? [];
226
+ this.nextChangeSeq = checkpoint.nextChangeSeq ?? 1;
227
+ if (checkpoint.processedEntryIds) {
228
+ this.processedEntryIds = checkpoint.processedEntryIds;
229
+ }
230
+ // Rebuild indexLookup from index
231
+ this.indexLookup.clear();
232
+ for (let i = 0; i < this.index.length; i++) {
233
+ if (typeof this.index[i].changeSeq !== "number") {
234
+ this.index[i].changeSeq = i + 1;
235
+ }
236
+ this.indexLookup.set(this.index[i].docId, i);
237
+ }
238
+ if (this.index.length > 0 && this.nextChangeSeq <= this.index.length) {
239
+ this.nextChangeSeq = Math.max(...this.index.map(e => e.changeSeq)) + 1;
240
+ }
241
+ // Restore automergeHashToEntryId
242
+ if (checkpoint.automergeHashToEntryId) {
243
+ this.automergeHashToEntryId.clear();
244
+ for (const [docId, inner] of Object.entries(checkpoint.automergeHashToEntryId)) {
245
+ this.automergeHashToEntryId.set(docId, new Map(Object.entries(inner)));
246
+ }
247
+ }
248
+ // 3. Load cached documents
249
+ const docIds = await store.list("doc");
250
+ const docPrefix = `${prefix}/`;
251
+ let restoredDocs = 0;
252
+ for (const id of docIds) {
253
+ if (restoredDocs >= this.cacheRestoreLimit) {
254
+ break;
255
+ }
256
+ if (!id.startsWith(docPrefix))
257
+ continue;
258
+ const docId = id.slice(docPrefix.length);
259
+ const docBytes = await store.get("doc", id);
260
+ if (!docBytes)
261
+ continue;
262
+ try {
263
+ const internal = this.deserializeDoc(docBytes);
264
+ this.storeCachedDocument(internal);
265
+ restoredDocs++;
266
+ }
267
+ catch (e) {
268
+ this.logger.warn(`Failed to restore cached doc ${docId}, will reload from store: ${e}`);
269
+ }
270
+ }
271
+ this.logger.info(`Restored ${restoredDocs} cached documents for ${this.store.getId()}`);
272
+ this.logger.info(`Restored ${restoredDocs} documents from cache for ${prefix}`);
273
+ return true;
274
+ }
275
+ catch (e) {
276
+ this.logger.warn(`Cache restore failed, will do full rebuild: ${e}`);
277
+ return false;
278
+ }
279
+ }
280
+ deserializeDoc(value) {
281
+ const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
282
+ const headerLen = view.getUint32(0, false);
283
+ const headerBytes = value.slice(4, 4 + headerLen);
284
+ const amBinary = value.slice(4 + headerLen);
285
+ const header = JSON.parse(new TextDecoder().decode(headerBytes));
286
+ const doc = automerge_adapter_1.Automerge.load(amBinary);
287
+ return {
288
+ id: header.id,
289
+ doc,
290
+ createdAt: header.createdAt,
291
+ lastModified: header.lastModified,
292
+ decryptionKeyId: header.decryptionKeyId,
293
+ isDeleted: header.isDeleted,
294
+ };
295
+ }
75
296
  /**
76
297
  * Get or import a CryptoKey for signature verification, with caching.
77
298
  * This avoids re-importing the same public key multiple times.
@@ -113,18 +334,18 @@ class BaseMindooDB {
113
334
  /**
114
335
  * Compare two index entries for sorting.
115
336
  * Returns negative if a < b, positive if a > b, 0 if equal.
116
- * Sorts by lastModified first, then by docId for uniqueness.
337
+ * Sorts by deterministic changeSeq first, then by docId for uniqueness.
117
338
  */
118
339
  compareIndexEntries(a, b) {
119
- if (a.lastModified !== b.lastModified) {
120
- return a.lastModified - b.lastModified;
340
+ if (a.changeSeq !== b.changeSeq) {
341
+ return a.changeSeq - b.changeSeq;
121
342
  }
122
343
  return a.docId.localeCompare(b.docId);
123
344
  }
124
345
  /**
125
346
  * Update the index entry for a document.
126
- * When a document changes, it's removed from its current position and inserted
127
- * at the correct sorted position to maintain order by (lastModified, docId).
347
+ * When a document changes, it gets a new monotonic sequence and is moved to
348
+ * maintain order by (changeSeq, docId).
128
349
  *
129
350
  * Optimized to only update lookup map for entries that actually moved.
130
351
  *
@@ -133,12 +354,14 @@ class BaseMindooDB {
133
354
  * @param isDeleted Whether the document is deleted
134
355
  */
135
356
  updateIndex(docId, lastModified, isDeleted) {
136
- const newEntry = { docId, lastModified, isDeleted };
357
+ const startedAt = Date.now();
358
+ const assignedSeq = this.nextChangeSeq;
359
+ const newEntry = { docId, changeSeq: assignedSeq, lastModified, isDeleted };
137
360
  const existingIndex = this.indexLookup.get(docId);
138
361
  // Check if the entry already exists and hasn't changed position
139
362
  if (existingIndex !== undefined) {
140
363
  const existingEntry = this.index[existingIndex];
141
- // If position hasn't changed (same lastModified and docId), no update needed
364
+ // If only metadata flags stayed identical and caller replays same state, skip.
142
365
  if (existingEntry.lastModified === lastModified && existingEntry.isDeleted === isDeleted) {
143
366
  return; // No change needed
144
367
  }
@@ -171,11 +394,17 @@ class BaseMindooDB {
171
394
  }
172
395
  // Insert at the correct position
173
396
  this.index.splice(insertIndex, 0, newEntry);
397
+ this.nextChangeSeq = assignedSeq + 1;
174
398
  // Update lookup map for entries from insertion point onwards
175
399
  // Only update entries that actually moved (from insertIndex to end)
176
400
  for (let i = insertIndex; i < this.index.length; i++) {
177
401
  this.indexLookup.set(this.index[i].docId, i);
178
402
  }
403
+ this.performanceCallback?.onIndexUpdate?.({
404
+ docId,
405
+ operation: existingIndex === undefined ? "insert" : "update",
406
+ time: Date.now() - startedAt,
407
+ });
179
408
  }
180
409
  /**
181
410
  * Register an automerge hash -> entry ID mapping for a document.
@@ -223,24 +452,76 @@ class BaseMindooDB {
223
452
  }
224
453
  /**
225
454
  * Initialize the database instance.
455
+ * If a cache store is available, attempts to restore from cache first,
456
+ * then processes only the delta. Falls back to full rebuild on cache miss.
226
457
  */
227
458
  async initialize() {
228
459
  this.logger.info(`Initializing database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
460
+ const cacheStore = this.cacheManager?.getStore();
461
+ if (cacheStore) {
462
+ const restored = await this.restoreFromCache(cacheStore);
463
+ if (restored) {
464
+ this.logger.info("Restored from cache, processing delta only");
465
+ await this.syncStoreChanges();
466
+ if (this.reconcileRestoredIndexOnInit) {
467
+ await this.reconcileRestoredIndexWithStore();
468
+ }
469
+ return;
470
+ }
471
+ }
229
472
  await this.syncStoreChanges();
230
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
+ }
231
502
  /**
232
503
  * Sync changes from the content-addressed store by finding new entries and processing them.
233
504
  * This method can be called multiple times to incrementally sync new entries.
234
505
  * On first call (when processedEntryIds is empty), it will process all entries.
235
506
  */
236
507
  async syncStoreChanges() {
508
+ const syncStartedAt = Date.now();
237
509
  this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
238
510
  this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
239
511
  // Find new entries that we haven't processed yet
240
- const newEntryMetadata = await this.getNewEntryMetadataForSync();
512
+ const { entries: newEntryMetadata, nextCursor } = await this.getNewEntryMetadataForSync();
241
513
  this.logger.debug(`Found ${newEntryMetadata.length} new entries`);
242
514
  if (newEntryMetadata.length === 0) {
515
+ this.processedEntryCursor = nextCursor;
243
516
  this.logger.debug(`No new entries to process`);
517
+ this.performanceCallback?.onSyncOperation?.({
518
+ operation: "findNewEntries",
519
+ time: Date.now() - syncStartedAt,
520
+ details: {
521
+ newEntryCount: 0,
522
+ processedEntryCount: this.processedEntryIds.length,
523
+ },
524
+ });
244
525
  return;
245
526
  }
246
527
  // Group new entries by document ID
@@ -257,10 +538,11 @@ class BaseMindooDB {
257
538
  this.logger.debug(`Processing ${entriesByDoc.size} documents with new entries`);
258
539
  // Helper function to process a single document
259
540
  const processDocument = async (docId, entryMetadataList) => {
541
+ const processStartedAt = Date.now();
260
542
  try {
261
543
  this.logger.debug(`===== Processing document ${docId} with ${entryMetadataList.length} new entry(s) in syncStoreChanges =====`);
262
544
  // Check if document is cached
263
- const cachedDoc = this.docCache.get(docId);
545
+ const cachedDoc = this.getCachedDocument(docId);
264
546
  let updatedDoc = null;
265
547
  if (cachedDoc) {
266
548
  // Document is cached - try incremental update
@@ -288,16 +570,37 @@ class BaseMindooDB {
288
570
  }
289
571
  }
290
572
  else {
291
- // Document not cached - load from scratch
292
- this.logger.debug(`Document ${docId} not in cache, loading from store`);
293
- updatedDoc = await this.loadDocumentInternal(docId);
294
- if (updatedDoc) {
295
- this.logger.debug(`Successfully loaded document ${docId}, updating index`);
296
- this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted);
297
- this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted})`);
573
+ // Metadata-first startup: avoid full materialization for uncached docs.
574
+ // Index based on doc-lifecycle entries (create/change/delete) plus
575
+ // doc_snapshot which may be the sole entry after a dense sync.
576
+ // Attachment-only batches should not trigger document index updates.
577
+ const docLifecycleEntries = entryMetadataList.filter((e) => e.entryType === "doc_create" ||
578
+ e.entryType === "doc_change" ||
579
+ e.entryType === "doc_delete" ||
580
+ e.entryType === "doc_snapshot");
581
+ if (docLifecycleEntries.length === 0) {
582
+ this.logger.debug(`Skipping metadata-first index for doc ${docId} — no document lifecycle entries (${entryMetadataList.length} attachment/other entries only)`);
298
583
  }
299
584
  else {
300
- this.logger.warn(`Document ${docId} returned null from loadDocumentInternal`);
585
+ // Before indexing, verify the user has the decryption key so that
586
+ // documents the user cannot access do not appear in getAllDocumentIds.
587
+ const representativeEntry = docLifecycleEntries[0];
588
+ const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
589
+ if (!keyAvailable) {
590
+ this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
591
+ }
592
+ else {
593
+ const mutationEntries = docLifecycleEntries.filter((e) => e.entryType === "doc_create" ||
594
+ e.entryType === "doc_change" ||
595
+ e.entryType === "doc_delete");
596
+ // Snapshots compact replay history but should not change the user-visible
597
+ // modification time when the original create/change/delete entries still exist.
598
+ const entriesForLastModified = mutationEntries.length > 0 ? mutationEntries : docLifecycleEntries;
599
+ const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
600
+ const isDeleted = mutationEntries.some((e) => e.entryType === "doc_delete");
601
+ this.updateIndex(docId, lastModified, isDeleted);
602
+ this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted})`);
603
+ }
301
604
  }
302
605
  }
303
606
  }
@@ -313,31 +616,46 @@ class BaseMindooDB {
313
616
  // Re-throw the error so we can see what's happening in the test
314
617
  throw error;
315
618
  }
316
- };
317
- // Process documents in parallel with concurrency limit (default: 10)
318
- const maxConcurrency = 10;
319
- const documentEntries = Array.from(entriesByDoc.entries());
320
- // Process in batches to limit concurrency
321
- const results = [];
322
- for (let i = 0; i < documentEntries.length; i += maxConcurrency) {
323
- const batch = documentEntries.slice(i, i + maxConcurrency);
324
- const batchResults = await Promise.allSettled(batch.map(([docId, entryMetadataList]) => processDocument(docId, entryMetadataList)));
325
- results.push(...batchResults);
326
- }
327
- // Check for errors (excluding SymmetricKeyNotFoundError which is handled gracefully)
328
- for (let i = 0; i < results.length; i++) {
329
- const result = results[i];
330
- if (result.status === 'rejected') {
331
- const error = result.reason;
332
- if (!(error instanceof errors_1.SymmetricKeyNotFoundError)) {
333
- // Re-throw non-key errors
334
- throw error;
335
- }
619
+ finally {
620
+ this.performanceCallback?.onSyncOperation?.({
621
+ operation: "processDocument",
622
+ time: Date.now() - processStartedAt,
623
+ details: {
624
+ docId,
625
+ entryCount: entryMetadataList.length,
626
+ cacheHit: this.docCache.has(docId),
627
+ },
628
+ });
336
629
  }
630
+ };
631
+ const documentEntries = Array.from(entriesByDoc.entries())
632
+ .sort((a, b) => {
633
+ const aMinCreatedAt = Math.min(...a[1].map((e) => e.createdAt));
634
+ const bMinCreatedAt = Math.min(...b[1].map((e) => e.createdAt));
635
+ if (aMinCreatedAt !== bMinCreatedAt) {
636
+ return aMinCreatedAt - bMinCreatedAt;
637
+ }
638
+ return a[0].localeCompare(b[0]);
639
+ });
640
+ // Deterministic sequential processing ensures stable changefeed ordering.
641
+ for (const [docId, entryMetadataList] of documentEntries) {
642
+ await processDocument(docId, entryMetadataList);
337
643
  }
338
644
  // Append new entry IDs to our processed list
339
645
  this.processedEntryIds.push(...newEntryMetadata.map(em => em.id));
646
+ this.processedEntryCursor = nextCursor;
647
+ this.cacheMetaDirty = true;
648
+ this.cacheManager?.markDirty();
340
649
  this.logger.debug(`Synced ${newEntryMetadata.length} new entries, index now has ${this.index.length} documents`);
650
+ this.performanceCallback?.onSyncOperation?.({
651
+ operation: "findNewEntries",
652
+ time: Date.now() - syncStartedAt,
653
+ details: {
654
+ newEntryCount: newEntryMetadata.length,
655
+ documentCount: entriesByDoc.size,
656
+ indexSize: this.index.length,
657
+ },
658
+ });
341
659
  }
342
660
  supportsCursorScan(store) {
343
661
  return typeof store.scanEntriesSince === "function";
@@ -362,8 +680,22 @@ class BaseMindooDB {
362
680
  return all;
363
681
  }
364
682
  async getNewEntryMetadataForSync() {
683
+ const startedAt = Date.now();
365
684
  if (!this.supportsCursorScan(this.store)) {
366
- return this.store.findNewEntries(this.processedEntryIds);
685
+ const result = await this.store.findNewEntries(this.processedEntryIds);
686
+ this.performanceCallback?.onSyncOperation?.({
687
+ operation: "findNewEntries",
688
+ time: Date.now() - startedAt,
689
+ details: {
690
+ mode: "knownIds",
691
+ resultCount: result.length,
692
+ processedEntryCount: this.processedEntryIds.length,
693
+ },
694
+ });
695
+ return {
696
+ entries: result,
697
+ nextCursor: this.processedEntryCursor,
698
+ };
367
699
  }
368
700
  const allNew = [];
369
701
  let cursor = this.processedEntryCursor;
@@ -375,68 +707,251 @@ class BaseMindooDB {
375
707
  break;
376
708
  }
377
709
  }
378
- this.processedEntryCursor = cursor;
379
- return allNew;
710
+ this.performanceCallback?.onSyncOperation?.({
711
+ operation: "findNewEntries",
712
+ time: Date.now() - startedAt,
713
+ details: {
714
+ mode: "cursorScan",
715
+ resultCount: allNew.length,
716
+ cursor,
717
+ },
718
+ });
719
+ return {
720
+ entries: allNew,
721
+ nextCursor: cursor,
722
+ };
380
723
  }
381
- async syncEntriesFromStore(sourceStore, targetStore) {
382
- let transferred = 0;
383
- let targetBloom = null;
384
- if (typeof targetStore.getIdBloomSummary === "function") {
385
- try {
386
- targetBloom = await targetStore.getIdBloomSummary();
387
- }
388
- catch (error) {
389
- this.logger.warn("Failed to get bloom summary from target store, falling back to exact checks", error);
724
+ /**
725
+ * Fetch the target store's bloom filter summary, returning null if
726
+ * unsupported or on error (callers fall back to exact checks).
727
+ */
728
+ async getTargetBloomSummary(targetStore) {
729
+ if (typeof targetStore.getIdBloomSummary !== "function") {
730
+ return null;
731
+ }
732
+ try {
733
+ return await targetStore.getIdBloomSummary();
734
+ }
735
+ catch (error) {
736
+ this.logger.warn("Failed to get bloom summary from target store, falling back to exact checks", error);
737
+ return null;
738
+ }
739
+ }
740
+ /**
741
+ * From a list of candidate IDs, return only those the target store is
742
+ * missing. Uses bloom-filter pre-screening when available, then falls
743
+ * back to exact `hasEntries` for the uncertain set.
744
+ */
745
+ async filterMissingIds(targetStore, candidateIds, bloom) {
746
+ let definitelyMissing = [];
747
+ let maybeExisting = candidateIds;
748
+ if (bloom) {
749
+ definitelyMissing = [];
750
+ maybeExisting = [];
751
+ for (const id of candidateIds) {
752
+ if ((0, bloom_1.bloomMightContainId)(bloom, id)) {
753
+ maybeExisting.push(id);
754
+ }
755
+ else {
756
+ definitelyMissing.push(id);
757
+ }
390
758
  }
391
759
  }
760
+ let missingIds = definitelyMissing;
761
+ if (maybeExisting.length > 0) {
762
+ const existing = await targetStore.hasEntries(maybeExisting);
763
+ const existingSet = new Set(existing);
764
+ missingIds = missingIds.concat(maybeExisting.filter((id) => !existingSet.has(id)));
765
+ }
766
+ return missingIds;
767
+ }
768
+ async syncEntriesFromStore(sourceStore, targetStore, options) {
769
+ if (options?.mode === "dense") {
770
+ return this.syncEntriesFromStoreDense(sourceStore, targetStore, options);
771
+ }
772
+ let transferred = 0;
773
+ let scanned = 0;
774
+ const onProgress = options?.onProgress;
775
+ const pageSize = options?.pageSize ?? 1000;
776
+ const targetBloom = await this.getTargetBloomSummary(targetStore);
777
+ const totalSourceEstimate = targetBloom?.totalIds;
392
778
  if (this.supportsCursorScan(sourceStore)) {
779
+ onProgress?.({
780
+ phase: 'preparing',
781
+ message: 'Preparing to sync entries...',
782
+ transferredEntries: 0,
783
+ scannedEntries: 0,
784
+ totalSourceEntries: totalSourceEstimate,
785
+ });
393
786
  let cursor = null;
787
+ let currentPage = 0;
394
788
  while (true) {
395
- const page = await sourceStore.scanEntriesSince(cursor, 1000);
789
+ if (options?.signal?.aborted) {
790
+ return { transferred, scanned, cancelled: true };
791
+ }
792
+ const page = await sourceStore.scanEntriesSince(cursor, pageSize);
793
+ currentPage++;
794
+ scanned += page.entries.length;
396
795
  if (page.entries.length > 0) {
397
796
  const ids = page.entries.map((m) => m.id);
398
- let definitelyMissing = [];
399
- let maybeExisting = ids;
400
- if (targetBloom) {
401
- definitelyMissing = [];
402
- maybeExisting = [];
403
- for (const id of ids) {
404
- if ((0, bloom_1.bloomMightContainId)(targetBloom, id)) {
405
- maybeExisting.push(id);
406
- }
407
- else {
408
- definitelyMissing.push(id);
409
- }
410
- }
411
- }
412
- let missingIds = definitelyMissing;
413
- if (maybeExisting.length > 0) {
414
- const existing = await targetStore.hasEntries(maybeExisting);
415
- const existingSet = new Set(existing);
416
- const maybeMissing = maybeExisting.filter((id) => !existingSet.has(id));
417
- missingIds = missingIds.concat(maybeMissing);
418
- }
797
+ const missingIds = await this.filterMissingIds(targetStore, ids, targetBloom);
419
798
  if (missingIds.length > 0) {
420
799
  const missingEntries = await sourceStore.getEntries(missingIds);
421
800
  await targetStore.putEntries(missingEntries);
422
801
  transferred += missingEntries.length;
423
802
  }
424
803
  }
804
+ onProgress?.({
805
+ phase: 'transferring',
806
+ message: `Transferred ${transferred} entries (page ${currentPage}, scanned ${scanned})`,
807
+ transferredEntries: transferred,
808
+ scannedEntries: scanned,
809
+ totalSourceEntries: totalSourceEstimate,
810
+ currentPage,
811
+ });
425
812
  cursor = page.nextCursor;
426
813
  if (!page.hasMore) {
427
814
  break;
428
815
  }
429
816
  }
430
- return transferred;
817
+ return { transferred, scanned, cancelled: false };
431
818
  }
819
+ onProgress?.({
820
+ phase: 'preparing',
821
+ message: 'Finding new entries...',
822
+ transferredEntries: 0,
823
+ scannedEntries: 0,
824
+ });
432
825
  const targetIds = await targetStore.getAllIds();
433
826
  const sourceNewMetadata = await sourceStore.findNewEntries(targetIds);
434
827
  if (sourceNewMetadata.length === 0) {
435
- return 0;
828
+ return { transferred: 0, scanned: 0, cancelled: false };
436
829
  }
830
+ if (options?.signal?.aborted) {
831
+ return { transferred: 0, scanned: 0, cancelled: true };
832
+ }
833
+ onProgress?.({
834
+ phase: 'transferring',
835
+ message: `Transferring ${sourceNewMetadata.length} entries...`,
836
+ transferredEntries: 0,
837
+ scannedEntries: sourceNewMetadata.length,
838
+ totalSourceEntries: sourceNewMetadata.length,
839
+ });
437
840
  const sourceEntries = await sourceStore.getEntries(sourceNewMetadata.map((m) => m.id));
438
841
  await targetStore.putEntries(sourceEntries);
439
- return sourceEntries.length;
842
+ onProgress?.({
843
+ phase: 'transferring',
844
+ message: `Transferred ${sourceEntries.length} entries`,
845
+ transferredEntries: sourceEntries.length,
846
+ scannedEntries: sourceNewMetadata.length,
847
+ totalSourceEntries: sourceNewMetadata.length,
848
+ });
849
+ return {
850
+ transferred: sourceEntries.length,
851
+ scanned: sourceNewMetadata.length,
852
+ cancelled: false,
853
+ };
854
+ }
855
+ /**
856
+ * Dense sync: transfer only the entries required to reconstruct the latest
857
+ * state of each document, using the batch materialization planner to skip
858
+ * historical entries already superseded by snapshots.
859
+ *
860
+ * Algorithm:
861
+ * 1. Discover all documents on the source via `doc_create` metadata.
862
+ * 2. Also fetch `doc_delete` metadata so deletion markers are transferred.
863
+ * 3. Ask the source's batch planner for the optimal replay set per document.
864
+ * 4. Merge all entry IDs: doc_create + doc_delete + snapshot + uncovered changes.
865
+ * 5. Filter out IDs the target already has (bloom + exact check).
866
+ * 6. Transfer only the missing entries.
867
+ *
868
+ * Attachment chunks are intentionally skipped — they are not part of the
869
+ * Automerge document DAG and can be fetched on demand when accessed.
870
+ */
871
+ async syncEntriesFromStoreDense(sourceStore, targetStore, options) {
872
+ const onProgress = options?.onProgress;
873
+ onProgress?.({
874
+ phase: "preparing",
875
+ message: "Dense sync: discovering documents on source...",
876
+ transferredEntries: 0,
877
+ scannedEntries: 0,
878
+ });
879
+ if (options?.signal?.aborted) {
880
+ return { transferred: 0, scanned: 0, cancelled: true };
881
+ }
882
+ // ── Phase 1: discover documents ──────────────────────────────────
883
+ const docCreateEntries = await sourceStore.findEntries("doc_create", null, null);
884
+ const docDeleteEntries = await sourceStore.findEntries("doc_delete", null, null);
885
+ const docIds = [...new Set(docCreateEntries.map((e) => e.docId))];
886
+ this.logger.info(`Dense sync: found ${docIds.length} documents on source`);
887
+ if (docIds.length === 0) {
888
+ return { transferred: 0, scanned: 0, cancelled: false };
889
+ }
890
+ if (options?.signal?.aborted) {
891
+ return { transferred: 0, scanned: 0, cancelled: true };
892
+ }
893
+ // ── Phase 2: batch plan on source ────────────────────────────────
894
+ onProgress?.({
895
+ phase: "planning",
896
+ message: `Dense sync: computing materialization plans for ${docIds.length} documents...`,
897
+ transferredEntries: 0,
898
+ scannedEntries: docIds.length,
899
+ });
900
+ const batchPlan = await sourceStore.planDocumentMaterializationBatch(docIds);
901
+ // ── Phase 3: collect needed entry IDs ────────────────────────────
902
+ const neededIds = new Set();
903
+ for (const entry of docCreateEntries) {
904
+ neededIds.add(entry.id);
905
+ }
906
+ for (const entry of docDeleteEntries) {
907
+ neededIds.add(entry.id);
908
+ }
909
+ for (const plan of batchPlan.plans) {
910
+ if (plan.snapshotEntryId) {
911
+ neededIds.add(plan.snapshotEntryId);
912
+ }
913
+ for (const id of plan.entryIdsToApply) {
914
+ neededIds.add(id);
915
+ }
916
+ }
917
+ this.logger.info(`Dense sync: planner identified ${neededIds.size} required entries ` +
918
+ `(from ${docIds.length} documents)`);
919
+ if (options?.signal?.aborted) {
920
+ return { transferred: 0, scanned: neededIds.size, cancelled: true };
921
+ }
922
+ // ── Phase 4: filter out entries the target already has ───────────
923
+ const allNeededArray = Array.from(neededIds);
924
+ const targetBloom = await this.getTargetBloomSummary(targetStore);
925
+ const missingIds = await this.filterMissingIds(targetStore, allNeededArray, targetBloom);
926
+ this.logger.info(`Dense sync: ${missingIds.length} entries to transfer ` +
927
+ `(${allNeededArray.length - missingIds.length} already present)`);
928
+ if (missingIds.length === 0) {
929
+ return { transferred: 0, scanned: allNeededArray.length, cancelled: false };
930
+ }
931
+ if (options?.signal?.aborted) {
932
+ return { transferred: 0, scanned: allNeededArray.length, cancelled: true };
933
+ }
934
+ // ── Phase 5: transfer missing entries in pages ───────────────────
935
+ const pageSize = options?.pageSize ?? 500;
936
+ let transferred = 0;
937
+ for (let offset = 0; offset < missingIds.length; offset += pageSize) {
938
+ if (options?.signal?.aborted) {
939
+ return { transferred, scanned: allNeededArray.length, cancelled: true };
940
+ }
941
+ const batch = missingIds.slice(offset, offset + pageSize);
942
+ const entries = await sourceStore.getEntries(batch);
943
+ await targetStore.putEntries(entries);
944
+ transferred += entries.length;
945
+ onProgress?.({
946
+ phase: "transferring",
947
+ message: `Dense sync: transferred ${transferred}/${missingIds.length} entries`,
948
+ transferredEntries: transferred,
949
+ scannedEntries: allNeededArray.length,
950
+ totalSourceEntries: allNeededArray.length,
951
+ });
952
+ }
953
+ this.logger.info(`Dense sync complete: transferred ${transferred} entries`);
954
+ return { transferred, scanned: allNeededArray.length, cancelled: false };
440
955
  }
441
956
  getStore() {
442
957
  return this.store;
@@ -575,8 +1090,9 @@ class BaseMindooDB {
575
1090
  isDeleted: false,
576
1091
  };
577
1092
  // Update cache and index
578
- this.docCache.set(docId, internalDoc);
1093
+ this.storeCachedDocument(internalDoc);
579
1094
  this.updateIndex(docId, internalDoc.lastModified, false);
1095
+ this.markDocDirty(docId);
580
1096
  this.logger.info(`Document ${docId} created successfully`);
581
1097
  this.logger.debug(`Document ${docId} cached and indexed (lastModified: ${internalDoc.lastModified})`);
582
1098
  return this.wrapDocument(internalDoc);
@@ -592,53 +1108,109 @@ class BaseMindooDB {
592
1108
  return this.wrapDocument(internalDoc);
593
1109
  }
594
1110
  async getDocumentAtTimestamp(docId, timestamp) {
1111
+ const startedAt = Date.now();
595
1112
  this.logger.debug(`Getting document ${docId} at timestamp ${timestamp}`);
596
1113
  // Get all entry metadata for this document
597
1114
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
598
- // 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.
599
1118
  const relevantEntries = allEntryMetadata
600
- .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"))
601
1124
  .sort((a, b) => a.createdAt - b.createdAt);
602
1125
  if (relevantEntries.length === 0) {
1126
+ this.performanceCallback?.onHistoryOperation?.({
1127
+ operation: "getDocumentAtTimestamp",
1128
+ docId,
1129
+ time: Date.now() - startedAt,
1130
+ scannedEntries: 0,
1131
+ bounded: false,
1132
+ });
603
1133
  return null; // Document didn't exist at that time
604
1134
  }
605
- // Load entries
606
- const entries = await this.store.getEntries(relevantEntries.map(em => em.id));
607
- // Apply changes in order
608
- let doc = automerge_adapter_1.Automerge.init();
609
- for (const entryData of entries) {
610
- // 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
+ }
611
1187
  if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
612
1188
  this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
613
1189
  continue;
614
1190
  }
615
- // Verify signature against the encrypted payload (no decryption needed)
616
- // We sign the encrypted payload, so anyone can verify signatures without decryption keys
617
1191
  const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
618
1192
  if (!isValid) {
619
1193
  this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
620
1194
  continue;
621
1195
  }
622
- // Decrypt payload (only after signature verification passes)
623
1196
  const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
624
- // Apply change using loadIncremental - this is the recommended way for binary change data
625
1197
  doc = automerge_adapter_1.Automerge.loadIncremental(doc, decryptedPayload);
626
- // Register the automerge hash -> entry ID mapping for future dependency resolution
627
1198
  const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
628
1199
  if (parsed) {
629
1200
  this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
630
1201
  }
631
1202
  }
632
- // Check if document was deleted at this timestamp
633
- // Look for a "doc_delete" type entry in the relevant entries
634
- const deleteEntry = relevantEntries.find(em => em.entryType === "doc_delete" && em.createdAt <= timestamp);
635
- const isDeleted = deleteEntry !== undefined;
636
- // Find the first entry to get createdAt and decryptionKeyId
637
- const firstEntry = relevantEntries.length > 0 ? relevantEntries[0] : null;
638
- const createdAt = firstEntry ? firstEntry.createdAt : timestamp;
639
- const decryptionKeyId = firstEntry ? firstEntry.decryptionKeyId : "default";
640
- // Use delete entry timestamp as lastModified if deleted, otherwise use the requested timestamp
641
- 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;
642
1214
  const internalDoc = {
643
1215
  id: docId,
644
1216
  doc,
@@ -647,6 +1219,14 @@ class BaseMindooDB {
647
1219
  decryptionKeyId,
648
1220
  isDeleted,
649
1221
  };
1222
+ this.performanceCallback?.onHistoryOperation?.({
1223
+ operation: "getDocumentAtTimestamp",
1224
+ docId,
1225
+ time: Date.now() - startedAt,
1226
+ scannedEntries: relevantEntries.length,
1227
+ returnedEntries: 1,
1228
+ bounded: false,
1229
+ });
650
1230
  return this.wrapDocument(internalDoc);
651
1231
  }
652
1232
  async *iterateDocumentHistory(docId) {
@@ -750,6 +1330,46 @@ class BaseMindooDB {
750
1330
  }
751
1331
  }
752
1332
  }
1333
+ async getDocumentHistoryPage(docId, options) {
1334
+ const startedAt = Date.now();
1335
+ const limit = Math.max(1, Math.floor(options?.limit ?? 100));
1336
+ const offset = Math.max(0, Math.floor(options?.cursor?.offset ?? 0));
1337
+ // This API stays metadata-only so large history views can page cheaply
1338
+ // without reconstructing every historical Automerge document state.
1339
+ const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
1340
+ const relevantEntries = allEntryMetadata
1341
+ .filter((em) => em.entryType === "doc_create" ||
1342
+ em.entryType === "doc_change" ||
1343
+ em.entryType === "doc_delete")
1344
+ .sort((a, b) => a.createdAt - b.createdAt);
1345
+ // Cursor paging is offset-based because the result is a bounded timeline view
1346
+ // over the doc's sorted change metadata, not a resumable store scan cursor.
1347
+ const slice = relevantEntries.slice(offset, offset + limit);
1348
+ const entries = slice.map((entry) => ({
1349
+ entryId: entry.id,
1350
+ entryType: entry.entryType,
1351
+ changeCreatedAt: entry.createdAt,
1352
+ changeCreatedByPublicKey: entry.createdByPublicKey,
1353
+ dependencyIds: [...entry.dependencyIds],
1354
+ isDeleted: entry.entryType === "doc_delete",
1355
+ }));
1356
+ const nextOffset = offset + entries.length;
1357
+ const hasMore = nextOffset < relevantEntries.length;
1358
+ const result = {
1359
+ entries,
1360
+ nextCursor: hasMore ? { offset: nextOffset } : null,
1361
+ hasMore,
1362
+ };
1363
+ this.performanceCallback?.onHistoryOperation?.({
1364
+ operation: "getDocumentHistoryPage",
1365
+ docId,
1366
+ time: Date.now() - startedAt,
1367
+ scannedEntries: relevantEntries.length,
1368
+ returnedEntries: entries.length,
1369
+ bounded: true,
1370
+ });
1371
+ return result;
1372
+ }
753
1373
  async getAllDocumentIds() {
754
1374
  // Return all non-deleted document IDs from index
755
1375
  const docIds = [];
@@ -760,6 +1380,15 @@ class BaseMindooDB {
760
1380
  }
761
1381
  return docIds;
762
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
+ }
763
1392
  async getAllDocumentIdsAtTimestamp(timestamp) {
764
1393
  // findEntries() uses an exclusive upper bound (createdAt < creationDateUntil).
765
1394
  // Query up to timestamp + 1 so entries created exactly at `timestamp` are included
@@ -836,6 +1465,7 @@ class BaseMindooDB {
836
1465
  if (!internalDoc || internalDoc.isDeleted) {
837
1466
  throw new Error(`Document ${docId} not found or already deleted`);
838
1467
  }
1468
+ const now = Date.now();
839
1469
  // Create deletion change by clearing all fields from the document
840
1470
  // This ensures Automerge produces actual change bytes
841
1471
  // The deletion is also tracked via the "doc_delete" type entry in the append-only store
@@ -846,6 +1476,7 @@ class BaseMindooDB {
846
1476
  }
847
1477
  // Mark as deleted for clarity
848
1478
  doc._deleted = true;
1479
+ doc._lastModified = now;
849
1480
  });
850
1481
  // Get the change bytes from the document
851
1482
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
@@ -885,7 +1516,7 @@ class BaseMindooDB {
885
1516
  contentHash,
886
1517
  docId,
887
1518
  dependencyIds,
888
- createdAt: Date.now(),
1519
+ createdAt: now,
889
1520
  createdByPublicKey,
890
1521
  decryptionKeyId: internalDoc.decryptionKeyId,
891
1522
  signature,
@@ -903,9 +1534,10 @@ class BaseMindooDB {
903
1534
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
904
1535
  // Update cache and index
905
1536
  internalDoc.isDeleted = true;
906
- internalDoc.lastModified = Date.now();
907
- this.docCache.set(docId, internalDoc);
1537
+ internalDoc.lastModified = now;
1538
+ this.storeCachedDocument(internalDoc);
908
1539
  this.updateIndex(docId, internalDoc.lastModified, true);
1540
+ this.markDocDirty(docId);
909
1541
  this.logger.info(`Document ${docId} deleted successfully`);
910
1542
  }
911
1543
  async changeDoc(doc, changeFunc) {
@@ -929,7 +1561,7 @@ class BaseMindooDB {
929
1561
  }
930
1562
  }
931
1563
  // Get internal document from cache or load it
932
- let internalDoc = this.docCache.get(docId);
1564
+ let internalDoc = this.getCachedDocument(docId);
933
1565
  if (!internalDoc) {
934
1566
  this.logger.debug(`Document ${docId} not in cache, loading from store`);
935
1567
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -1208,7 +1840,7 @@ class BaseMindooDB {
1208
1840
  contentHash,
1209
1841
  docId,
1210
1842
  dependencyIds,
1211
- createdAt: Date.now(),
1843
+ createdAt: now,
1212
1844
  createdByPublicKey,
1213
1845
  decryptionKeyId: internalDoc.decryptionKeyId,
1214
1846
  signature,
@@ -1226,98 +1858,242 @@ class BaseMindooDB {
1226
1858
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
1227
1859
  // Update cache and index
1228
1860
  internalDoc.doc = newDoc;
1229
- internalDoc.lastModified = Date.now();
1230
- this.docCache.set(docId, internalDoc);
1861
+ internalDoc.lastModified = now;
1862
+ this.storeCachedDocument(internalDoc);
1231
1863
  this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
1864
+ this.markDocDirty(docId);
1232
1865
  this.logger.info(`Document ${docId} ${useCustomKey ? 'changed with custom signing key' : 'changed'} successfully`);
1866
+ // Snapshot creation runs as a best-effort background optimization.
1867
+ await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
1868
+ ? { signingKeyPair, signingKeyPassword, createdByPublicKey }
1869
+ : { createdByPublicKey });
1233
1870
  }
1234
- async *iterateChangesSince(cursor) {
1235
- // Default to initial cursor if null is provided
1236
- const actualCursor = cursor ?? { lastModified: 0, docId: "" };
1237
- this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
1238
- // Find starting position using binary search
1239
- // We want to find the first entry that is greater than the cursor
1240
- let startIndex = 0;
1241
- if (this.index.length > 0) {
1242
- let left = 0;
1243
- let right = this.index.length - 1;
1244
- while (left <= right) {
1245
- const mid = Math.floor((left + right) / 2);
1246
- const entry = this.index[mid];
1247
- const cmp = this.compareIndexEntries({ docId: actualCursor.docId, lastModified: actualCursor.lastModified }, entry);
1248
- if (cmp < 0) {
1249
- right = mid - 1;
1250
- startIndex = mid;
1251
- }
1252
- else {
1253
- left = mid + 1;
1254
- startIndex = mid + 1;
1255
- }
1871
+ /**
1872
+ * Best-effort snapshot scheduling and writing.
1873
+ * A snapshot is written only when enough replay history has accumulated and
1874
+ * a cooldown window has elapsed, to avoid snapshot churn on hot documents.
1875
+ */
1876
+ async maybeWriteSnapshotForDocument(internalDoc, options) {
1877
+ const docId = internalDoc.id;
1878
+ try {
1879
+ const allMetadata = await this.scanAllMetadata(this.store, { docId });
1880
+ const replayEntries = allMetadata.filter((em) => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
1881
+ const snapshots = allMetadata
1882
+ .filter((em) => em.entryType === "doc_snapshot")
1883
+ .sort((a, b) => b.createdAt - a.createdAt);
1884
+ const latestSnapshot = snapshots[0] || null;
1885
+ const latestSnapshotAt = latestSnapshot?.createdAt ?? 0;
1886
+ const changesSinceSnapshot = replayEntries.filter((em) => em.createdAt > latestSnapshotAt).length;
1887
+ if (changesSinceSnapshot < this.snapshotMinChanges) {
1888
+ return;
1889
+ }
1890
+ if (latestSnapshot &&
1891
+ Date.now() - latestSnapshot.createdAt < this.snapshotCooldownMs) {
1892
+ return;
1893
+ }
1894
+ const headHashes = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
1895
+ const headEntryIds = this.resolveAutomergeDepsToEntryIds(docId, headHashes);
1896
+ const snapshotBytes = automerge_adapter_1.Automerge.save(internalDoc.doc);
1897
+ const encryptedPayload = await this.tenant.encryptPayload(snapshotBytes, internalDoc.decryptionKeyId);
1898
+ const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
1899
+ const pseudoSnapshotHash = `snapshot-${(0, uuid_1.v7)()}`;
1900
+ const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, pseudoSnapshotHash, headHashes, this.getSubtle());
1901
+ let signature;
1902
+ if (options.signingKeyPair && options.signingKeyPassword) {
1903
+ signature = await this.tenant.signPayloadWithKey(encryptedPayload, options.signingKeyPair, options.signingKeyPassword);
1256
1904
  }
1257
- // If we found an exact match, start from the next entry
1258
- if (startIndex < this.index.length) {
1259
- const entry = this.index[startIndex];
1260
- if (entry.lastModified === actualCursor.lastModified && entry.docId === actualCursor.docId) {
1261
- startIndex++;
1262
- }
1905
+ else {
1906
+ signature = await this.tenant.signPayload(encryptedPayload);
1263
1907
  }
1908
+ const snapshotEntry = {
1909
+ entryType: "doc_snapshot",
1910
+ id: entryId,
1911
+ contentHash,
1912
+ docId,
1913
+ dependencyIds: headEntryIds,
1914
+ createdAt: Date.now(),
1915
+ createdByPublicKey: options.createdByPublicKey,
1916
+ decryptionKeyId: internalDoc.decryptionKeyId,
1917
+ snapshotHeadHashes: headHashes,
1918
+ snapshotHeadEntryIds: headEntryIds,
1919
+ signature,
1920
+ originalSize: snapshotBytes.length,
1921
+ encryptedSize: encryptedPayload.length,
1922
+ encryptedData: encryptedPayload,
1923
+ };
1924
+ await this.store.putEntries([snapshotEntry]);
1925
+ this.logger.debug(`Created snapshot for document ${docId} with ${headHashes.length} heads and ${changesSinceSnapshot} changes since previous snapshot`);
1926
+ }
1927
+ catch (error) {
1928
+ this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
1264
1929
  }
1265
- // Pre-check which documents are in cache to optimize iteration
1266
- const uncachedDocIds = [];
1267
- for (let i = startIndex; i < this.index.length; i++) {
1268
- const entry = this.index[i];
1269
- if (!this.docCache.has(entry.docId)) {
1270
- uncachedDocIds.push(entry.docId);
1930
+ }
1931
+ async prefetchIterationWindow(indexSnapshot, startIndex) {
1932
+ if (this.iteratePrefetchWindowDocs <= 0) {
1933
+ return 0;
1934
+ }
1935
+ const docIds = [];
1936
+ const seen = new Set();
1937
+ // Only look ahead a bounded number of uncached docs so iteration does not
1938
+ // materialize the entire remaining changefeed tail up front.
1939
+ for (let i = startIndex; i < indexSnapshot.length && docIds.length < this.iteratePrefetchWindowDocs; i++) {
1940
+ const docId = indexSnapshot[i].docId;
1941
+ if (seen.has(docId) || this.docCache.has(docId)) {
1942
+ continue;
1271
1943
  }
1944
+ seen.add(docId);
1945
+ docIds.push(docId);
1946
+ }
1947
+ if (docIds.length === 0) {
1948
+ return 0;
1949
+ }
1950
+ // Prefetch is opportunistic: iteration can still fall back to on-demand
1951
+ // loads, so individual prefetch failures are logged and ignored here.
1952
+ await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId).catch((err) => {
1953
+ this.logger.warn(`Failed to prefetch document ${docId}:`, err);
1954
+ return null;
1955
+ })));
1956
+ return docIds.length;
1957
+ }
1958
+ getStartIndexForCursor(indexSnapshot, actualCursor) {
1959
+ let startIndex = 0;
1960
+ if (indexSnapshot.length === 0) {
1961
+ return startIndex;
1272
1962
  }
1273
- // Prefetch uncached documents in batches (if any)
1274
- if (uncachedDocIds.length > 0) {
1275
- this.logger.debug(`Prefetching ${uncachedDocIds.length} uncached documents for iteration`);
1276
- const prefetchBatchSize = 10;
1277
- for (let i = 0; i < uncachedDocIds.length; i += prefetchBatchSize) {
1278
- const batch = uncachedDocIds.slice(i, i + prefetchBatchSize);
1279
- await Promise.all(batch.map(docId => this.loadDocumentInternal(docId).catch(err => {
1280
- this.logger.warn(`Failed to prefetch document ${docId}:`, err);
1281
- return null;
1282
- })));
1963
+ const cursorSeq = typeof actualCursor.changeSeq === "number" ? actualCursor.changeSeq : 0;
1964
+ let left = 0;
1965
+ let right = indexSnapshot.length - 1;
1966
+ while (left <= right) {
1967
+ const mid = Math.floor((left + right) / 2);
1968
+ const entry = indexSnapshot[mid];
1969
+ const cursorComparable = {
1970
+ docId: actualCursor.docId,
1971
+ changeSeq: cursorSeq,
1972
+ };
1973
+ const cmp = this.compareIndexEntries(cursorComparable, entry);
1974
+ if (cmp < 0) {
1975
+ right = mid - 1;
1976
+ startIndex = mid;
1977
+ }
1978
+ else {
1979
+ left = mid + 1;
1980
+ startIndex = mid + 1;
1283
1981
  }
1284
1982
  }
1285
- // Iterate through the index and yield documents one at a time
1286
- // Documents should now be in cache from prefetching
1287
- for (let i = startIndex; i < this.index.length; i++) {
1288
- const entry = this.index[i];
1289
- try {
1290
- this.logger.debug(`Yielding document ${entry.docId} from index (lastModified: ${entry.lastModified}, isDeleted: ${entry.isDeleted})`);
1291
- // Check cache first (should be there after prefetching)
1292
- let internalDoc = this.docCache.get(entry.docId) || null;
1293
- if (!internalDoc) {
1294
- // Fallback to loading if not in cache (shouldn't happen after prefetching)
1295
- this.logger.debug(`Document ${entry.docId} not in cache, loading from store`);
1296
- internalDoc = await this.loadDocumentInternal(entry.docId);
1297
- }
1298
- if (!internalDoc) {
1299
- this.logger.warn(`Document ${entry.docId} not found, skipping`);
1300
- continue;
1301
- }
1302
- // Wrap the document (works for both deleted and non-deleted documents)
1303
- const doc = this.wrapDocument(internalDoc);
1304
- this.logger.debug(`Successfully loaded document ${entry.docId} (isDeleted: ${doc.isDeleted()})`);
1305
- // Create cursor for current document
1983
+ if (startIndex < indexSnapshot.length) {
1984
+ const entry = indexSnapshot[startIndex];
1985
+ if (entry.changeSeq === cursorSeq && entry.docId === actualCursor.docId) {
1986
+ startIndex++;
1987
+ }
1988
+ }
1989
+ return startIndex;
1990
+ }
1991
+ async *iterateChangeMetadataSince(cursor) {
1992
+ const startedAt = Date.now();
1993
+ const actualCursor = cursor ?? {
1994
+ changeSeq: 0,
1995
+ lastModified: 0,
1996
+ docId: "",
1997
+ };
1998
+ const indexSnapshot = [...this.index];
1999
+ const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
2000
+ let yieldedDocuments = 0;
2001
+ try {
2002
+ for (let i = startIndex; i < indexSnapshot.length; i++) {
2003
+ const entry = indexSnapshot[i];
1306
2004
  const currentCursor = {
2005
+ changeSeq: entry.changeSeq,
1307
2006
  lastModified: entry.lastModified,
1308
2007
  docId: entry.docId,
1309
2008
  };
1310
- // Yield immediately - this allows the caller to break early after each document
1311
- // Deleted documents are included so external indexes can handle deletions
1312
- yield { doc, cursor: currentCursor };
2009
+ yieldedDocuments++;
2010
+ yield {
2011
+ docId: entry.docId,
2012
+ lastModified: entry.lastModified,
2013
+ isDeleted: entry.isDeleted,
2014
+ cursor: currentCursor,
2015
+ };
1313
2016
  }
1314
- catch (error) {
1315
- this.logger.error(`Error processing document ${entry.docId}:`, error);
1316
- // Stop processing on error
1317
- throw error;
2017
+ }
2018
+ finally {
2019
+ this.performanceCallback?.onSyncOperation?.({
2020
+ operation: "iterateChangeMetadataSince",
2021
+ time: Date.now() - startedAt,
2022
+ details: {
2023
+ yieldedDocuments,
2024
+ startIndex,
2025
+ },
2026
+ });
2027
+ }
2028
+ }
2029
+ async *iterateChangesSince(cursor) {
2030
+ const startedAt = Date.now();
2031
+ // Default to initial cursor if null is provided.
2032
+ // Prefer deterministic sequence-based cursoring; keep legacy fallback compatibility.
2033
+ const actualCursor = cursor ?? { changeSeq: 0, lastModified: 0, docId: "" };
2034
+ this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
2035
+ // Use a stable snapshot of the index for this generator run so concurrent
2036
+ // updates do not reorder/skip entries while iterating.
2037
+ const indexSnapshot = [...this.index];
2038
+ // Find starting position using binary search
2039
+ // We want to find the first entry that is greater than the cursor.
2040
+ const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
2041
+ let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex);
2042
+ let yieldedDocuments = 0;
2043
+ let loadedDocuments = 0;
2044
+ try {
2045
+ // Iterate through the stable snapshot and yield documents one at a time.
2046
+ for (let i = startIndex; i < indexSnapshot.length; i++) {
2047
+ const entry = indexSnapshot[i];
2048
+ try {
2049
+ this.logger.debug(`Yielding document ${entry.docId} from index (lastModified: ${entry.lastModified}, isDeleted: ${entry.isDeleted})`);
2050
+ let internalDoc = this.getCachedDocument(entry.docId);
2051
+ if (!internalDoc) {
2052
+ this.logger.debug(`Document ${entry.docId} not in cache, loading from store`);
2053
+ internalDoc = await this.loadDocumentInternal(entry.docId);
2054
+ if (internalDoc) {
2055
+ loadedDocuments++;
2056
+ }
2057
+ }
2058
+ if (!internalDoc) {
2059
+ this.logger.warn(`Document ${entry.docId} not found, skipping`);
2060
+ continue;
2061
+ }
2062
+ // Wrap the document (works for both deleted and non-deleted documents)
2063
+ const doc = this.wrapDocument(internalDoc);
2064
+ this.logger.debug(`Successfully loaded document ${entry.docId} (isDeleted: ${doc.isDeleted()})`);
2065
+ // Create cursor for current document
2066
+ const currentCursor = {
2067
+ changeSeq: entry.changeSeq,
2068
+ lastModified: entry.lastModified,
2069
+ docId: entry.docId,
2070
+ };
2071
+ // Yield immediately - this allows the caller to break early after each document
2072
+ // Deleted documents are included so external indexes can handle deletions
2073
+ yieldedDocuments++;
2074
+ yield { doc, cursor: currentCursor };
2075
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
2076
+ }
2077
+ catch (error) {
2078
+ this.logger.error(`Error processing document ${entry.docId}:`, error);
2079
+ // Stop processing on error
2080
+ throw error;
2081
+ }
1318
2082
  }
1319
2083
  }
1320
- this.logger.debug(`Iteration completed`);
2084
+ finally {
2085
+ this.logger.debug(`Iteration completed`);
2086
+ this.performanceCallback?.onSyncOperation?.({
2087
+ operation: "iterateChangesSince",
2088
+ time: Date.now() - startedAt,
2089
+ details: {
2090
+ yieldedDocuments,
2091
+ prefetchedDocuments,
2092
+ loadedDocuments,
2093
+ startIndex,
2094
+ },
2095
+ });
2096
+ }
1321
2097
  }
1322
2098
  /**
1323
2099
  * Incrementally update a cached document with new entries.
@@ -1338,10 +2114,10 @@ class BaseMindooDB {
1338
2114
  this.logger.debug(`No change entries to apply for document ${docId}`);
1339
2115
  return null; // No changes
1340
2116
  }
1341
- // Sort entries by timestamp
1342
- 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);
1343
2119
  // Load entries from store
1344
- const entries = await this.store.getEntries(entriesToApply.map(em => em.id));
2120
+ const entries = await this.store.getEntries(orderedEntryIds);
1345
2121
  // Filter entries for admin-only mode first
1346
2122
  const validEntries = entries.filter(entryData => {
1347
2123
  if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
@@ -1429,9 +2205,9 @@ class BaseMindooDB {
1429
2205
  this.logger.debug(`Document ${docId} changed: heads before=${JSON.stringify(headsBefore)}, after=${JSON.stringify(headsAfter)}`);
1430
2206
  // Update metadata
1431
2207
  const payload = updatedDoc;
1432
- const lastEntry = entries[entries.length - 1];
2208
+ const lastEntryCreatedAt = entries.reduce((maxCreatedAt, entry) => Math.max(maxCreatedAt, entry.createdAt), cachedDoc.lastModified);
1433
2209
  const lastModified = payload._lastModified ||
1434
- (lastEntry ? lastEntry.createdAt : cachedDoc.lastModified);
2210
+ lastEntryCreatedAt;
1435
2211
  // Check if document was deleted
1436
2212
  const hasDeleteEntry = newEntryMetadata.some(em => em.entryType === "doc_delete");
1437
2213
  const isDeleted = hasDeleteEntry || cachedDoc.isDeleted;
@@ -1444,57 +2220,113 @@ class BaseMindooDB {
1444
2220
  isDeleted,
1445
2221
  };
1446
2222
  // Update cache
1447
- this.docCache.set(docId, updatedInternalDoc);
2223
+ this.storeCachedDocument(updatedInternalDoc);
2224
+ this.markDocDirty(docId);
1448
2225
  return updatedInternalDoc;
1449
2226
  }
1450
2227
  /**
1451
2228
  * Internal method to load a document from the content-addressed store
1452
2229
  */
1453
2230
  async loadDocumentInternal(docId) {
2231
+ const startedAt = Date.now();
2232
+ const cacheCheckStartedAt = Date.now();
1454
2233
  // Check cache first
1455
2234
  if (this.docCache.has(docId)) {
1456
2235
  this.logger.debug(`Document ${docId} found in cache, returning cached version`);
1457
- return this.docCache.get(docId);
2236
+ const cached = this.getCachedDocument(docId);
2237
+ this.performanceCallback?.onDocumentLoad?.({
2238
+ docId,
2239
+ cacheHit: true,
2240
+ metadataEntriesScanned: 0,
2241
+ replayEntriesLoaded: 0,
2242
+ snapshotUsed: false,
2243
+ cacheCheckTime: Date.now() - cacheCheckStartedAt,
2244
+ storeQueryTime: 0,
2245
+ entryLoadTime: 0,
2246
+ signatureVerificationTime: 0,
2247
+ decryptionTime: 0,
2248
+ automergeTime: 0,
2249
+ totalTime: Date.now() - startedAt,
2250
+ });
2251
+ return cached;
1458
2252
  }
1459
2253
  this.logger.debug(`===== Starting to load document ${docId} from store =====`);
2254
+ const cacheCheckTime = Date.now() - cacheCheckStartedAt;
2255
+ let storeQueryTime = 0;
2256
+ let entryLoadTime = 0;
2257
+ let signatureVerificationTime = 0;
2258
+ let decryptionTime = 0;
2259
+ let automergeTime = 0;
1460
2260
  // Get all entry metadata for this document
1461
2261
  // TODO: Implement loading from last snapshot if available
1462
2262
  this.logger.debug(`Getting all entry hashes for document ${docId}`);
2263
+ const storeQueryStartedAt = Date.now();
1463
2264
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
2265
+ storeQueryTime += Date.now() - storeQueryStartedAt;
1464
2266
  this.logger.debug(`Found ${allEntryMetadata.length} total entry hashes for document ${docId}`);
1465
2267
  if (allEntryMetadata.length === 0) {
1466
2268
  this.logger.debug(`No entry hashes found for document ${docId}, returning null`);
2269
+ this.performanceCallback?.onDocumentLoad?.({
2270
+ docId,
2271
+ cacheHit: false,
2272
+ metadataEntriesScanned: 0,
2273
+ replayEntriesLoaded: 0,
2274
+ snapshotUsed: false,
2275
+ cacheCheckTime,
2276
+ storeQueryTime,
2277
+ entryLoadTime,
2278
+ signatureVerificationTime,
2279
+ decryptionTime,
2280
+ automergeTime,
2281
+ totalTime: Date.now() - startedAt,
2282
+ });
1467
2283
  return null;
1468
2284
  }
1469
2285
  // Log all entry types
1470
2286
  const entryTypes = allEntryMetadata.map(em => `${em.entryType}@${em.createdAt}`).join(', ');
1471
2287
  this.logger.debug(`Entry types for ${docId}: ${entryTypes}`);
1472
- // Find the most recent snapshot (if any)
1473
- const snapshots = allEntryMetadata.filter(em => em.entryType === "doc_snapshot");
1474
- this.logger.debug(`Found ${snapshots.length} snapshot(s) for document ${docId}`);
1475
- let startFromSnapshot = false;
1476
- let snapshotMeta = null;
1477
- if (snapshots.length > 0) {
1478
- // Use the most recent snapshot
1479
- snapshots.sort((a, b) => b.createdAt - a.createdAt);
1480
- snapshotMeta = snapshots[0];
1481
- startFromSnapshot = true;
1482
- this.logger.debug(`Will start from snapshot ${snapshotMeta.id} created at ${snapshotMeta.createdAt}`);
2288
+ const metadataById = new Map(allEntryMetadata.map((meta) => [meta.id, meta]));
2289
+ const planStartedAt = Date.now();
2290
+ const materializationPlan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, allEntryMetadata, {
2291
+ includeDiagnostics: true,
2292
+ });
2293
+ const planTime = Date.now() - planStartedAt;
2294
+ this.performanceCallback?.onSyncOperation?.({
2295
+ operation: "planDocumentMaterialization",
2296
+ time: planTime,
2297
+ details: {
2298
+ docId,
2299
+ metadataEntriesScanned: allEntryMetadata.length,
2300
+ replayEntriesLoaded: materializationPlan.entryIdsToApply.length,
2301
+ snapshotEntryId: materializationPlan.snapshotEntryId,
2302
+ diagnostics: materializationPlan.diagnostics,
2303
+ },
2304
+ });
2305
+ let startFromSnapshot = materializationPlan.snapshotEntryId !== null;
2306
+ const snapshotMeta = materializationPlan.snapshotEntryId
2307
+ ? (metadataById.get(materializationPlan.snapshotEntryId) || null)
2308
+ : null;
2309
+ if (startFromSnapshot && !snapshotMeta) {
2310
+ this.logger.warn(`Planner referenced snapshot ${materializationPlan.snapshotEntryId} not found in metadata for ${docId}; falling back to replay without snapshot`);
2311
+ startFromSnapshot = false;
2312
+ }
2313
+ if (startFromSnapshot && snapshotMeta) {
2314
+ this.logger.debug(`Planner selected snapshot ${snapshotMeta.id} for ${docId}`);
1483
2315
  }
1484
2316
  else {
1485
- this.logger.debug(`No snapshot found, will start from scratch`);
1486
- }
1487
- // Get all entries (excluding snapshot entries - we'll handle delete separately)
1488
- // Include "doc_create", "doc_change", and "doc_delete" types as they all contain Automerge changes to apply
1489
- const entriesToLoad = startFromSnapshot
1490
- ? allEntryMetadata.filter(em => (em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete") && em.createdAt > snapshotMeta.createdAt)
1491
- : allEntryMetadata.filter(em => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
1492
- this.logger.debug(`Will load ${entriesToLoad.length} entries for document ${docId} (after snapshot filter)`);
2317
+ this.logger.debug(`Planner did not select a snapshot for ${docId}`);
2318
+ }
2319
+ const entriesToLoad = materializationPlan.entryIdsToApply
2320
+ .map((id) => metadataById.get(id))
2321
+ .filter((m) => m !== undefined);
2322
+ this.logger.debug(`Planner returned ${entriesToLoad.length} replay entries for ${docId}; diagnostics=${JSON.stringify(materializationPlan.diagnostics || {})}`);
1493
2323
  // Load the snapshot first if we have one
1494
2324
  let doc = undefined;
1495
2325
  if (startFromSnapshot && snapshotMeta) {
1496
2326
  this.logger.debug(`Loading snapshot for document ${docId}`);
2327
+ const snapshotLoadStartedAt = Date.now();
1497
2328
  const snapshotEntries = await this.store.getEntries([snapshotMeta.id]);
2329
+ entryLoadTime += Date.now() - snapshotLoadStartedAt;
1498
2330
  this.logger.debug(`Retrieved ${snapshotEntries.length} snapshot entry(s) from store`);
1499
2331
  if (snapshotEntries.length > 0) {
1500
2332
  const snapshotData = snapshotEntries[0];
@@ -1506,7 +2338,9 @@ class BaseMindooDB {
1506
2338
  else {
1507
2339
  // Verify signature against the encrypted snapshot (no decryption needed)
1508
2340
  // We sign the encrypted payload, so anyone can verify signatures without decryption keys
2341
+ const signatureStartedAt = Date.now();
1509
2342
  isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
2343
+ signatureVerificationTime += Date.now() - signatureStartedAt;
1510
2344
  }
1511
2345
  if (!isValid) {
1512
2346
  this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to loading from scratch`);
@@ -1516,13 +2350,17 @@ class BaseMindooDB {
1516
2350
  else {
1517
2351
  this.logger.debug(`Snapshot signature valid, decrypting snapshot`);
1518
2352
  // Decrypt snapshot (only after signature verification passes)
2353
+ const decryptStartedAt = Date.now();
1519
2354
  const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
2355
+ decryptionTime += Date.now() - decryptStartedAt;
1520
2356
  this.logger.debug(`Decrypted snapshot (${snapshotData.encryptedData.length} -> ${decryptedSnapshot.length} bytes)`);
1521
2357
  // Load snapshot using Automerge.load()
1522
2358
  // This deserializes a full document snapshot from binary data
1523
2359
  // According to Automerge docs: load() is equivalent to init() followed by loadIncremental()
1524
2360
  this.logger.debug(`Loading snapshot into Automerge document`);
2361
+ const automergeStartedAt = Date.now();
1525
2362
  doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
2363
+ automergeTime += Date.now() - automergeStartedAt;
1526
2364
  this.logger.debug(`Successfully loaded snapshot, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
1527
2365
  // Register the snapshot's automerge hash -> entry ID mapping
1528
2366
  const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
@@ -1538,12 +2376,11 @@ class BaseMindooDB {
1538
2376
  doc = automerge_adapter_1.Automerge.init();
1539
2377
  this.logger.debug(`Initialized empty document, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
1540
2378
  }
1541
- // Sort entries by timestamp
1542
- entriesToLoad.sort((a, b) => a.createdAt - b.createdAt);
1543
- this.logger.debug(`Sorted ${entriesToLoad.length} entries by timestamp for document ${docId}`);
1544
2379
  // Load and apply all entries
1545
2380
  this.logger.debug(`Fetching ${entriesToLoad.length} entries from store for document ${docId}`);
2381
+ const entryLoadStartedAt = Date.now();
1546
2382
  const entries = await this.store.getEntries(entriesToLoad.map(em => em.id));
2383
+ entryLoadTime += Date.now() - entryLoadStartedAt;
1547
2384
  this.logger.debug(`Retrieved ${entries.length} entries from store for document ${docId}`);
1548
2385
  this.logger.debug(`Loading document ${docId}: found ${entries.length} entries to apply (${startFromSnapshot ? 'starting from snapshot' : 'starting from scratch'})`);
1549
2386
  // Log current document state before applying entries
@@ -1583,6 +2420,7 @@ class BaseMindooDB {
1583
2420
  }
1584
2421
  }
1585
2422
  // Verify all signatures in parallel using cached keys
2423
+ const signatureStartedAt = Date.now();
1586
2424
  const signatureVerificationResults = await Promise.all(validEntries.map(async (entryData) => {
1587
2425
  const cryptoKey = keyMap.get(entryData.createdByPublicKey);
1588
2426
  if (!cryptoKey) {
@@ -1592,6 +2430,7 @@ class BaseMindooDB {
1592
2430
  const isValid = await this.verifySignatureWithKey(cryptoKey, entryData.encryptedData, entryData.signature);
1593
2431
  return { entryData, isValid };
1594
2432
  }));
2433
+ signatureVerificationTime += Date.now() - signatureStartedAt;
1595
2434
  // Filter out entries with invalid signatures
1596
2435
  const verifiedEntries = signatureVerificationResults
1597
2436
  .filter(({ isValid }) => {
@@ -1608,10 +2447,12 @@ class BaseMindooDB {
1608
2447
  // Parallel decryption - decrypt all entries concurrently
1609
2448
  // Automerge handles dependency buffering internally, so we can decrypt all in parallel
1610
2449
  this.logger.debug(`Decrypting ${verifiedEntries.length} entries in parallel`);
2450
+ const decryptionStartedAt = Date.now();
1611
2451
  const decryptionResults = await Promise.all(verifiedEntries.map(async (entryData) => {
1612
2452
  const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
1613
2453
  return { entryData, decryptedPayload };
1614
2454
  }));
2455
+ decryptionTime += Date.now() - decryptionStartedAt;
1615
2456
  // Collect change bytes and register automerge hash mappings
1616
2457
  const changeBytes = [];
1617
2458
  for (const { entryData, decryptedPayload } of decryptionResults) {
@@ -1626,8 +2467,10 @@ class BaseMindooDB {
1626
2467
  if (changeBytes.length > 0) {
1627
2468
  this.logger.debug(`Applying ${changeBytes.length} changes to document ${docId} using batch applyChanges`);
1628
2469
  try {
2470
+ const automergeStartedAt = Date.now();
1629
2471
  const result = automerge_adapter_1.Automerge.applyChanges(doc, changeBytes);
1630
2472
  doc = result[0];
2473
+ automergeTime += Date.now() - automergeStartedAt;
1631
2474
  this.logger.debug(`Successfully applied ${changeBytes.length} changes to document ${docId}`);
1632
2475
  this.logger.debug(`Document state after applying changes: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
1633
2476
  }
@@ -1647,7 +2490,10 @@ class BaseMindooDB {
1647
2490
  const hasDeleteEntry = allEntryMetadata.some(em => em.entryType === "doc_delete");
1648
2491
  const isDeleted = hasDeleteEntry;
1649
2492
  this.logger.debug(`Document ${docId} isDeleted: ${isDeleted}`);
1650
- const decryptionKeyId = payload._decryptionKeyId || "default";
2493
+ // The authoritative decryptionKeyId comes from the doc_create entry's metadata,
2494
+ // not from the Automerge payload (which does not store encryption metadata).
2495
+ const createEntry = allEntryMetadata.find(em => em.entryType === "doc_create");
2496
+ const decryptionKeyId = createEntry ? createEntry.decryptionKeyId : "default";
1651
2497
  // Get lastModified from payload, or use the timestamp of the last entry
1652
2498
  const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
1653
2499
  const lastModified = payload._lastModified ||
@@ -1665,8 +2511,23 @@ class BaseMindooDB {
1665
2511
  isDeleted,
1666
2512
  };
1667
2513
  // Update cache
1668
- this.docCache.set(docId, internalDoc);
2514
+ this.storeCachedDocument(internalDoc);
2515
+ this.markDocDirty(docId);
1669
2516
  this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
2517
+ this.performanceCallback?.onDocumentLoad?.({
2518
+ docId,
2519
+ cacheHit: false,
2520
+ metadataEntriesScanned: allEntryMetadata.length,
2521
+ replayEntriesLoaded: entriesToLoad.length,
2522
+ snapshotUsed: startFromSnapshot && snapshotMeta !== null,
2523
+ cacheCheckTime,
2524
+ storeQueryTime,
2525
+ entryLoadTime,
2526
+ signatureVerificationTime,
2527
+ decryptionTime,
2528
+ automergeTime,
2529
+ totalTime: Date.now() - startedAt,
2530
+ });
1670
2531
  return internalDoc;
1671
2532
  }
1672
2533
  /**
@@ -1785,7 +2646,7 @@ class BaseMindooDB {
1785
2646
  * Get an attachment reference by ID from a document's _attachments array.
1786
2647
  */
1787
2648
  getAttachmentRefInternal(docId, attachmentId) {
1788
- const internalDoc = this.docCache.get(docId);
2649
+ const internalDoc = this.getCachedDocument(docId);
1789
2650
  if (!internalDoc) {
1790
2651
  throw new Error(`Document ${docId} not found in cache`);
1791
2652
  }
@@ -1797,6 +2658,39 @@ class BaseMindooDB {
1797
2658
  }
1798
2659
  return ref;
1799
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
+ }
1800
2694
  /**
1801
2695
  * Internal method to fetch and concatenate all chunks for an attachment.
1802
2696
  */
@@ -1813,17 +2707,7 @@ class BaseMindooDB {
1813
2707
  const plaintextChunks = [];
1814
2708
  let totalSize = 0;
1815
2709
  for (const chunk of chunks) {
1816
- // Admin-only mode: only accept chunks signed by the admin key
1817
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
1818
- throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
1819
- }
1820
- // Verify signature
1821
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
1822
- if (!isValid) {
1823
- throw new Error(`Invalid signature for chunk ${chunk.id}`);
1824
- }
1825
- // Decrypt
1826
- const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2710
+ const plaintext = await this.decryptAttachmentChunk(chunk);
1827
2711
  plaintextChunks.push(plaintext);
1828
2712
  totalSize += plaintext.length;
1829
2713
  }
@@ -1846,37 +2730,16 @@ class BaseMindooDB {
1846
2730
  throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
1847
2731
  }
1848
2732
  const ref = this.getAttachmentRefInternal(docId, attachmentId);
1849
- if (endByte > ref.size) {
1850
- throw new Error(`End byte ${endByte} exceeds attachment size ${ref.size}`);
1851
- }
1852
2733
  const store = this.getEffectiveAttachmentStore();
1853
- const chunkSize = this.chunkSizeBytes;
1854
- // Calculate which chunks we need
1855
- const startChunkIndex = Math.floor(startByte / chunkSize);
1856
- const endChunkIndex = Math.floor((endByte - 1) / chunkSize);
1857
- // Resolve dependency chain to get all chunk IDs
1858
- const allChunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
1859
- // Get only the chunks we need
1860
- 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);
1861
2736
  const chunks = await store.getEntries(neededChunkIds);
1862
2737
  // Decrypt needed chunks
1863
2738
  const plaintextChunks = [];
1864
2739
  for (const chunk of chunks) {
1865
- // Admin-only mode: only accept chunks signed by the admin key
1866
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
1867
- throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
1868
- }
1869
- // Verify signature
1870
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
1871
- if (!isValid) {
1872
- throw new Error(`Invalid signature for chunk ${chunk.id}`);
1873
- }
1874
- // Decrypt
1875
- const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2740
+ const plaintext = await this.decryptAttachmentChunk(chunk);
1876
2741
  plaintextChunks.push(plaintext);
1877
2742
  }
1878
- // Calculate offsets within the fetched chunks
1879
- const offsetInFirstChunk = startByte - (startChunkIndex * chunkSize);
1880
2743
  const totalNeededBytes = endByte - startByte;
1881
2744
  // Extract the requested range
1882
2745
  const result = new Uint8Array(totalNeededBytes);
@@ -1884,7 +2747,7 @@ class BaseMindooDB {
1884
2747
  let bytesRemaining = totalNeededBytes;
1885
2748
  for (let i = 0; i < plaintextChunks.length && bytesRemaining > 0; i++) {
1886
2749
  const chunk = plaintextChunks[i];
1887
- const chunkStart = i === 0 ? offsetInFirstChunk : 0;
2750
+ const chunkStart = i === 0 ? readPlan.offsetInFirstChunk : 0;
1888
2751
  const bytesToCopy = Math.min(chunk.length - chunkStart, bytesRemaining);
1889
2752
  result.set(chunk.slice(chunkStart, chunkStart + bytesToCopy), resultOffset);
1890
2753
  resultOffset += bytesToCopy;
@@ -1899,30 +2762,25 @@ class BaseMindooDB {
1899
2762
  async *streamAttachmentInternal(docId, attachmentId, startOffset) {
1900
2763
  this.logger.debug(`Streaming attachment ${attachmentId} from offset ${startOffset}`);
1901
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
+ }
1902
2771
  const store = this.getEffectiveAttachmentStore();
1903
- const chunkSize = this.chunkSizeBytes;
1904
- // Calculate starting chunk
1905
- const startChunkIndex = Math.floor(startOffset / chunkSize);
1906
- const offsetInStartChunk = startOffset % chunkSize;
1907
- // Resolve dependency chain to get all chunk IDs
1908
- const allChunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
2772
+ const readPlan = await this.planAttachmentRead(store, ref, startOffset, ref.size);
1909
2773
  // Stream chunks starting from startChunkIndex
1910
- for (let i = startChunkIndex; i < allChunkIds.length; i++) {
1911
- const [chunk] = await store.getEntries([allChunkIds[i]]);
1912
- // Admin-only mode: only accept chunks signed by the admin key
1913
- if (this._isAdminOnlyDb && chunk.createdByPublicKey !== this.getAdminPublicKey()) {
1914
- throw new Error(`Admin-only DB: chunk ${chunk.id} not signed by admin key`);
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`);
1915
2779
  }
1916
- // Verify signature
1917
- const isValid = await this.tenant.verifySignature(chunk.encryptedData, chunk.signature, chunk.createdByPublicKey);
1918
- if (!isValid) {
1919
- throw new Error(`Invalid signature for chunk ${chunk.id}`);
1920
- }
1921
- // Decrypt
1922
- const plaintext = await this.tenant.decryptAttachmentPayload(chunk.encryptedData, chunk.decryptionKeyId);
2780
+ const plaintext = await this.decryptAttachmentChunk(chunk);
1923
2781
  // For first chunk, skip bytes before startOffset
1924
- if (i === startChunkIndex && offsetInStartChunk > 0) {
1925
- yield plaintext.slice(offsetInStartChunk);
2782
+ if (i === 0 && readPlan.offsetInFirstChunk > 0) {
2783
+ yield plaintext.slice(readPlan.offsetInFirstChunk);
1926
2784
  }
1927
2785
  else {
1928
2786
  yield plaintext;
@@ -2126,7 +2984,64 @@ class BaseMindooDB {
2126
2984
  };
2127
2985
  }
2128
2986
  /**
2129
- * Pull changes from a remote content-addressed store.
2987
+ * Resolve a sync target to a ContentAddressedStore.
2988
+ * Accepts either a raw store or a MindooDB instance (calls getStore()).
2989
+ */
2990
+ resolveStore(remote) {
2991
+ if ('getStore' in remote && typeof remote.getStore === 'function') {
2992
+ return remote.getStore();
2993
+ }
2994
+ return remote;
2995
+ }
2996
+ /**
2997
+ * Apply a temporary network-auth identity override for a single sync call.
2998
+ *
2999
+ * Purpose:
3000
+ * - Allows per-call authentication as a different user (for example admin bootstrap)
3001
+ * without changing the tenant's default connected user.
3002
+ * - Returns a cleanup callback so caller can always restore default auth state in `finally`.
3003
+ */
3004
+ async applyNetworkAuthOverrideForSync(remoteStore, options) {
3005
+ // Per-call override: use an alternate identity (for example, admin bootstrap)
3006
+ // only for this sync operation.
3007
+ const override = options?.networkAuthOverride;
3008
+ if (!override) {
3009
+ // No override requested: return a no-op cleanup callback for unified call sites.
3010
+ return () => { };
3011
+ }
3012
+ const overrideCapableStore = remoteStore;
3013
+ if (typeof overrideCapableStore.setSyncAuthOverride !== "function") {
3014
+ // Local/in-memory stores do not support network auth override; keep default auth.
3015
+ this.logger.warn("networkAuthOverride was provided, but remote store does not support auth override");
3016
+ return () => { };
3017
+ }
3018
+ const subtle = this.tenant.getCryptoAdapter().getSubtle();
3019
+ // Decrypt and import override signing key (Ed25519) for challenge signing.
3020
+ const signingKeyBuffer = await this.tenant.decryptPrivateKey(override.user.userSigningKeyPair.privateKey, override.password, "signing");
3021
+ const signingKey = await subtle.importKey("pkcs8", signingKeyBuffer, { name: "Ed25519" }, false, ["sign"]);
3022
+ // Decrypt and import override encryption key (RSA-OAEP) so encrypted network
3023
+ // entries can be decrypted with the same override identity.
3024
+ const encryptionKeyBuffer = await this.tenant.decryptPrivateKey(override.user.userEncryptionKeyPair.privateKey, override.password, "encryption");
3025
+ const encryptionKey = await subtle.importKey("pkcs8", encryptionKeyBuffer, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"]);
3026
+ // Activate temporary override on the remote store.
3027
+ overrideCapableStore.setSyncAuthOverride({
3028
+ username: override.user.username,
3029
+ signingKey,
3030
+ privateEncryptionKey: encryptionKey,
3031
+ });
3032
+ // Return cleanup callback so caller can always restore normal auth in finally.
3033
+ return () => {
3034
+ if (typeof overrideCapableStore.clearSyncAuthOverride === "function") {
3035
+ overrideCapableStore.clearSyncAuthOverride();
3036
+ }
3037
+ else {
3038
+ // Backward-compatible fallback if explicit clear API is not implemented.
3039
+ overrideCapableStore.setSyncAuthOverride?.(null);
3040
+ }
3041
+ };
3042
+ }
3043
+ /**
3044
+ * Pull changes from a remote content-addressed store or another MindooDB instance.
2130
3045
  *
2131
3046
  * This method:
2132
3047
  * 1. Finds entries in the remote store that we don't have locally
@@ -2134,48 +3049,109 @@ class BaseMindooDB {
2134
3049
  * 3. Stores them in our local store
2135
3050
  * 4. Syncs the local store to process the new entries
2136
3051
  *
2137
- * @param remoteStore The remote store to pull entries from
2138
- * @return A promise that resolves when the pull is complete
3052
+ * @param remote The remote store or MindooDB instance to pull entries from
3053
+ * @param options Optional sync options for progress tracking, paging, and cancellation
3054
+ * @return A promise that resolves with the sync result
2139
3055
  */
2140
- async pullChangesFrom(remoteStore) {
3056
+ async pullChangesFrom(remote, options) {
3057
+ const remoteStore = this.resolveStore(remote);
2141
3058
  if (this.store.getId() !== remoteStore.getId()) {
2142
3059
  throw new Error(`[BaseMindooDB] Cannot pull entries from the incompatible store ${this.store.getId()}`);
2143
3060
  }
2144
3061
  this.logger.info(`Pulling entries from remote store ${remoteStore.getId()}`);
2145
- const transferred = await this.syncEntriesFromStore(remoteStore, this.store);
2146
- this.logger.debug(`Transferred ${transferred} entries from remote store`);
2147
- if (transferred === 0) {
2148
- this.logger.debug(`No new entries to pull`);
2149
- return;
3062
+ const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
3063
+ try {
3064
+ const syncResult = await this.syncEntriesFromStore(remoteStore, this.store, options);
3065
+ this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
3066
+ if (syncResult.cancelled) {
3067
+ this.logger.info(`Pull cancelled after transferring ${syncResult.transferred} entries`);
3068
+ return {
3069
+ transferredEntries: syncResult.transferred,
3070
+ scannedEntries: syncResult.scanned,
3071
+ cancelled: true,
3072
+ };
3073
+ }
3074
+ if (syncResult.transferred === 0) {
3075
+ this.logger.debug(`No new entries to pull`);
3076
+ return { transferredEntries: 0, scannedEntries: syncResult.scanned, cancelled: false };
3077
+ }
3078
+ options?.onProgress?.({
3079
+ phase: 'processing',
3080
+ message: `Processing ${syncResult.transferred} new entries...`,
3081
+ transferredEntries: syncResult.transferred,
3082
+ scannedEntries: syncResult.transferred,
3083
+ });
3084
+ // Sync the local store to process the new entries
3085
+ // This will update the index, cache, and processedEntryIds
3086
+ await this.syncStoreChanges();
3087
+ this.logger.info(`Pull complete, synced ${syncResult.transferred} entries`);
3088
+ options?.onProgress?.({
3089
+ phase: 'complete',
3090
+ message: `Pull complete: ${syncResult.transferred} entries synced`,
3091
+ transferredEntries: syncResult.transferred,
3092
+ scannedEntries: syncResult.transferred,
3093
+ });
3094
+ return {
3095
+ transferredEntries: syncResult.transferred,
3096
+ scannedEntries: syncResult.scanned,
3097
+ cancelled: false,
3098
+ };
3099
+ }
3100
+ finally {
3101
+ restoreAuthOverride();
2150
3102
  }
2151
- // Sync the local store to process the new entries
2152
- // This will update the index, cache, and processedEntryIds
2153
- await this.syncStoreChanges();
2154
- this.logger.info(`Pull complete, synced ${transferred} entries`);
2155
3103
  }
2156
3104
  /**
2157
- * Push changes to a remote content-addressed store.
3105
+ * Push changes to a remote content-addressed store or another MindooDB instance.
2158
3106
  *
2159
3107
  * This method:
2160
3108
  * 1. Finds entries in our local store that the remote doesn't have
2161
3109
  * 2. Retrieves those entries from our local store
2162
3110
  * 3. Stores them in the remote store
2163
3111
  *
2164
- * @param remoteStore The remote store to push entries to
2165
- * @return A promise that resolves when the push is complete
3112
+ * @param remote The remote store or MindooDB instance to push entries to
3113
+ * @param options Optional sync options for progress tracking, paging, and cancellation
3114
+ * @return A promise that resolves with the sync result
2166
3115
  */
2167
- async pushChangesTo(remoteStore) {
3116
+ async pushChangesTo(remote, options) {
3117
+ const remoteStore = this.resolveStore(remote);
2168
3118
  if (this.store.getId() !== remoteStore.getId()) {
2169
3119
  throw new Error(`[BaseMindooDB] Cannot push entries to the incompatible store ${this.store.getId()}`);
2170
3120
  }
2171
3121
  this.logger.info(`Pushing entries to remote store ${remoteStore.getId()}`);
2172
- const transferred = await this.syncEntriesFromStore(this.store, remoteStore);
2173
- this.logger.debug(`Transferred ${transferred} entries to remote store`);
2174
- if (transferred === 0) {
2175
- this.logger.debug(`No new entries to push`);
2176
- return;
3122
+ const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
3123
+ try {
3124
+ const syncResult = await this.syncEntriesFromStore(this.store, remoteStore, options);
3125
+ this.logger.debug(`Transferred ${syncResult.transferred} entries to remote store`);
3126
+ if (syncResult.cancelled) {
3127
+ this.logger.info(`Push cancelled after transferring ${syncResult.transferred} entries`);
3128
+ return {
3129
+ transferredEntries: syncResult.transferred,
3130
+ scannedEntries: syncResult.scanned,
3131
+ cancelled: true,
3132
+ };
3133
+ }
3134
+ if (syncResult.transferred === 0) {
3135
+ this.logger.debug(`No new entries to push`);
3136
+ }
3137
+ else {
3138
+ this.logger.info(`Pushed ${syncResult.transferred} entries to remote store`);
3139
+ }
3140
+ options?.onProgress?.({
3141
+ phase: 'complete',
3142
+ message: `Push complete: ${syncResult.transferred} entries transferred`,
3143
+ transferredEntries: syncResult.transferred,
3144
+ scannedEntries: syncResult.transferred,
3145
+ });
3146
+ return {
3147
+ transferredEntries: syncResult.transferred,
3148
+ scannedEntries: syncResult.scanned,
3149
+ cancelled: false,
3150
+ };
3151
+ }
3152
+ finally {
3153
+ restoreAuthOverride();
2177
3154
  }
2178
- this.logger.info(`Pushed ${transferred} entries to remote store`);
2179
3155
  }
2180
3156
  }
2181
3157
  exports.BaseMindooDB = BaseMindooDB;