@docstack/pouchdb-adapter-googledrive 0.1.0 → 0.1.1
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/lib/client.js +2 -2
- package/lib/drive.d.ts +3 -0
- package/lib/drive.js +164 -39
- package/lib/types.d.ts +2 -0
- package/package.json +1 -1
- package/jest.config.js +0 -8
package/lib/client.js
CHANGED
|
@@ -70,7 +70,7 @@ class GoogleDriveClient {
|
|
|
70
70
|
async listFiles(q) {
|
|
71
71
|
const params = new URLSearchParams({
|
|
72
72
|
q,
|
|
73
|
-
fields: 'files(id,name,mimeType,parents,modifiedTime)'
|
|
73
|
+
fields: 'files(id,name,mimeType,parents,modifiedTime,md5Checksum)'
|
|
74
74
|
});
|
|
75
75
|
// FIX: URLSearchParams uses '+', but Drive API is safer with '%20'
|
|
76
76
|
const queryString = params.toString().replace(/\+/g, '%20');
|
|
@@ -103,7 +103,7 @@ class GoogleDriveClient {
|
|
|
103
103
|
}
|
|
104
104
|
// Single metadata get (for etag check)
|
|
105
105
|
async getFileMetadata(fileId) {
|
|
106
|
-
const params = new URLSearchParams({ fields: 'id,name,mimeType,parents,modifiedTime' });
|
|
106
|
+
const params = new URLSearchParams({ fields: 'id,name,mimeType,parents,modifiedTime,md5Checksum' });
|
|
107
107
|
const queryString = params.toString().replace(/\+/g, '%20');
|
|
108
108
|
const res = await this.fetch(`${this.baseUrl}/${fileId}?${queryString}`, { method: 'GET' });
|
|
109
109
|
const data = await res.json();
|
package/lib/drive.d.ts
CHANGED
|
@@ -19,7 +19,9 @@ export declare class DriveHandler {
|
|
|
19
19
|
private compactionSizeThreshold;
|
|
20
20
|
private meta;
|
|
21
21
|
private metaEtag;
|
|
22
|
+
private metaMd5;
|
|
22
23
|
private metaModifiedTime;
|
|
24
|
+
private localDocsEtag;
|
|
23
25
|
private index;
|
|
24
26
|
private docCache;
|
|
25
27
|
private pendingChanges;
|
|
@@ -56,6 +58,7 @@ export declare class DriveHandler {
|
|
|
56
58
|
appendChange(change: ChangeEntry): Promise<void>;
|
|
57
59
|
/** Append changes with OCC */
|
|
58
60
|
appendChanges(changes: ChangeEntry[]): Promise<void>;
|
|
61
|
+
private appendLocalDocs;
|
|
59
62
|
private tryAppendChanges;
|
|
60
63
|
/** Update Index with a new change */
|
|
61
64
|
private updateIndex;
|
package/lib/drive.js
CHANGED
|
@@ -30,7 +30,9 @@ class DriveHandler {
|
|
|
30
30
|
dbName: ''
|
|
31
31
|
};
|
|
32
32
|
this.metaEtag = null;
|
|
33
|
+
this.metaMd5 = null;
|
|
33
34
|
this.metaModifiedTime = null;
|
|
35
|
+
this.localDocsEtag = null;
|
|
34
36
|
// In-Memory Index: ID -> Metadata/Pointer
|
|
35
37
|
this.index = {};
|
|
36
38
|
this.pendingChanges = [];
|
|
@@ -98,6 +100,7 @@ class DriveHandler {
|
|
|
98
100
|
this.log('Retrieved meta file', { fileId: metaFile.id });
|
|
99
101
|
this.meta = await this.downloadJson(metaFile.id, true); // No cache for meta
|
|
100
102
|
this.metaEtag = metaFile.etag || null;
|
|
103
|
+
this.metaMd5 = metaFile.md5Checksum || null;
|
|
101
104
|
this.metaModifiedTime = metaFile.modifiedTime || null;
|
|
102
105
|
}
|
|
103
106
|
else {
|
|
@@ -142,36 +145,64 @@ class DriveHandler {
|
|
|
142
145
|
}
|
|
143
146
|
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
144
147
|
this.log('Replaying change logs');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
148
|
+
const pendingLogs = this.meta.changeLogIds.filter(id => !this.processedLogIds.has(id));
|
|
149
|
+
if (pendingLogs.length > 0) {
|
|
150
|
+
this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
|
|
151
|
+
const logResults = await Promise.all(pendingLogs.map(async (id) => {
|
|
152
|
+
try {
|
|
153
|
+
const changes = await this.downloadNdjson(id);
|
|
154
|
+
return { id, changes };
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
this.log(`Failed to download change log ${id}`, e);
|
|
158
|
+
return { id, changes: null };
|
|
159
|
+
}
|
|
160
|
+
}));
|
|
161
|
+
for (const { id, changes } of logResults) {
|
|
162
|
+
if (!changes) {
|
|
163
|
+
this.log(`Skipping failed log ${id}`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
let changesArray = Array.isArray(changes) ? changes : [changes];
|
|
167
|
+
this.currentLogSizeEstimate += 100 * changesArray.length;
|
|
168
|
+
for (const change of changesArray) {
|
|
169
|
+
this.log('Processing change, sequence', change.seq);
|
|
170
|
+
this.updateIndex(change, id);
|
|
171
|
+
if (this.docCache.get(change.id)) {
|
|
172
|
+
this.docCache.remove(change.id);
|
|
173
|
+
}
|
|
162
174
|
}
|
|
175
|
+
this.processedLogIds.add(id);
|
|
176
|
+
this.log('Processed log', id);
|
|
163
177
|
}
|
|
164
|
-
this.processedLogIds.add(logId);
|
|
165
|
-
this.log('Processed log', logId);
|
|
166
178
|
}
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
179
|
+
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
180
|
+
// ... (previous logic for change logs)
|
|
181
|
+
// (Already updated in previous turn, keep it)
|
|
182
|
+
// 2b. Load Local Documents Store (Pinned in meta)
|
|
183
|
+
if (this.meta.localDocsId) {
|
|
184
|
+
this.log('Loading local docs store', this.meta.localDocsId);
|
|
185
|
+
try {
|
|
186
|
+
const localStore = await this.client.getFileMetadata(this.meta.localDocsId);
|
|
187
|
+
this.localDocsEtag = localStore.etag || null;
|
|
188
|
+
const localDocsChunk = await this.downloadJson(this.meta.localDocsId, true);
|
|
189
|
+
if (localDocsChunk && localDocsChunk.docs) {
|
|
190
|
+
for (const [id, doc] of Object.entries(localDocsChunk.docs)) {
|
|
191
|
+
this.log('Merging local doc', id);
|
|
192
|
+
this.index[id] = {
|
|
193
|
+
rev: doc._rev,
|
|
194
|
+
seq: 0, // Local docs don't participate in shared sequences
|
|
195
|
+
location: { fileId: this.meta.localDocsId }
|
|
196
|
+
};
|
|
197
|
+
this.docCache.put(id, doc);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
this.log('Failed to load local docs store', e);
|
|
203
|
+
}
|
|
174
204
|
}
|
|
205
|
+
// 3. Start Polling ...
|
|
175
206
|
}
|
|
176
207
|
catch (e) {
|
|
177
208
|
console.error('Failed to load database', e);
|
|
@@ -383,24 +414,33 @@ class DriveHandler {
|
|
|
383
414
|
/** Append changes with OCC */
|
|
384
415
|
async appendChanges(changes) {
|
|
385
416
|
const MAX_RETRIES = 5;
|
|
386
|
-
let
|
|
387
|
-
|
|
417
|
+
let attemptNum = 0;
|
|
418
|
+
const local = changes.filter(c => c.id.startsWith('_local/'));
|
|
419
|
+
const remote = changes.filter(c => !c.id.startsWith('_local/'));
|
|
420
|
+
// Handle Local Docs (Pinned Store)
|
|
421
|
+
if (local.length > 0) {
|
|
422
|
+
await this.appendLocalDocs(local);
|
|
423
|
+
}
|
|
424
|
+
// Handle Remote Docs (App Log)
|
|
425
|
+
if (remote.length === 0)
|
|
426
|
+
return;
|
|
427
|
+
while (attemptNum < MAX_RETRIES) {
|
|
388
428
|
try {
|
|
389
|
-
return await this.tryAppendChanges(
|
|
429
|
+
return await this.tryAppendChanges(remote);
|
|
390
430
|
}
|
|
391
431
|
catch (err) {
|
|
392
432
|
if (err.status === 412 || err.status === 409) {
|
|
393
433
|
// Reload and RETRY
|
|
394
434
|
await this.load();
|
|
395
435
|
// Check conflicts against Index (Metadata sufficient)
|
|
396
|
-
this.checkConflicts(
|
|
436
|
+
this.checkConflicts(remote);
|
|
397
437
|
// Reseq
|
|
398
438
|
let currentSeq = this.meta.seq;
|
|
399
|
-
for (const change of
|
|
439
|
+
for (const change of remote) {
|
|
400
440
|
currentSeq++;
|
|
401
441
|
change.seq = currentSeq;
|
|
402
442
|
}
|
|
403
|
-
|
|
443
|
+
attemptNum++;
|
|
404
444
|
await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
|
|
405
445
|
continue;
|
|
406
446
|
}
|
|
@@ -409,6 +449,66 @@ class DriveHandler {
|
|
|
409
449
|
}
|
|
410
450
|
throw new Error('Failed to append changes');
|
|
411
451
|
}
|
|
452
|
+
async appendLocalDocs(changes) {
|
|
453
|
+
const MAX_RETRIES = 5;
|
|
454
|
+
let attempt = 0;
|
|
455
|
+
while (attempt < MAX_RETRIES) {
|
|
456
|
+
try {
|
|
457
|
+
// 1. Download current local docs (no cache)
|
|
458
|
+
let store = { docs: {} };
|
|
459
|
+
let currentEtag = null;
|
|
460
|
+
if (this.meta.localDocsId) {
|
|
461
|
+
try {
|
|
462
|
+
const fileMeta = await this.client.getFileMetadata(this.meta.localDocsId);
|
|
463
|
+
currentEtag = fileMeta.etag || null;
|
|
464
|
+
store = await this.downloadJson(this.meta.localDocsId, true);
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
if (e.status !== 404)
|
|
468
|
+
throw e;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// 2. Merge changes
|
|
472
|
+
for (const change of changes) {
|
|
473
|
+
if (change.deleted) {
|
|
474
|
+
delete store.docs[change.id];
|
|
475
|
+
}
|
|
476
|
+
else if (change.doc) {
|
|
477
|
+
store.docs[change.id] = change.doc;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// 3. Save back
|
|
481
|
+
const content = JSON.stringify(store);
|
|
482
|
+
let res;
|
|
483
|
+
if (this.meta.localDocsId) {
|
|
484
|
+
res = await this.client.updateFile(this.meta.localDocsId, content, currentEtag || undefined);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
res = await this.client.createFile('_local_docs.json', [this.folderId], 'application/json', content);
|
|
488
|
+
// Update Meta with new File ID
|
|
489
|
+
await this.atomicUpdateMeta((latest) => ({ ...latest, localDocsId: res.id }));
|
|
490
|
+
}
|
|
491
|
+
this.localDocsEtag = res.etag;
|
|
492
|
+
// Update Index
|
|
493
|
+
for (const change of changes) {
|
|
494
|
+
this.updateIndex(change, res.id);
|
|
495
|
+
if (change.doc)
|
|
496
|
+
this.docCache.put(change.id, change.doc);
|
|
497
|
+
else
|
|
498
|
+
this.docCache.remove(change.id);
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
if (err.status === 412 || err.status === 409) {
|
|
504
|
+
attempt++;
|
|
505
|
+
await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
412
512
|
async tryAppendChanges(changes) {
|
|
413
513
|
// 1. Write Log File (Upload Data)
|
|
414
514
|
const fileId = await this.writeChangeFile(changes);
|
|
@@ -485,13 +585,22 @@ class DriveHandler {
|
|
|
485
585
|
// This is the one time we download everything if not cached.
|
|
486
586
|
// Optimization: We could reuse existing `snapshot-data` chunks and only append new data
|
|
487
587
|
// to a new chunk, but for simplicity: Merge All.
|
|
488
|
-
const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted);
|
|
588
|
+
const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted && !id.startsWith('_local/'));
|
|
489
589
|
const allDocs = await this.getMulti(allIds);
|
|
490
590
|
const snapshotData = { docs: {} };
|
|
591
|
+
const missingDocs = [];
|
|
491
592
|
allIds.forEach((id, i) => {
|
|
492
|
-
if (allDocs[i])
|
|
593
|
+
if (allDocs[i]) {
|
|
493
594
|
snapshotData.docs[id] = allDocs[i];
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
missingDocs.push(id);
|
|
598
|
+
}
|
|
494
599
|
});
|
|
600
|
+
if (missingDocs.length > 0) {
|
|
601
|
+
this.log('Compaction ABORTED: Failed to fetch documents', missingDocs);
|
|
602
|
+
throw new Error(`Compaction failed: missing ${missingDocs.length} documents. Aborting to prevent data loss.`);
|
|
603
|
+
}
|
|
495
604
|
// 2. Upload Data File
|
|
496
605
|
const dataContent = JSON.stringify(snapshotData);
|
|
497
606
|
const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
|
|
@@ -617,12 +726,14 @@ class DriveHandler {
|
|
|
617
726
|
if (metaFile) {
|
|
618
727
|
const res = await this.client.updateFile(metaFile.id, content, expectedEtag || undefined);
|
|
619
728
|
this.metaEtag = res.etag;
|
|
729
|
+
this.metaMd5 = res.md5Checksum || null;
|
|
620
730
|
this.metaModifiedTime = res.modifiedTime;
|
|
621
731
|
this.fileCache.remove(metaFile.id); // Invalidate cache
|
|
622
732
|
}
|
|
623
733
|
else {
|
|
624
734
|
const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
|
|
625
735
|
this.metaEtag = res.etag;
|
|
736
|
+
this.metaMd5 = res.md5Checksum || null;
|
|
626
737
|
this.metaModifiedTime = res.modifiedTime;
|
|
627
738
|
}
|
|
628
739
|
}
|
|
@@ -682,11 +793,25 @@ class DriveHandler {
|
|
|
682
793
|
this.log('Polling: _meta.json not found');
|
|
683
794
|
return;
|
|
684
795
|
}
|
|
685
|
-
// Compare etags, falling back to modifiedTime
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
796
|
+
// Compare etags, falling back to md5Checksum or modifiedTime
|
|
797
|
+
const remoteEtag = metaFile.etag;
|
|
798
|
+
const remoteMd5 = metaFile.md5Checksum;
|
|
799
|
+
const remoteModified = metaFile.modifiedTime;
|
|
800
|
+
this.log('Polling: comparing etag', remoteEtag, 'with', this.metaEtag, 'md5', remoteMd5, 'with', this.metaMd5);
|
|
801
|
+
let changed = false;
|
|
802
|
+
if (remoteEtag && this.metaEtag) {
|
|
803
|
+
if (remoteEtag !== this.metaEtag)
|
|
804
|
+
changed = true;
|
|
805
|
+
}
|
|
806
|
+
else if (remoteMd5 && this.metaMd5) {
|
|
807
|
+
if (remoteMd5 !== this.metaMd5)
|
|
808
|
+
changed = true;
|
|
809
|
+
}
|
|
810
|
+
else if (remoteModified !== this.metaModifiedTime) {
|
|
811
|
+
changed = true;
|
|
812
|
+
}
|
|
813
|
+
if (changed) {
|
|
814
|
+
this.log('Polling detected change!', remoteEtag || remoteMd5 || remoteModified);
|
|
690
815
|
await this.load();
|
|
691
816
|
this.notifyListeners();
|
|
692
817
|
}
|
package/lib/types.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface MetaData {
|
|
|
87
87
|
lastCompaction: number | null;
|
|
88
88
|
/** Database name */
|
|
89
89
|
dbName: string;
|
|
90
|
+
/** File ID for _local documents store (optimizes by avoiding app-log writes) */
|
|
91
|
+
localDocsId?: string | null;
|
|
90
92
|
/** Schema Version (for migration) */
|
|
91
93
|
version?: number;
|
|
92
94
|
}
|
package/package.json
CHANGED