mindoodb 0.0.3 → 0.0.4

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 (209) hide show
  1. package/README-server.md +1001 -0
  2. package/README.md +14 -26
  3. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +19 -0
  4. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
  5. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +58 -5
  6. package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
  7. package/dist/appendonlystores/network/HttpTransport.d.ts +4 -0
  8. package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
  9. package/dist/appendonlystores/network/HttpTransport.js +43 -0
  10. package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
  11. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +3 -0
  12. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
  13. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +15 -1
  14. package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
  15. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +5 -1
  16. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
  17. package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +59 -3
  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 +103 -13
  28. package/dist/core/BaseMindooDB.d.ts.map +1 -1
  29. package/dist/core/BaseMindooDB.js +1066 -205
  30. package/dist/core/BaseMindooDB.js.map +1 -1
  31. package/dist/core/BaseMindooTenant.d.ts +30 -1
  32. package/dist/core/BaseMindooTenant.d.ts.map +1 -1
  33. package/dist/core/BaseMindooTenant.js +143 -108
  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 +238 -0
  44. package/dist/core/MindooDBServerAdmin.d.ts.map +1 -0
  45. package/dist/core/MindooDBServerAdmin.js +345 -0
  46. package/dist/core/MindooDBServerAdmin.js.map +1 -0
  47. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +3 -1
  48. package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
  49. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +22 -0
  50. package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
  51. package/dist/core/appendonlystores/MaterializationPlanner.d.ts +49 -0
  52. package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -0
  53. package/dist/core/appendonlystores/MaterializationPlanner.js +325 -0
  54. package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -0
  55. package/dist/core/appendonlystores/network/NetworkTransport.d.ts +15 -0
  56. package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
  57. package/dist/core/appendonlystores/network/types.d.ts +2 -0
  58. package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
  59. package/dist/core/appendonlystores/network/types.js.map +1 -1
  60. package/dist/core/appendonlystores/types.d.ts +83 -0
  61. package/dist/core/appendonlystores/types.d.ts.map +1 -1
  62. package/dist/core/cache/CacheManager.d.ts +75 -0
  63. package/dist/core/cache/CacheManager.d.ts.map +1 -0
  64. package/dist/core/cache/CacheManager.js +101 -0
  65. package/dist/core/cache/CacheManager.js.map +1 -0
  66. package/dist/core/cache/EncryptedLocalCacheStore.d.ts +41 -0
  67. package/dist/core/cache/EncryptedLocalCacheStore.d.ts.map +1 -0
  68. package/dist/core/cache/EncryptedLocalCacheStore.js +120 -0
  69. package/dist/core/cache/EncryptedLocalCacheStore.js.map +1 -0
  70. package/dist/core/cache/LocalCacheStore.d.ts +50 -0
  71. package/dist/core/cache/LocalCacheStore.d.ts.map +1 -0
  72. package/dist/core/cache/LocalCacheStore.js +39 -0
  73. package/dist/core/cache/LocalCacheStore.js.map +1 -0
  74. package/dist/core/crypto/privateKeyEncryption.d.ts +5 -0
  75. package/dist/core/crypto/privateKeyEncryption.d.ts.map +1 -0
  76. package/dist/core/crypto/privateKeyEncryption.js +87 -0
  77. package/dist/core/crypto/privateKeyEncryption.js.map +1 -0
  78. package/dist/core/index.d.ts +13 -6
  79. package/dist/core/index.d.ts.map +1 -1
  80. package/dist/core/index.js +13 -1
  81. package/dist/core/index.js.map +1 -1
  82. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +4 -1
  83. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
  84. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +39 -11
  85. package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
  86. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.d.ts +1 -0
  87. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.d.ts.map +1 -1
  88. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.js +3 -0
  89. package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.js.map +1 -1
  90. package/dist/core/indexing/virtualviews/VirtualView.d.ts +43 -0
  91. package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
  92. package/dist/core/indexing/virtualviews/VirtualView.js +173 -0
  93. package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
  94. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +7 -0
  95. package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
  96. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +28 -0
  97. package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
  98. package/dist/core/keys/KeyBag.d.ts +38 -14
  99. package/dist/core/keys/KeyBag.d.ts.map +1 -1
  100. package/dist/core/keys/KeyBag.js +102 -76
  101. package/dist/core/keys/KeyBag.js.map +1 -1
  102. package/dist/core/keys/KeyContext.d.ts +3 -3
  103. package/dist/core/keys/KeyContext.d.ts.map +1 -1
  104. package/dist/core/keys/KeyContext.js +10 -4
  105. package/dist/core/keys/KeyContext.js.map +1 -1
  106. package/dist/core/types.d.ts +305 -21
  107. package/dist/core/types.d.ts.map +1 -1
  108. package/dist/core/types.js +7 -1
  109. package/dist/core/types.js.map +1 -1
  110. package/dist/core/utils/idGeneration.d.ts.map +1 -1
  111. package/dist/core/utils/idGeneration.js +37 -3
  112. package/dist/core/utils/idGeneration.js.map +1 -1
  113. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +4 -1
  114. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
  115. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +24 -0
  116. package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
  117. package/dist/node/attachments/writeAttachmentToFile.d.ts +15 -0
  118. package/dist/node/attachments/writeAttachmentToFile.d.ts.map +1 -0
  119. package/dist/node/attachments/writeAttachmentToFile.js +73 -0
  120. package/dist/node/attachments/writeAttachmentToFile.js.map +1 -0
  121. package/dist/node/cache/FileSystemLocalCacheStore.d.ts +17 -0
  122. package/dist/node/cache/FileSystemLocalCacheStore.d.ts.map +1 -0
  123. package/dist/node/cache/FileSystemLocalCacheStore.js +128 -0
  124. package/dist/node/cache/FileSystemLocalCacheStore.js.map +1 -0
  125. package/dist/node/cli/change-identity-password.d.ts +7 -0
  126. package/dist/node/cli/change-identity-password.d.ts.map +1 -0
  127. package/dist/node/cli/change-identity-password.js +67 -0
  128. package/dist/node/cli/change-identity-password.js.map +1 -0
  129. package/dist/node/cli/cli-utils.d.ts +16 -0
  130. package/dist/node/cli/cli-utils.d.ts.map +1 -0
  131. package/dist/node/cli/cli-utils.js +113 -0
  132. package/dist/node/cli/cli-utils.js.map +1 -0
  133. package/dist/node/cli/identity-export-public.d.ts +4 -0
  134. package/dist/node/cli/identity-export-public.d.ts.map +1 -0
  135. package/dist/node/cli/identity-export-public.js +55 -0
  136. package/dist/node/cli/identity-export-public.js.map +1 -0
  137. package/dist/node/cli/identity-info.d.ts +4 -0
  138. package/dist/node/cli/identity-info.d.ts.map +1 -0
  139. package/dist/node/cli/identity-info.js +57 -0
  140. package/dist/node/cli/identity-info.js.map +1 -0
  141. package/dist/node/index.d.ts +1 -0
  142. package/dist/node/index.d.ts.map +1 -1
  143. package/dist/node/index.js +4 -1
  144. package/dist/node/index.js.map +1 -1
  145. package/dist/node/server/CapabilityMatcher.d.ts +45 -0
  146. package/dist/node/server/CapabilityMatcher.d.ts.map +1 -0
  147. package/dist/node/server/CapabilityMatcher.js +117 -0
  148. package/dist/node/server/CapabilityMatcher.js.map +1 -0
  149. package/dist/node/server/MindooDBServer.d.ts +87 -0
  150. package/dist/node/server/MindooDBServer.d.ts.map +1 -0
  151. package/dist/node/server/MindooDBServer.js +1040 -0
  152. package/dist/node/server/MindooDBServer.js.map +1 -0
  153. package/dist/node/server/ServerSync.d.ts +41 -0
  154. package/dist/node/server/ServerSync.d.ts.map +1 -0
  155. package/dist/node/server/ServerSync.js +187 -0
  156. package/dist/node/server/ServerSync.js.map +1 -0
  157. package/dist/node/server/StoreFactory.d.ts +45 -0
  158. package/dist/node/server/StoreFactory.d.ts.map +1 -0
  159. package/dist/node/server/StoreFactory.js +89 -0
  160. package/dist/node/server/StoreFactory.js.map +1 -0
  161. package/dist/node/server/SystemAdminAuth.d.ts +65 -0
  162. package/dist/node/server/SystemAdminAuth.d.ts.map +1 -0
  163. package/dist/node/server/SystemAdminAuth.js +200 -0
  164. package/dist/node/server/SystemAdminAuth.js.map +1 -0
  165. package/dist/node/server/SystemIpAllowlist.d.ts +21 -0
  166. package/dist/node/server/SystemIpAllowlist.d.ts.map +1 -0
  167. package/dist/node/server/SystemIpAllowlist.js +210 -0
  168. package/dist/node/server/SystemIpAllowlist.js.map +1 -0
  169. package/dist/node/server/TenantManager.d.ts +85 -0
  170. package/dist/node/server/TenantManager.d.ts.map +1 -0
  171. package/dist/node/server/TenantManager.js +493 -0
  172. package/dist/node/server/TenantManager.js.map +1 -0
  173. package/dist/node/server/add-to-network.d.ts +20 -0
  174. package/dist/node/server/add-to-network.d.ts.map +1 -0
  175. package/dist/node/server/add-to-network.js +275 -0
  176. package/dist/node/server/add-to-network.js.map +1 -0
  177. package/dist/node/server/config.d.ts +51 -0
  178. package/dist/node/server/config.d.ts.map +1 -0
  179. package/dist/node/server/config.js +218 -0
  180. package/dist/node/server/config.js.map +1 -0
  181. package/dist/node/server/resolveServerPassword.d.ts +7 -0
  182. package/dist/node/server/resolveServerPassword.d.ts.map +1 -0
  183. package/dist/node/server/resolveServerPassword.js +25 -0
  184. package/dist/node/server/resolveServerPassword.js.map +1 -0
  185. package/dist/node/server/server.d.ts +22 -0
  186. package/dist/node/server/server.d.ts.map +1 -0
  187. package/dist/node/server/server.js +225 -0
  188. package/dist/node/server/server.js.map +1 -0
  189. package/dist/node/server/serverinit.d.ts +21 -0
  190. package/dist/node/server/serverinit.d.ts.map +1 -0
  191. package/dist/node/server/serverinit.js +317 -0
  192. package/dist/node/server/serverinit.js.map +1 -0
  193. package/dist/node/server/types.d.ts +162 -0
  194. package/dist/node/server/types.d.ts.map +1 -0
  195. package/dist/node/server/types.js +24 -0
  196. package/dist/node/server/types.js.map +1 -0
  197. package/dist/node/server/validation.d.ts +47 -0
  198. package/dist/node/server/validation.d.ts.map +1 -0
  199. package/dist/node/server/validation.js +114 -0
  200. package/dist/node/server/validation.js.map +1 -0
  201. package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts +48 -0
  202. package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts.map +1 -0
  203. package/dist/reactnative/cache/MMKVLocalCacheStore.js +113 -0
  204. package/dist/reactnative/cache/MMKVLocalCacheStore.js.map +1 -0
  205. package/dist/reactnative/index.d.ts +1 -0
  206. package/dist/reactnative/index.d.ts.map +1 -1
  207. package/dist/reactnative/index.js +4 -1
  208. package/dist/reactnative/index.js.map +1 -1
  209. package/package.json +14 -3
