@docstack/pouchdb-adapter-googledrive 0.0.9 → 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 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
- for (const logId of this.meta.changeLogIds) {
146
- if (this.processedLogIds.has(logId))
147
- continue;
148
- this.log('Replaying change log', logId);
149
- let changes = await this.downloadNdjson(logId);
150
- if (!Array.isArray(changes)) {
151
- this.log("Unexpected changes", { changes, logId });
152
- this.log('Downloaded changes not an array, wrapping/ignoring', typeof changes);
153
- changes = changes ? [changes] : []; // Fallback
154
- }
155
- this.currentLogSizeEstimate += 100 * changes.length;
156
- this.log('Replayed change log', logId);
157
- for (const change of changes) {
158
- this.log('Processing change, sequence', change.seq);
159
- this.updateIndex(change, logId);
160
- if (this.docCache.get(change.id)) {
161
- this.docCache.remove(change.id);
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
- // 3. Start Polling (if enabled)
168
- if (this.options.pollingIntervalMs) {
169
- this.log('Starting polling with interval', this.options.pollingIntervalMs);
170
- this.startPolling(Number(this.options.pollingIntervalMs));
171
- }
172
- else {
173
- this.log('Polling disabled (no interval provided)');
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 attempt = 0;
387
- while (attempt < MAX_RETRIES) {
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(changes);
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(changes);
436
+ this.checkConflicts(remote);
397
437
  // Reseq
398
438
  let currentSeq = this.meta.seq;
399
- for (const change of changes) {
439
+ for (const change of remote) {
400
440
  currentSeq++;
401
441
  change.seq = currentSeq;
402
442
  }
403
- attempt++;
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
- this.log('Polling: comparing etag', metaFile.etag, 'with', this.metaEtag);
687
- if ((metaFile.etag && this.metaEtag && metaFile.etag !== this.metaEtag) ||
688
- (!metaFile.etag && metaFile.modifiedTime !== this.metaModifiedTime)) {
689
- this.log('Polling detected change!', metaFile.etag || metaFile.modifiedTime);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docstack/pouchdb-adapter-googledrive",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "description": "PouchDB adapter for Google Drive",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",