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