@@ -7,6 +7,7 @@ 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");
10
11
  const idGeneration_1 = require("./utils/idGeneration");
11
12
  const errors_1 = require("./errors");
12
13
  const logging_1 = require("./logging");
@@ -14,6 +15,10 @@ const logging_1 = require("./logging");
14
15
  * Default chunk size for attachments: 256KB
15
16
  */
16
17
  const DEFAULT_CHUNK_SIZE_BYTES = 256 * 1024;
18
+ const DEFAULT_SNAPSHOT_MIN_CHANGES = 100;
19
+ const DEFAULT_SNAPSHOT_COOLDOWN_MS = 10 * 60 * 1000;
20
+ const DEFAULT_MAX_CACHED_DOCS = 128;
21
+ const DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS = 0;
17
22
  /**
18
23
  * BaseMindooDB is a platform-agnostic implementation of MindooDB
19
24
  * that works in both browser and server environments.
@@ -34,12 +39,13 @@ const DEFAULT_CHUNK_SIZE_BYTES = 256 * 1024;
34
39
  * - Automerge.applyChanges(doc, changes) - verify method signature
35
40
  */
36
41
  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
42
+ constructor(tenant, store, attachmentStore, attachmentConfig, documentCacheConfig, snapshotConfig, adminOnlyDb = false, logger, performanceCallback) {
43
+ // Internal changefeed index: sorted by (changeSeq, docId) for deterministic iteration.
44
+ // lastModified remains available for UX metadata but is not the primary cursor key.
40
45
  this.index = [];
41
46
  // Lookup map for O(1) access to index entries by docId
42
47
  this.indexLookup = new Map(); // Map<docId, arrayIndex>
48
+ this.nextChangeSeq = 1;
43
49
  // Cache of loaded documents: Map<docId, InternalDoc>
44
50
  this.docCache = new Map();
45
51
  // Track which entry IDs we've already processed
@@ -51,11 +57,22 @@ class BaseMindooDB {
51
57
  // Cache for imported public keys (CryptoKey objects) to avoid re-importing the same key
52
58
  // Map<publicKeyPEM, CryptoKey>
53
59
  this.publicKeyCache = new Map();
60
+ // Local cache support
61
+ this.cacheManager = null;
62
+ this.cachePrefix = null;
63
+ this.dirtyDocIds = new Set();
64
+ this.cacheMetaDirty = false;
54
65
  this.tenant = tenant;
55
66
  this.store = store;
56
67
  this.attachmentStore = attachmentStore;
57
68
  this.chunkSizeBytes = attachmentConfig?.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES;
58
69
  this._isAdminOnlyDb = adminOnlyDb;
70
+ this.maxCachedDocs = Math.max(1, Math.floor(documentCacheConfig?.maxEntries ?? DEFAULT_MAX_CACHED_DOCS));
71
+ this.iteratePrefetchWindowDocs = Math.max(0, Math.floor(documentCacheConfig?.iteratePrefetchWindowDocs ??
72
+ DEFAULT_ITERATE_PREFETCH_WINDOW_DOCS));
73
+ this.cacheRestoreLimit = Math.max(1, Math.floor(documentCacheConfig?.restoreLimit ?? this.maxCachedDocs));
74
+ this.snapshotMinChanges = Math.max(1, Math.floor(snapshotConfig?.minChanges ?? DEFAULT_SNAPSHOT_MIN_CHANGES));
75
+ this.snapshotCooldownMs = Math.max(0, Math.floor(snapshotConfig?.cooldownMs ?? DEFAULT_SNAPSHOT_COOLDOWN_MS));
59
76
  // Create logger if not provided (for backward compatibility)
60
77
  this.logger =
61
78
  logger ||
@@ -72,6 +89,207 @@ class BaseMindooDB {
72
89
  isAdminOnlyDb() {
73
90
  return this._isAdminOnlyDb;
74
91
  }
92
+ // ---------------------------------------------------------------------------
93
+ // Local cache support (ICacheable)
94
+ // ---------------------------------------------------------------------------
95
+ /**
96
+ * Attach a CacheManager so this DB participates in periodic cache flushing.
97
+ */
98
+ setCacheManager(cacheManager) {
99
+ this.cacheManager = cacheManager;
100
+ const cacheIdentity = this.store.getCacheIdentity?.() ?? this.store.getId();
101
+ this.cachePrefix = `${this.tenant.getId()}/${cacheIdentity}`;
102
+ cacheManager.register(this);
103
+ }
104
+ getCachePrefix() {
105
+ return this.cachePrefix ?? `${this.tenant.getId()}/${this.store.getId()}`;
106
+ }
107
+ hasDirtyState() {
108
+ return this.dirtyDocIds.size > 0 || this.cacheMetaDirty;
109
+ }
110
+ clearDirty() {
111
+ this.dirtyDocIds.clear();
112
+ this.cacheMetaDirty = false;
113
+ this.evictCachedDocsIfNeeded();
114
+ }
115
+ markDocDirty(docId) {
116
+ this.dirtyDocIds.add(docId);
117
+ this.cacheMetaDirty = true;
118
+ this.cacheManager?.markDirty();
119
+ }
120
+ getCachedDocument(docId) {
121
+ const cached = this.docCache.get(docId) ?? null;
122
+ if (!cached) {
123
+ return null;
124
+ }
125
+ this.touchCachedDocument(docId, cached);
126
+ return cached;
127
+ }
128
+ touchCachedDocument(docId, internalDoc) {
129
+ this.docCache.delete(docId);
130
+ this.docCache.set(docId, internalDoc);
131
+ }
132
+ storeCachedDocument(internalDoc) {
133
+ this.touchCachedDocument(internalDoc.id, internalDoc);
134
+ this.evictCachedDocsIfNeeded(new Set([internalDoc.id]));
135
+ }
136
+ evictCachedDocsIfNeeded(protectedDocIds) {
137
+ if (this.docCache.size <= this.maxCachedDocs) {
138
+ return;
139
+ }
140
+ for (const [docId] of this.docCache) {
141
+ if (this.docCache.size <= this.maxCachedDocs) {
142
+ break;
143
+ }
144
+ if (protectedDocIds?.has(docId)) {
145
+ continue;
146
+ }
147
+ this.docCache.delete(docId);
148
+ this.dirtyDocIds.delete(docId);
149
+ }
150
+ }
151
+ /**
152
+ * Flush dirty documents and metadata to the cache store.
153
+ */
154
+ async flushToCache(store) {
155
+ const prefix = this.getCachePrefix();
156
+ let written = 0;
157
+ for (const docId of this.dirtyDocIds) {
158
+ const internal = this.docCache.get(docId);
159
+ if (!internal)
160
+ continue;
161
+ const amBinary = automerge_adapter_1.Automerge.save(internal.doc);
162
+ const header = JSON.stringify({
163
+ id: internal.id,
164
+ createdAt: internal.createdAt,
165
+ lastModified: internal.lastModified,
166
+ decryptionKeyId: internal.decryptionKeyId,
167
+ isDeleted: internal.isDeleted,
168
+ });
169
+ const headerBytes = new TextEncoder().encode(header);
170
+ // Format: 4-byte header length (big-endian) + header JSON + Automerge binary
171
+ const value = new Uint8Array(4 + headerBytes.length + amBinary.length);
172
+ const view = new DataView(value.buffer);
173
+ view.setUint32(0, headerBytes.length, false);
174
+ value.set(headerBytes, 4);
175
+ value.set(amBinary, 4 + headerBytes.length);
176
+ await store.put("doc", `${prefix}/${docId}`, value);
177
+ written++;
178
+ }
179
+ // Write metadata checkpoint
180
+ const meta = this.exportMetadataCheckpoint();
181
+ await store.put("db-meta", prefix, meta);
182
+ written++;
183
+ return written;
184
+ }
185
+ exportMetadataCheckpoint() {
186
+ const checkpoint = {
187
+ version: 1,
188
+ processedEntryCursor: this.processedEntryCursor,
189
+ index: this.index,
190
+ nextChangeSeq: this.nextChangeSeq,
191
+ };
192
+ // Serialize automergeHashToEntryId: Map<string, Map<string,string>> -> nested object
193
+ const hashMap = {};
194
+ for (const [docId, inner] of this.automergeHashToEntryId) {
195
+ hashMap[docId] = Object.fromEntries(inner);
196
+ }
197
+ checkpoint.automergeHashToEntryId = hashMap;
198
+ // For stores without cursor scan, include processedEntryIds
199
+ if (!this.supportsCursorScan(this.store)) {
200
+ checkpoint.processedEntryIds = this.processedEntryIds;
201
+ }
202
+ return new TextEncoder().encode(JSON.stringify(checkpoint));
203
+ }
204
+ /**
205
+ * Attempt to restore state from cache. Returns true on success.
206
+ */
207
+ async restoreFromCache(store) {
208
+ const prefix = this.getCachePrefix();
209
+ try {
210
+ // 1. Load metadata checkpoint
211
+ const metaBytes = await store.get("db-meta", prefix);
212
+ if (!metaBytes) {
213
+ this.logger.debug("No cache metadata found, will do full rebuild");
214
+ return false;
215
+ }
216
+ const checkpoint = JSON.parse(new TextDecoder().decode(metaBytes));
217
+ if (checkpoint.version !== 1) {
218
+ this.logger.warn(`Unknown cache version ${checkpoint.version}, ignoring cache`);
219
+ return false;
220
+ }
221
+ // 2. Restore metadata
222
+ this.processedEntryCursor = checkpoint.processedEntryCursor ?? null;
223
+ this.index = checkpoint.index ?? [];
224
+ this.nextChangeSeq = checkpoint.nextChangeSeq ?? 1;
225
+ if (checkpoint.processedEntryIds) {
226
+ this.processedEntryIds = checkpoint.processedEntryIds;
227
+ }
228
+ // Rebuild indexLookup from index
229
+ this.indexLookup.clear();
230
+ for (let i = 0; i < this.index.length; i++) {
231
+ if (typeof this.index[i].changeSeq !== "number") {
232
+ this.index[i].changeSeq = i + 1;
233
+ }
234
+ this.indexLookup.set(this.index[i].docId, i);
235
+ }
236
+ if (this.index.length > 0 && this.nextChangeSeq <= this.index.length) {
237
+ this.nextChangeSeq = Math.max(...this.index.map(e => e.changeSeq)) + 1;
238
+ }
239
+ // Restore automergeHashToEntryId
240
+ if (checkpoint.automergeHashToEntryId) {
241
+ this.automergeHashToEntryId.clear();
242
+ for (const [docId, inner] of Object.entries(checkpoint.automergeHashToEntryId)) {
243
+ this.automergeHashToEntryId.set(docId, new Map(Object.entries(inner)));
244
+ }
245
+ }
246
+ // 3. Load cached documents
247
+ const docIds = await store.list("doc");
248
+ const docPrefix = `${prefix}/`;
249
+ let restoredDocs = 0;
250
+ for (const id of docIds) {
251
+ if (restoredDocs >= this.cacheRestoreLimit) {
252
+ break;
253
+ }
254
+ if (!id.startsWith(docPrefix))
255
+ continue;
256
+ const docId = id.slice(docPrefix.length);
257
+ const docBytes = await store.get("doc", id);
258
+ if (!docBytes)
259
+ continue;
260
+ try {
261
+ const internal = this.deserializeDoc(docBytes);
262
+ this.storeCachedDocument(internal);
263
+ restoredDocs++;
264
+ }
265
+ catch (e) {
266
+ this.logger.warn(`Failed to restore cached doc ${docId}, will reload from store: ${e}`);
267
+ }
268
+ }
269
+ this.logger.info(`Restored ${restoredDocs} documents from cache for ${prefix}`);
270
+ return true;
271
+ }
272
+ catch (e) {
273
+ this.logger.warn(`Cache restore failed, will do full rebuild: ${e}`);
274
+ return false;
275
+ }
276
+ }
277
+ deserializeDoc(value) {
278
+ const view = new DataView(value.buffer, value.byteOffset, value.byteLength);
279
+ const headerLen = view.getUint32(0, false);
280
+ const headerBytes = value.slice(4, 4 + headerLen);
281
+ const amBinary = value.slice(4 + headerLen);
282
+ const header = JSON.parse(new TextDecoder().decode(headerBytes));
283
+ const doc = automerge_adapter_1.Automerge.load(amBinary);
284
+ return {
285
+ id: header.id,
286
+ doc,
287
+ createdAt: header.createdAt,
288
+ lastModified: header.lastModified,
289
+ decryptionKeyId: header.decryptionKeyId,
290
+ isDeleted: header.isDeleted,
291
+ };
292
+ }
75
293
  /**
76
294
  * Get or import a CryptoKey for signature verification, with caching.
77
295
  * This avoids re-importing the same public key multiple times.
@@ -113,18 +331,18 @@ class BaseMindooDB {
113
331
  /**
114
332
  * Compare two index entries for sorting.
115
333
  * Returns negative if a < b, positive if a > b, 0 if equal.
116
- * Sorts by lastModified first, then by docId for uniqueness.
334
+ * Sorts by deterministic changeSeq first, then by docId for uniqueness.
117
335
  */
118
336
  compareIndexEntries(a, b) {
119
- if (a.lastModified !== b.lastModified) {
120
- return a.lastModified - b.lastModified;
337
+ if (a.changeSeq !== b.changeSeq) {
338
+ return a.changeSeq - b.changeSeq;
121
339
  }
122
340
  return a.docId.localeCompare(b.docId);
123
341
  }
124
342
  /**
125
343
  * 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).
344
+ * When a document changes, it gets a new monotonic sequence and is moved to
345
+ * maintain order by (changeSeq, docId).
128
346
  *
129
347
  * Optimized to only update lookup map for entries that actually moved.
130
348
  *
@@ -133,12 +351,14 @@ class BaseMindooDB {
133
351
  * @param isDeleted Whether the document is deleted
134
352
  */
135
353
  updateIndex(docId, lastModified, isDeleted) {
136
- const newEntry = { docId, lastModified, isDeleted };
354
+ const startedAt = Date.now();
355
+ const assignedSeq = this.nextChangeSeq;
356
+ const newEntry = { docId, changeSeq: assignedSeq, lastModified, isDeleted };
137
357
  const existingIndex = this.indexLookup.get(docId);
138
358
  // Check if the entry already exists and hasn't changed position
139
359
  if (existingIndex !== undefined) {
140
360
  const existingEntry = this.index[existingIndex];
141
- // If position hasn't changed (same lastModified and docId), no update needed
361
+ // If only metadata flags stayed identical and caller replays same state, skip.
142
362
  if (existingEntry.lastModified === lastModified && existingEntry.isDeleted === isDeleted) {
143
363
  return; // No change needed
144
364
  }
@@ -171,11 +391,17 @@ class BaseMindooDB {
171
391
  }
172
392
  // Insert at the correct position
173
393
  this.index.splice(insertIndex, 0, newEntry);
394
+ this.nextChangeSeq = assignedSeq + 1;
174
395
  // Update lookup map for entries from insertion point onwards
175
396
  // Only update entries that actually moved (from insertIndex to end)
176
397
  for (let i = insertIndex; i < this.index.length; i++) {
177
398
  this.indexLookup.set(this.index[i].docId, i);
178
399
  }
400
+ this.performanceCallback?.onIndexUpdate?.({
401
+ docId,
402
+ operation: existingIndex === undefined ? "insert" : "update",
403
+ time: Date.now() - startedAt,
404
+ });
179
405
  }
180
406
  /**
181
407
  * Register an automerge hash -> entry ID mapping for a document.
@@ -223,9 +449,20 @@ class BaseMindooDB {
223
449
  }
224
450
  /**
225
451
  * Initialize the database instance.
452
+ * If a cache store is available, attempts to restore from cache first,
453
+ * then processes only the delta. Falls back to full rebuild on cache miss.
226
454
  */
227
455
  async initialize() {
228
456
  this.logger.info(`Initializing database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
457
+ const cacheStore = this.cacheManager?.getStore();
458
+ if (cacheStore) {
459
+ const restored = await this.restoreFromCache(cacheStore);
460
+ if (restored) {
461
+ this.logger.info("Restored from cache, processing delta only");
462
+ await this.syncStoreChanges();
463
+ return;
464
+ }
465
+ }
229
466
  await this.syncStoreChanges();
230
467
  }
231
468
  /**
@@ -234,6 +471,7 @@ class BaseMindooDB {
234
471
  * On first call (when processedEntryIds is empty), it will process all entries.
235
472
  */
236
473
  async syncStoreChanges() {
474
+ const syncStartedAt = Date.now();
237
475
  this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
238
476
  this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
239
477
  // Find new entries that we haven't processed yet
@@ -241,6 +479,14 @@ class BaseMindooDB {
241
479
  this.logger.debug(`Found ${newEntryMetadata.length} new entries`);
242
480
  if (newEntryMetadata.length === 0) {
243
481
  this.logger.debug(`No new entries to process`);
482
+ this.performanceCallback?.onSyncOperation?.({
483
+ operation: "findNewEntries",
484
+ time: Date.now() - syncStartedAt,
485
+ details: {
486
+ newEntryCount: 0,
487
+ processedEntryCount: this.processedEntryIds.length,
488
+ },
489
+ });
244
490
  return;
245
491
  }
246
492
  // Group new entries by document ID
@@ -257,10 +503,11 @@ class BaseMindooDB {
257
503
  this.logger.debug(`Processing ${entriesByDoc.size} documents with new entries`);
258
504
  // Helper function to process a single document
259
505
  const processDocument = async (docId, entryMetadataList) => {
506
+ const processStartedAt = Date.now();
260
507
  try {
261
508
  this.logger.debug(`===== Processing document ${docId} with ${entryMetadataList.length} new entry(s) in syncStoreChanges =====`);
262
509
  // Check if document is cached
263
- const cachedDoc = this.docCache.get(docId);
510
+ const cachedDoc = this.getCachedDocument(docId);
264
511
  let updatedDoc = null;
265
512
  if (cachedDoc) {
266
513
  // Document is cached - try incremental update
@@ -288,16 +535,37 @@ class BaseMindooDB {
288
535
  }
289
536
  }
290
537
  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})`);
538
+ // Metadata-first startup: avoid full materialization for uncached docs.
539
+ // Index based on doc-lifecycle entries (create/change/delete) plus
540
+ // doc_snapshot which may be the sole entry after a dense sync.
541
+ // Attachment-only batches should not trigger document index updates.
542
+ const docLifecycleEntries = entryMetadataList.filter((e) => e.entryType === "doc_create" ||
543
+ e.entryType === "doc_change" ||
544
+ e.entryType === "doc_delete" ||
545
+ e.entryType === "doc_snapshot");
546
+ if (docLifecycleEntries.length === 0) {
547
+ this.logger.debug(`Skipping metadata-first index for doc ${docId} — no document lifecycle entries (${entryMetadataList.length} attachment/other entries only)`);
298
548
  }
299
549
  else {
300
- this.logger.warn(`Document ${docId} returned null from loadDocumentInternal`);
550
+ // Before indexing, verify the user has the decryption key so that
551
+ // documents the user cannot access do not appear in getAllDocumentIds.
552
+ const representativeEntry = docLifecycleEntries[0];
553
+ const keyAvailable = await this.tenant.hasDecryptionKey(representativeEntry.decryptionKeyId);
554
+ if (!keyAvailable) {
555
+ this.logger.debug(`Skipping metadata-first index for doc ${docId} — decryption key "${representativeEntry.decryptionKeyId}" not available`);
556
+ }
557
+ else {
558
+ const mutationEntries = docLifecycleEntries.filter((e) => e.entryType === "doc_create" ||
559
+ e.entryType === "doc_change" ||
560
+ e.entryType === "doc_delete");
561
+ // Snapshots compact replay history but should not change the user-visible
562
+ // modification time when the original create/change/delete entries still exist.
563
+ const entriesForLastModified = mutationEntries.length > 0 ? mutationEntries : docLifecycleEntries;
564
+ const lastModified = Math.max(...entriesForLastModified.map((e) => e.createdAt));
565
+ const isDeleted = mutationEntries.some((e) => e.entryType === "doc_delete");
566
+ this.updateIndex(docId, lastModified, isDeleted);
567
+ this.logger.debug(`Metadata-first update for uncached doc ${docId} (lastModified: ${lastModified}, isDeleted: ${isDeleted})`);
568
+ }
301
569
  }
302
570
  }
303
571
  }
@@ -313,31 +581,45 @@ class BaseMindooDB {
313
581
  // Re-throw the error so we can see what's happening in the test
314
582
  throw error;
315
583
  }
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
- }
584
+ finally {
585
+ this.performanceCallback?.onSyncOperation?.({
586
+ operation: "processDocument",
587
+ time: Date.now() - processStartedAt,
588
+ details: {
589
+ docId,
590
+ entryCount: entryMetadataList.length,
591
+ cacheHit: this.docCache.has(docId),
592
+ },
593
+ });
336
594
  }
595
+ };
596
+ const documentEntries = Array.from(entriesByDoc.entries())
597
+ .sort((a, b) => {
598
+ const aMinCreatedAt = Math.min(...a[1].map((e) => e.createdAt));
599
+ const bMinCreatedAt = Math.min(...b[1].map((e) => e.createdAt));
600
+ if (aMinCreatedAt !== bMinCreatedAt) {
601
+ return aMinCreatedAt - bMinCreatedAt;
602
+ }
603
+ return a[0].localeCompare(b[0]);
604
+ });
605
+ // Deterministic sequential processing ensures stable changefeed ordering.
606
+ for (const [docId, entryMetadataList] of documentEntries) {
607
+ await processDocument(docId, entryMetadataList);
337
608
  }
338
609
  // Append new entry IDs to our processed list
339
610
  this.processedEntryIds.push(...newEntryMetadata.map(em => em.id));
611
+ this.cacheMetaDirty = true;
612
+ this.cacheManager?.markDirty();
340
613
  this.logger.debug(`Synced ${newEntryMetadata.length} new entries, index now has ${this.index.length} documents`);
614
+ this.performanceCallback?.onSyncOperation?.({
615
+ operation: "findNewEntries",
616
+ time: Date.now() - syncStartedAt,
617
+ details: {
618
+ newEntryCount: newEntryMetadata.length,
619
+ documentCount: entriesByDoc.size,
620
+ indexSize: this.index.length,
621
+ },
622
+ });
341
623
  }
342
624
  supportsCursorScan(store) {
343
625
  return typeof store.scanEntriesSince === "function";
@@ -362,8 +644,19 @@ class BaseMindooDB {
362
644
  return all;
363
645
  }
364
646
  async getNewEntryMetadataForSync() {
647
+ const startedAt = Date.now();
365
648
  if (!this.supportsCursorScan(this.store)) {
366
- return this.store.findNewEntries(this.processedEntryIds);
649
+ const result = await this.store.findNewEntries(this.processedEntryIds);
650
+ this.performanceCallback?.onSyncOperation?.({
651
+ operation: "findNewEntries",
652
+ time: Date.now() - startedAt,
653
+ details: {
654
+ mode: "knownIds",
655
+ resultCount: result.length,
656
+ processedEntryCount: this.processedEntryIds.length,
657
+ },
658
+ });
659
+ return result;
367
660
  }
368
661
  const allNew = [];
369
662
  let cursor = this.processedEntryCursor;
@@ -376,67 +669,244 @@ class BaseMindooDB {
376
669
  }
377
670
  }
378
671
  this.processedEntryCursor = cursor;
672
+ this.performanceCallback?.onSyncOperation?.({
673
+ operation: "findNewEntries",
674
+ time: Date.now() - startedAt,
675
+ details: {
676
+ mode: "cursorScan",
677
+ resultCount: allNew.length,
678
+ cursor,
679
+ },
680
+ });
379
681
  return allNew;
380
682
  }
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);
683
+ /**
684
+ * Fetch the target store's bloom filter summary, returning null if
685
+ * unsupported or on error (callers fall back to exact checks).
686
+ */
687
+ async getTargetBloomSummary(targetStore) {
688
+ if (typeof targetStore.getIdBloomSummary !== "function") {
689
+ return null;
690
+ }
691
+ try {
692
+ return await targetStore.getIdBloomSummary();
693
+ }
694
+ catch (error) {
695
+ this.logger.warn("Failed to get bloom summary from target store, falling back to exact checks", error);
696
+ return null;
697
+ }
698
+ }
699
+ /**
700
+ * From a list of candidate IDs, return only those the target store is
701
+ * missing. Uses bloom-filter pre-screening when available, then falls
702
+ * back to exact `hasEntries` for the uncertain set.
703
+ */
704
+ async filterMissingIds(targetStore, candidateIds, bloom) {
705
+ let definitelyMissing = [];
706
+ let maybeExisting = candidateIds;
707
+ if (bloom) {
708
+ definitelyMissing = [];
709
+ maybeExisting = [];
710
+ for (const id of candidateIds) {
711
+ if ((0, bloom_1.bloomMightContainId)(bloom, id)) {
712
+ maybeExisting.push(id);
713
+ }
714
+ else {
715
+ definitelyMissing.push(id);
716
+ }
390
717
  }
391
718
  }
719
+ let missingIds = definitelyMissing;
720
+ if (maybeExisting.length > 0) {
721
+ const existing = await targetStore.hasEntries(maybeExisting);
722
+ const existingSet = new Set(existing);
723
+ missingIds = missingIds.concat(maybeExisting.filter((id) => !existingSet.has(id)));
724
+ }
725
+ return missingIds;
726
+ }
727
+ async syncEntriesFromStore(sourceStore, targetStore, options) {
728
+ if (options?.mode === "dense") {
729
+ return this.syncEntriesFromStoreDense(sourceStore, targetStore, options);
730
+ }
731
+ let transferred = 0;
732
+ let scanned = 0;
733
+ const onProgress = options?.onProgress;
734
+ const pageSize = options?.pageSize ?? 1000;
735
+ const targetBloom = await this.getTargetBloomSummary(targetStore);
736
+ const totalSourceEstimate = targetBloom?.totalIds;
392
737
  if (this.supportsCursorScan(sourceStore)) {
738
+ onProgress?.({
739
+ phase: 'preparing',
740
+ message: 'Preparing to sync entries...',
741
+ transferredEntries: 0,
742
+ scannedEntries: 0,
743
+ totalSourceEntries: totalSourceEstimate,
744
+ });
393
745
  let cursor = null;
746
+ let currentPage = 0;
394
747
  while (true) {
395
- const page = await sourceStore.scanEntriesSince(cursor, 1000);
748
+ if (options?.signal?.aborted) {
749
+ return { transferred, cancelled: true };
750
+ }
751
+ const page = await sourceStore.scanEntriesSince(cursor, pageSize);
752
+ currentPage++;
753
+ scanned += page.entries.length;
396
754
  if (page.entries.length > 0) {
397
755
  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
- }
756
+ const missingIds = await this.filterMissingIds(targetStore, ids, targetBloom);
419
757
  if (missingIds.length > 0) {
420
758
  const missingEntries = await sourceStore.getEntries(missingIds);
421
759
  await targetStore.putEntries(missingEntries);
422
760
  transferred += missingEntries.length;
423
761
  }
424
762
  }
763
+ onProgress?.({
764
+ phase: 'transferring',
765
+ message: `Transferred ${transferred} entries (page ${currentPage}, scanned ${scanned})`,
766
+ transferredEntries: transferred,
767
+ scannedEntries: scanned,
768
+ totalSourceEntries: totalSourceEstimate,
769
+ currentPage,
770
+ });
425
771
  cursor = page.nextCursor;
426
772
  if (!page.hasMore) {
427
773
  break;
428
774
  }
429
775
  }
430
- return transferred;
776
+ return { transferred, cancelled: false };
431
777
  }
