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.
- package/README-server.md +1001 -0
- package/README.md +14 -26
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +19 -0
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +58 -5
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.d.ts +4 -0
- package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.js +43 -0
- package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +3 -0
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +15 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts +5 -1
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.d.ts.map +1 -1
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js +59 -3
- package/dist/browser/appendonlystores/IndexedDBContentAddressedStore.js.map +1 -1
- package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts +21 -0
- package/dist/browser/cache/IndexedDBLocalCacheStore.d.ts.map +1 -0
- package/dist/browser/cache/IndexedDBLocalCacheStore.js +106 -0
- package/dist/browser/cache/IndexedDBLocalCacheStore.js.map +1 -0
- package/dist/browser/index.d.ts +3 -0
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +9 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/core/BaseMindooDB.d.ts +103 -13
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +1066 -205
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts +30 -1
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +143 -108
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.d.ts +13 -10
- package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.js +134 -27
- package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
- package/dist/core/BaseMindooTenantFactory.d.ts +5 -1
- package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantFactory.js +46 -69
- package/dist/core/BaseMindooTenantFactory.js.map +1 -1
- package/dist/core/MindooDBServerAdmin.d.ts +238 -0
- package/dist/core/MindooDBServerAdmin.d.ts.map +1 -0
- package/dist/core/MindooDBServerAdmin.js +345 -0
- package/dist/core/MindooDBServerAdmin.js.map +1 -0
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +3 -1
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +22 -0
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
- package/dist/core/appendonlystores/MaterializationPlanner.d.ts +49 -0
- package/dist/core/appendonlystores/MaterializationPlanner.d.ts.map +1 -0
- package/dist/core/appendonlystores/MaterializationPlanner.js +325 -0
- package/dist/core/appendonlystores/MaterializationPlanner.js.map +1 -0
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts +15 -0
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/types.d.ts +2 -0
- package/dist/core/appendonlystores/network/types.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/types.js.map +1 -1
- package/dist/core/appendonlystores/types.d.ts +83 -0
- package/dist/core/appendonlystores/types.d.ts.map +1 -1
- package/dist/core/cache/CacheManager.d.ts +75 -0
- package/dist/core/cache/CacheManager.d.ts.map +1 -0
- package/dist/core/cache/CacheManager.js +101 -0
- package/dist/core/cache/CacheManager.js.map +1 -0
- package/dist/core/cache/EncryptedLocalCacheStore.d.ts +41 -0
- package/dist/core/cache/EncryptedLocalCacheStore.d.ts.map +1 -0
- package/dist/core/cache/EncryptedLocalCacheStore.js +120 -0
- package/dist/core/cache/EncryptedLocalCacheStore.js.map +1 -0
- package/dist/core/cache/LocalCacheStore.d.ts +50 -0
- package/dist/core/cache/LocalCacheStore.d.ts.map +1 -0
- package/dist/core/cache/LocalCacheStore.js +39 -0
- package/dist/core/cache/LocalCacheStore.js.map +1 -0
- package/dist/core/crypto/privateKeyEncryption.d.ts +5 -0
- package/dist/core/crypto/privateKeyEncryption.d.ts.map +1 -0
- package/dist/core/crypto/privateKeyEncryption.js +87 -0
- package/dist/core/crypto/privateKeyEncryption.js.map +1 -0
- package/dist/core/index.d.ts +13 -6
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +13 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +4 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +39 -11
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
- package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.d.ts +1 -0
- package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.js +3 -0
- package/dist/core/indexing/virtualviews/ViewEntrySortKeyComparator.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualView.d.ts +43 -0
- package/dist/core/indexing/virtualviews/VirtualView.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualView.js +173 -0
- package/dist/core/indexing/virtualviews/VirtualView.js.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts +7 -0
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.d.ts.map +1 -1
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.js +28 -0
- package/dist/core/indexing/virtualviews/VirtualViewEntryData.js.map +1 -1
- package/dist/core/keys/KeyBag.d.ts +38 -14
- package/dist/core/keys/KeyBag.d.ts.map +1 -1
- package/dist/core/keys/KeyBag.js +102 -76
- package/dist/core/keys/KeyBag.js.map +1 -1
- package/dist/core/keys/KeyContext.d.ts +3 -3
- package/dist/core/keys/KeyContext.d.ts.map +1 -1
- package/dist/core/keys/KeyContext.js +10 -4
- package/dist/core/keys/KeyContext.js.map +1 -1
- package/dist/core/types.d.ts +305 -21
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +7 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/utils/idGeneration.d.ts.map +1 -1
- package/dist/core/utils/idGeneration.js +37 -3
- package/dist/core/utils/idGeneration.js.map +1 -1
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts +4 -1
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.d.ts.map +1 -1
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js +24 -0
- package/dist/node/appendonlystores/BasicOnDiskContentAddressedStore.js.map +1 -1
- package/dist/node/attachments/writeAttachmentToFile.d.ts +15 -0
- package/dist/node/attachments/writeAttachmentToFile.d.ts.map +1 -0
- package/dist/node/attachments/writeAttachmentToFile.js +73 -0
- package/dist/node/attachments/writeAttachmentToFile.js.map +1 -0
- package/dist/node/cache/FileSystemLocalCacheStore.d.ts +17 -0
- package/dist/node/cache/FileSystemLocalCacheStore.d.ts.map +1 -0
- package/dist/node/cache/FileSystemLocalCacheStore.js +128 -0
- package/dist/node/cache/FileSystemLocalCacheStore.js.map +1 -0
- package/dist/node/cli/change-identity-password.d.ts +7 -0
- package/dist/node/cli/change-identity-password.d.ts.map +1 -0
- package/dist/node/cli/change-identity-password.js +67 -0
- package/dist/node/cli/change-identity-password.js.map +1 -0
- package/dist/node/cli/cli-utils.d.ts +16 -0
- package/dist/node/cli/cli-utils.d.ts.map +1 -0
- package/dist/node/cli/cli-utils.js +113 -0
- package/dist/node/cli/cli-utils.js.map +1 -0
- package/dist/node/cli/identity-export-public.d.ts +4 -0
- package/dist/node/cli/identity-export-public.d.ts.map +1 -0
- package/dist/node/cli/identity-export-public.js +55 -0
- package/dist/node/cli/identity-export-public.js.map +1 -0
- package/dist/node/cli/identity-info.d.ts +4 -0
- package/dist/node/cli/identity-info.d.ts.map +1 -0
- package/dist/node/cli/identity-info.js +57 -0
- package/dist/node/cli/identity-info.js.map +1 -0
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +4 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/server/CapabilityMatcher.d.ts +45 -0
- package/dist/node/server/CapabilityMatcher.d.ts.map +1 -0
- package/dist/node/server/CapabilityMatcher.js +117 -0
- package/dist/node/server/CapabilityMatcher.js.map +1 -0
- package/dist/node/server/MindooDBServer.d.ts +87 -0
- package/dist/node/server/MindooDBServer.d.ts.map +1 -0
- package/dist/node/server/MindooDBServer.js +1040 -0
- package/dist/node/server/MindooDBServer.js.map +1 -0
- package/dist/node/server/ServerSync.d.ts +41 -0
- package/dist/node/server/ServerSync.d.ts.map +1 -0
- package/dist/node/server/ServerSync.js +187 -0
- package/dist/node/server/ServerSync.js.map +1 -0
- package/dist/node/server/StoreFactory.d.ts +45 -0
- package/dist/node/server/StoreFactory.d.ts.map +1 -0
- package/dist/node/server/StoreFactory.js +89 -0
- package/dist/node/server/StoreFactory.js.map +1 -0
- package/dist/node/server/SystemAdminAuth.d.ts +65 -0
- package/dist/node/server/SystemAdminAuth.d.ts.map +1 -0
- package/dist/node/server/SystemAdminAuth.js +200 -0
- package/dist/node/server/SystemAdminAuth.js.map +1 -0
- package/dist/node/server/SystemIpAllowlist.d.ts +21 -0
- package/dist/node/server/SystemIpAllowlist.d.ts.map +1 -0
- package/dist/node/server/SystemIpAllowlist.js +210 -0
- package/dist/node/server/SystemIpAllowlist.js.map +1 -0
- package/dist/node/server/TenantManager.d.ts +85 -0
- package/dist/node/server/TenantManager.d.ts.map +1 -0
- package/dist/node/server/TenantManager.js +493 -0
- package/dist/node/server/TenantManager.js.map +1 -0
- package/dist/node/server/add-to-network.d.ts +20 -0
- package/dist/node/server/add-to-network.d.ts.map +1 -0
- package/dist/node/server/add-to-network.js +275 -0
- package/dist/node/server/add-to-network.js.map +1 -0
- package/dist/node/server/config.d.ts +51 -0
- package/dist/node/server/config.d.ts.map +1 -0
- package/dist/node/server/config.js +218 -0
- package/dist/node/server/config.js.map +1 -0
- package/dist/node/server/resolveServerPassword.d.ts +7 -0
- package/dist/node/server/resolveServerPassword.d.ts.map +1 -0
- package/dist/node/server/resolveServerPassword.js +25 -0
- package/dist/node/server/resolveServerPassword.js.map +1 -0
- package/dist/node/server/server.d.ts +22 -0
- package/dist/node/server/server.d.ts.map +1 -0
- package/dist/node/server/server.js +225 -0
- package/dist/node/server/server.js.map +1 -0
- package/dist/node/server/serverinit.d.ts +21 -0
- package/dist/node/server/serverinit.d.ts.map +1 -0
- package/dist/node/server/serverinit.js +317 -0
- package/dist/node/server/serverinit.js.map +1 -0
- package/dist/node/server/types.d.ts +162 -0
- package/dist/node/server/types.d.ts.map +1 -0
- package/dist/node/server/types.js +24 -0
- package/dist/node/server/types.js.map +1 -0
- package/dist/node/server/validation.d.ts +47 -0
- package/dist/node/server/validation.d.ts.map +1 -0
- package/dist/node/server/validation.js +114 -0
- package/dist/node/server/validation.js.map +1 -0
- package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts +48 -0
- package/dist/reactnative/cache/MMKVLocalCacheStore.d.ts.map +1 -0
- package/dist/reactnative/cache/MMKVLocalCacheStore.js +113 -0
- package/dist/reactnative/cache/MMKVLocalCacheStore.js.map +1 -0
- package/dist/reactnative/index.d.ts +1 -0
- package/dist/reactnative/index.d.ts.map +1 -1
- package/dist/reactnative/index.js +4 -1
- package/dist/reactnative/index.js.map +1 -1
- 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
|
|
39
|
-
//
|
|
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
|
|
334
|
+
* Sorts by deterministic changeSeq first, then by docId for uniqueness.
|
|
117
335
|
*/
|
|
118
336
|
compareIndexEntries(a, b) {
|
|
119
|
-
if (a.
|
|
120
|
-
return a.
|
|
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
|
|
127
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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 =
|
|
907
|
-
this.
|
|
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.
|
|
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:
|
|
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 =
|
|
1230
|
-
this.
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1258
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1907
|
+
yieldedDocuments++;
|
|
1908
|
+
yield {
|
|
1909
|
+
docId: entry.docId,
|
|
1910
|
+
lastModified: entry.lastModified,
|
|
1911
|
+
isDeleted: entry.isDeleted,
|
|
1912
|
+
cursor: currentCursor,
|
|
1913
|
+
};
|
|
1313
1914
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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(`
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
2138
|
-
* @
|
|
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(
|
|
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
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
this.logger.debug(`
|
|
2149
|
-
|
|
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
|
|
2165
|
-
* @
|
|
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(
|
|
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
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
this.logger.debug(`
|
|
2176
|
-
|
|
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;
|