mindoodb 0.0.1 → 0.0.2
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.md +5 -5
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts +9 -2
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js +39 -22
- package/dist/appendonlystores/network/ClientNetworkContentAddressedStore.js.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.d.ts +8 -2
- package/dist/appendonlystores/network/HttpTransport.d.ts.map +1 -1
- package/dist/appendonlystores/network/HttpTransport.js +50 -21
- package/dist/appendonlystores/network/HttpTransport.js.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts +15 -2
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.d.ts.map +1 -1
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js +52 -27
- package/dist/appendonlystores/network/ServerNetworkContentAddressedStore.js.map +1 -1
- package/dist/core/BaseMindooDB.d.ts +26 -4
- package/dist/core/BaseMindooDB.d.ts.map +1 -1
- package/dist/core/BaseMindooDB.js +632 -253
- package/dist/core/BaseMindooDB.js.map +1 -1
- package/dist/core/BaseMindooTenant.d.ts +3 -1
- package/dist/core/BaseMindooTenant.d.ts.map +1 -1
- package/dist/core/BaseMindooTenant.js +83 -75
- package/dist/core/BaseMindooTenant.js.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.d.ts +3 -1
- package/dist/core/BaseMindooTenantDirectory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantDirectory.js +40 -40
- package/dist/core/BaseMindooTenantDirectory.js.map +1 -1
- package/dist/core/BaseMindooTenantFactory.d.ts +3 -1
- package/dist/core/BaseMindooTenantFactory.d.ts.map +1 -1
- package/dist/core/BaseMindooTenantFactory.js +18 -12
- package/dist/core/BaseMindooTenantFactory.js.map +1 -1
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts +8 -2
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.d.ts.map +1 -1
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.js +44 -18
- package/dist/core/appendonlystores/InMemoryContentAddressedStore.js.map +1 -1
- package/dist/core/appendonlystores/network/AuthenticationService.d.ts +3 -0
- package/dist/core/appendonlystores/network/AuthenticationService.d.ts.map +1 -1
- package/dist/core/appendonlystores/network/AuthenticationService.js +21 -17
- package/dist/core/appendonlystores/network/AuthenticationService.js.map +1 -1
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts +15 -1
- package/dist/core/appendonlystores/network/NetworkTransport.d.ts.map +1 -1
- package/dist/core/appendonlystores/types.d.ts +14 -1
- package/dist/core/appendonlystores/types.d.ts.map +1 -1
- package/dist/core/crypto/MindooDocSigner.d.ts +3 -1
- package/dist/core/crypto/MindooDocSigner.d.ts.map +1 -1
- package/dist/core/crypto/MindooDocSigner.js +9 -5
- package/dist/core/crypto/MindooDocSigner.js.map +1 -1
- package/dist/core/crypto/RSAEncryption.d.ts +3 -1
- package/dist/core/crypto/RSAEncryption.d.ts.map +1 -1
- package/dist/core/crypto/RSAEncryption.js +13 -9
- package/dist/core/crypto/RSAEncryption.js.map +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.d.ts +1 -1
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js +2 -2
- package/dist/core/indexing/virtualviews/MindooDBVirtualViewDataProvider.js.map +1 -1
- package/dist/core/indexing/virtualviews/index.d.ts +1 -1
- package/dist/core/indexing/virtualviews/index.js +1 -1
- package/dist/core/keys/KeyBag.d.ts +4 -1
- package/dist/core/keys/KeyBag.d.ts.map +1 -1
- package/dist/core/keys/KeyBag.js +10 -5
- package/dist/core/keys/KeyBag.js.map +1 -1
- package/dist/core/logging/LogLevel.d.ts +21 -0
- package/dist/core/logging/LogLevel.d.ts.map +1 -0
- package/dist/core/logging/LogLevel.js +56 -0
- package/dist/core/logging/LogLevel.js.map +1 -0
- package/dist/core/logging/Logger.d.ts +37 -0
- package/dist/core/logging/Logger.d.ts.map +1 -0
- package/dist/core/logging/Logger.js +3 -0
- package/dist/core/logging/Logger.js.map +1 -0
- package/dist/core/logging/MindooLogger.d.ts +28 -0
- package/dist/core/logging/MindooLogger.d.ts.map +1 -0
- package/dist/core/logging/MindooLogger.js +217 -0
- package/dist/core/logging/MindooLogger.js.map +1 -0
- package/dist/core/logging/index.d.ts +4 -0
- package/dist/core/logging/index.d.ts.map +1 -0
- package/dist/core/logging/index.js +10 -0
- package/dist/core/logging/index.js.map +1 -0
- package/dist/core/types.d.ts +96 -28
- package/dist/core/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -38,6 +38,7 @@ const Automerge = __importStar(require("@automerge/automerge"));
|
|
|
38
38
|
const uuid_1 = require("uuid");
|
|
39
39
|
const idGeneration_1 = require("./utils/idGeneration");
|
|
40
40
|
const errors_1 = require("./errors");
|
|
41
|
+
const logging_1 = require("./logging");
|
|
41
42
|
/**
|
|
42
43
|
* Default chunk size for attachments: 256KB
|
|
43
44
|
*/
|
|
@@ -62,7 +63,7 @@ const DEFAULT_CHUNK_SIZE_BYTES = 256 * 1024;
|
|
|
62
63
|
* - Automerge.applyChanges(doc, changes) - verify method signature
|
|
63
64
|
*/
|
|
64
65
|
class BaseMindooDB {
|
|
65
|
-
constructor(tenant, store, attachmentStore, attachmentConfig, adminOnlyDb = false) {
|
|
66
|
+
constructor(tenant, store, attachmentStore, attachmentConfig, adminOnlyDb = false, logger, performanceCallback) {
|
|
66
67
|
// Internal index: sorted array of document entries, maintained in order by (lastModified, docId)
|
|
67
68
|
// This allows efficient incremental processing without sorting on each call
|
|
68
69
|
this.index = [];
|
|
@@ -75,11 +76,19 @@ class BaseMindooDB {
|
|
|
75
76
|
// Index: automergeHash -> entryId for each document
|
|
76
77
|
// Used for resolving Automerge dependency hashes to entry IDs
|
|
77
78
|
this.automergeHashToEntryId = new Map(); // Map<docId, Map<automergeHash, entryId>>
|
|
79
|
+
// Cache for imported public keys (CryptoKey objects) to avoid re-importing the same key
|
|
80
|
+
// Map<publicKeyPEM, CryptoKey>
|
|
81
|
+
this.publicKeyCache = new Map();
|
|
78
82
|
this.tenant = tenant;
|
|
79
83
|
this.store = store;
|
|
80
84
|
this.attachmentStore = attachmentStore;
|
|
81
85
|
this.chunkSizeBytes = attachmentConfig?.chunkSizeBytes ?? DEFAULT_CHUNK_SIZE_BYTES;
|
|
82
86
|
this._isAdminOnlyDb = adminOnlyDb;
|
|
87
|
+
// Create logger if not provided (for backward compatibility)
|
|
88
|
+
this.logger =
|
|
89
|
+
logger ||
|
|
90
|
+
new logging_1.MindooLogger((0, logging_1.getDefaultLogLevel)(), "BaseMindooDB", true);
|
|
91
|
+
this.performanceCallback = performanceCallback;
|
|
83
92
|
}
|
|
84
93
|
/**
|
|
85
94
|
* Get the admin public key from the tenant.
|
|
@@ -91,6 +100,44 @@ class BaseMindooDB {
|
|
|
91
100
|
isAdminOnlyDb() {
|
|
92
101
|
return this._isAdminOnlyDb;
|
|
93
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Get or import a CryptoKey for signature verification, with caching.
|
|
105
|
+
* This avoids re-importing the same public key multiple times.
|
|
106
|
+
*/
|
|
107
|
+
async getOrImportPublicKey(publicKey) {
|
|
108
|
+
// Check cache first
|
|
109
|
+
if (this.publicKeyCache.has(publicKey)) {
|
|
110
|
+
return this.publicKeyCache.get(publicKey);
|
|
111
|
+
}
|
|
112
|
+
// Validate the public key is trusted
|
|
113
|
+
const directory = await this.tenant.openDirectory();
|
|
114
|
+
const isTrusted = await directory.validatePublicSigningKey(publicKey);
|
|
115
|
+
if (!isTrusted) {
|
|
116
|
+
this.logger.warn(`Public key not trusted: ${publicKey}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Import the key
|
|
120
|
+
const subtle = this.tenant.getCryptoAdapter().getSubtle();
|
|
121
|
+
const publicKeyBuffer = this.tenant.pemToArrayBuffer(publicKey);
|
|
122
|
+
const cryptoKey = await subtle.importKey("spki", publicKeyBuffer, {
|
|
123
|
+
name: "Ed25519",
|
|
124
|
+
}, false, // not extractable
|
|
125
|
+
["verify"]);
|
|
126
|
+
// Cache the imported key
|
|
127
|
+
this.publicKeyCache.set(publicKey, cryptoKey);
|
|
128
|
+
return cryptoKey;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Verify a signature using a pre-imported CryptoKey.
|
|
132
|
+
* This bypasses the key import step for better performance when keys are cached.
|
|
133
|
+
*/
|
|
134
|
+
async verifySignatureWithKey(cryptoKey, payload, signature) {
|
|
135
|
+
const subtle = this.tenant.getCryptoAdapter().getSubtle();
|
|
136
|
+
const isValid = await subtle.verify({
|
|
137
|
+
name: "Ed25519",
|
|
138
|
+
}, cryptoKey, signature.buffer, payload.buffer);
|
|
139
|
+
return isValid;
|
|
140
|
+
}
|
|
94
141
|
/**
|
|
95
142
|
* Compare two index entries for sorting.
|
|
96
143
|
* Returns negative if a < b, positive if a > b, 0 if equal.
|
|
@@ -107,25 +154,33 @@ class BaseMindooDB {
|
|
|
107
154
|
* When a document changes, it's removed from its current position and inserted
|
|
108
155
|
* at the correct sorted position to maintain order by (lastModified, docId).
|
|
109
156
|
*
|
|
157
|
+
* Optimized to only update lookup map for entries that actually moved.
|
|
158
|
+
*
|
|
110
159
|
* @param docId The document ID
|
|
111
160
|
* @param lastModified The new last modified timestamp
|
|
112
161
|
* @param isDeleted Whether the document is deleted
|
|
113
162
|
*/
|
|
114
163
|
updateIndex(docId, lastModified, isDeleted) {
|
|
115
|
-
|
|
164
|
+
const newEntry = { docId, lastModified, isDeleted };
|
|
116
165
|
const existingIndex = this.indexLookup.get(docId);
|
|
166
|
+
// Check if the entry already exists and hasn't changed position
|
|
117
167
|
if (existingIndex !== undefined) {
|
|
118
|
-
|
|
168
|
+
const existingEntry = this.index[existingIndex];
|
|
169
|
+
// If position hasn't changed (same lastModified and docId), no update needed
|
|
170
|
+
if (existingEntry.lastModified === lastModified && existingEntry.isDeleted === isDeleted) {
|
|
171
|
+
return; // No change needed
|
|
172
|
+
}
|
|
173
|
+
// Remove from current position
|
|
119
174
|
this.index.splice(existingIndex, 1);
|
|
120
|
-
// Update lookup map for
|
|
121
|
-
|
|
175
|
+
// Update lookup map for entries that moved (only those after the removed position)
|
|
176
|
+
// We'll update the full range after insertion to be safe
|
|
177
|
+
const minAffectedIndex = Math.min(existingIndex, this.index.length);
|
|
178
|
+
for (let i = minAffectedIndex; i < this.index.length; i++) {
|
|
122
179
|
this.indexLookup.set(this.index[i].docId, i);
|
|
123
180
|
}
|
|
124
|
-
// Remove the entry from lookup
|
|
125
181
|
this.indexLookup.delete(docId);
|
|
126
182
|
}
|
|
127
183
|
// Find insertion point using binary search to maintain sorted order
|
|
128
|
-
const newEntry = { docId, lastModified, isDeleted };
|
|
129
184
|
let insertIndex = this.index.length; // Default to end
|
|
130
185
|
// Binary search for insertion point
|
|
131
186
|
let left = 0;
|
|
@@ -144,7 +199,8 @@ class BaseMindooDB {
|
|
|
144
199
|
}
|
|
145
200
|
// Insert at the correct position
|
|
146
201
|
this.index.splice(insertIndex, 0, newEntry);
|
|
147
|
-
// Update lookup map for
|
|
202
|
+
// Update lookup map for entries from insertion point onwards
|
|
203
|
+
// Only update entries that actually moved (from insertIndex to end)
|
|
148
204
|
for (let i = insertIndex; i < this.index.length; i++) {
|
|
149
205
|
this.indexLookup.set(this.index[i].docId, i);
|
|
150
206
|
}
|
|
@@ -182,7 +238,7 @@ class BaseMindooDB {
|
|
|
182
238
|
entryIds.push(entryId);
|
|
183
239
|
}
|
|
184
240
|
else {
|
|
185
|
-
|
|
241
|
+
this.logger.warn(`Could not resolve automerge hash ${hash} to entry ID for doc ${docId}`);
|
|
186
242
|
}
|
|
187
243
|
}
|
|
188
244
|
return entryIds;
|
|
@@ -197,7 +253,7 @@ class BaseMindooDB {
|
|
|
197
253
|
* Initialize the database instance.
|
|
198
254
|
*/
|
|
199
255
|
async initialize() {
|
|
200
|
-
|
|
256
|
+
this.logger.info(`Initializing database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
|
|
201
257
|
await this.syncStoreChanges();
|
|
202
258
|
}
|
|
203
259
|
/**
|
|
@@ -206,13 +262,13 @@ class BaseMindooDB {
|
|
|
206
262
|
* On first call (when processedEntryIds is empty), it will process all entries.
|
|
207
263
|
*/
|
|
208
264
|
async syncStoreChanges() {
|
|
209
|
-
|
|
210
|
-
|
|
265
|
+
this.logger.debug(`Syncing store changes for database ${this.store.getId()} in tenant ${this.tenant.getId()}`);
|
|
266
|
+
this.logger.debug(`Already processed ${this.processedEntryIds.length} entry IDs`);
|
|
211
267
|
// Find new entries that we haven't processed yet
|
|
212
268
|
const newEntryMetadata = await this.store.findNewEntries(this.processedEntryIds);
|
|
213
|
-
|
|
269
|
+
this.logger.debug(`Found ${newEntryMetadata.length} new entries`);
|
|
214
270
|
if (newEntryMetadata.length === 0) {
|
|
215
|
-
|
|
271
|
+
this.logger.debug(`No new entries to process`);
|
|
216
272
|
return;
|
|
217
273
|
}
|
|
218
274
|
// Group new entries by document ID
|
|
@@ -224,49 +280,92 @@ class BaseMindooDB {
|
|
|
224
280
|
entriesByDoc.get(entryMeta.docId).push(entryMeta);
|
|
225
281
|
}
|
|
226
282
|
// Process each document with new entries
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
283
|
+
// Use incremental cache updates when possible
|
|
284
|
+
// Process documents in parallel with concurrency limit
|
|
285
|
+
this.logger.debug(`Processing ${entriesByDoc.size} documents with new entries`);
|
|
286
|
+
// Helper function to process a single document
|
|
287
|
+
const processDocument = async (docId, entryMetadataList) => {
|
|
230
288
|
try {
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
this.docCache.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
289
|
+
this.logger.debug(`===== Processing document ${docId} with ${entryMetadataList.length} new entry(s) in syncStoreChanges =====`);
|
|
290
|
+
// Check if document is cached
|
|
291
|
+
const cachedDoc = this.docCache.get(docId);
|
|
292
|
+
let updatedDoc = null;
|
|
293
|
+
if (cachedDoc) {
|
|
294
|
+
// Document is cached - try incremental update
|
|
295
|
+
this.logger.debug(`Document ${docId} found in cache, attempting incremental update`);
|
|
296
|
+
try {
|
|
297
|
+
updatedDoc = await this.applyNewEntriesToCachedDocument(cachedDoc, entryMetadataList);
|
|
298
|
+
if (updatedDoc) {
|
|
299
|
+
this.logger.debug(`Successfully updated cached document ${docId} incrementally`);
|
|
300
|
+
// Only update index if document actually changed
|
|
301
|
+
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted);
|
|
302
|
+
this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted})`);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
this.logger.debug(`Document ${docId} unchanged after applying new entries, skipping index update`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
// If incremental update fails, fall back to full reload
|
|
310
|
+
this.logger.warn(`Incremental update failed for document ${docId}, falling back to full reload:`, error);
|
|
311
|
+
this.docCache.delete(docId);
|
|
312
|
+
updatedDoc = await this.loadDocumentInternal(docId);
|
|
313
|
+
if (updatedDoc) {
|
|
314
|
+
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
243
317
|
}
|
|
244
318
|
else {
|
|
245
|
-
|
|
319
|
+
// Document not cached - load from scratch
|
|
320
|
+
this.logger.debug(`Document ${docId} not in cache, loading from store`);
|
|
321
|
+
updatedDoc = await this.loadDocumentInternal(docId);
|
|
322
|
+
if (updatedDoc) {
|
|
323
|
+
this.logger.debug(`Successfully loaded document ${docId}, updating index`);
|
|
324
|
+
this.updateIndex(docId, updatedDoc.lastModified, updatedDoc.isDeleted);
|
|
325
|
+
this.logger.debug(`Updated index for document ${docId} (lastModified: ${updatedDoc.lastModified}, isDeleted: ${updatedDoc.isDeleted})`);
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
this.logger.warn(`Document ${docId} returned null from loadDocumentInternal`);
|
|
329
|
+
}
|
|
246
330
|
}
|
|
247
331
|
}
|
|
248
332
|
catch (error) {
|
|
249
333
|
// Handle missing symmetric key gracefully - skip documents we can't decrypt
|
|
250
334
|
if (error instanceof errors_1.SymmetricKeyNotFoundError) {
|
|
251
|
-
|
|
335
|
+
this.logger.debug(`Skipping document ${docId} - missing key: ${error.keyId}`);
|
|
252
336
|
// Mark entry IDs as processed so we don't retry them
|
|
253
337
|
this.processedEntryIds.push(...entryMetadataList.map(em => em.id));
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
console.error(`[BaseMindooDB] ===== ERROR processing document ${docId} in syncStoreChanges =====`);
|
|
257
|
-
console.error(`[BaseMindooDB] Error type: ${error instanceof Error ? error.constructor.name : typeof error}`);
|
|
258
|
-
console.error(`[BaseMindooDB] Error message: ${error instanceof Error ? error.message : String(error)}`);
|
|
259
|
-
if (error instanceof Error && error.stack) {
|
|
260
|
-
console.error(`[BaseMindooDB] Error stack: ${error.stack}`);
|
|
338
|
+
return; // Skip this document
|
|
261
339
|
}
|
|
340
|
+
this.logger.error(`===== ERROR processing document ${docId} in syncStoreChanges =====`, error);
|
|
262
341
|
// Re-throw the error so we can see what's happening in the test
|
|
263
|
-
console.error(`[BaseMindooDB] Re-throwing error for document ${docId}`);
|
|
264
342
|
throw error;
|
|
265
343
|
}
|
|
344
|
+
};
|
|
345
|
+
// Process documents in parallel with concurrency limit (default: 10)
|
|
346
|
+
const maxConcurrency = 10;
|
|
347
|
+
const documentEntries = Array.from(entriesByDoc.entries());
|
|
348
|
+
// Process in batches to limit concurrency
|
|
349
|
+
const results = [];
|
|
350
|
+
for (let i = 0; i < documentEntries.length; i += maxConcurrency) {
|
|
351
|
+
const batch = documentEntries.slice(i, i + maxConcurrency);
|
|
352
|
+
const batchResults = await Promise.allSettled(batch.map(([docId, entryMetadataList]) => processDocument(docId, entryMetadataList)));
|
|
353
|
+
results.push(...batchResults);
|
|
354
|
+
}
|
|
355
|
+
// Check for errors (excluding SymmetricKeyNotFoundError which is handled gracefully)
|
|
356
|
+
for (let i = 0; i < results.length; i++) {
|
|
357
|
+
const result = results[i];
|
|
358
|
+
if (result.status === 'rejected') {
|
|
359
|
+
const error = result.reason;
|
|
360
|
+
if (!(error instanceof errors_1.SymmetricKeyNotFoundError)) {
|
|
361
|
+
// Re-throw non-key errors
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
266
365
|
}
|
|
267
366
|
// Append new entry IDs to our processed list
|
|
268
367
|
this.processedEntryIds.push(...newEntryMetadata.map(em => em.id));
|
|
269
|
-
|
|
368
|
+
this.logger.debug(`Synced ${newEntryMetadata.length} new entries, index now has ${this.index.length} documents`);
|
|
270
369
|
}
|
|
271
370
|
getStore() {
|
|
272
371
|
return this.store;
|
|
@@ -306,12 +405,12 @@ class BaseMindooDB {
|
|
|
306
405
|
}
|
|
307
406
|
// Generate UUID7 for document ID
|
|
308
407
|
const docId = (0, uuid_1.v7)();
|
|
309
|
-
|
|
408
|
+
this.logger.debug(`Creating document ${docId} with key ${keyId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
|
|
310
409
|
// Create initial Automerge document
|
|
311
410
|
const initialDoc = Automerge.init();
|
|
312
411
|
// Create the first change
|
|
313
412
|
const now = Date.now();
|
|
314
|
-
|
|
413
|
+
this.logger.debug(`Creating initial Automerge change for document ${docId}`);
|
|
315
414
|
let newDoc;
|
|
316
415
|
try {
|
|
317
416
|
newDoc = Automerge.change(initialDoc, (doc) => {
|
|
@@ -319,59 +418,59 @@ class BaseMindooDB {
|
|
|
319
418
|
// We need to modify the document to ensure a change is created
|
|
320
419
|
doc._attachments = [];
|
|
321
420
|
});
|
|
322
|
-
|
|
421
|
+
this.logger.debug(`Successfully created Automerge change, document heads: ${JSON.stringify(Automerge.getHeads(newDoc))}`);
|
|
323
422
|
}
|
|
324
423
|
catch (error) {
|
|
325
|
-
|
|
424
|
+
this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
|
|
326
425
|
throw error;
|
|
327
426
|
}
|
|
328
427
|
// Get the change bytes from the document
|
|
329
|
-
|
|
428
|
+
this.logger.debug(`Getting change bytes from document ${docId}`);
|
|
330
429
|
const changeBytes = Automerge.getLastLocalChange(newDoc);
|
|
331
430
|
if (!changeBytes) {
|
|
332
431
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
333
432
|
}
|
|
334
|
-
|
|
433
|
+
this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
|
|
335
434
|
// Decode the change to get hash and dependencies
|
|
336
|
-
|
|
435
|
+
this.logger.debug(`Decoding change to get hash and dependencies`);
|
|
337
436
|
let decodedChange;
|
|
338
437
|
try {
|
|
339
438
|
decodedChange = Automerge.decodeChange(changeBytes);
|
|
340
|
-
|
|
439
|
+
this.logger.debug(`Successfully decoded change, hash: ${decodedChange.hash}, deps: ${decodedChange.deps?.length || 0}`);
|
|
341
440
|
}
|
|
342
441
|
catch (error) {
|
|
343
|
-
|
|
442
|
+
this.logger.error(`Error decoding change for document ${docId}:`, error);
|
|
344
443
|
throw error;
|
|
345
444
|
}
|
|
346
445
|
const automergeHash = decodedChange.hash;
|
|
347
446
|
const automergeDepHashes = decodedChange.deps || []; // First change has no dependencies
|
|
348
447
|
// Encrypt the change payload first
|
|
349
|
-
|
|
448
|
+
this.logger.debug(`Encrypting change payload for document ${docId}`);
|
|
350
449
|
const encryptedPayload = await this.tenant.encryptPayload(changeBytes, keyId);
|
|
351
|
-
|
|
450
|
+
this.logger.debug(`Encrypted payload: ${changeBytes.length} -> ${encryptedPayload.length} bytes`);
|
|
352
451
|
// Compute content hash from encrypted data
|
|
353
452
|
const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
|
|
354
|
-
|
|
453
|
+
this.logger.debug(`Computed content hash: ${contentHash.substring(0, 16)}...`);
|
|
355
454
|
// Generate entry ID with blockchain-like chaining
|
|
356
455
|
const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, automergeHash, automergeDepHashes, this.getSubtle());
|
|
357
|
-
|
|
456
|
+
this.logger.debug(`Generated entry ID: ${entryId}`);
|
|
358
457
|
// Resolve Automerge dependency hashes to entry IDs (empty for first change)
|
|
359
458
|
const dependencyIds = this.resolveAutomergeDepsToEntryIds(docId, automergeDepHashes);
|
|
360
459
|
// Sign the encrypted payload - either with custom key or current user's key
|
|
361
460
|
let signature;
|
|
362
461
|
let createdByPublicKey;
|
|
363
462
|
if (useCustomSigningKey) {
|
|
364
|
-
|
|
463
|
+
this.logger.debug(`Signing encrypted payload for document ${docId} with provided key`);
|
|
365
464
|
signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
|
|
366
465
|
createdByPublicKey = signingKeyPair.publicKey;
|
|
367
466
|
}
|
|
368
467
|
else {
|
|
369
|
-
|
|
468
|
+
this.logger.debug(`Signing encrypted payload for document ${docId}`);
|
|
370
469
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
371
470
|
signature = await this.tenant.signPayload(encryptedPayload);
|
|
372
471
|
createdByPublicKey = currentUser.userSigningPublicKey;
|
|
373
472
|
}
|
|
374
|
-
|
|
473
|
+
this.logger.debug(`Signed payload, signature length: ${signature.length} bytes`);
|
|
375
474
|
// Create entry metadata
|
|
376
475
|
const entryMetadata = {
|
|
377
476
|
entryType: "doc_create",
|
|
@@ -407,8 +506,8 @@ class BaseMindooDB {
|
|
|
407
506
|
// Update cache and index
|
|
408
507
|
this.docCache.set(docId, internalDoc);
|
|
409
508
|
this.updateIndex(docId, internalDoc.lastModified, false);
|
|
410
|
-
|
|
411
|
-
|
|
509
|
+
this.logger.info(`Document ${docId} created successfully`);
|
|
510
|
+
this.logger.debug(`Document ${docId} cached and indexed (lastModified: ${internalDoc.lastModified})`);
|
|
412
511
|
return this.wrapDocument(internalDoc);
|
|
413
512
|
}
|
|
414
513
|
async getDocument(docId) {
|
|
@@ -422,7 +521,7 @@ class BaseMindooDB {
|
|
|
422
521
|
return this.wrapDocument(internalDoc);
|
|
423
522
|
}
|
|
424
523
|
async getDocumentAtTimestamp(docId, timestamp) {
|
|
425
|
-
|
|
524
|
+
this.logger.debug(`Getting document ${docId} at timestamp ${timestamp}`);
|
|
426
525
|
// Get all entry metadata for this document
|
|
427
526
|
const allEntryMetadata = await this.store.findNewEntriesForDoc([], docId);
|
|
428
527
|
// Filter entries up to the timestamp
|
|
@@ -439,14 +538,14 @@ class BaseMindooDB {
|
|
|
439
538
|
for (const entryData of entries) {
|
|
440
539
|
// Admin-only mode: only accept entries signed by the admin key
|
|
441
540
|
if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
442
|
-
|
|
541
|
+
this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
|
|
443
542
|
continue;
|
|
444
543
|
}
|
|
445
544
|
// Verify signature against the encrypted payload (no decryption needed)
|
|
446
545
|
// We sign the encrypted payload, so anyone can verify signatures without decryption keys
|
|
447
546
|
const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
|
|
448
547
|
if (!isValid) {
|
|
449
|
-
|
|
548
|
+
this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
|
|
450
549
|
continue;
|
|
451
550
|
}
|
|
452
551
|
// Decrypt payload (only after signature verification passes)
|
|
@@ -461,24 +560,125 @@ class BaseMindooDB {
|
|
|
461
560
|
}
|
|
462
561
|
// Check if document was deleted at this timestamp
|
|
463
562
|
// Look for a "doc_delete" type entry in the relevant entries
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
return null; // Document was deleted at this time
|
|
467
|
-
}
|
|
563
|
+
const deleteEntry = relevantEntries.find(em => em.entryType === "doc_delete" && em.createdAt <= timestamp);
|
|
564
|
+
const isDeleted = deleteEntry !== undefined;
|
|
468
565
|
// Find the first entry to get createdAt and decryptionKeyId
|
|
469
566
|
const firstEntry = relevantEntries.length > 0 ? relevantEntries[0] : null;
|
|
470
567
|
const createdAt = firstEntry ? firstEntry.createdAt : timestamp;
|
|
471
568
|
const decryptionKeyId = firstEntry ? firstEntry.decryptionKeyId : "default";
|
|
569
|
+
// Use delete entry timestamp as lastModified if deleted, otherwise use the requested timestamp
|
|
570
|
+
const lastModified = isDeleted && deleteEntry ? deleteEntry.createdAt : timestamp;
|
|
472
571
|
const internalDoc = {
|
|
473
572
|
id: docId,
|
|
474
573
|
doc,
|
|
475
574
|
createdAt,
|
|
476
|
-
lastModified
|
|
575
|
+
lastModified,
|
|
477
576
|
decryptionKeyId,
|
|
478
|
-
isDeleted
|
|
577
|
+
isDeleted,
|
|
479
578
|
};
|
|
480
579
|
return this.wrapDocument(internalDoc);
|
|
481
580
|
}
|
|
581
|
+
async *iterateDocumentHistory(docId) {
|
|
582
|
+
this.logger.debug(`Iterating document history for ${docId}`);
|
|
583
|
+
// Get all entry metadata for this document
|
|
584
|
+
const allEntryMetadata = await this.store.findNewEntriesForDoc([], docId);
|
|
585
|
+
// Filter to only doc_create, doc_change, and doc_delete entries (exclude snapshots)
|
|
586
|
+
const relevantEntries = allEntryMetadata
|
|
587
|
+
.filter(em => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete")
|
|
588
|
+
.sort((a, b) => a.createdAt - b.createdAt);
|
|
589
|
+
if (relevantEntries.length === 0) {
|
|
590
|
+
return; // Document has no history
|
|
591
|
+
}
|
|
592
|
+
// Load all entries
|
|
593
|
+
const entries = await this.store.getEntries(relevantEntries.map(em => em.id));
|
|
594
|
+
// Build a map for quick lookup
|
|
595
|
+
const entryMap = new Map(entries.map(e => [e.id, e]));
|
|
596
|
+
// Apply changes in order
|
|
597
|
+
let currentDoc = null;
|
|
598
|
+
let createdAt = null;
|
|
599
|
+
let decryptionKeyId = "default";
|
|
600
|
+
for (const entryMetadata of relevantEntries) {
|
|
601
|
+
const entryData = entryMap.get(entryMetadata.id);
|
|
602
|
+
if (!entryData) {
|
|
603
|
+
this.logger.warn(`Entry ${entryMetadata.id} not found in store, skipping`);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
// Admin-only mode: only accept entries signed by the admin key
|
|
607
|
+
if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
608
|
+
this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
// Verify signature
|
|
612
|
+
const isValid = await this.tenant.verifySignature(entryData.encryptedData, entryData.signature, entryData.createdByPublicKey);
|
|
613
|
+
if (!isValid) {
|
|
614
|
+
this.logger.warn(`Invalid signature for entry ${entryData.id}, skipping`);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
// Decrypt payload
|
|
618
|
+
const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
|
|
619
|
+
// Initialize document if this is the first entry (doc_create)
|
|
620
|
+
const isFirstEntry = currentDoc === null;
|
|
621
|
+
if (isFirstEntry) {
|
|
622
|
+
if (entryMetadata.entryType !== "doc_create") {
|
|
623
|
+
this.logger.warn(`First entry is not doc_create, skipping`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
currentDoc = Automerge.init();
|
|
627
|
+
createdAt = entryMetadata.createdAt;
|
|
628
|
+
decryptionKeyId = entryMetadata.decryptionKeyId;
|
|
629
|
+
}
|
|
630
|
+
// Check document heads before applying change (for non-first entries)
|
|
631
|
+
const headsBefore = isFirstEntry ? null : (currentDoc ? Automerge.getHeads(currentDoc) : null);
|
|
632
|
+
// Apply change using loadIncremental
|
|
633
|
+
if (currentDoc === null) {
|
|
634
|
+
throw new Error("currentDoc should not be null at this point");
|
|
635
|
+
}
|
|
636
|
+
currentDoc = Automerge.loadIncremental(currentDoc, decryptedPayload);
|
|
637
|
+
// Check if document actually changed by comparing heads
|
|
638
|
+
// For first entry (doc_create), always yield since it's the initial creation
|
|
639
|
+
// For delete entries, always yield
|
|
640
|
+
// For other entries, only yield if heads changed
|
|
641
|
+
let shouldYield = false;
|
|
642
|
+
if (isFirstEntry || entryMetadata.entryType === "doc_delete") {
|
|
643
|
+
shouldYield = true;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
const headsAfter = Automerge.getHeads(currentDoc);
|
|
647
|
+
const headsChanged = headsBefore !== null && JSON.stringify(headsBefore) !== JSON.stringify(headsAfter);
|
|
648
|
+
shouldYield = headsChanged;
|
|
649
|
+
}
|
|
650
|
+
// Register automerge hash mapping
|
|
651
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
|
|
652
|
+
if (parsed) {
|
|
653
|
+
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
|
|
654
|
+
}
|
|
655
|
+
// Only yield if document actually changed (or if this is create/delete entry)
|
|
656
|
+
if (shouldYield) {
|
|
657
|
+
// Clone the document to ensure independence
|
|
658
|
+
const clonedDoc = Automerge.clone(currentDoc);
|
|
659
|
+
// Create internal doc representation
|
|
660
|
+
const internalDoc = {
|
|
661
|
+
id: docId,
|
|
662
|
+
doc: clonedDoc,
|
|
663
|
+
createdAt: createdAt,
|
|
664
|
+
lastModified: entryMetadata.createdAt,
|
|
665
|
+
decryptionKeyId,
|
|
666
|
+
isDeleted: entryMetadata.entryType === "doc_delete",
|
|
667
|
+
};
|
|
668
|
+
// Wrap and yield (including delete entries)
|
|
669
|
+
const wrappedDoc = this.wrapDocument(internalDoc);
|
|
670
|
+
yield {
|
|
671
|
+
doc: wrappedDoc,
|
|
672
|
+
changeCreatedAt: entryMetadata.createdAt,
|
|
673
|
+
changeCreatedByPublicKey: entryMetadata.createdByPublicKey,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
// Stop after delete entry (document is deleted, no more changes)
|
|
677
|
+
if (entryMetadata.entryType === "doc_delete") {
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
482
682
|
async getAllDocumentIds() {
|
|
483
683
|
// Return all non-deleted document IDs from index
|
|
484
684
|
const docIds = [];
|
|
@@ -489,6 +689,49 @@ class BaseMindooDB {
|
|
|
489
689
|
}
|
|
490
690
|
return docIds;
|
|
491
691
|
}
|
|
692
|
+
async getAllDocumentIdsAtTimestamp(timestamp) {
|
|
693
|
+
// Efficiently query for doc_create and doc_delete entries before the timestamp
|
|
694
|
+
const [creates, deletes] = await Promise.all([
|
|
695
|
+
this.store.findEntries("doc_create", null, timestamp),
|
|
696
|
+
this.store.findEntries("doc_delete", null, timestamp)
|
|
697
|
+
]);
|
|
698
|
+
// Build sets of docIds for efficient lookup
|
|
699
|
+
const createdDocIds = new Set();
|
|
700
|
+
const deletedDocIds = new Set();
|
|
701
|
+
// Track earliest create time for each doc
|
|
702
|
+
const createTimes = new Map();
|
|
703
|
+
for (const entry of creates) {
|
|
704
|
+
const existingTime = createTimes.get(entry.docId);
|
|
705
|
+
if (!existingTime || entry.createdAt < existingTime) {
|
|
706
|
+
createTimes.set(entry.docId, entry.createdAt);
|
|
707
|
+
createdDocIds.add(entry.docId);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
// Track earliest delete time for each doc
|
|
711
|
+
const deleteTimes = new Map();
|
|
712
|
+
for (const entry of deletes) {
|
|
713
|
+
const existingTime = deleteTimes.get(entry.docId);
|
|
714
|
+
if (!existingTime || entry.createdAt < existingTime) {
|
|
715
|
+
deleteTimes.set(entry.docId, entry.createdAt);
|
|
716
|
+
deletedDocIds.add(entry.docId);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Find documents that existed at the timestamp
|
|
720
|
+
const docIds = [];
|
|
721
|
+
for (const docId of createdDocIds) {
|
|
722
|
+
const createTime = createTimes.get(docId);
|
|
723
|
+
const deleteTime = deleteTimes.get(docId);
|
|
724
|
+
// Document exists at timestamp if:
|
|
725
|
+
// 1. It was created before the timestamp
|
|
726
|
+
// 2. Either it was never deleted, or it was deleted after the timestamp
|
|
727
|
+
if (createTime < timestamp) {
|
|
728
|
+
if (!deleteTime || deleteTime > timestamp) {
|
|
729
|
+
docIds.push(docId);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return docIds;
|
|
734
|
+
}
|
|
492
735
|
async deleteDocument(docId) {
|
|
493
736
|
return this.deleteDocInternal(docId);
|
|
494
737
|
}
|
|
@@ -502,7 +745,7 @@ class BaseMindooDB {
|
|
|
502
745
|
*/
|
|
503
746
|
async deleteDocInternal(docId, signingKeyPair, signingKeyPassword) {
|
|
504
747
|
const useCustomSigningKey = signingKeyPair !== undefined && signingKeyPassword !== undefined;
|
|
505
|
-
|
|
748
|
+
this.logger.debug(`Deleting document ${docId}${useCustomSigningKey ? ' using custom signing key' : ''}`);
|
|
506
749
|
// Admin-only validation: only admin key can modify data in admin-only databases
|
|
507
750
|
if (this._isAdminOnlyDb) {
|
|
508
751
|
const adminPublicKey = this.getAdminPublicKey();
|
|
@@ -550,12 +793,12 @@ class BaseMindooDB {
|
|
|
550
793
|
let signature;
|
|
551
794
|
let createdByPublicKey;
|
|
552
795
|
if (useCustomSigningKey) {
|
|
553
|
-
|
|
796
|
+
this.logger.debug(`Signing deletion for document ${docId} with provided key`);
|
|
554
797
|
signature = await this.tenant.signPayloadWithKey(encryptedPayload, signingKeyPair, signingKeyPassword);
|
|
555
798
|
createdByPublicKey = signingKeyPair.publicKey;
|
|
556
799
|
}
|
|
557
800
|
else {
|
|
558
|
-
|
|
801
|
+
this.logger.debug(`Signing deletion for document ${docId} with current user's key`);
|
|
559
802
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
560
803
|
signature = await this.tenant.signPayload(encryptedPayload);
|
|
561
804
|
createdByPublicKey = currentUser.userSigningPublicKey;
|
|
@@ -588,7 +831,7 @@ class BaseMindooDB {
|
|
|
588
831
|
internalDoc.lastModified = Date.now();
|
|
589
832
|
this.docCache.set(docId, internalDoc);
|
|
590
833
|
this.updateIndex(docId, internalDoc.lastModified, true);
|
|
591
|
-
|
|
834
|
+
this.logger.info(`Document ${docId} deleted successfully`);
|
|
592
835
|
}
|
|
593
836
|
async changeDoc(doc, changeFunc) {
|
|
594
837
|
return this.changeDocInternal(doc, changeFunc);
|
|
@@ -599,7 +842,7 @@ class BaseMindooDB {
|
|
|
599
842
|
async changeDocInternal(doc, changeFunc, signingKeyPair, signingKeyPassword) {
|
|
600
843
|
const docId = doc.getId();
|
|
601
844
|
const useCustomKey = signingKeyPair && signingKeyPassword;
|
|
602
|
-
|
|
845
|
+
this.logger.debug(`===== ${useCustomKey ? 'changeDocWithSigningKey' : 'changeDoc'} called for document ${docId} =====`);
|
|
603
846
|
// Admin-only validation: only admin key can modify data in admin-only databases
|
|
604
847
|
if (this._isAdminOnlyDb) {
|
|
605
848
|
const adminPublicKey = this.getAdminPublicKey();
|
|
@@ -613,24 +856,24 @@ class BaseMindooDB {
|
|
|
613
856
|
// Get internal document from cache or load it
|
|
614
857
|
let internalDoc = this.docCache.get(docId);
|
|
615
858
|
if (!internalDoc) {
|
|
616
|
-
|
|
859
|
+
this.logger.debug(`Document ${docId} not in cache, loading from store`);
|
|
617
860
|
const loadedDoc = await this.loadDocumentInternal(docId);
|
|
618
861
|
if (!loadedDoc) {
|
|
619
862
|
throw new Error(`Document ${docId} not found`);
|
|
620
863
|
}
|
|
621
864
|
internalDoc = loadedDoc;
|
|
622
|
-
|
|
865
|
+
this.logger.debug(`Successfully loaded document ${docId} from store for ${useCustomKey ? 'changeDocWithSigningKey' : 'changeDoc'}`);
|
|
623
866
|
}
|
|
624
867
|
else {
|
|
625
|
-
|
|
868
|
+
this.logger.debug(`Document ${docId} found in cache`);
|
|
626
869
|
}
|
|
627
870
|
if (internalDoc.isDeleted) {
|
|
628
871
|
throw new Error(`Document ${docId} has been deleted`);
|
|
629
872
|
}
|
|
630
873
|
// Apply the change function
|
|
631
874
|
const now = Date.now();
|
|
632
|
-
|
|
633
|
-
|
|
875
|
+
this.logger.debug(`Applying change function to document ${docId}`);
|
|
876
|
+
this.logger.debug(`Document state before change: heads=${JSON.stringify(Automerge.getHeads(internalDoc.doc))}`);
|
|
634
877
|
// For async callbacks, we need to handle document modifications carefully.
|
|
635
878
|
// Automerge.change() requires synchronous modifications, so we'll:
|
|
636
879
|
// 1. Execute the async callback to do any async work (like signing)
|
|
@@ -844,20 +1087,20 @@ class BaseMindooDB {
|
|
|
844
1087
|
// Update lastModified timestamp
|
|
845
1088
|
automergeDoc._lastModified = now;
|
|
846
1089
|
});
|
|
847
|
-
|
|
1090
|
+
this.logger.debug(`Successfully applied change function, new document heads: ${JSON.stringify(Automerge.getHeads(newDoc))}`);
|
|
848
1091
|
}
|
|
849
1092
|
catch (error) {
|
|
850
|
-
|
|
1093
|
+
this.logger.error(`Error in Automerge.change for document ${docId}:`, error);
|
|
851
1094
|
throw error;
|
|
852
1095
|
}
|
|
853
1096
|
// Get the change bytes from the document
|
|
854
|
-
|
|
1097
|
+
this.logger.debug(`Getting change bytes from document ${docId}`);
|
|
855
1098
|
const changeBytes = Automerge.getLastLocalChange(newDoc);
|
|
856
1099
|
if (!changeBytes) {
|
|
857
1100
|
//TODO decide if we just exit here or throw an error
|
|
858
1101
|
throw new Error("Failed to get change bytes from Automerge document");
|
|
859
1102
|
}
|
|
860
|
-
|
|
1103
|
+
this.logger.debug(`Got change bytes: ${changeBytes.length} bytes`);
|
|
861
1104
|
// Decode the change to get hash and dependencies
|
|
862
1105
|
const decodedChange = Automerge.decodeChange(changeBytes);
|
|
863
1106
|
const automergeHash = decodedChange.hash;
|
|
@@ -868,7 +1111,7 @@ class BaseMindooDB {
|
|
|
868
1111
|
const contentHash = await (0, idGeneration_1.computeContentHash)(encryptedPayload, this.getSubtle());
|
|
869
1112
|
// Generate entry ID with blockchain-like chaining
|
|
870
1113
|
const entryId = await (0, idGeneration_1.generateDocEntryId)(docId, automergeHash, automergeDepHashes, this.getSubtle());
|
|
871
|
-
|
|
1114
|
+
this.logger.debug(`Generated entry ID for change: ${entryId}`);
|
|
872
1115
|
// Resolve Automerge dependency hashes to entry IDs
|
|
873
1116
|
const dependencyIds = this.resolveAutomergeDepsToEntryIds(docId, automergeDepHashes);
|
|
874
1117
|
// Sign the encrypted payload - use custom key if provided, otherwise current user's key
|
|
@@ -911,12 +1154,12 @@ class BaseMindooDB {
|
|
|
911
1154
|
internalDoc.lastModified = Date.now();
|
|
912
1155
|
this.docCache.set(docId, internalDoc);
|
|
913
1156
|
this.updateIndex(docId, internalDoc.lastModified, internalDoc.isDeleted);
|
|
914
|
-
|
|
1157
|
+
this.logger.info(`Document ${docId} ${useCustomKey ? 'changed with custom signing key' : 'changed'} successfully`);
|
|
915
1158
|
}
|
|
916
|
-
async
|
|
1159
|
+
async *iterateChangesSince(cursor) {
|
|
917
1160
|
// Default to initial cursor if null is provided
|
|
918
1161
|
const actualCursor = cursor ?? { lastModified: 0, docId: "" };
|
|
919
|
-
|
|
1162
|
+
this.logger.debug(`Starting iteration from cursor ${JSON.stringify(actualCursor)}`);
|
|
920
1163
|
// Find starting position using binary search
|
|
921
1164
|
// We want to find the first entry that is greater than the cursor
|
|
922
1165
|
let startIndex = 0;
|
|
@@ -944,77 +1187,190 @@ class BaseMindooDB {
|
|
|
944
1187
|
}
|
|
945
1188
|
}
|
|
946
1189
|
}
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
let
|
|
950
|
-
for (let i = startIndex; i < this.index.length && processedCount < limit; i++) {
|
|
1190
|
+
// Pre-check which documents are in cache to optimize iteration
|
|
1191
|
+
const uncachedDocIds = [];
|
|
1192
|
+
for (let i = startIndex; i < this.index.length; i++) {
|
|
951
1193
|
const entry = this.index[i];
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1194
|
+
if (!this.docCache.has(entry.docId)) {
|
|
1195
|
+
uncachedDocIds.push(entry.docId);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Prefetch uncached documents in batches (if any)
|
|
1199
|
+
if (uncachedDocIds.length > 0) {
|
|
1200
|
+
this.logger.debug(`Prefetching ${uncachedDocIds.length} uncached documents for iteration`);
|
|
1201
|
+
const prefetchBatchSize = 10;
|
|
1202
|
+
for (let i = 0; i < uncachedDocIds.length; i += prefetchBatchSize) {
|
|
1203
|
+
const batch = uncachedDocIds.slice(i, i + prefetchBatchSize);
|
|
1204
|
+
await Promise.all(batch.map(docId => this.loadDocumentInternal(docId).catch(err => {
|
|
1205
|
+
this.logger.warn(`Failed to prefetch document ${docId}:`, err);
|
|
1206
|
+
return null;
|
|
1207
|
+
})));
|
|
955
1208
|
}
|
|
1209
|
+
}
|
|
1210
|
+
// Iterate through the index and yield documents one at a time
|
|
1211
|
+
// Documents should now be in cache from prefetching
|
|
1212
|
+
for (let i = startIndex; i < this.index.length; i++) {
|
|
1213
|
+
const entry = this.index[i];
|
|
956
1214
|
try {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1215
|
+
this.logger.debug(`Yielding document ${entry.docId} from index (lastModified: ${entry.lastModified}, isDeleted: ${entry.isDeleted})`);
|
|
1216
|
+
// Check cache first (should be there after prefetching)
|
|
1217
|
+
let internalDoc = this.docCache.get(entry.docId) || null;
|
|
1218
|
+
if (!internalDoc) {
|
|
1219
|
+
// Fallback to loading if not in cache (shouldn't happen after prefetching)
|
|
1220
|
+
this.logger.debug(`Document ${entry.docId} not in cache, loading from store`);
|
|
1221
|
+
internalDoc = await this.loadDocumentInternal(entry.docId);
|
|
1222
|
+
}
|
|
1223
|
+
if (!internalDoc) {
|
|
1224
|
+
this.logger.warn(`Document ${entry.docId} not found, skipping`);
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
// Wrap the document (works for both deleted and non-deleted documents)
|
|
1228
|
+
const doc = this.wrapDocument(internalDoc);
|
|
1229
|
+
this.logger.debug(`Successfully loaded document ${entry.docId} (isDeleted: ${doc.isDeleted()})`);
|
|
960
1230
|
// Create cursor for current document
|
|
961
1231
|
const currentCursor = {
|
|
962
1232
|
lastModified: entry.lastModified,
|
|
963
1233
|
docId: entry.docId,
|
|
964
1234
|
};
|
|
965
|
-
|
|
966
|
-
//
|
|
967
|
-
|
|
968
|
-
processedCount++;
|
|
969
|
-
// Stop if callback returns false
|
|
970
|
-
if (shouldContinue === false) {
|
|
971
|
-
console.log(`[BaseMindooDB] Callback requested to stop processing`);
|
|
972
|
-
break;
|
|
973
|
-
}
|
|
1235
|
+
// Yield immediately - this allows the caller to break early after each document
|
|
1236
|
+
// Deleted documents are included so external indexes can handle deletions
|
|
1237
|
+
yield { doc, cursor: currentCursor };
|
|
974
1238
|
}
|
|
975
1239
|
catch (error) {
|
|
976
|
-
|
|
1240
|
+
this.logger.error(`Error processing document ${entry.docId}:`, error);
|
|
977
1241
|
// Stop processing on error
|
|
978
1242
|
throw error;
|
|
979
1243
|
}
|
|
980
1244
|
}
|
|
981
|
-
|
|
982
|
-
return lastCursor;
|
|
1245
|
+
this.logger.debug(`Iteration completed`);
|
|
983
1246
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1247
|
+
/**
|
|
1248
|
+
* Incrementally update a cached document with new entries.
|
|
1249
|
+
* Returns the updated document, or null if document wasn't actually changed.
|
|
1250
|
+
*/
|
|
1251
|
+
async applyNewEntriesToCachedDocument(cachedDoc, newEntryMetadata) {
|
|
1252
|
+
const docId = cachedDoc.id;
|
|
1253
|
+
if (newEntryMetadata.length === 0) {
|
|
1254
|
+
this.logger.debug(`No new entries for cached document ${docId}`);
|
|
1255
|
+
return null; // No changes
|
|
1256
|
+
}
|
|
1257
|
+
this.logger.debug(`Applying ${newEntryMetadata.length} new entries to cached document ${docId}`);
|
|
1258
|
+
// Get current document heads to check if it changes
|
|
1259
|
+
const headsBefore = Automerge.getHeads(cachedDoc.doc);
|
|
1260
|
+
// Filter entries to only include change entries (exclude snapshots)
|
|
1261
|
+
const entriesToApply = newEntryMetadata.filter(em => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
|
|
1262
|
+
if (entriesToApply.length === 0) {
|
|
1263
|
+
this.logger.debug(`No change entries to apply for document ${docId}`);
|
|
1264
|
+
return null; // No changes
|
|
1265
|
+
}
|
|
1266
|
+
// Sort entries by timestamp
|
|
1267
|
+
entriesToApply.sort((a, b) => a.createdAt - b.createdAt);
|
|
1268
|
+
// Load entries from store
|
|
1269
|
+
const entries = await this.store.getEntries(entriesToApply.map(em => em.id));
|
|
1270
|
+
// Filter entries for admin-only mode first
|
|
1271
|
+
const validEntries = entries.filter(entryData => {
|
|
1272
|
+
if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
1273
|
+
this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
|
|
1274
|
+
return false;
|
|
1001
1275
|
}
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1276
|
+
return true;
|
|
1277
|
+
});
|
|
1278
|
+
if (validEntries.length === 0) {
|
|
1279
|
+
this.logger.debug(`No valid entries to process for cached document ${docId}`);
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
// Batch signature verification with key caching
|
|
1283
|
+
// Group entries by public key to import each key only once
|
|
1284
|
+
const entriesByPublicKey = new Map();
|
|
1285
|
+
for (const entryData of validEntries) {
|
|
1286
|
+
if (!entriesByPublicKey.has(entryData.createdByPublicKey)) {
|
|
1287
|
+
entriesByPublicKey.set(entryData.createdByPublicKey, []);
|
|
1006
1288
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1289
|
+
entriesByPublicKey.get(entryData.createdByPublicKey).push(entryData);
|
|
1290
|
+
}
|
|
1291
|
+
// Import all unique public keys in parallel (with caching)
|
|
1292
|
+
const keyImportResults = await Promise.all(Array.from(entriesByPublicKey.keys()).map(async (publicKey) => {
|
|
1293
|
+
const cryptoKey = await this.getOrImportPublicKey(publicKey);
|
|
1294
|
+
return { publicKey, cryptoKey };
|
|
1295
|
+
}));
|
|
1296
|
+
// Create a map of public key -> CryptoKey for quick lookup
|
|
1297
|
+
const keyMap = new Map();
|
|
1298
|
+
for (const { publicKey, cryptoKey } of keyImportResults) {
|
|
1299
|
+
if (cryptoKey) {
|
|
1300
|
+
keyMap.set(publicKey, cryptoKey);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
// Verify all signatures in parallel using cached keys
|
|
1304
|
+
const signatureVerificationResults = await Promise.all(validEntries.map(async (entryData) => {
|
|
1305
|
+
const cryptoKey = keyMap.get(entryData.createdByPublicKey);
|
|
1306
|
+
if (!cryptoKey) {
|
|
1307
|
+
// Key was not trusted or failed to import
|
|
1308
|
+
return { entryData, isValid: false };
|
|
1309
|
+
}
|
|
1310
|
+
const isValid = await this.verifySignatureWithKey(cryptoKey, entryData.encryptedData, entryData.signature);
|
|
1311
|
+
return { entryData, isValid };
|
|
1312
|
+
}));
|
|
1313
|
+
// Filter out entries with invalid signatures
|
|
1314
|
+
const verifiedEntries = signatureVerificationResults
|
|
1315
|
+
.filter(({ isValid }) => isValid)
|
|
1316
|
+
.map(({ entryData }) => entryData);
|
|
1317
|
+
if (verifiedEntries.length === 0) {
|
|
1318
|
+
this.logger.debug(`No entries with valid signatures for cached document ${docId}`);
|
|
1319
|
+
return null;
|
|
1320
|
+
}
|
|
1321
|
+
// Parallel decryption - decrypt all entries concurrently
|
|
1322
|
+
const decryptionResults = await Promise.all(verifiedEntries.map(async (entryData) => {
|
|
1323
|
+
const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
|
|
1324
|
+
return { entryData, decryptedPayload };
|
|
1325
|
+
}));
|
|
1326
|
+
// Collect change bytes and register automerge hash mappings
|
|
1327
|
+
const changeBytes = [];
|
|
1328
|
+
for (const { entryData, decryptedPayload } of decryptionResults) {
|
|
1329
|
+
changeBytes.push(decryptedPayload);
|
|
1330
|
+
// Register automerge hash -> entry ID mapping
|
|
1331
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
|
|
1332
|
+
if (parsed) {
|
|
1333
|
+
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
|
|
1013
1334
|
}
|
|
1014
|
-
// Continue with the next page using the returned cursor
|
|
1015
|
-
currentCursor = returnedCursor;
|
|
1016
1335
|
}
|
|
1017
|
-
|
|
1336
|
+
if (changeBytes.length === 0) {
|
|
1337
|
+
this.logger.debug(`No valid change bytes to apply for document ${docId}`);
|
|
1338
|
+
return null; // No changes
|
|
1339
|
+
}
|
|
1340
|
+
// Clone the cached document to avoid "outdated document" error
|
|
1341
|
+
// This happens when the document has been wrapped and returned to the user
|
|
1342
|
+
// Automerge marks documents as outdated when they're accessed, preventing direct mutation
|
|
1343
|
+
const clonedDoc = Automerge.clone(cachedDoc.doc);
|
|
1344
|
+
// Apply all changes at once (Automerge handles dependency ordering)
|
|
1345
|
+
const result = Automerge.applyChanges(clonedDoc, changeBytes);
|
|
1346
|
+
const updatedDoc = result[0];
|
|
1347
|
+
// Check if document actually changed
|
|
1348
|
+
const headsAfter = Automerge.getHeads(updatedDoc);
|
|
1349
|
+
const headsChanged = JSON.stringify(headsBefore) !== JSON.stringify(headsAfter);
|
|
1350
|
+
if (!headsChanged) {
|
|
1351
|
+
this.logger.debug(`Document ${docId} heads unchanged after applying new entries`);
|
|
1352
|
+
return null; // Document didn't actually change
|
|
1353
|
+
}
|
|
1354
|
+
this.logger.debug(`Document ${docId} changed: heads before=${JSON.stringify(headsBefore)}, after=${JSON.stringify(headsAfter)}`);
|
|
1355
|
+
// Update metadata
|
|
1356
|
+
const payload = updatedDoc;
|
|
1357
|
+
const lastEntry = entries[entries.length - 1];
|
|
1358
|
+
const lastModified = payload._lastModified ||
|
|
1359
|
+
(lastEntry ? lastEntry.createdAt : cachedDoc.lastModified);
|
|
1360
|
+
// Check if document was deleted
|
|
1361
|
+
const hasDeleteEntry = newEntryMetadata.some(em => em.entryType === "doc_delete");
|
|
1362
|
+
const isDeleted = hasDeleteEntry || cachedDoc.isDeleted;
|
|
1363
|
+
const updatedInternalDoc = {
|
|
1364
|
+
id: docId,
|
|
1365
|
+
doc: updatedDoc,
|
|
1366
|
+
createdAt: cachedDoc.createdAt,
|
|
1367
|
+
lastModified,
|
|
1368
|
+
decryptionKeyId: cachedDoc.decryptionKeyId,
|
|
1369
|
+
isDeleted,
|
|
1370
|
+
};
|
|
1371
|
+
// Update cache
|
|
1372
|
+
this.docCache.set(docId, updatedInternalDoc);
|
|
1373
|
+
return updatedInternalDoc;
|
|
1018
1374
|
}
|
|
1019
1375
|
/**
|
|
1020
1376
|
* Internal method to load a document from the content-addressed store
|
|
@@ -1022,25 +1378,25 @@ class BaseMindooDB {
|
|
|
1022
1378
|
async loadDocumentInternal(docId) {
|
|
1023
1379
|
// Check cache first
|
|
1024
1380
|
if (this.docCache.has(docId)) {
|
|
1025
|
-
|
|
1381
|
+
this.logger.debug(`Document ${docId} found in cache, returning cached version`);
|
|
1026
1382
|
return this.docCache.get(docId);
|
|
1027
1383
|
}
|
|
1028
|
-
|
|
1384
|
+
this.logger.debug(`===== Starting to load document ${docId} from store =====`);
|
|
1029
1385
|
// Get all entry metadata for this document
|
|
1030
1386
|
// TODO: Implement loading from last snapshot if available
|
|
1031
|
-
|
|
1387
|
+
this.logger.debug(`Getting all entry hashes for document ${docId}`);
|
|
1032
1388
|
const allEntryMetadata = await this.store.findNewEntriesForDoc([], docId);
|
|
1033
|
-
|
|
1389
|
+
this.logger.debug(`Found ${allEntryMetadata.length} total entry hashes for document ${docId}`);
|
|
1034
1390
|
if (allEntryMetadata.length === 0) {
|
|
1035
|
-
|
|
1391
|
+
this.logger.debug(`No entry hashes found for document ${docId}, returning null`);
|
|
1036
1392
|
return null;
|
|
1037
1393
|
}
|
|
1038
1394
|
// Log all entry types
|
|
1039
1395
|
const entryTypes = allEntryMetadata.map(em => `${em.entryType}@${em.createdAt}`).join(', ');
|
|
1040
|
-
|
|
1396
|
+
this.logger.debug(`Entry types for ${docId}: ${entryTypes}`);
|
|
1041
1397
|
// Find the most recent snapshot (if any)
|
|
1042
1398
|
const snapshots = allEntryMetadata.filter(em => em.entryType === "doc_snapshot");
|
|
1043
|
-
|
|
1399
|
+
this.logger.debug(`Found ${snapshots.length} snapshot(s) for document ${docId}`);
|
|
1044
1400
|
let startFromSnapshot = false;
|
|
1045
1401
|
let snapshotMeta = null;
|
|
1046
1402
|
if (snapshots.length > 0) {
|
|
@@ -1048,29 +1404,29 @@ class BaseMindooDB {
|
|
|
1048
1404
|
snapshots.sort((a, b) => b.createdAt - a.createdAt);
|
|
1049
1405
|
snapshotMeta = snapshots[0];
|
|
1050
1406
|
startFromSnapshot = true;
|
|
1051
|
-
|
|
1407
|
+
this.logger.debug(`Will start from snapshot ${snapshotMeta.id} created at ${snapshotMeta.createdAt}`);
|
|
1052
1408
|
}
|
|
1053
1409
|
else {
|
|
1054
|
-
|
|
1410
|
+
this.logger.debug(`No snapshot found, will start from scratch`);
|
|
1055
1411
|
}
|
|
1056
1412
|
// Get all entries (excluding snapshot entries - we'll handle delete separately)
|
|
1057
1413
|
// Include "doc_create", "doc_change", and "doc_delete" types as they all contain Automerge changes to apply
|
|
1058
1414
|
const entriesToLoad = startFromSnapshot
|
|
1059
1415
|
? allEntryMetadata.filter(em => (em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete") && em.createdAt > snapshotMeta.createdAt)
|
|
1060
1416
|
: allEntryMetadata.filter(em => em.entryType === "doc_create" || em.entryType === "doc_change" || em.entryType === "doc_delete");
|
|
1061
|
-
|
|
1417
|
+
this.logger.debug(`Will load ${entriesToLoad.length} entries for document ${docId} (after snapshot filter)`);
|
|
1062
1418
|
// Load the snapshot first if we have one
|
|
1063
1419
|
let doc = undefined;
|
|
1064
1420
|
if (startFromSnapshot && snapshotMeta) {
|
|
1065
|
-
|
|
1421
|
+
this.logger.debug(`Loading snapshot for document ${docId}`);
|
|
1066
1422
|
const snapshotEntries = await this.store.getEntries([snapshotMeta.id]);
|
|
1067
|
-
|
|
1423
|
+
this.logger.debug(`Retrieved ${snapshotEntries.length} snapshot entry(s) from store`);
|
|
1068
1424
|
if (snapshotEntries.length > 0) {
|
|
1069
1425
|
const snapshotData = snapshotEntries[0];
|
|
1070
1426
|
// Admin-only mode: only accept snapshots signed by admin
|
|
1071
1427
|
let isValid = false;
|
|
1072
1428
|
if (this._isAdminOnlyDb && snapshotData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
1073
|
-
|
|
1429
|
+
this.logger.warn(`Admin-only DB: skipping snapshot ${snapshotData.id} not signed by admin key`);
|
|
1074
1430
|
}
|
|
1075
1431
|
else {
|
|
1076
1432
|
// Verify signature against the encrypted snapshot (no decryption needed)
|
|
@@ -1078,21 +1434,21 @@ class BaseMindooDB {
|
|
|
1078
1434
|
isValid = await this.tenant.verifySignature(snapshotData.encryptedData, snapshotData.signature, snapshotData.createdByPublicKey);
|
|
1079
1435
|
}
|
|
1080
1436
|
if (!isValid) {
|
|
1081
|
-
|
|
1437
|
+
this.logger.warn(`Invalid signature for snapshot ${snapshotData.id}, falling back to loading from scratch`);
|
|
1082
1438
|
// Fall back to loading from scratch
|
|
1083
1439
|
startFromSnapshot = false;
|
|
1084
1440
|
}
|
|
1085
1441
|
else {
|
|
1086
|
-
|
|
1442
|
+
this.logger.debug(`Snapshot signature valid, decrypting snapshot`);
|
|
1087
1443
|
// Decrypt snapshot (only after signature verification passes)
|
|
1088
1444
|
const decryptedSnapshot = await this.tenant.decryptPayload(snapshotData.encryptedData, snapshotData.decryptionKeyId);
|
|
1089
|
-
|
|
1445
|
+
this.logger.debug(`Decrypted snapshot (${snapshotData.encryptedData.length} -> ${decryptedSnapshot.length} bytes)`);
|
|
1090
1446
|
// Load snapshot using Automerge.load()
|
|
1091
1447
|
// This deserializes a full document snapshot from binary data
|
|
1092
1448
|
// According to Automerge docs: load() is equivalent to init() followed by loadIncremental()
|
|
1093
|
-
|
|
1449
|
+
this.logger.debug(`Loading snapshot into Automerge document`);
|
|
1094
1450
|
doc = Automerge.load(decryptedSnapshot);
|
|
1095
|
-
|
|
1451
|
+
this.logger.debug(`Successfully loaded snapshot, document heads: ${JSON.stringify(Automerge.getHeads(doc))}`);
|
|
1096
1452
|
// Register the snapshot's automerge hash -> entry ID mapping
|
|
1097
1453
|
const parsed = (0, idGeneration_1.parseDocEntryId)(snapshotData.id);
|
|
1098
1454
|
if (parsed) {
|
|
@@ -1103,96 +1459,119 @@ class BaseMindooDB {
|
|
|
1103
1459
|
}
|
|
1104
1460
|
// If we don't have a snapshot, start from scratch
|
|
1105
1461
|
if (!doc) {
|
|
1106
|
-
|
|
1462
|
+
this.logger.debug(`Initializing new Automerge document for ${docId}`);
|
|
1107
1463
|
doc = Automerge.init();
|
|
1108
|
-
|
|
1464
|
+
this.logger.debug(`Initialized empty document, heads: ${JSON.stringify(Automerge.getHeads(doc))}`);
|
|
1109
1465
|
}
|
|
1110
1466
|
// Sort entries by timestamp
|
|
1111
1467
|
entriesToLoad.sort((a, b) => a.createdAt - b.createdAt);
|
|
1112
|
-
|
|
1468
|
+
this.logger.debug(`Sorted ${entriesToLoad.length} entries by timestamp for document ${docId}`);
|
|
1113
1469
|
// Load and apply all entries
|
|
1114
|
-
|
|
1470
|
+
this.logger.debug(`Fetching ${entriesToLoad.length} entries from store for document ${docId}`);
|
|
1115
1471
|
const entries = await this.store.getEntries(entriesToLoad.map(em => em.id));
|
|
1116
|
-
|
|
1117
|
-
|
|
1472
|
+
this.logger.debug(`Retrieved ${entries.length} entries from store for document ${docId}`);
|
|
1473
|
+
this.logger.debug(`Loading document ${docId}: found ${entries.length} entries to apply (${startFromSnapshot ? 'starting from snapshot' : 'starting from scratch'})`);
|
|
1118
1474
|
// Log current document state before applying entries
|
|
1119
|
-
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
for (let i = 0; i < entries.length; i++) {
|
|
1123
|
-
const entryData = entries[i];
|
|
1124
|
-
console.log(`[BaseMindooDB] ===== Processing entry ${i + 1}/${entries.length} for document ${docId} =====`);
|
|
1125
|
-
console.log(`[BaseMindooDB] Entry id: ${entryData.id}`);
|
|
1126
|
-
console.log(`[BaseMindooDB] Entry type: ${entryData.entryType}`);
|
|
1127
|
-
console.log(`[BaseMindooDB] Entry createdAt: ${entryData.createdAt}`);
|
|
1128
|
-
console.log(`[BaseMindooDB] Entry dependencies: ${JSON.stringify(entryData.dependencyIds || [])}`);
|
|
1129
|
-
console.log(`[BaseMindooDB] Entry payload size: ${entryData.encryptedData.length} bytes`);
|
|
1130
|
-
// Admin-only mode: only accept entries signed by the admin key
|
|
1131
|
-
// This prevents recursion and ensures security for the directory database
|
|
1475
|
+
this.logger.debug(`Document state before applying entries: heads=${JSON.stringify(Automerge.getHeads(doc))}`);
|
|
1476
|
+
// Filter entries for admin-only mode first
|
|
1477
|
+
const validEntries = entries.filter(entryData => {
|
|
1132
1478
|
if (this._isAdminOnlyDb && entryData.createdByPublicKey !== this.getAdminPublicKey()) {
|
|
1133
|
-
|
|
1134
|
-
|
|
1479
|
+
this.logger.warn(`Admin-only DB: skipping entry ${entryData.id} not signed by admin key`);
|
|
1480
|
+
return false;
|
|
1135
1481
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1482
|
+
return true;
|
|
1483
|
+
});
|
|
1484
|
+
if (validEntries.length === 0) {
|
|
1485
|
+
this.logger.debug(`No valid entries to process for document ${docId}`);
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
// Batch signature verification with key caching
|
|
1489
|
+
// Group entries by public key to import each key only once
|
|
1490
|
+
this.logger.debug(`Verifying ${validEntries.length} signatures in parallel with key caching`);
|
|
1491
|
+
const entriesByPublicKey = new Map();
|
|
1492
|
+
for (const entryData of validEntries) {
|
|
1493
|
+
if (!entriesByPublicKey.has(entryData.createdByPublicKey)) {
|
|
1494
|
+
entriesByPublicKey.set(entryData.createdByPublicKey, []);
|
|
1495
|
+
}
|
|
1496
|
+
entriesByPublicKey.get(entryData.createdByPublicKey).push(entryData);
|
|
1143
1497
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
console.log(`[BaseMindooDB] Decrypted payload length: ${decryptedPayload.length} bytes`);
|
|
1155
|
-
const currentDoc = doc;
|
|
1156
|
-
const result = Automerge.applyChanges(currentDoc, [decryptedPayload]);
|
|
1157
|
-
doc = result[0];
|
|
1158
|
-
console.log(`[BaseMindooDB] Successfully applied entry ${i + 1}/${entries.length}`);
|
|
1159
|
-
console.log(`[BaseMindooDB] Document state after applying entry ${i + 1}: heads=${JSON.stringify(Automerge.getHeads(doc))}`);
|
|
1160
|
-
// Register the automerge hash -> entry ID mapping for future dependency resolution
|
|
1161
|
-
const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
|
|
1162
|
-
if (parsed) {
|
|
1163
|
-
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
|
|
1498
|
+
// Import all unique public keys in parallel (with caching)
|
|
1499
|
+
const keyImportResults = await Promise.all(Array.from(entriesByPublicKey.keys()).map(async (publicKey) => {
|
|
1500
|
+
const cryptoKey = await this.getOrImportPublicKey(publicKey);
|
|
1501
|
+
return { publicKey, cryptoKey };
|
|
1502
|
+
}));
|
|
1503
|
+
// Create a map of public key -> CryptoKey for quick lookup
|
|
1504
|
+
const keyMap = new Map();
|
|
1505
|
+
for (const { publicKey, cryptoKey } of keyImportResults) {
|
|
1506
|
+
if (cryptoKey) {
|
|
1507
|
+
keyMap.set(publicKey, cryptoKey);
|
|
1164
1508
|
}
|
|
1165
1509
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1510
|
+
// Verify all signatures in parallel using cached keys
|
|
1511
|
+
const signatureVerificationResults = await Promise.all(validEntries.map(async (entryData) => {
|
|
1512
|
+
const cryptoKey = keyMap.get(entryData.createdByPublicKey);
|
|
1513
|
+
if (!cryptoKey) {
|
|
1514
|
+
// Key was not trusted or failed to import
|
|
1515
|
+
return { entryData, isValid: false };
|
|
1516
|
+
}
|
|
1517
|
+
const isValid = await this.verifySignatureWithKey(cryptoKey, entryData.encryptedData, entryData.signature);
|
|
1518
|
+
return { entryData, isValid };
|
|
1519
|
+
}));
|
|
1520
|
+
// Filter out entries with invalid signatures
|
|
1521
|
+
const verifiedEntries = signatureVerificationResults
|
|
1522
|
+
.filter(({ isValid }) => {
|
|
1523
|
+
if (!isValid) {
|
|
1524
|
+
this.logger.warn(`Invalid signature for entry, skipping`);
|
|
1525
|
+
}
|
|
1526
|
+
return isValid;
|
|
1527
|
+
})
|
|
1528
|
+
.map(({ entryData }) => entryData);
|
|
1529
|
+
if (verifiedEntries.length === 0) {
|
|
1530
|
+
this.logger.debug(`No entries with valid signatures for document ${docId}`);
|
|
1531
|
+
}
|
|
1532
|
+
else {
|
|
1533
|
+
// Parallel decryption - decrypt all entries concurrently
|
|
1534
|
+
// Automerge handles dependency buffering internally, so we can decrypt all in parallel
|
|
1535
|
+
this.logger.debug(`Decrypting ${verifiedEntries.length} entries in parallel`);
|
|
1536
|
+
const decryptionResults = await Promise.all(verifiedEntries.map(async (entryData) => {
|
|
1537
|
+
const decryptedPayload = await this.tenant.decryptPayload(entryData.encryptedData, entryData.decryptionKeyId);
|
|
1538
|
+
return { entryData, decryptedPayload };
|
|
1539
|
+
}));
|
|
1540
|
+
// Collect change bytes and register automerge hash mappings
|
|
1541
|
+
const changeBytes = [];
|
|
1542
|
+
for (const { entryData, decryptedPayload } of decryptionResults) {
|
|
1543
|
+
changeBytes.push(decryptedPayload);
|
|
1544
|
+
// Register the automerge hash -> entry ID mapping for future dependency resolution
|
|
1545
|
+
const parsed = (0, idGeneration_1.parseDocEntryId)(entryData.id);
|
|
1546
|
+
if (parsed) {
|
|
1547
|
+
this.registerAutomergeHashMapping(docId, parsed.automergeHash, entryData.id);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
// Batch apply all changes at once - Automerge handles dependency ordering
|
|
1551
|
+
if (changeBytes.length > 0) {
|
|
1552
|
+
this.logger.debug(`Applying ${changeBytes.length} changes to document ${docId} using batch applyChanges`);
|
|
1553
|
+
try {
|
|
1554
|
+
const result = Automerge.applyChanges(doc, changeBytes);
|
|
1555
|
+
doc = result[0];
|
|
1556
|
+
this.logger.debug(`Successfully applied ${changeBytes.length} changes to document ${docId}`);
|
|
1557
|
+
this.logger.debug(`Document state after applying changes: heads=${JSON.stringify(Automerge.getHeads(doc))}`);
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
this.logger.error(`Error applying changes to document ${docId}:`, error);
|
|
1561
|
+
this.logger.error(`Number of changes: ${changeBytes.length}`);
|
|
1562
|
+
throw error;
|
|
1563
|
+
}
|
|
1184
1564
|
}
|
|
1185
|
-
throw error;
|
|
1186
1565
|
}
|
|
1187
1566
|
}
|
|
1188
1567
|
// Extract metadata from document (doc is guaranteed to be defined at this point)
|
|
1189
|
-
|
|
1190
|
-
|
|
1568
|
+
this.logger.debug(`All entries applied successfully for document ${docId}`);
|
|
1569
|
+
this.logger.debug(`Final document heads: ${JSON.stringify(Automerge.getHeads(doc))}`);
|
|
1191
1570
|
const payload = doc;
|
|
1192
1571
|
// Check if document was deleted by looking for a "doc_delete" type entry
|
|
1193
1572
|
const hasDeleteEntry = allEntryMetadata.some(em => em.entryType === "doc_delete");
|
|
1194
1573
|
const isDeleted = hasDeleteEntry;
|
|
1195
|
-
|
|
1574
|
+
this.logger.debug(`Document ${docId} isDeleted: ${isDeleted}`);
|
|
1196
1575
|
const decryptionKeyId = payload._decryptionKeyId || "default";
|
|
1197
1576
|
// Get lastModified from payload, or use the timestamp of the last entry
|
|
1198
1577
|
const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
|
|
@@ -1201,7 +1580,7 @@ class BaseMindooDB {
|
|
|
1201
1580
|
// Get createdAt from the first entry
|
|
1202
1581
|
const firstEntry = allEntryMetadata.length > 0 ? allEntryMetadata[0] : null;
|
|
1203
1582
|
const createdAt = firstEntry ? firstEntry.createdAt : lastModified;
|
|
1204
|
-
|
|
1583
|
+
this.logger.debug(`Document ${docId} metadata: createdAt=${createdAt}, lastModified=${lastModified}, decryptionKeyId=${decryptionKeyId}`);
|
|
1205
1584
|
const internalDoc = {
|
|
1206
1585
|
id: docId,
|
|
1207
1586
|
doc: doc, // doc is guaranteed to be defined at this point
|
|
@@ -1212,7 +1591,7 @@ class BaseMindooDB {
|
|
|
1212
1591
|
};
|
|
1213
1592
|
// Update cache
|
|
1214
1593
|
this.docCache.set(docId, internalDoc);
|
|
1215
|
-
|
|
1594
|
+
this.logger.debug(`===== Successfully loaded document ${docId} and cached it =====`);
|
|
1216
1595
|
return internalDoc;
|
|
1217
1596
|
}
|
|
1218
1597
|
/**
|
|
@@ -1299,12 +1678,12 @@ class BaseMindooDB {
|
|
|
1299
1678
|
* Internal method to fetch and concatenate all chunks for an attachment.
|
|
1300
1679
|
*/
|
|
1301
1680
|
async getAttachmentInternal(docId, attachmentId) {
|
|
1302
|
-
|
|
1681
|
+
this.logger.debug(`Getting attachment ${attachmentId} from document ${docId}`);
|
|
1303
1682
|
const ref = this.getAttachmentRefInternal(docId, attachmentId);
|
|
1304
1683
|
const store = this.getEffectiveAttachmentStore();
|
|
1305
1684
|
// Resolve dependency chain to get all chunk IDs in order (oldest first)
|
|
1306
1685
|
const chunkIds = await store.resolveDependencies(ref.lastChunkId, { includeStart: true });
|
|
1307
|
-
|
|
1686
|
+
this.logger.debug(`Resolved ${chunkIds.length} chunks for attachment ${attachmentId}`);
|
|
1308
1687
|
// Fetch all chunks
|
|
1309
1688
|
const chunks = await store.getEntries(chunkIds);
|
|
1310
1689
|
// Verify signatures, decrypt, and collect plaintext chunks
|
|
@@ -1332,14 +1711,14 @@ class BaseMindooDB {
|
|
|
1332
1711
|
result.set(chunk, offset);
|
|
1333
1712
|
offset += chunk.length;
|
|
1334
1713
|
}
|
|
1335
|
-
|
|
1714
|
+
this.logger.debug(`Retrieved attachment ${attachmentId}: ${result.length} bytes`);
|
|
1336
1715
|
return result;
|
|
1337
1716
|
}
|
|
1338
1717
|
/**
|
|
1339
1718
|
* Internal method to get a byte range from an attachment.
|
|
1340
1719
|
*/
|
|
1341
1720
|
async getAttachmentRangeInternal(docId, attachmentId, startByte, endByte) {
|
|
1342
|
-
|
|
1721
|
+
this.logger.debug(`Getting attachment ${attachmentId} range [${startByte}, ${endByte}) from document ${docId}`);
|
|
1343
1722
|
if (startByte < 0 || endByte <= startByte) {
|
|
1344
1723
|
throw new Error(`Invalid byte range: [${startByte}, ${endByte})`);
|
|
1345
1724
|
}
|
|
@@ -1388,14 +1767,14 @@ class BaseMindooDB {
|
|
|
1388
1767
|
resultOffset += bytesToCopy;
|
|
1389
1768
|
bytesRemaining -= bytesToCopy;
|
|
1390
1769
|
}
|
|
1391
|
-
|
|
1770
|
+
this.logger.debug(`Retrieved attachment ${attachmentId} range: ${result.length} bytes`);
|
|
1392
1771
|
return result;
|
|
1393
1772
|
}
|
|
1394
1773
|
/**
|
|
1395
1774
|
* Internal async generator to stream attachment data.
|
|
1396
1775
|
*/
|
|
1397
1776
|
async *streamAttachmentInternal(docId, attachmentId, startOffset) {
|
|
1398
|
-
|
|
1777
|
+
this.logger.debug(`Streaming attachment ${attachmentId} from offset ${startOffset}`);
|
|
1399
1778
|
const ref = this.getAttachmentRefInternal(docId, attachmentId);
|
|
1400
1779
|
const store = this.getEffectiveAttachmentStore();
|
|
1401
1780
|
const chunkSize = this.chunkSizeBytes;
|
|
@@ -1426,7 +1805,7 @@ class BaseMindooDB {
|
|
|
1426
1805
|
yield plaintext;
|
|
1427
1806
|
}
|
|
1428
1807
|
}
|
|
1429
|
-
|
|
1808
|
+
this.logger.debug(`Finished streaming attachment ${attachmentId}`);
|
|
1430
1809
|
}
|
|
1431
1810
|
/**
|
|
1432
1811
|
* Get the effective attachment store (attachment store if configured, otherwise doc store).
|
|
@@ -1438,7 +1817,7 @@ class BaseMindooDB {
|
|
|
1438
1817
|
* Internal method to add an attachment by chunking the file and storing chunks.
|
|
1439
1818
|
*/
|
|
1440
1819
|
async addAttachmentInternal(docId, fileData, fileName, mimeType, decryptionKeyId, createdAt) {
|
|
1441
|
-
|
|
1820
|
+
this.logger.debug(`Adding attachment to document ${docId}: ${fileName} (${fileData.length} bytes)`);
|
|
1442
1821
|
const store = this.getEffectiveAttachmentStore();
|
|
1443
1822
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
1444
1823
|
const attachmentId = (0, idGeneration_1.generateFileUuid7)();
|
|
@@ -1477,7 +1856,7 @@ class BaseMindooDB {
|
|
|
1477
1856
|
}
|
|
1478
1857
|
// Store all chunks
|
|
1479
1858
|
await store.putEntries(chunks);
|
|
1480
|
-
|
|
1859
|
+
this.logger.debug(`Stored ${chunks.length} chunks for attachment ${attachmentId}`);
|
|
1481
1860
|
// Create attachment reference
|
|
1482
1861
|
const ref = {
|
|
1483
1862
|
attachmentId,
|
|
@@ -1496,7 +1875,7 @@ class BaseMindooDB {
|
|
|
1496
1875
|
* Memory efficient - processes data chunk by chunk without loading entire file into memory.
|
|
1497
1876
|
*/
|
|
1498
1877
|
async addAttachmentStreamInternal(docId, dataStream, fileName, mimeType, decryptionKeyId, createdAt) {
|
|
1499
|
-
|
|
1878
|
+
this.logger.debug(`Adding streaming attachment to document ${docId}: ${fileName}`);
|
|
1500
1879
|
const store = this.getEffectiveAttachmentStore();
|
|
1501
1880
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
1502
1881
|
const attachmentId = (0, idGeneration_1.generateFileUuid7)();
|
|
@@ -1561,7 +1940,7 @@ class BaseMindooDB {
|
|
|
1561
1940
|
lastChunkId = await storeChunk(buffer);
|
|
1562
1941
|
totalSize += buffer.length;
|
|
1563
1942
|
}
|
|
1564
|
-
|
|
1943
|
+
this.logger.debug(`Stored ${chunkCount} chunks for streaming attachment ${attachmentId} (${totalSize} bytes)`);
|
|
1565
1944
|
// Create attachment reference
|
|
1566
1945
|
const ref = {
|
|
1567
1946
|
attachmentId,
|
|
@@ -1579,7 +1958,7 @@ class BaseMindooDB {
|
|
|
1579
1958
|
* Internal method to append data to an existing attachment.
|
|
1580
1959
|
*/
|
|
1581
1960
|
async appendToAttachmentInternal(docId, attachmentId, decryptionKeyId, prevLastChunkId, data, createdAt) {
|
|
1582
|
-
|
|
1961
|
+
this.logger.debug(`Appending ${data.length} bytes to attachment ${attachmentId}`);
|
|
1583
1962
|
const store = this.getEffectiveAttachmentStore();
|
|
1584
1963
|
const currentUser = await this.tenant.getCurrentUserId();
|
|
1585
1964
|
// Chunk the data
|
|
@@ -1617,7 +1996,7 @@ class BaseMindooDB {
|
|
|
1617
1996
|
}
|
|
1618
1997
|
// Store all chunks
|
|
1619
1998
|
await store.putEntries(chunks);
|
|
1620
|
-
|
|
1999
|
+
this.logger.debug(`Appended ${chunks.length} chunks to attachment ${attachmentId}`);
|
|
1621
2000
|
return {
|
|
1622
2001
|
lastChunkId,
|
|
1623
2002
|
sizeIncrease: data.length,
|
|
@@ -1639,28 +2018,28 @@ class BaseMindooDB {
|
|
|
1639
2018
|
if (this.store.getId() !== remoteStore.getId()) {
|
|
1640
2019
|
throw new Error(`[BaseMindooDB] Cannot pull entries from the incompatible store ${this.store.getId()}`);
|
|
1641
2020
|
}
|
|
1642
|
-
|
|
2021
|
+
this.logger.info(`Pulling entries from remote store ${remoteStore.getId()}`);
|
|
1643
2022
|
// Get all entry IDs we already have in our local store
|
|
1644
2023
|
const localEntryIds = await this.store.getAllIds();
|
|
1645
|
-
|
|
2024
|
+
this.logger.debug(`Local store has ${localEntryIds.length} entry IDs`);
|
|
1646
2025
|
// Find entries in the remote store that we don't have
|
|
1647
2026
|
const newEntryMetadata = await remoteStore.findNewEntries(localEntryIds);
|
|
1648
|
-
|
|
2027
|
+
this.logger.debug(`Found ${newEntryMetadata.length} new entries in remote store`);
|
|
1649
2028
|
if (newEntryMetadata.length === 0) {
|
|
1650
|
-
|
|
2029
|
+
this.logger.debug(`No new entries to pull`);
|
|
1651
2030
|
return;
|
|
1652
2031
|
}
|
|
1653
2032
|
// Get the full entries from the remote store
|
|
1654
2033
|
const newEntries = await remoteStore.getEntries(newEntryMetadata.map(em => em.id));
|
|
1655
|
-
|
|
2034
|
+
this.logger.debug(`Retrieved ${newEntries.length} entries from remote store`);
|
|
1656
2035
|
// Store all entries in our local store
|
|
1657
2036
|
// The putEntries method handles deduplication (no-op if entry already exists)
|
|
1658
2037
|
await this.store.putEntries(newEntries);
|
|
1659
|
-
|
|
2038
|
+
this.logger.debug(`Stored ${newEntries.length} entries in local store`);
|
|
1660
2039
|
// Sync the local store to process the new entries
|
|
1661
2040
|
// This will update the index, cache, and processedEntryIds
|
|
1662
2041
|
await this.syncStoreChanges();
|
|
1663
|
-
|
|
2042
|
+
this.logger.info(`Pull complete, synced ${newEntries.length} entries`);
|
|
1664
2043
|
}
|
|
1665
2044
|
/**
|
|
1666
2045
|
* Push changes to a remote content-addressed store.
|
|
@@ -1677,24 +2056,24 @@ class BaseMindooDB {
|
|
|
1677
2056
|
if (this.store.getId() !== remoteStore.getId()) {
|
|
1678
2057
|
throw new Error(`[BaseMindooDB] Cannot push entries to the incompatible store ${this.store.getId()}`);
|
|
1679
2058
|
}
|
|
1680
|
-
|
|
2059
|
+
this.logger.info(`Pushing entries to remote store ${remoteStore.getId()}`);
|
|
1681
2060
|
// Get all entry IDs the remote store already has
|
|
1682
2061
|
const remoteEntryIds = await remoteStore.getAllIds();
|
|
1683
|
-
|
|
2062
|
+
this.logger.debug(`Remote store has ${remoteEntryIds.length} entry IDs`);
|
|
1684
2063
|
// Find entries in our local store that the remote doesn't have
|
|
1685
2064
|
const newEntryMetadata = await this.store.findNewEntries(remoteEntryIds);
|
|
1686
|
-
|
|
2065
|
+
this.logger.debug(`Found ${newEntryMetadata.length} new entries to push`);
|
|
1687
2066
|
if (newEntryMetadata.length === 0) {
|
|
1688
|
-
|
|
2067
|
+
this.logger.debug(`No new entries to push`);
|
|
1689
2068
|
return;
|
|
1690
2069
|
}
|
|
1691
2070
|
// Get the full entries from our local store
|
|
1692
2071
|
const newEntries = await this.store.getEntries(newEntryMetadata.map(em => em.id));
|
|
1693
|
-
|
|
2072
|
+
this.logger.debug(`Retrieved ${newEntries.length} entries from local store`);
|
|
1694
2073
|
// Store all entries in the remote store
|
|
1695
2074
|
// The putEntries method handles deduplication (no-op if entry already exists)
|
|
1696
2075
|
await remoteStore.putEntries(newEntries);
|
|
1697
|
-
|
|
2076
|
+
this.logger.info(`Pushed ${newEntries.length} entries to remote store`);
|
|
1698
2077
|
}
|
|
1699
2078
|
}
|
|
1700
2079
|
exports.BaseMindooDB = BaseMindooDB;
|