778
+ onProgress?.({
779
+ phase: 'preparing',
780
+ message: 'Finding new entries...',
781
+ transferredEntries: 0,
782
+ scannedEntries: 0,
783
+ });
432
784
  const targetIds = await targetStore.getAllIds();
433
785
  const sourceNewMetadata = await sourceStore.findNewEntries(targetIds);
434
786
  if (sourceNewMetadata.length === 0) {
435
- return 0;
787
+ return { transferred: 0, cancelled: false };
436
788
  }
789
+ if (options?.signal?.aborted) {
790
+ return { transferred: 0, cancelled: true };
791
+ }
792
+ onProgress?.({
793
+ phase: 'transferring',
794
+ message: `Transferring ${sourceNewMetadata.length} entries...`,
795
+ transferredEntries: 0,
796
+ scannedEntries: sourceNewMetadata.length,
797
+ totalSourceEntries: sourceNewMetadata.length,
798
+ });
437
799
  const sourceEntries = await sourceStore.getEntries(sourceNewMetadata.map((m) => m.id));
438
800
  await targetStore.putEntries(sourceEntries);
439
- return sourceEntries.length;
801
+ onProgress?.({
802
+ phase: 'transferring',
803
+ message: `Transferred ${sourceEntries.length} entries`,
804
+ transferredEntries: sourceEntries.length,
805
+ scannedEntries: sourceNewMetadata.length,
806
+ totalSourceEntries: sourceNewMetadata.length,
807
+ });
808
+ return { transferred: sourceEntries.length, cancelled: false };
809
+ }
810
+ /**
811
+ * Dense sync: transfer only the entries required to reconstruct the latest
812
+ * state of each document, using the batch materialization planner to skip
813
+ * historical entries already superseded by snapshots.
814
+ *
815
+ * Algorithm:
816
+ * 1. Discover all documents on the source via `doc_create` metadata.
817
+ * 2. Also fetch `doc_delete` metadata so deletion markers are transferred.
818
+ * 3. Ask the source's batch planner for the optimal replay set per document.
819
+ * 4. Merge all entry IDs: doc_create + doc_delete + snapshot + uncovered changes.
820
+ * 5. Filter out IDs the target already has (bloom + exact check).
821
+ * 6. Transfer only the missing entries.
822
+ *
823
+ * Attachment chunks are intentionally skipped — they are not part of the
824
+ * Automerge document DAG and can be fetched on demand when accessed.
825
+ */
826
+ async syncEntriesFromStoreDense(sourceStore, targetStore, options) {
827
+ const onProgress = options?.onProgress;
828
+ onProgress?.({
829
+ phase: "preparing",
830
+ message: "Dense sync: discovering documents on source...",
831
+ transferredEntries: 0,
832
+ scannedEntries: 0,
833
+ });
834
+ if (options?.signal?.aborted) {
835
+ return { transferred: 0, cancelled: true };
836
+ }
837
+ // ── Phase 1: discover documents ──────────────────────────────────
838
+ const docCreateEntries = await sourceStore.findEntries("doc_create", null, null);
839
+ const docDeleteEntries = await sourceStore.findEntries("doc_delete", null, null);
840
+ const docIds = [...new Set(docCreateEntries.map((e) => e.docId))];
841
+ this.logger.info(`Dense sync: found ${docIds.length} documents on source`);
842
+ if (docIds.length === 0) {
843
+ return { transferred: 0, cancelled: false };
844
+ }
845
+ if (options?.signal?.aborted) {
846
+ return { transferred: 0, cancelled: true };
847
+ }
848
+ // ── Phase 2: batch plan on source ────────────────────────────────
849
+ onProgress?.({
850
+ phase: "planning",
851
+ message: `Dense sync: computing materialization plans for ${docIds.length} documents...`,
852
+ transferredEntries: 0,
853
+ scannedEntries: docIds.length,
854
+ });
855
+ const batchPlan = await sourceStore.planDocumentMaterializationBatch(docIds);
856
+ // ── Phase 3: collect needed entry IDs ────────────────────────────
857
+ const neededIds = new Set();
858
+ for (const entry of docCreateEntries) {
859
+ neededIds.add(entry.id);
860
+ }
861
+ for (const entry of docDeleteEntries) {
862
+ neededIds.add(entry.id);
863
+ }
864
+ for (const plan of batchPlan.plans) {
865
+ if (plan.snapshotEntryId) {
866
+ neededIds.add(plan.snapshotEntryId);
867
+ }
868
+ for (const id of plan.entryIdsToApply) {
869
+ neededIds.add(id);
870
+ }
871
+ }
872
+ this.logger.info(`Dense sync: planner identified ${neededIds.size} required entries ` +
873
+ `(from ${docIds.length} documents)`);
874
+ if (options?.signal?.aborted) {
875
+ return { transferred: 0, cancelled: true };
876
+ }
877
+ // ── Phase 4: filter out entries the target already has ───────────
878
+ const allNeededArray = Array.from(neededIds);
879
+ const targetBloom = await this.getTargetBloomSummary(targetStore);
880
+ const missingIds = await this.filterMissingIds(targetStore, allNeededArray, targetBloom);
881
+ this.logger.info(`Dense sync: ${missingIds.length} entries to transfer ` +
882
+ `(${allNeededArray.length - missingIds.length} already present)`);
883
+ if (missingIds.length === 0) {
884
+ return { transferred: 0, cancelled: false };
885
+ }
886
+ if (options?.signal?.aborted) {
887
+ return { transferred: 0, cancelled: true };
888
+ }
889
+ // ── Phase 5: transfer missing entries in pages ───────────────────
890
+ const pageSize = options?.pageSize ?? 500;
891
+ let transferred = 0;
892
+ for (let offset = 0; offset < missingIds.length; offset += pageSize) {
893
+ if (options?.signal?.aborted) {
894
+ return { transferred, cancelled: true };
895
+ }
896
+ const batch = missingIds.slice(offset, offset + pageSize);
897
+ const entries = await sourceStore.getEntries(batch);
898
+ await targetStore.putEntries(entries);
899
+ transferred += entries.length;
900
+ onProgress?.({
901
+ phase: "transferring",
902
+ message: `Dense sync: transferred ${transferred}/${missingIds.length} entries`,
903
+ transferredEntries: transferred,
904
+ scannedEntries: allNeededArray.length,
905
+ totalSourceEntries: allNeededArray.length,
906
+ });
907
+ }
908
+ this.logger.info(`Dense sync complete: transferred ${transferred} entries`);
909
+ return { transferred, cancelled: false };
440
910
  }
