@docstack/pouchdb-adapter-googledrive 0.0.6 → 0.0.9

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/drive.js CHANGED
@@ -17,6 +17,9 @@ const DEFAULT_CACHE_SIZE = 1000; // Number of docs
17
17
  * └── changes-*.ndjson # Append logs
18
18
  */
19
19
  class DriveHandler {
20
+ log(...args) {
21
+ console.log(`[googledrive-drive] [${this.meta.dbName}]`, ...args);
22
+ }
20
23
  constructor(options, dbName) {
21
24
  this.folderId = null;
22
25
  this.meta = {
@@ -34,7 +37,35 @@ class DriveHandler {
34
37
  this.currentLogSizeEstimate = 0;
35
38
  this.listeners = [];
36
39
  this.pollingInterval = null;
37
- this.client = new client_1.GoogleDriveClient(options);
40
+ this.loadingPromise = null;
41
+ this.isPollingActive = false;
42
+ this.processedLogIds = new Set();
43
+ this.currentSnapshotIndexId = null;
44
+ this.debug = false;
45
+ this.isCompacting = false;
46
+ const clientOptions = { ...options };
47
+ if (options.testMode) {
48
+ const serverUrl = options.testServerUrl || 'http://localhost:3000';
49
+ // @ts-ignore - baseUrl/uploadUrl might not be in the strict type if we didn't update types.ts definition for DriveClientOptions in client.ts yet?
50
+ // We did update DriveClientOptions in client.ts.
51
+ // But GoogleDriveAdapterOptions extends DriveClientOptions?
52
+ // types.ts: export interface GoogleDriveAdapterOptions extends DriveClientOptions
53
+ // client.ts: export interface DriveClientOptions { accessToken: ...; baseUrl?: string; uploadUrl?: string; }
54
+ // So typescript should be happy.
55
+ // Using /drive/v3/files as base for the test server if simplified?
56
+ // The TestServer mounts at /drive/v3/files.
57
+ // But the client appends /drive/v3/files to BASE_URL?
58
+ // In client.ts default is `https://www.googleapis.com/drive/v3/files`.
59
+ // Our TestServer mounts `/drive/v3/files`.
60
+ // So testUrl should be `http://localhost:3000/drive/v3/files`.
61
+ const testBase = `${serverUrl}/drive/v3/files`;
62
+ const testUpload = `${serverUrl}/upload/drive/v3/files`;
63
+ // @ts-ignore
64
+ clientOptions.baseUrl = testBase;
65
+ // @ts-ignore
66
+ clientOptions.uploadUrl = testUpload;
67
+ }
68
+ this.client = new client_1.GoogleDriveClient(clientOptions);
38
69
  this.options = options;
39
70
  this.folderId = options.folderId || null;
40
71
  this.folderName = options.folderName || dbName;
@@ -42,7 +73,9 @@ class DriveHandler {
42
73
  this.compactionThreshold = options.compactionThreshold || DEFAULT_COMPACTION_THRESHOLD;
43
74
  this.compactionSizeThreshold = options.compactionSizeThreshold || DEFAULT_SIZE_THRESHOLD;
44
75
  this.meta.dbName = dbName;
76
+ this.debug = !!options.debug;
45
77
  this.docCache = new cache_1.LRUCache(options.cacheSize || DEFAULT_CACHE_SIZE);
78
+ this.fileCache = new cache_1.LRUCache(100); // Cache for last 100 files
46
79
  // Polling will be started in load() after folderId is resolved
47
80
  }
48
81
  // Public getter for Sequence (used by adapter)
@@ -51,69 +84,104 @@ class DriveHandler {
51
84
  }
52
85
  /** Load the database (Index Only) */
53
86
  async load() {
54
- if (!this.folderId) {
55
- this.folderId = await this.findOrCreateFolder();
56
- }
57
- const metaFile = await this.findFile('_meta.json');
58
- if (metaFile) {
59
- this.meta = await this.downloadJson(metaFile.id);
60
- this.metaEtag = metaFile.etag || null;
61
- this.metaModifiedTime = metaFile.modifiedTime || null;
62
- }
63
- else {
64
- await this.saveMeta(this.meta);
65
- }
66
- // Initialize Index
67
- this.index = {};
68
- // 1. Load Snapshot Index
69
- if (this.meta.snapshotIndexId) {
87
+ if (this.loadingPromise)
88
+ return this.loadingPromise;
89
+ this.loadingPromise = (async () => {
70
90
  try {
71
- // Try strictly as new format first
72
- const snapshotIdx = await this.downloadJson(this.meta.snapshotIndexId);
73
- // Check if it's actually a legacy snapshot (has 'docs' with bodies)
74
- if (snapshotIdx.docs) {
75
- // Migration Path: Handle legacy snapshot
76
- this.filesFromLegacySnapshot(snapshotIdx);
91
+ this.log('Loading database, options', { options: this.options });
92
+ if (!this.folderId) {
93
+ this.folderId = await this.findOrCreateFolder();
94
+ this.log('Retrieved folder', { folderId: this.folderId });
95
+ }
96
+ const metaFile = await this.findFile('_meta.json');
97
+ if (metaFile) {
98
+ this.log('Retrieved meta file', { fileId: metaFile.id });
99
+ this.meta = await this.downloadJson(metaFile.id, true); // No cache for meta
100
+ this.metaEtag = metaFile.etag || null;
101
+ this.metaModifiedTime = metaFile.modifiedTime || null;
77
102
  }
78
103
  else {
79
- this.index = snapshotIdx.entries || {};
80
- // We assume seq is synced with meta usually, but use snapshot's seq as base
104
+ this.log('Meta file not found, creating new');
105
+ await this.saveMeta(this.meta);
106
+ }
107
+ if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
108
+ this.log('Snapshot index changed, loading index', {
109
+ snapshotIndexId: this.meta.snapshotIndexId,
110
+ currentSnapshotIndexId: this.currentSnapshotIndexId
111
+ });
112
+ // Compaction occurred or initial load
113
+ this.index = {};
114
+ this.processedLogIds.clear();
115
+ this.currentSnapshotIndexId = this.meta.snapshotIndexId;
116
+ if (this.meta.snapshotIndexId) {
117
+ try {
118
+ const snapshotIdx = await this.downloadJson(this.meta.snapshotIndexId);
119
+ if (snapshotIdx.docs) {
120
+ this.filesFromLegacySnapshot(snapshotIdx);
121
+ }
122
+ else {
123
+ this.index = snapshotIdx.entries || {};
124
+ }
125
+ }
126
+ catch (e) {
127
+ console.warn('Failed to load snapshot index', e);
128
+ }
129
+ }
130
+ else if (this.meta.snapshotId) {
131
+ this.log('Legacy snapshot found, loading index', {
132
+ snapshotId: this.meta.snapshotId
133
+ });
134
+ try {
135
+ const legacySnapshot = await this.downloadJson(this.meta.snapshotId);
136
+ this.filesFromLegacySnapshot(legacySnapshot);
137
+ }
138
+ catch (e) {
139
+ console.warn('Failed to load legacy snapshot', e);
140
+ }
141
+ }
142
+ }
143
+ // 2. Replay NEW Change Logs (Metadata only updates)
144
+ 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);
162
+ }
163
+ }
164
+ this.processedLogIds.add(logId);
165
+ this.log('Processed log', logId);
166
+ }
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)');
81
174
  }
82
175
  }
83
176
  catch (e) {
84
- console.warn('Failed to load snapshot index', e);
85
- this.index = {};
86
- }
87
- }
88
- else if (this.meta.snapshotId) {
89
- // Legacy support: field was renamed
90
- try {
91
- const legacySnapshot = await this.downloadJson(this.meta.snapshotId);
92
- this.filesFromLegacySnapshot(legacySnapshot);
93
- }
94
- catch (e) {
95
- console.warn('Failed to load legacy snapshot', e);
177
+ console.error('Failed to load database', e);
178
+ throw e;
96
179
  }
97
- }
98
- // 2. Replay Change Logs (Metadata only updates)
99
- this.pendingChanges = [];
100
- this.currentLogSizeEstimate = 0;
101
- for (const logId of this.meta.changeLogIds) {
102
- const changes = await this.downloadNdjson(logId);
103
- this.currentLogSizeEstimate += 100 * changes.length;
104
- for (const change of changes) {
105
- this.updateIndex(change, logId);
106
- // We do NOT load body into cache automatically
107
- // But we must invalidate cache if we had old data
108
- if (this.docCache.get(change.id)) {
109
- this.docCache.remove(change.id);
110
- }
180
+ finally {
181
+ this.loadingPromise = null;
111
182
  }
112
- }
113
- // 3. Start Polling (if enabled)
114
- if (this.options.pollingIntervalMs) {
115
- this.startPolling(this.options.pollingIntervalMs);
116
- }
183
+ })();
184
+ return this.loadingPromise;
117
185
  }