441
911
  getStore() {
442
912
  return this.store;
@@ -575,8 +1045,9 @@ class BaseMindooDB {
575
1045
  isDeleted: false,
576
1046
  };
577
1047
  // Update cache and index
578
- this.docCache.set(docId, internalDoc);
1048
+ this.storeCachedDocument(internalDoc);
579
1049
  this.updateIndex(docId, internalDoc.lastModified, false);
1050
+ this.markDocDirty(docId);
580
1051
  this.logger.info(`Document ${docId} created successfully`);
581
1052
  this.logger.debug(`Document ${docId} cached and indexed (lastModified: ${internalDoc.lastModified})`);
582
1053
  return this.wrapDocument(internalDoc);
@@ -592,6 +1063,7 @@ class BaseMindooDB {
592
1063
  return this.wrapDocument(internalDoc);
593
1064
  }
594
1065
  async getDocumentAtTimestamp(docId, timestamp) {
1066
+ const startedAt = Date.now();
595
1067
  this.logger.debug(`Getting document ${docId} at timestamp ${timestamp}`);
596
1068
  // Get all entry metadata for this document
597
1069
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
@@ -600,6 +1072,13 @@ class BaseMindooDB {
600
1072
  .filter(em => em.createdAt <= timestamp)
601
1073
  .sort((a, b) => a.createdAt - b.createdAt);
602
1074
  if (relevantEntries.length === 0) {
1075
+ this.performanceCallback?.onHistoryOperation?.({
1076
+ operation: "getDocumentAtTimestamp",
1077
+ docId,
1078
+ time: Date.now() - startedAt,
1079
+ scannedEntries: 0,
1080
+ bounded: false,
1081
+ });
603
1082
  return null; // Document didn't exist at that time
604
1083
  }
605
1084
  // Load entries
@@ -647,6 +1126,14 @@ class BaseMindooDB {
647
1126
  decryptionKeyId,
648
1127
  isDeleted,
649
1128
  };
1129
+ this.performanceCallback?.onHistoryOperation?.({
1130
+ operation: "getDocumentAtTimestamp",
1131
+ docId,
1132
+ time: Date.now() - startedAt,
1133
+ scannedEntries: relevantEntries.length,
1134
+ returnedEntries: 1,
1135
+ bounded: false,
1136
+ });
650
1137
  return this.wrapDocument(internalDoc);
651
1138
  }
652
1139
  async *iterateDocumentHistory(docId) {
@@ -750,6 +1237,46 @@ class BaseMindooDB {
750
1237
  }
751
1238
  }
752
1239
  }
1240
+ async getDocumentHistoryPage(docId, options) {
1241
+ const startedAt = Date.now();
1242
+ const limit = Math.max(1, Math.floor(options?.limit ?? 100));
1243
+ const offset = Math.max(0, Math.floor(options?.cursor?.offset ?? 0));
1244
+ // This API stays metadata-only so large history views can page cheaply
1245
+ // without reconstructing every historical Automerge document state.
1246
+ const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
1247
+ const relevantEntries = allEntryMetadata
1248
+ .filter((em) => em.entryType === "doc_create" ||
1249
+ em.entryType === "doc_change" ||
1250
+ em.entryType === "doc_delete")
1251
+ .sort((a, b) => a.createdAt - b.createdAt);
1252
+ // Cursor paging is offset-based because the result is a bounded timeline view
1253
+ // over the doc's sorted change metadata, not a resumable store scan cursor.
1254
+ const slice = relevantEntries.slice(offset, offset + limit);
1255
+ const entries = slice.map((entry) => ({
1256
+ entryId: entry.id,
1257
+ entryType: entry.entryType,
1258
+ changeCreatedAt: entry.createdAt,
1259
+ changeCreatedByPublicKey: entry.createdByPublicKey,
1260
+ dependencyIds: [...entry.dependencyIds],
1261
+ isDeleted: entry.entryType === "doc_delete",
1262
+ }));
1263
+ const nextOffset = offset + entries.length;
1264
+ const hasMore = nextOffset < relevantEntries.length;
1265
+ const result = {
1266
+ entries,
1267
+ nextCursor: hasMore ? { offset: nextOffset } : null,
1268
+ hasMore,
1269
+ };
1270
+ this.performanceCallback?.onHistoryOperation?.({
1271
+ operation: "getDocumentHistoryPage",
1272
+ docId,
1273
+ time: Date.now() - startedAt,
1274
+ scannedEntries: relevantEntries.length,
1275
+ returnedEntries: entries.length,
1276
+ bounded: true,
1277
+ });
1278
+ return result;
1279
+ }
753
1280
  async getAllDocumentIds() {
754
1281
  // Return all non-deleted document IDs from index
755
1282
  const docIds = [];
@@ -836,6 +1363,7 @@ class BaseMindooDB {
836
1363
  if (!internalDoc || internalDoc.isDeleted) {
837
1364
  throw new Error(`Document ${docId} not found or already deleted`);
838
1365
  }
1366
+ const now = Date.now();
839
1367
  // Create deletion change by clearing all fields from the document
840
1368
  // This ensures Automerge produces actual change bytes
841
1369
  // The deletion is also tracked via the "doc_delete" type entry in the append-only store
@@ -846,6 +1374,7 @@ class BaseMindooDB {
846
1374
  }
847
1375
  // Mark as deleted for clarity
848
1376
  doc._deleted = true;
1377
+ doc._lastModified = now;
849
1378
  });
850
1379
  // Get the change bytes from the document
851
1380
  const changeBytes = automerge_adapter_1.Automerge.getLastLocalChange(newDoc);
@@ -885,7 +1414,7 @@ class BaseMindooDB {
885
1414
  contentHash,
886
1415
  docId,
887
1416
  dependencyIds,
888
- createdAt: Date.now(),
1417
+ createdAt: now,
889
1418
  createdByPublicKey,
890
1419
  decryptionKeyId: internalDoc.decryptionKeyId,
891
1420
  signature,
@@ -903,9 +1432,10 @@ class BaseMindooDB {
903
1432
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
904
1433
  // Update cache and index
905
1434
  internalDoc.isDeleted = true;
906
- internalDoc.lastModified = Date.now();
907
- this.docCache.set(docId, internalDoc);
1435
+ internalDoc.lastModified = now;
1436
+ this.storeCachedDocument(internalDoc);
908
1437
  this.updateIndex(docId, internalDoc.lastModified, true);
1438
+ this.markDocDirty(docId);
909
1439
  this.logger.info(`Document ${docId} deleted successfully`);
910
1440
  }
911
1441
  async changeDoc(doc, changeFunc) {
@@ -929,7 +1459,7 @@ class BaseMindooDB {
929
1459
  }
930
1460
  }
931
1461
  // Get internal document from cache or load it
932
- let internalDoc = this.docCache.get(docId);
1462
+ let internalDoc = this.getCachedDocument(docId);
933
1463
  if (!internalDoc) {
934
1464
  this.logger.debug(`Document ${docId} not in cache, loading from store`);
935
1465
  const loadedDoc = await this.loadDocumentInternal(docId);
@@ -1208,7 +1738,7 @@ class BaseMindooDB {
1208
1738
  contentHash,
1209
1739
  docId,
1210
1740
  dependencyIds,
1211
- createdAt: Date.now(),
1741
+ createdAt: now,
1212
1742
  createdByPublicKey,
1213
1743
  decryptionKeyId: internalDoc.decryptionKeyId,
1214
1744
  signature,
@@ -1226,98 +1756,242 @@ class BaseMindooDB {
1226
1756
  this.registerAutomergeHashMapping(docId, automergeHash, entryId);
1227
1757
  // Update cache and index
1228
1758
  internalDoc.doc = newDoc;
1229
- internalDoc.lastModified = Date.now();
1230
- this.docCache.set(docId, internalDoc);
1759
+ internalDoc.lastModified = now;
1760
+ this.storeCachedDocument(internalDoc);
1231
1761
  this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
1762
+ this.markDocDirty(docId);
1232
1763
  this.logger.info(`Document ${docId} ${useCustomKey ? 'changed with custom signing key' : 'changed'} successfully`);
1764
+ // Snapshot creation runs as a best-effort background optimization.
1765
+ await this.maybeWriteSnapshotForDocument(internalDoc, useCustomKey
1766
+ ? { signingKeyPair, signingKeyPassword, createdByPublicKey }
1767
+ : { createdByPublicKey });
1233
1768
  }
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
- }
1769
+ /**
1770
+ * Best-effort snapshot scheduling and writing.
1771
+ * A snapshot is written only when enough replay history has accumulated and
1772
+ * a cooldown window has elapsed, to avoid snapshot churn on hot documents.
1773
+ */
1774
+ async maybeWriteSnapshotForDocument(internalDoc, options) {
1775
+ const docId = internalDoc.id;
1776
+ try {
1777
+ const allMetadata = await this.scanAllMetadata(this.store, { docId });
1778
+ const replayEntries = allMetadata.filter((em) => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
1779
+ const snapshots = allMetadata
1780
+ .filter((em) => em.entryType === "doc_snapshot")
1781
+ .sort((a, b) => b.createdAt - a.createdAt);
1782
+ const latestSnapshot = snapshots[0] || null;
1783
+ const latestSnapshotAt = latestSnapshot?.createdAt ?? 0;
1784
+ const changesSinceSnapshot = replayEntries.filter((em) => em.createdAt > latestSnapshotAt).length;
1785
+ if (changesSinceSnapshot < this.snapshotMinChanges) {
1786
+ return;
1787
+ }
1788
+ if (latestSnapshot &&
1789
+ Date.now() - latestSnapshot.createdAt < this.snapshotCooldownMs) {
1790
+ return;
1791
+ }
1792
+ const headHashes = automerge_adapter_1.Automerge.getHeads(internalDoc.doc);
1793
+ const headEntryIds = this.resolveAutomergeDepsToEntryIds(docId, headHashes);
1794
+ const snapshotBytes = automerge_adapter_1.Automerge.save(internalDoc.doc);
1795
+ const encryptedPayload = await this.tenant.encryptPayload(snapshotBytes, internalDoc.decryptionKeyId);
1796
+ const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
1797
+ const pseudoSnapshotHash = `snapshot-${(0, uuid_1.v7)()}`;
1798
+ const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, pseudoSnapshotHash, headHashes, this.getSubtle());
1799
+ let signature;
1800
+ if (options.signingKeyPair && options.signingKeyPassword) {
1801
+ signature = await this.tenant.signPayloadWithKey(encryptedPayload, options.signingKeyPair, options.signingKeyPassword);
1256
1802
  }
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
- }
1803
+ else {
1804
+ signature = await this.tenant.signPayload(encryptedPayload);
1263
1805
  }
1806
+ const snapshotEntry = {
1807
+ entryType: "doc_snapshot",
1808
+ id: entryId,
1809
+ contentHash,
1810
+ docId,
1811
+ dependencyIds: headEntryIds,
1812
+ createdAt: Date.now(),
1813
+ createdByPublicKey: options.createdByPublicKey,
1814
+ decryptionKeyId: internalDoc.decryptionKeyId,
1815
+ snapshotHeadHashes: headHashes,
1816
+ snapshotHeadEntryIds: headEntryIds,
1817
+ signature,
1818
+ originalSize: snapshotBytes.length,
1819
+ encryptedSize: encryptedPayload.length,
1820
+ encryptedData: encryptedPayload,
1821
+ };
1822
+ await this.store.putEntries([snapshotEntry]);
1823
+ this.logger.debug(`Created snapshot for document ${docId} with ${headHashes.length} heads and ${changesSinceSnapshot} changes since previous snapshot`);
1824
+ }
1825
+ catch (error) {
1826
+ this.logger.warn(`Failed to create snapshot for document ${docId}, continuing without snapshot`, error);
1264
1827
  }
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);
1828
+ }
1829
+ async prefetchIterationWindow(indexSnapshot, startIndex) {
1830
+ if (this.iteratePrefetchWindowDocs <= 0) {
1831
+ return 0;
1832
+ }
1833
+ const docIds = [];
1834
+ const seen = new Set();
1835
+ // Only look ahead a bounded number of uncached docs so iteration does not
1836
+ // materialize the entire remaining changefeed tail up front.
1837
+ for (let i = startIndex; i < indexSnapshot.length && docIds.length < this.iteratePrefetchWindowDocs; i++) {
1838
+ const docId = indexSnapshot[i].docId;
1839
+ if (seen.has(docId) || this.docCache.has(docId)) {
1840
+ continue;
1841
+ }
1842
+ seen.add(docId);
1843
+ docIds.push(docId);
1844
+ }
1845
+ if (docIds.length === 0) {
1846
+ return 0;
1847
+ }
1848
+ // Prefetch is opportunistic: iteration can still fall back to on-demand
1849
+ // loads, so individual prefetch failures are logged and ignored here.
1850
+ await Promise.all(docIds.map((docId) => this.loadDocumentInternal(docId).catch((err) => {
1851
+ this.logger.warn(`Failed to prefetch document ${docId}:`, err);
1852
+ return null;
1853
+ })));
1854
+ return docIds.length;
1855
+ }
1856
+ getStartIndexForCursor(indexSnapshot, actualCursor) {
1857
+ let startIndex = 0;
1858
+ if (indexSnapshot.length === 0) {
1859
+ return startIndex;
1860
+ }
1861
+ const cursorSeq = typeof actualCursor.changeSeq === "number" ? actualCursor.changeSeq : 0;
1862
+ let left = 0;
1863
+ let right = indexSnapshot.length - 1;
1864
+ while (left <= right) {
1865
+ const mid = Math.floor((left + right) / 2);
1866
+ const entry = indexSnapshot[mid];
1867
+ const cursorComparable = {
1868
+ docId: actualCursor.docId,
1869
+ changeSeq: cursorSeq,
1870
+ };
1871
+ const cmp = this.compareIndexEntries(cursorComparable, entry);
1872
+ if (cmp < 0) {
1873
+ right = mid - 1;
1874
+ startIndex = mid;
1875
+ }
1876
+ else {
1877
+ left = mid + 1;
1878
+ startIndex = mid + 1;
1271
1879
  }
1272
1880
  }
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
- })));
1881
+ if (startIndex < indexSnapshot.length) {
1882
+ const entry = indexSnapshot[startIndex];
1883
+ if (entry.changeSeq === cursorSeq && entry.docId === actualCursor.docId) {
1884
+ startIndex++;
1283
1885
  }
1284
1886
  }
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
1887
+ return startIndex;
1888
+ }
1889
+ async *iterateChangeMetadataSince(cursor) {
1890
+ const startedAt = Date.now();
1891
+ const actualCursor = cursor ?? {
1892
+ changeSeq: 0,
1893
+ lastModified: 0,
1894
+ docId: "",
1895
+ };
1896
+ const indexSnapshot = [...this.index];
1897
+ const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
1898
+ let yieldedDocuments = 0;
1899
+ try {
1900
+ for (let i = startIndex; i < indexSnapshot.length; i++) {
1901
+ const entry = indexSnapshot[i];
1306
1902
  const currentCursor = {
1903
+ changeSeq: entry.changeSeq,
1307
1904
  lastModified: entry.lastModified,
1308
1905
  docId: entry.docId,
1309
1906
  };
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 };
1907
+ yieldedDocuments++;
1908
+ yield {
1909
+ docId: entry.docId,
1910
+ lastModified: entry.lastModified,
1911
+ isDeleted: entry.isDeleted,
1912
+ cursor: currentCursor,
1913
+ };
1313
1914
  }
1314
- catch (error) {
1315
- this.logger.error(`Error processing document ${entry.docId}:`, error);
1316
- // Stop processing on error
1317
- throw error;
1915
+ }
1916
+ finally {
1917
+ this.performanceCallback?.onSyncOperation?.({
1918
+ operation: "iterateChangeMetadataSince",
1919
+ time: Date.now() - startedAt,
1920
+ details: {
1921
+ yieldedDocuments,
1922
+ startIndex,
1923
+ },
1924
+ });
1925
+ }
1926
+ }
1927
+ async *iterateChangesSince(cursor) {
1928
+ const startedAt = Date.now();
1929
+ // Default to initial cursor if null is provided.
1930
+ // Prefer deterministic sequence-based cursoring; keep legacy fallback compatibility.
1931
+ const actualCursor = cursor ?? { changeSeq: 0, lastModified: 0, docId: "" };
1932
+ this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
1933
+ // Use a stable snapshot of the index for this generator run so concurrent
1934
+ // updates do not reorder/skip entries while iterating.
1935
+ const indexSnapshot = [...this.index];
1936
+ // Find starting position using binary search
1937
+ // We want to find the first entry that is greater than the cursor.
1938
+ const startIndex = this.getStartIndexForCursor(indexSnapshot, actualCursor);
1939
+ let prefetchedDocuments = await this.prefetchIterationWindow(indexSnapshot, startIndex);
1940
+ let yieldedDocuments = 0;
1941
+ let loadedDocuments = 0;
1942
+ try {
1943
+ // Iterate through the stable snapshot and yield documents one at a time.
1944
+ for (let i = startIndex; i < indexSnapshot.length; i++) {
1945
+ const entry = indexSnapshot[i];
1946
+ try {
1947
+ this.logger.debug(`Yielding document ${entry.docId} from index (lastModified: ${entry.lastModified}, isDeleted: ${entry.isDeleted})`);
1948
+ let internalDoc = this.getCachedDocument(entry.docId);
1949
+ if (!internalDoc) {
1950
+ this.logger.debug(`Document ${entry.docId} not in cache, loading from store`);
1951
+ internalDoc = await this.loadDocumentInternal(entry.docId);
1952
+ if (internalDoc) {
1953
+ loadedDocuments++;
1954
+ }
1955
+ }
1956
+ if (!internalDoc) {
1957
+ this.logger.warn(`Document ${entry.docId} not found, skipping`);
1958
+ continue;
1959
+ }
1960
+ // Wrap the document (works for both deleted and non-deleted documents)
1961
+ const doc = this.wrapDocument(internalDoc);
1962
+ this.logger.debug(`Successfully loaded document ${entry.docId} (isDeleted: ${doc.isDeleted()})`);
1963
+ // Create cursor for current document
1964
+ const currentCursor = {
1965
+ changeSeq: entry.changeSeq,
1966
+ lastModified: entry.lastModified,
1967
+ docId: entry.docId,
1968
+ };
1969
+ // Yield immediately - this allows the caller to break early after each document
1970
+ // Deleted documents are included so external indexes can handle deletions
1971
+ yieldedDocuments++;
1972
+ yield { doc, cursor: currentCursor };
1973
+ prefetchedDocuments += await this.prefetchIterationWindow(indexSnapshot, i + 1);
1974
+ }
1975
+ catch (error) {
1976
+ this.logger.error(`Error processing document ${entry.docId}:`, error);
1977
+ // Stop processing on error
1978
+ throw error;
1979
+ }
1318
1980
  }
1319
1981
  }
1320
- this.logger.debug(`Iteration completed`);
1982
+ finally {
1983
+ this.logger.debug(`Iteration completed`);
1984
+ this.performanceCallback?.onSyncOperation?.({
1985
+ operation: "iterateChangesSince",
1986
+ time: Date.now() - startedAt,
1987
+ details: {
1988
+ yieldedDocuments,
1989
+ prefetchedDocuments,
1990
+ loadedDocuments,
1991
+ startIndex,
1992
+ },
1993
+ });
1994
+ }
1321
1995
  }
1322
1996
  /**
1323
1997
  * Incrementally update a cached document with new entries.
@@ -1444,57 +2118,113 @@ class BaseMindooDB {
1444
2118
  isDeleted,
1445
2119
  };
1446
2120
  // Update cache
1447
- this.docCache.set(docId, updatedInternalDoc);
2121
+ this.storeCachedDocument(updatedInternalDoc);
2122
+ this.markDocDirty(docId);
1448
2123
  return updatedInternalDoc;
1449
2124
  }
1450
2125
  /**
1451
2126
  * Internal method to load a document from the content-addressed store
1452
2127
  */
1453
2128
  async loadDocumentInternal(docId) {
2129
+ const startedAt = Date.now();
2130
+ const cacheCheckStartedAt = Date.now();
1454
2131
  // Check cache first
1455
2132
  if (this.docCache.has(docId)) {
1456
2133
  this.logger.debug(`Document ${docId} found in cache, returning cached version`);
1457
- return this.docCache.get(docId);
2134
+ const cached = this.getCachedDocument(docId);
2135
+ this.performanceCallback?.onDocumentLoad?.({
2136
+ docId,
2137
+ cacheHit: true,
2138
+ metadataEntriesScanned: 0,
2139
+ replayEntriesLoaded: 0,
2140
+ snapshotUsed: false,
2141
+ cacheCheckTime: Date.now() - cacheCheckStartedAt,
2142
+ storeQueryTime: 0,
2143
+ entryLoadTime: 0,
2144
+ signatureVerificationTime: 0,
2145
+ decryptionTime: 0,
2146
+ automergeTime: 0,
2147
+ totalTime: Date.now() - startedAt,
2148
+ });
2149
+ return cached;
1458
2150
  }
1459
2151
  this.logger.debug(`===== Starting to load document ${docId} from store =====`);
2152
+ const cacheCheckTime = Date.now() - cacheCheckStartedAt;
2153
+ let storeQueryTime = 0;
2154
+ let entryLoadTime = 0;
2155
+ let signatureVerificationTime = 0;
2156
+ let decryptionTime = 0;
2157
+ let automergeTime = 0;
1460
2158
  // Get all entry metadata for this document
1461
2159
  // TODO: Implement loading from last snapshot if available
1462
2160
  this.logger.debug(`Getting all entry hashes for document ${docId}`);
2161
+ const storeQueryStartedAt = Date.now();
1463
2162
  const allEntryMetadata = await this.scanAllMetadata(this.store, { docId });
2163
+ storeQueryTime += Date.now() - storeQueryStartedAt;
1464
2164
  this.logger.debug(`Found ${allEntryMetadata.length} total entry hashes for document ${docId}`);
1465
2165
  if (allEntryMetadata.length === 0) {
1466
2166
  this.logger.debug(`No entry hashes found for document ${docId}, returning null`);
2167
+ this.performanceCallback?.onDocumentLoad?.({
2168
+ docId,
2169
+ cacheHit: false,
2170
+ metadataEntriesScanned: 0,
2171
+ replayEntriesLoaded: 0,
2172
+ snapshotUsed: false,
2173
+ cacheCheckTime,
2174
+ storeQueryTime,
2175
+ entryLoadTime,
2176
+ signatureVerificationTime,
2177
+ decryptionTime,
2178
+ automergeTime,
2179
+ totalTime: Date.now() - startedAt,
2180
+ });
1467
2181
  return null;
1468
2182
  }
1469
2183
  // Log all entry types
1470
2184
  const entryTypes = allEntryMetadata.map(em => `${em.entryType}@${em.createdAt}`).join(', ');
1471
2185
  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}`);
2186
+ const metadataById = new Map(allEntryMetadata.map((meta) => [meta.id, meta]));
2187
+ const planStartedAt = Date.now();
2188
+ const materializationPlan = (0, MaterializationPlanner_1.computeDocumentMaterializationPlan)(docId, allEntryMetadata, {
2189
+ includeDiagnostics: true,
2190
+ });
2191
+ const planTime = Date.now() - planStartedAt;
2192
+ this.performanceCallback?.onSyncOperation?.({
2193
+ operation: "planDocumentMaterialization",
2194
+ time: planTime,
2195
+ details: {
2196
+ docId,
2197
+ metadataEntriesScanned: allEntryMetadata.length,
2198
+ replayEntriesLoaded: materializationPlan.entryIdsToApply.length,
2199
+ snapshotEntryId: materializationPlan.snapshotEntryId,
2200
+ diagnostics: materializationPlan.diagnostics,
2201
+ },
2202
+ });
2203
+ let startFromSnapshot = materializationPlan.snapshotEntryId !== null;
2204
+ const snapshotMeta = materializationPlan.snapshotEntryId
2205
+ ? (metadataById.get(materializationPlan.snapshotEntryId) || null)
2206
+ : null;
2207
+ if (startFromSnapshot && !snapshotMeta) {
2208
+ this.logger.warn(`Planner referenced snapshot ${materializationPlan.snapshotEntryId} not found in metadata for ${docId}; falling back to replay without snapshot`);
2209
+ startFromSnapshot = false;
2210
+ }
2211
+ if (startFromSnapshot && snapshotMeta) {
2212
+ this.logger.debug(`Planner selected snapshot ${snapshotMeta.id} for ${docId}`);
1483
2213
  }
1484
2214
  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)`);