118
186
  // Migration helper
119
187
  filesFromLegacySnapshot(snapshot) {
@@ -141,17 +209,12 @@ class DriveHandler {
141
209
  return null;
142
210
  if (entry.deleted)
143
211
  return null;
144
- // 1. Check Cache
145
- const cached = this.docCache.get(id);
146
- if (cached)
147
- return cached;
148
- // 2. Fetch from Drive
149
- // If it's a legacy entry currently in memory (should have been cached), returns null if evicted?
212
+ // 1. Check Doc Cache
213
+ const cachedDoc = this.docCache.get(id);
214
+ if (cachedDoc)
215
+ return cachedDoc;
216
+ // 2. Fetch from Drive (via File Cache)
150
217
  if (entry.location.fileId === 'LEGACY_MEMORY') {
151
- // If evicted, we are in trouble unless we re-download the legacy snapshot.
152
- // For robustness, let's say we reload the legacy snapshot if needed.
153
- // OR simpler: we assume compaction will fix this soon.
154
- // Let's implement fetch for safety.
155
218
  if (this.meta.snapshotId) {
156
219
  const legacy = await this.downloadJson(this.meta.snapshotId);
157
220
  if (legacy.docs[id]) {
@@ -159,36 +222,83 @@ class DriveHandler {
159
222
  return legacy.docs[id];
160
223
  }
161
224
  }
162
- return null; // Should not happen
225
+ return null;
163
226
  }
164
227
  const fileId = entry.location.fileId;
165
- // Is it a change file (NDJSON) or snapshot file (JSON)?
166
- // We can infer or we could have stored type.
167
- // Usually, we just download the file.
168
- // Optimization: If we have many docs in one file, we might want to cache that file's contents?
169
- // For now, naive fetch: download file, find doc.
170
- const content = await this.downloadFileAny(fileId);
228
+ const content = await this.fetchFile(fileId);
171
229
  let doc = null;
172
230
  if (Array.isArray(content)) {
173
- // It's a change log (array of entries)
174
- // Find the *last* entry for this ID in this file
175
- const match = content.reverse().find((c) => c.id === id);
231
+ // It's a change log (NDJSON parsed as array)
232
+ const match = [...content].reverse().find((c) => c.id === id);
176
233
  doc = match ? match.doc : null;
177
234
  }
178
- else if (content.docs) {
235
+ else if (content && content.docs) {
179
236
  // It's a snapshot-data chunk
180
237
  doc = content.docs[id];
181
238
  }
182
- else {
183
- // Single doc file? (Not used yet)
239
+ else if (content && content.id === id && content.doc) {
240
+ // It's a single ChangeEntry object (parsed from single-line NDJSON)
241
+ doc = content.doc;
242
+ }
243
+ else if (content && (content._id === id || content.id === id)) {
244
+ // Single doc file or raw doc body
184
245
  doc = content;
185
246
  }
186
247
  if (doc) {
187
248
  this.docCache.put(id, doc);
188
- doc._rev = entry.rev; // Ensure consistent rev
249
+ doc._rev = entry.rev;
189
250
  }
190
251
  return doc;
191
252
  }
253
+ /** Generic Download with Caching and Parsing */
254
+ async fetchFile(fileId, skipCache = false) {
255
+ if (!skipCache) {
256
+ const cached = this.fileCache.get(fileId);
257
+ if (cached) {
258
+ this.log('fetchFile cache hit', fileId);
259
+ return cached;
260
+ }
261
+ }
262
+ this.log('fetchFile downloading', fileId);
263
+ const data = await this.client.getFile(fileId);
264
+ let parsed;
265
+ if (typeof data === 'string') {
266
+ const trimmed = data.trim();
267
+ if (trimmed.startsWith('{')) {
268
+ // Could be JSON or NDJSON
269
+ if (trimmed.includes('\n')) {
270
+ // Definitely NDJSON (multiple lines)
271
+ try {
272
+ const lines = trimmed.split('\n').filter(l => l);
273
+ parsed = lines.map(line => JSON.parse(line));
274
+ }
275
+ catch (e) {
276
+ parsed = data;
277
+ }
278
+ }
279
+ else {
280
+ // Single line. Try regular JSON first.
281
+ try {
282
+ parsed = JSON.parse(trimmed);
283
+ // Optional: if we know it was supposed to be NDJSON?
284
+ // Our change files are always NDJSON.
285
+ // But _meta.json and snapshot-*.json are regular JSON.
286
+ }
287
+ catch (e) {
288
+ parsed = data;
289
+ }
290
+ }
291
+ }
292
+ else {
293
+ parsed = data;
294
+ }
295
+ }
296
+ else {
297
+ parsed = data;
298
+ }
299
+ this.fileCache.put(fileId, parsed);
300
+ return parsed;
301
+ }
192
302
  /** Get multiple docs (Atomic-ish) used for _allDocs */
193
303
  async getMulti(ids) {
194
304
  // Naive parallel fetch
@@ -222,16 +332,20 @@ class DriveHandler {
222
332
  // Fetch files
223
333
  for (const [fileId, docIds] of Object.entries(byFile)) {
224
334
  try {
225
- const content = await this.downloadFileAny(fileId);
335
+ const content = await this.fetchFile(fileId);
226
336
  for (const docId of docIds) {
227
337
  let doc = null;
228
338
  if (Array.isArray(content)) {
229
- const match = content.reverse().find((c) => c.id === docId);
339
+ const match = [...content].reverse().find((c) => c.id === docId);
230
340
  doc = match ? match.doc : null;
231
341
  }
232
- else if (content.docs) {
342
+ else if (content && content.docs) {
233
343
  doc = content.docs[docId];
234
344
  }
345
+ else if (content && content.id === docId && content.doc) {
346
+ // Single ChangeEntry object
347
+ doc = content.doc;
348
+ }
235
349
  if (doc) {
236
350
  // Add entry.rev to doc just in case
237
351
  if (this.index[docId])
@@ -253,7 +367,9 @@ class DriveHandler {
253
367
  return ids.map(id => results[id]);
254
368
  }
255
369
  /** Return all keys in Index */
256
- getIndexKeys() {
370
+ async getIndexKeys() {
371
+ if (this.loadingPromise)
372
+ await this.loadingPromise;
257
373
  return Object.keys(this.index);
258
374
  }
259
375
  /** Get metadata for a specific ID from Index */
@@ -296,29 +412,37 @@ class DriveHandler {
296
412
  async tryAppendChanges(changes) {
297
413
  // 1. Write Log File (Upload Data)
298
414
  const fileId = await this.writeChangeFile(changes);
299
- // 2. Prepare speculative meta update
300
- const nextMeta = { ...this.meta };
301
- nextMeta.changeLogIds = [...nextMeta.changeLogIds, fileId];
302
- nextMeta.seq = changes[changes.length - 1].seq;
303
- // 3. Commit Lock
304
- await this.saveMeta(nextMeta, this.metaEtag);
305
- // 4. Update Local State
306
- this.meta = nextMeta;
307
- for (const change of changes) {
308
- this.updateIndex(change, fileId);
309
- if (change.doc) {
310
- this.docCache.put(change.id, change.doc);
415
+ try {
416
+ // 2. Prepare speculative meta update
417
+ const nextMeta = { ...this.meta };
418
+ nextMeta.changeLogIds = [...nextMeta.changeLogIds, fileId];
419
+ nextMeta.seq = changes[changes.length - 1].seq;
420
+ // 3. Commit Lock
421
+ await this.saveMeta(nextMeta, this.metaEtag);
422
+ // 4. Update Local State
423
+ this.meta = nextMeta;
424
+ for (const change of changes) {
425
+ this.updateIndex(change, fileId);
426
+ if (change.doc) {
427
+ this.docCache.put(change.id, change.doc);
428
+ }
429
+ else if (change.deleted) {
430
+ this.docCache.remove(change.id);
431
+ }
311
432
  }
312
- else if (change.deleted) {
313
- this.docCache.remove(change.id);
433
+ // Notify local changes feed listeners about our own write
434
+ this.notifyListeners();
435
+ // 5. Compaction Check
436
+ const totalChanges = await this.countTotalChanges();
437
+ if (totalChanges >= this.compactionThreshold ||
438
+ this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
439
+ this.compact().catch(e => console.error('Compaction failed', e));
314
440
  }
315
441
  }
316
- // 5. Compaction Check
317
- // Count changes since last compaction *pointer*, not just list length
318
- const totalChanges = await this.countTotalChanges();
319
- if (totalChanges >= this.compactionThreshold ||
320
- this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
321
- this.compact().catch(e => console.error('Compaction failed', e));
442
+ catch (err) {
443
+ // Cleanup orphaned log file on metadata update failure
444
+ this.client.deleteFile(fileId).catch(e => this.log('Failed to cleanup orphaned log', fileId, e));
445
+ throw err;
322
446
  }
323
447
  }
324
448
  /** Update Index with a new change */
@@ -348,55 +472,67 @@ class DriveHandler {
348
472
  }
349
473
  /** Compact: Create SnapshotIndex + SnapshotData */
350
474
  async compact() {
351
- const snapshotSeq = this.meta.seq;
352
- const oldLogIds = [...this.meta.changeLogIds];
353
- const oldIndexId = this.meta.snapshotIndexId;
354
- // 1. Fetch ALL active documents
355
- // We need them to build the new large snapshot-data file
356
- // This is the one time we download everything if not cached.
357
- // Optimization: We could reuse existing `snapshot-data` chunks and only append new data
358
- // to a new chunk, but for simplicity: Merge All.
359
- const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted);
360
- const allDocs = await this.getMulti(allIds);
361
- const snapshotData = { docs: {} };
362
- allIds.forEach((id, i) => {
363
- if (allDocs[i])
364
- snapshotData.docs[id] = allDocs[i];
365
- });
366
- // 2. Upload Data File
367
- const dataContent = JSON.stringify(snapshotData);
368
- const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
369
- const dataFileId = dataRes.id;
370
- // 3. Create Index pointing to this Data File
371
- const newIndexEntries = {};
372
- for (const id of Object.keys(snapshotData.docs)) {
373
- newIndexEntries[id] = {
374
- rev: this.index[id].rev,
375
- seq: this.index[id].seq,
376
- location: { fileId: dataFileId }
475
+ if (this.isCompacting)
476
+ return;
477
+ this.isCompacting = true;
478
+ try {
479
+ this.log('Starting compaction');
480
+ const snapshotSeq = this.meta.seq;
481
+ const oldLogIds = [...this.meta.changeLogIds];
482
+ const oldIndexId = this.meta.snapshotIndexId;
483
+ // 1. Fetch ALL active documents
484
+ // We need them to build the new large snapshot-data file
485
+ // This is the one time we download everything if not cached.
486
+ // Optimization: We could reuse existing `snapshot-data` chunks and only append new data
487
+ // to a new chunk, but for simplicity: Merge All.
488
+ const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted);
489
+ const allDocs = await this.getMulti(allIds);
490
+ const snapshotData = { docs: {} };
491
+ allIds.forEach((id, i) => {
492
+ if (allDocs[i])
493
+ snapshotData.docs[id] = allDocs[i];
494
+ });
495
+ // 2. Upload Data File
496
+ const dataContent = JSON.stringify(snapshotData);
497
+ const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
498
+ const dataFileId = dataRes.id;
499
+ // 3. Create Index pointing to this Data File
500
+ const newIndexEntries = {};
501
+ for (const id of Object.keys(snapshotData.docs)) {
502
+ newIndexEntries[id] = {
503
+ rev: this.index[id].rev,
504
+ seq: this.index[id].seq,
505
+ location: { fileId: dataFileId }
506
+ };
507
+ }
508
+ const snapshotIndex = {
509
+ entries: newIndexEntries,
510
+ seq: snapshotSeq,
511
+ createdAt: Date.now()
377
512
  };
513
+ const indexContent = JSON.stringify(snapshotIndex);
514
+ const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
515
+ const newIndexId = indexRes.id;
516
+ // 4. Update Meta
517
+ let filesToDelete = [];
518
+ await this.atomicUpdateMeta((latest) => {
519
+ const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
520
+ // Only delete files that were in oldLogIds but not in remainingLogs
521
+ filesToDelete = oldLogIds.filter(id => !remainingLogs.includes(id));
522
+ return {
523
+ ...latest,
524
+ snapshotIndexId: newIndexId,
525
+ changeLogIds: remainingLogs,
526
+ lastCompaction: Date.now()
527
+ };
528
+ });
529
+ // 5. Cleanup - Only delete files that were confirmed removed from metadata
530
+ await this.cleanupOldFiles(oldIndexId, filesToDelete);
531
+ this.currentLogSizeEstimate = 0;
532
+ }
533
+ finally {
534
+ this.isCompacting = false;
378
535
  }
379
- const snapshotIndex = {
380
- entries: newIndexEntries,
381
- seq: snapshotSeq,
382
- createdAt: Date.now()
383
- };
384
- const indexContent = JSON.stringify(snapshotIndex);
385
- const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
386
- const newIndexId = indexRes.id;
387
- // 4. Update Meta
388
- await this.atomicUpdateMeta((latest) => {
389
- const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
390
- return {
391
- ...latest,
392
- snapshotIndexId: newIndexId,
393
- changeLogIds: remainingLogs,
394
- lastCompaction: Date.now()
395
- };
396
- });
397
- // 5. Cleanup
398
- this.cleanupOldFiles(oldIndexId, oldLogIds); // And potentially old data files if we tracked them
399
- this.currentLogSizeEstimate = 0;
400
536
  }
401
537
  // ... Helpers (atomicUpdateMeta, saveMeta, writeChangeFile same as before) ...
402
538
  async atomicUpdateMeta(modifier) {
@@ -407,7 +543,7 @@ class DriveHandler {
407
543
  const metaFile = await this.findFile('_meta.json');
408
544
  if (!metaFile)
409
545
  throw new Error('Meta missing');
410
- const validMeta = await this.downloadJson(metaFile.id);
546
+ const validMeta = await this.downloadJson(metaFile.id, true); // No cache
411
547
  const newMeta = modifier(validMeta);
412
548
  await this.saveMeta(newMeta, metaFile.etag);
413
549
  this.meta = newMeta;
@@ -439,30 +575,33 @@ class DriveHandler {
439
575
  const safeName = this.escapeQuery(name);
440
576
  const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
441
577
  const files = await this.client.listFiles(q);
442
- if (files.length > 0)
578
+ if (files.length > 0) {
579
+ let file = files[0];
580
+ if (!file.etag) {
581
+ // Robustness: Fetch metadata for the file if etag is missing from list
582
+ try {
583
+ file = await this.client.getFileMetadata(file.id);
584
+ }
585
+ catch (e) {
586
+ this.log('Failed to fetch file metadata for etag', file.id, e);
587
+ }
588
+ }
443
589
  return {
444
- id: files[0].id,
445
- etag: files[0].etag || '',
446
- modifiedTime: files[0].modifiedTime || ''
590
+ id: file.id,
591
+ etag: file.etag,
592
+ modifiedTime: file.modifiedTime
447
593
  };
594
+ }
448
595
  return null;
449
596
  }
450
- async downloadJson(fileId) {
451
- return await this.client.getFile(fileId);
597
+ async downloadJson(fileId, skipCache = false) {
598
+ return await this.fetchFile(fileId, skipCache);
452
599
  }
453
600
  async downloadFileAny(fileId) {
454
- return await this.client.getFile(fileId);
601
+ return await this.fetchFile(fileId);
455
602
  }
456
603
  async downloadNdjson(fileId) {
457
- const data = await this.client.getFile(fileId);
458
- // data will likely be a string if NDJSON is returned and getFile sees weird content-type
459
- // Or if getFile auto-parsed standard "application/json" but NDJSON is just text.
460
- // Google Drive might return application/json for everything if we aren't careful?
461
- // Actually .ndjson is separate.
462
- // Safest: Handle string or object.
463
- const content = typeof data === 'string' ? data : JSON.stringify(data);
464
- const lines = content.trim().split('\n').filter((l) => l);
465
- return lines.map((line) => JSON.parse(line));
604
+ return await this.fetchFile(fileId);
466
605
  }
467
606
  async writeChangeFile(changes) {
468
607
  const lines = changes.map(c => JSON.stringify(c)).join('\n') + '\n';
@@ -479,6 +618,7 @@ class DriveHandler {
479
618
  const res = await this.client.updateFile(metaFile.id, content, expectedEtag || undefined);
480
619
  this.metaEtag = res.etag;
481
620
  this.metaModifiedTime = res.modifiedTime;
621
+ this.fileCache.remove(metaFile.id); // Invalidate cache
482
622
  }
483
623
  else {
484
624
  const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
@@ -487,41 +627,75 @@ class DriveHandler {
487
627
  }
488
628
  }
489
629
  async countTotalChanges() {
490
- // Calculate diff between meta.seq and snapshot seq
491
- // But we don't store snapshot seq in meta directly?
492
- // We can approximate by pending changes count + known gaps?
493
- // Actually we used to check snapshot.seq.
494
- // We can assume snapshot is somewhat recent.
495
- return this.pendingChanges.length + 10; // dummy for now, rely on log size
630
+ // If no snapshot exists yet, total changes = meta.seq (all changes)
631
+ if (!this.meta.snapshotIndexId) {
632
+ return this.meta.seq;
633
+ }
634
+ // Each log file ID in changeLogIds represents some number of changes.
635
+ // For simplicity and to trigger compaction based on file count (which is what matters for Drive),
636
+ // we can return the number of log files.
637
+ // But since compactionThreshold is usually in ENTRIES, let's keep a rough estimate
638
+ // or just return the log file count if that's what the user expects.
639
+ // The previous "* 5" was too aggressive.
640
+ // Let's assume on average 1 change per log file in tests (worst case).
641
+ return this.meta.changeLogIds.length + this.pendingChanges.length;
496
642
  }
497
643
  async cleanupOldFiles(oldIndexId, oldLogIds) {
498
- if (oldIndexId)
644
+ const deleteFile = async (fileId) => {
499
645
  try {
500
- await this.client.deleteFile(oldIndexId);
646
+ await this.client.deleteFile(fileId);
647
+ this.log('Deleted file', fileId);
501
648
  }
502
- catch { }
503
- for (const id of oldLogIds)
504
- try {
505
- await this.client.deleteFile(id);
649
+ catch (err) {
650
+ // 404 is ok - file already deleted or doesn't exist
651
+ if (err.status === 404 || err.code === 404) {
652
+ this.log('File already deleted or not found', fileId);
653
+ return;
654
+ }
655
+ // Log other errors but don't fail
656
+ this.log('Failed to delete file', fileId, err);
506
657
  }
507
- catch { }
658
+ };
659
+ if (oldIndexId) {
660
+ await deleteFile(oldIndexId);
661
+ }
662
+ for (const id of oldLogIds) {
663
+ await deleteFile(id);
664
+ }
508
665
  }
509
666
  startPolling(intervalMs) {
667
+ this.log('Starting polling with interval', { intervalMs });
668
+ if (isNaN(intervalMs) || intervalMs <= 0)
669
+ return;
510
670
  if (this.pollingInterval)
511
671
  clearInterval(this.pollingInterval);
512
672
  this.pollingInterval = setInterval(async () => {
673
+ this.log('Polling tick...');
674
+ if (this.isPollingActive) {
675
+ this.log('Polling already in progress, skipping tick');
676
+ return;
677
+ }
678
+ this.isPollingActive = true;
513
679
  try {
514
680
  const metaFile = await this.findFile('_meta.json');
515
- if (!metaFile)
681
+ if (!metaFile) {
682
+ this.log('Polling: _meta.json not found');
516
683
  return;
517
- // Use modifiedTime for polling as it's readable in projections
518
- if (metaFile.modifiedTime !== this.metaModifiedTime) {
684
+ }
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);
519
690
  await this.load();
520
691
  this.notifyListeners();
521
692
  }
522
693
  }
523
694
  catch (err) {
524
- console.error('Polling error', err);
695
+ this.log('Polling error', err);
696
+ }
697
+ finally {
698
+ this.isPollingActive = false;
525
699
  }
526
700
  }, intervalMs);
527
701
  }
@@ -534,13 +708,23 @@ class DriveHandler {
534
708
  // Adapter needs to handle this.
535
709
  const changes = {};
536
710
  for (const [id, entry] of Object.entries(this.index)) {
537
- changes[id] = { _id: id, _rev: entry.rev, _deleted: entry.deleted };
711
+ changes[id] = {
712
+ _id: id,
713
+ _rev: entry.rev,
714
+ _deleted: !!entry.deleted,
715
+ seq: entry.seq // IMPORTANT: Missing previously, preventing filtered changes from working
716
+ };
538
717
  }
539
- for (const l of this.listeners)
540
- l(changes);
718
+ for (const cb of this.listeners)
719
+ cb(changes);
541
720
  }
542
721
  // For tests/debug
543
- onChange(cb) { this.listeners.push(cb); }
722
+ onChange(cb) {
723
+ this.listeners.push(cb);
724
+ return () => {
725
+ this.listeners = this.listeners.filter(l => l !== cb);
726
+ };
727
+ }
544
728
  stopPolling() { if (this.pollingInterval)
545
729
  clearInterval(this.pollingInterval); }
546
730
  escapeQuery(value) {