2215
+ this.logger.debug(`Planner did not select a snapshot for ${docId}`);
2216
+ }
2217
+ const entriesToLoad = materializationPlan.entryIdsToApply
2218
+ .map((id) => metadataById.get(id))
2219
+ .filter((m) => m !== undefined);
2220
+ this.logger.debug(`Planner returned ${entriesToLoad.length} replay entries for ${docId}; diagnostics=${JSON.stringify(materializationPlan.diagnostics || {})}`);
1493
2221
  // Load the snapshot first if we have one
1494
2222
  let doc = undefined;
1495
2223
  if (startFromSnapshot && snapshotMeta) {
1496
2224
  this.logger.debug(`Loading snapshot for document ${docId}`);
2225
+ const snapshotLoadStartedAt = Date.now();
1497
2226
  const snapshotEntries = await this.store.getEntries([snapshotMeta.id]);
2227
+ entryLoadTime += Date.now() - snapshotLoadStartedAt;
1498
2228
  this.logger.debug(`Retrieved ${snapshotEntries.length} snapshot entry(s) from store`);
1499
2229
  if (snapshotEntries.length > 0) {
1500
2230
  const snapshotData = snapshotEntries[0];
@@ -1506,7 +2236,9 @@ class BaseMindooDB {
1506
2236
  else {
1507
2237
  // Verify signature against the encrypted snapshot (no decryption needed)
1508
2238
  // We sign the encrypted payload, so anyone can verify signatures without decryption keys
2239
+ const signatureStartedAt = Date.now();
1509
2240
  isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
2241
+ signatureVerificationTime += Date.now() - signatureStartedAt;
1510
2242
  }
1511
2243
  if (!isValid) {
1512
2244
  this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to loading from scratch`);
@@ -1516,13 +2248,17 @@ class BaseMindooDB {
1516
2248
  else {
1517
2249
  this.logger.debug(`Snapshot signature valid, decrypting snapshot`);
1518
2250
  // Decrypt snapshot (only after signature verification passes)
2251
+ const decryptStartedAt = Date.now();
1519
2252
  const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
2253
+ decryptionTime += Date.now() - decryptStartedAt;
1520
2254
  this.logger.debug(`Decrypted snapshot (${snapshotData.encryptedData.length} -> ${decryptedSnapshot.length} bytes)`);
1521
2255
  // Load snapshot using Automerge.load()
1522
2256
  // This deserializes a full document snapshot from binary data
1523
2257
  // According to Automerge docs: load() is equivalent to init() followed by loadIncremental()
1524
2258
  this.logger.debug(`Loading snapshot into Automerge document`);
2259
+ const automergeStartedAt = Date.now();
1525
2260
  doc = automerge_adapter_1.Automerge.load(decryptedSnapshot);
2261
+ automergeTime += Date.now() - automergeStartedAt;
1526
2262
  this.logger.debug(`Successfully loaded snapshot, document heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
1527
2263
  // Register the snapshot's automerge hash -> entry ID mapping
1528
2264
  const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
@@ -1538,12 +2274,11 @@ class BaseMindooDB {
1538
2274
  doc = automerge_adapter_1.Automerge.init();
1539
2275
  this.logger.debug(`Initialized empty document, heads: ${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
1540
2276
  }
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
2277
  // Load and apply all entries
1545
2278
  this.logger.debug(`Fetching ${entriesToLoad.length} entries from store for document ${docId}`);
2279
+ const entryLoadStartedAt = Date.now();
1546
2280
  const entries = await this.store.getEntries(entriesToLoad.map(em => em.id));
2281
+ entryLoadTime += Date.now() - entryLoadStartedAt;
1547
2282
  this.logger.debug(`Retrieved ${entries.length} entries from store for document ${docId}`);
1548
2283
  this.logger.debug(`Loading document ${docId}: found ${entries.length} entries to apply (${startFromSnapshot ? 'starting from snapshot' : 'starting from scratch'})`);
1549
2284
  // Log current document state before applying entries
@@ -1583,6 +2318,7 @@ class BaseMindooDB {
1583
2318
  }
1584
2319
  }
1585
2320
  // Verify all signatures in parallel using cached keys
2321
+ const signatureStartedAt = Date.now();
1586
2322
  const signatureVerificationResults = await Promise.all(validEntries.map(async (entryData) => {
1587
2323
  const cryptoKey = keyMap.get(entryData.createdByPublicKey);
1588
2324
  if (!cryptoKey) {
@@ -1592,6 +2328,7 @@ class BaseMindooDB {
1592
2328
  const isValid = await this.verifySignatureWithKey(cryptoKey, entryData.encryptedData, entryData.signature);
1593
2329
  return { entryData, isValid };
1594
2330
  }));
2331
+ signatureVerificationTime += Date.now() - signatureStartedAt;
1595
2332
  // Filter out entries with invalid signatures
1596
2333
  const verifiedEntries = signatureVerificationResults
1597
2334
  .filter(({ isValid }) => {
@@ -1608,10 +2345,12 @@ class BaseMindooDB {
1608
2345
  // Parallel decryption - decrypt all entries concurrently
1609
2346
  // Automerge handles dependency buffering internally, so we can decrypt all in parallel
1610
2347
  this.logger.debug(`Decrypting ${verifiedEntries.length} entries in parallel`);
2348
+ const decryptionStartedAt = Date.now();
1611
2349
  const decryptionResults = await Promise.all(verifiedEntries.map(async (entryData) => {
1612
2350
  const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
1613
2351
  return { entryData, decryptedPayload };
1614
2352
  }));
2353
+ decryptionTime += Date.now() - decryptionStartedAt;
1615
2354
  // Collect change bytes and register automerge hash mappings
1616
2355
  const changeBytes = [];
1617
2356
  for (const { entryData, decryptedPayload } of decryptionResults) {
@@ -1626,8 +2365,10 @@ class BaseMindooDB {
1626
2365
  if (changeBytes.length > 0) {
1627
2366
  this.logger.debug(`Applying ${changeBytes.length} changes to document ${docId} using batch applyChanges`);
1628
2367
  try {
2368
+ const automergeStartedAt = Date.now();
1629
2369
  const result = automerge_adapter_1.Automerge.applyChanges(doc, changeBytes);
1630
2370
  doc = result[0];
2371
+ automergeTime += Date.now() - automergeStartedAt;
1631
2372
  this.logger.debug(`Successfully applied ${changeBytes.length} changes to document ${docId}`);
1632
2373
  this.logger.debug(`Document state after applying changes: heads=${JSON.stringify(automerge_adapter_1.Automerge.getHeads(doc))}`);
1633
2374
  }
@@ -1647,7 +2388,10 @@ class BaseMindooDB {
1647
2388
  const hasDeleteEntry = allEntryMetadata.some(em => em.entryType === "doc_delete");
1648
2389
  const isDeleted = hasDeleteEntry;
1649
2390
  this.logger.debug(`Document ${docId} isDeleted: ${isDeleted}`);
1650
- const decryptionKeyId = payload._decryptionKeyId || "default";
2391
+ // The authoritative decryptionKeyId comes from the doc_create entry's metadata,
2392
+ // not from the Automerge payload (which does not store encryption metadata).
2393
+ const createEntry = allEntryMetadata.find(em => em.entryType === "doc_create");
2394
+ const decryptionKeyId = createEntry ? createEntry.decryptionKeyId : "default";
1651
2395
  // Get lastModified from payload, or use the timestamp of the last entry
1652
2396
  const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
1653
2397
  const lastModified = payload._lastModified ||
@@ -1665,8 +2409,23 @@ class BaseMindooDB {
1665
2409
  isDeleted,
1666
2410
  };
1667
2411
  // Update cache
1668
- this.docCache.set(docId, internalDoc);
2412
+ this.storeCachedDocument(internalDoc);
2413
+ this.markDocDirty(docId);
1669
2414
  this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
2415
+ this.performanceCallback?.onDocumentLoad?.({
2416
+ docId,
2417
+ cacheHit: false,
2418
+ metadataEntriesScanned: allEntryMetadata.length,
2419
+ replayEntriesLoaded: entriesToLoad.length,
2420
+ snapshotUsed: startFromSnapshot && snapshotMeta !== null,
2421
+ cacheCheckTime,
2422
+ storeQueryTime,
2423
+ entryLoadTime,
2424
+ signatureVerificationTime,
2425
+ decryptionTime,
2426
+ automergeTime,
2427
+ totalTime: Date.now() - startedAt,
2428
+ });
1670
2429
  return internalDoc;
1671
2430
  }
1672
2431
  /**
@@ -1785,7 +2544,7 @@ class BaseMindooDB {
1785
2544
  * Get an attachment reference by ID from a document's _attachments array.
1786
2545
  */
1787
2546
  getAttachmentRefInternal(docId, attachmentId) {
1788
- const internalDoc = this.docCache.get(docId);
2547
+ const internalDoc = this.getCachedDocument(docId);
1789
2548
  if (!internalDoc) {
1790
2549
  throw new Error(`Document ${docId} not found in cache`);
1791
2550
  }
@@ -2126,7 +2885,64 @@ class BaseMindooDB {
2126
2885
  };
2127
2886
  }
2128
2887
  /**
2129
- * Pull changes from a remote content-addressed store.
2888
+ * Resolve a sync target to a ContentAddressedStore.
2889
+ * Accepts either a raw store or a MindooDB instance (calls getStore()).
2890
+ */
2891
+ resolveStore(remote) {
2892
+ if ('getStore' in remote && typeof remote.getStore === 'function') {
2893
+ return remote.getStore();
2894
+ }
2895
+ return remote;
2896
+ }
2897
+ /**
2898
+ * Apply a temporary network-auth identity override for a single sync call.
2899
+ *
2900
+ * Purpose:
2901
+ * - Allows per-call authentication as a different user (for example admin bootstrap)
2902
+ * without changing the tenant's default connected user.
2903
+ * - Returns a cleanup callback so caller can always restore default auth state in `finally`.
2904
+ */
2905
+ async applyNetworkAuthOverrideForSync(remoteStore, options) {
2906
+ // Per-call override: use an alternate identity (for example, admin bootstrap)
2907
+ // only for this sync operation.
2908
+ const override = options?.networkAuthOverride;
2909
+ if (!override) {
2910
+ // No override requested: return a no-op cleanup callback for unified call sites.
2911
+ return () => { };
2912
+ }
2913
+ const overrideCapableStore = remoteStore;
2914
+ if (typeof overrideCapableStore.setSyncAuthOverride !== "function") {
2915
+ // Local/in-memory stores do not support network auth override; keep default auth.
2916
+ this.logger.warn("networkAuthOverride was provided, but remote store does not support auth override");
2917
+ return () => { };
2918
+ }
2919
+ const subtle = this.tenant.getCryptoAdapter().getSubtle();
2920
+ // Decrypt and import override signing key (Ed25519) for challenge signing.
2921
+ const signingKeyBuffer = await this.tenant.decryptPrivateKey(override.user.userSigningKeyPair.privateKey, override.password, "signing");
2922
+ const signingKey = await subtle.importKey("pkcs8", signingKeyBuffer, { name: "Ed25519" }, false, ["sign"]);
2923
+ // Decrypt and import override encryption key (RSA-OAEP) so encrypted network
2924
+ // entries can be decrypted with the same override identity.
2925
+ const encryptionKeyBuffer = await this.tenant.decryptPrivateKey(override.user.userEncryptionKeyPair.privateKey, override.password, "encryption");
2926
+ const encryptionKey = await subtle.importKey("pkcs8", encryptionKeyBuffer, { name: "RSA-OAEP", hash: "SHA-256" }, false, ["decrypt"]);
2927
+ // Activate temporary override on the remote store.
2928
+ overrideCapableStore.setSyncAuthOverride({
2929
+ username: override.user.username,
2930
+ signingKey,
2931
+ privateEncryptionKey: encryptionKey,
2932
+ });
2933
+ // Return cleanup callback so caller can always restore normal auth in finally.
2934
+ return () => {
2935
+ if (typeof overrideCapableStore.clearSyncAuthOverride === "function") {
2936
+ overrideCapableStore.clearSyncAuthOverride();
2937
+ }
2938
+ else {
2939
+ // Backward-compatible fallback if explicit clear API is not implemented.
2940
+ overrideCapableStore.setSyncAuthOverride?.(null);
2941
+ }
2942
+ };
2943
+ }
2944
+ /**
2945
+ * Pull changes from a remote content-addressed store or another MindooDB instance.
2130
2946
  *
2131
2947
  * This method:
2132
2948
  * 1. Finds entries in the remote store that we don't have locally
@@ -2134,48 +2950,93 @@ class BaseMindooDB {
2134
2950
  * 3. Stores them in our local store
2135
2951
  * 4. Syncs the local store to process the new entries
2136
2952
  *
2137
- * @param remoteStore The remote store to pull entries from
2138
- * @return A promise that resolves when the pull is complete
2953
+ * @param remote The remote store or MindooDB instance to pull entries from
2954
+ * @param options Optional sync options for progress tracking, paging, and cancellation
2955
+ * @return A promise that resolves with the sync result
2139
2956
  */
2140
- async pullChangesFrom(remoteStore) {
2957
+ async pullChangesFrom(remote, options) {
2958
+ const remoteStore = this.resolveStore(remote);
2141
2959
  if (this.store.getId() !== remoteStore.getId()) {
2142
2960
  throw new Error(`[BaseMindooDB] Cannot pull entries from the incompatible store ${this.store.getId()}`);
2143
2961
  }
2144
2962
  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;
2963
+ const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
2964
+ try {
2965
+ const syncResult = await this.syncEntriesFromStore(remoteStore, this.store, options);
2966
+ this.logger.debug(`Transferred ${syncResult.transferred} entries from remote store`);
2967
+ if (syncResult.cancelled) {
2968
+ this.logger.info(`Pull cancelled after transferring ${syncResult.transferred} entries`);
2969
+ return { transferredEntries: syncResult.transferred, cancelled: true };
2970
+ }
2971
+ if (syncResult.transferred === 0) {
2972
+ this.logger.debug(`No new entries to pull`);
2973
+ return { transferredEntries: 0, cancelled: false };
2974
+ }
2975
+ options?.onProgress?.({
2976
+ phase: 'processing',
2977
+ message: `Processing ${syncResult.transferred} new entries...`,
2978
+ transferredEntries: syncResult.transferred,
2979
+ scannedEntries: syncResult.transferred,
2980
+ });
2981
+ // Sync the local store to process the new entries
2982
+ // This will update the index, cache, and processedEntryIds
2983
+ await this.syncStoreChanges();
2984
+ this.logger.info(`Pull complete, synced ${syncResult.transferred} entries`);
2985
+ options?.onProgress?.({
2986
+ phase: 'complete',
2987
+ message: `Pull complete: ${syncResult.transferred} entries synced`,
2988
+ transferredEntries: syncResult.transferred,
2989
+ scannedEntries: syncResult.transferred,
2990
+ });
2991
+ return { transferredEntries: syncResult.transferred, cancelled: false };
2992
+ }
2993
+ finally {
2994
+ restoreAuthOverride();
2150
2995
  }
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
2996
  }
2156
2997
  /**
2157
- * Push changes to a remote content-addressed store.
2998
+ * Push changes to a remote content-addressed store or another MindooDB instance.
2158
2999
  *
2159
3000
  * This method:
2160
3001
  * 1. Finds entries in our local store that the remote doesn't have
2161
3002
  * 2. Retrieves those entries from our local store
2162
3003
  * 3. Stores them in the remote store
2163
3004
  *
2164
- * @param remoteStore The remote store to push entries to
2165
- * @return A promise that resolves when the push is complete
3005
+ * @param remote The remote store or MindooDB instance to push entries to
3006
+ * @param options Optional sync options for progress tracking, paging, and cancellation
3007
+ * @return A promise that resolves with the sync result
2166
3008
  */
2167
- async pushChangesTo(remoteStore) {
3009
+ async pushChangesTo(remote, options) {
3010
+ const remoteStore = this.resolveStore(remote);
2168
3011
  if (this.store.getId() !== remoteStore.getId()) {
2169
3012
  throw new Error(`[BaseMindooDB] Cannot push entries to the incompatible store ${this.store.getId()}`);
2170
3013
  }
2171
3014
  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;
3015
+ const restoreAuthOverride = await this.applyNetworkAuthOverrideForSync(remoteStore, options);
3016
+ try {
3017
+ const syncResult = await this.syncEntriesFromStore(this.store, remoteStore, options);
3018
+ this.logger.debug(`Transferred ${syncResult.transferred} entries to remote store`);
3019
+ if (syncResult.cancelled) {
3020
+ this.logger.info(`Push cancelled after transferring ${syncResult.transferred} entries`);
3021
+ return { transferredEntries: syncResult.transferred, cancelled: true };
3022
+ }
3023
+ if (syncResult.transferred === 0) {
3024
+ this.logger.debug(`No new entries to push`);
3025
+ }
3026
+ else {
3027
+ this.logger.info(`Pushed ${syncResult.transferred} entries to remote store`);
3028
+ }
3029
+ options?.onProgress?.({
3030
+ phase: 'complete',
3031
+ message: `Push complete: ${syncResult.transferred} entries transferred`,
3032
+ transferredEntries: syncResult.transferred,
3033
+ scannedEntries: syncResult.transferred,
3034
+ });
3035
+ return { transferredEntries: syncResult.transferred, cancelled: false };
3036
+ }
3037
+ finally {
3038
+ restoreAuthOverride();
2177
3039
  }
2178
- this.logger.info(`Pushed ${transferred} entries to remote store`);
2179
3040
  }
2180
3041
  }
2181
3042
  exports.BaseMindooDB = BaseMindooDB;