@docstack/pouchdb-adapter-googledrive 0.1.0 → 0.1.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/lib/adapter.js +23 -3
- package/lib/client.js +2 -2
- package/lib/drive.d.ts +5 -0
- package/lib/drive.js +252 -96
- package/lib/types.d.ts +5 -0
- package/package.json +1 -1
- package/jest.config.js +0 -8
package/lib/adapter.js
CHANGED
|
@@ -350,15 +350,27 @@ function GoogleDriveAdapter(PouchDB) {
|
|
|
350
350
|
}
|
|
351
351
|
else {
|
|
352
352
|
let newRev;
|
|
353
|
+
let savedDoc;
|
|
353
354
|
if (newEdits) {
|
|
354
355
|
const oldRev = entry?.rev || '0-0';
|
|
355
356
|
const revNum = parseInt(oldRev.split('-')[0], 10) + 1;
|
|
356
|
-
|
|
357
|
+
const revHash = generateRevId();
|
|
358
|
+
newRev = revNum + '-' + revHash;
|
|
359
|
+
savedDoc = Object.assign({}, doc, { _rev: newRev });
|
|
360
|
+
if (doc._revisions) {
|
|
361
|
+
savedDoc._revisions = {
|
|
362
|
+
start: revNum,
|
|
363
|
+
ids: [revHash, ...(doc._revisions.ids || [])]
|
|
364
|
+
};
|
|
365
|
+
if (savedDoc._revisions.ids.length > 500) {
|
|
366
|
+
savedDoc._revisions.ids = savedDoc._revisions.ids.slice(0, 500);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
357
369
|
}
|
|
358
370
|
else {
|
|
359
371
|
newRev = doc._rev;
|
|
372
|
+
savedDoc = Object.assign({}, doc, { _rev: newRev });
|
|
360
373
|
}
|
|
361
|
-
const savedDoc = Object.assign({}, doc, { _rev: newRev });
|
|
362
374
|
changes.push({
|
|
363
375
|
seq,
|
|
364
376
|
id,
|
|
@@ -558,8 +570,16 @@ function GoogleDriveAdapter(PouchDB) {
|
|
|
558
570
|
opts = {};
|
|
559
571
|
}
|
|
560
572
|
const id = doc._id;
|
|
561
|
-
const
|
|
573
|
+
const revNum = 1;
|
|
574
|
+
const revHash = generateRevId();
|
|
575
|
+
const rev = revNum + '-' + revHash;
|
|
562
576
|
const savedDoc = Object.assign({}, doc, { _rev: rev });
|
|
577
|
+
if (doc._revisions) {
|
|
578
|
+
savedDoc._revisions = {
|
|
579
|
+
start: revNum,
|
|
580
|
+
ids: [revHash, ...(doc._revisions.ids || [])]
|
|
581
|
+
};
|
|
582
|
+
}
|
|
563
583
|
const change = {
|
|
564
584
|
seq: db.getNextSeq(),
|
|
565
585
|
id,
|
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;
|
|
@@ -33,6 +35,8 @@ export declare class DriveHandler {
|
|
|
33
35
|
private currentSnapshotIndexId;
|
|
34
36
|
private debug;
|
|
35
37
|
private isCompacting;
|
|
38
|
+
private pendingDownloads;
|
|
39
|
+
private pendingFinds;
|
|
36
40
|
private log;
|
|
37
41
|
constructor(options: GoogleDriveAdapterOptions, dbName: string);
|
|
38
42
|
get seq(): number;
|
|
@@ -56,6 +60,7 @@ export declare class DriveHandler {
|
|
|
56
60
|
appendChange(change: ChangeEntry): Promise<void>;
|
|
57
61
|
/** Append changes with OCC */
|
|
58
62
|
appendChanges(changes: ChangeEntry[]): Promise<void>;
|
|
63
|
+
private appendLocalDocs;
|
|
59
64
|
private tryAppendChanges;
|
|
60
65
|
/** Update Index with a new change */
|
|
61
66
|
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 = [];
|
|
@@ -43,6 +45,8 @@ class DriveHandler {
|
|
|
43
45
|
this.currentSnapshotIndexId = null;
|
|
44
46
|
this.debug = false;
|
|
45
47
|
this.isCompacting = false;
|
|
48
|
+
this.pendingDownloads = new Map();
|
|
49
|
+
this.pendingFinds = new Map();
|
|
46
50
|
const clientOptions = { ...options };
|
|
47
51
|
if (options.testMode) {
|
|
48
52
|
const serverUrl = options.testServerUrl || 'http://localhost:3000';
|
|
@@ -95,9 +99,10 @@ class DriveHandler {
|
|
|
95
99
|
}
|
|
96
100
|
const metaFile = await this.findFile('_meta.json');
|
|
97
101
|
if (metaFile) {
|
|
98
|
-
this.log('Retrieved meta file', { fileId: metaFile.
|
|
99
|
-
this.meta = await this.downloadJson(metaFile.
|
|
102
|
+
this.log('Retrieved meta file', { fileId: metaFile.fileId });
|
|
103
|
+
this.meta = await this.downloadJson(metaFile.fileId, true); // No cache for meta
|
|
100
104
|
this.metaEtag = metaFile.etag || null;
|
|
105
|
+
this.metaMd5 = metaFile.md5Checksum || null;
|
|
101
106
|
this.metaModifiedTime = metaFile.modifiedTime || null;
|
|
102
107
|
}
|
|
103
108
|
else {
|
|
@@ -142,36 +147,64 @@ class DriveHandler {
|
|
|
142
147
|
}
|
|
143
148
|
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
144
149
|
this.log('Replaying change logs');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
150
|
+
const pendingLogs = this.meta.changeLogIds.filter(id => !this.processedLogIds.has(id));
|
|
151
|
+
if (pendingLogs.length > 0) {
|
|
152
|
+
this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
|
|
153
|
+
const logResults = await Promise.all(pendingLogs.map(async (id) => {
|
|
154
|
+
try {
|
|
155
|
+
const changes = await this.downloadNdjson(id);
|
|
156
|
+
return { id, changes };
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
this.log(`Failed to download change log ${id}`, e);
|
|
160
|
+
return { id, changes: null };
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
for (const { id, changes } of logResults) {
|
|
164
|
+
if (!changes) {
|
|
165
|
+
this.log(`Skipping failed log ${id}`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let changesArray = Array.isArray(changes) ? changes : [changes];
|
|
169
|
+
this.currentLogSizeEstimate += 100 * changesArray.length;
|
|
170
|
+
for (const change of changesArray) {
|
|
171
|
+
this.log('Processing change, sequence', change.seq);
|
|
172
|
+
this.updateIndex(change, id);
|
|
173
|
+
if (this.docCache.get(change.id)) {
|
|
174
|
+
this.docCache.remove(change.id);
|
|
175
|
+
}
|
|
162
176
|
}
|
|
177
|
+
this.processedLogIds.add(id);
|
|
178
|
+
this.log('Processed log', id);
|
|
163
179
|
}
|
|
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
180
|
}
|
|
172
|
-
|
|
173
|
-
|
|
181
|
+
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
182
|
+
// ... (previous logic for change logs)
|
|
183
|
+
// (Already updated in previous turn, keep it)
|
|
184
|
+
// 2b. Load Local Documents Store (Pinned in meta)
|
|
185
|
+
if (this.meta.localDocsId) {
|
|
186
|
+
this.log('Loading local docs store', this.meta.localDocsId);
|
|
187
|
+
try {
|
|
188
|
+
const localStore = await this.client.getFileMetadata(this.meta.localDocsId);
|
|
189
|
+
this.localDocsEtag = localStore.etag || null;
|
|
190
|
+
const localDocsChunk = await this.downloadJson(this.meta.localDocsId, true);
|
|
191
|
+
if (localDocsChunk && localDocsChunk.docs) {
|
|
192
|
+
for (const [id, doc] of Object.entries(localDocsChunk.docs)) {
|
|
193
|
+
this.log('Merging local doc', id);
|
|
194
|
+
this.index[id] = {
|
|
195
|
+
rev: doc._rev,
|
|
196
|
+
seq: 0, // Local docs don't participate in shared sequences
|
|
197
|
+
location: { fileId: this.meta.localDocsId }
|
|
198
|
+
};
|
|
199
|
+
this.docCache.put(id, doc);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
this.log('Failed to load local docs store', e);
|
|
205
|
+
}
|
|
174
206
|
}
|
|
207
|
+
// 3. Start Polling ...
|
|
175
208
|
}
|
|
176
209
|
catch (e) {
|
|
177
210
|
console.error('Failed to load database', e);
|
|
@@ -259,45 +292,61 @@ class DriveHandler {
|
|
|
259
292
|
return cached;
|
|
260
293
|
}
|
|
261
294
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
295
|
+
// Always check pending downloads. A download in progress is as fresh as it
|
|
296
|
+
// can be right now, so we can reuse it even if skipCache is true.
|
|
297
|
+
const pending = this.pendingDownloads.get(fileId);
|
|
298
|
+
if (pending) {
|
|
299
|
+
this.log('fetchFile reuse pending download', fileId);
|
|
300
|
+
return await pending;
|
|
301
|
+
}
|
|
302
|
+
const downloadPromise = (async () => {
|
|
303
|
+
try {
|
|
304
|
+
this.log('fetchFile downloading', fileId);
|
|
305
|
+
const data = await this.client.getFile(fileId);
|
|
306
|
+
let parsed;
|
|
307
|
+
if (typeof data === 'string') {
|
|
308
|
+
const trimmed = data.trim();
|
|
309
|
+
if (trimmed.startsWith('{')) {
|
|
310
|
+
// Could be JSON or NDJSON
|
|
311
|
+
if (trimmed.includes('\n')) {
|
|
312
|
+
// Definitely NDJSON (multiple lines)
|
|
313
|
+
try {
|
|
314
|
+
const lines = trimmed.split('\n').filter(l => l);
|
|
315
|
+
parsed = lines.map(line => JSON.parse(line));
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
parsed = data;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Single line. Try regular JSON first.
|
|
323
|
+
try {
|
|
324
|
+
parsed = JSON.parse(trimmed);
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
parsed = data;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
274
330
|
}
|
|
275
|
-
|
|
331
|
+
else {
|
|
276
332
|
parsed = data;
|
|
277
333
|
}
|
|
278
334
|
}
|
|
279
335
|
else {
|
|
280
|
-
|
|
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
|
-
}
|
|
336
|
+
parsed = data;
|
|
290
337
|
}
|
|
338
|
+
if (!skipCache)
|
|
339
|
+
this.fileCache.put(fileId, parsed);
|
|
340
|
+
return parsed;
|
|
291
341
|
}
|
|
292
|
-
|
|
293
|
-
|
|
342
|
+
finally {
|
|
343
|
+
if (!skipCache)
|
|
344
|
+
this.pendingDownloads.delete(fileId);
|
|
294
345
|
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
this.fileCache.put(fileId, parsed);
|
|
300
|
-
return parsed;
|
|
346
|
+
})();
|
|
347
|
+
if (!skipCache)
|
|
348
|
+
this.pendingDownloads.set(fileId, downloadPromise);
|
|
349
|
+
return await downloadPromise;
|
|
301
350
|
}
|
|
302
351
|
/** Get multiple docs (Atomic-ish) used for _allDocs */
|
|
303
352
|
async getMulti(ids) {
|
|
@@ -383,24 +432,33 @@ class DriveHandler {
|
|
|
383
432
|
/** Append changes with OCC */
|
|
384
433
|
async appendChanges(changes) {
|
|
385
434
|
const MAX_RETRIES = 5;
|
|
386
|
-
let
|
|
387
|
-
|
|
435
|
+
let attemptNum = 0;
|
|
436
|
+
const local = changes.filter(c => c.id.startsWith('_local/'));
|
|
437
|
+
const remote = changes.filter(c => !c.id.startsWith('_local/'));
|
|
438
|
+
// Handle Local Docs (Pinned Store)
|
|
439
|
+
if (local.length > 0) {
|
|
440
|
+
await this.appendLocalDocs(local);
|
|
441
|
+
}
|
|
442
|
+
// Handle Remote Docs (App Log)
|
|
443
|
+
if (remote.length === 0)
|
|
444
|
+
return;
|
|
445
|
+
while (attemptNum < MAX_RETRIES) {
|
|
388
446
|
try {
|
|
389
|
-
return await this.tryAppendChanges(
|
|
447
|
+
return await this.tryAppendChanges(remote);
|
|
390
448
|
}
|
|
391
449
|
catch (err) {
|
|
392
450
|
if (err.status === 412 || err.status === 409) {
|
|
393
451
|
// Reload and RETRY
|
|
394
452
|
await this.load();
|
|
395
453
|
// Check conflicts against Index (Metadata sufficient)
|
|
396
|
-
this.checkConflicts(
|
|
454
|
+
this.checkConflicts(remote);
|
|
397
455
|
// Reseq
|
|
398
456
|
let currentSeq = this.meta.seq;
|
|
399
|
-
for (const change of
|
|
457
|
+
for (const change of remote) {
|
|
400
458
|
currentSeq++;
|
|
401
459
|
change.seq = currentSeq;
|
|
402
460
|
}
|
|
403
|
-
|
|
461
|
+
attemptNum++;
|
|
404
462
|
await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
|
|
405
463
|
continue;
|
|
406
464
|
}
|
|
@@ -409,6 +467,66 @@ class DriveHandler {
|
|
|
409
467
|
}
|
|
410
468
|
throw new Error('Failed to append changes');
|
|
411
469
|
}
|
|
470
|
+
async appendLocalDocs(changes) {
|
|
471
|
+
const MAX_RETRIES = 5;
|
|
472
|
+
let attempt = 0;
|
|
473
|
+
while (attempt < MAX_RETRIES) {
|
|
474
|
+
try {
|
|
475
|
+
// 1. Download current local docs (no cache)
|
|
476
|
+
let store = { docs: {} };
|
|
477
|
+
let currentEtag = null;
|
|
478
|
+
if (this.meta.localDocsId) {
|
|
479
|
+
try {
|
|
480
|
+
const fileMeta = await this.client.getFileMetadata(this.meta.localDocsId);
|
|
481
|
+
currentEtag = fileMeta.etag || null;
|
|
482
|
+
store = await this.downloadJson(this.meta.localDocsId, true);
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
if (e.status !== 404)
|
|
486
|
+
throw e;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// 2. Merge changes
|
|
490
|
+
for (const change of changes) {
|
|
491
|
+
if (change.deleted) {
|
|
492
|
+
delete store.docs[change.id];
|
|
493
|
+
}
|
|
494
|
+
else if (change.doc) {
|
|
495
|
+
store.docs[change.id] = change.doc;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// 3. Save back
|
|
499
|
+
const content = JSON.stringify(store);
|
|
500
|
+
let res;
|
|
501
|
+
if (this.meta.localDocsId) {
|
|
502
|
+
res = await this.client.updateFile(this.meta.localDocsId, content, currentEtag || undefined);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
res = await this.client.createFile('_local_docs.json', [this.folderId], 'application/json', content);
|
|
506
|
+
// Update Meta with new File ID
|
|
507
|
+
await this.atomicUpdateMeta((latest) => ({ ...latest, localDocsId: res.id }));
|
|
508
|
+
}
|
|
509
|
+
this.localDocsEtag = res.etag;
|
|
510
|
+
// Update Index
|
|
511
|
+
for (const change of changes) {
|
|
512
|
+
this.updateIndex(change, res.id);
|
|
513
|
+
if (change.doc)
|
|
514
|
+
this.docCache.put(change.id, change.doc);
|
|
515
|
+
else
|
|
516
|
+
this.docCache.remove(change.id);
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
catch (err) {
|
|
521
|
+
if (err.status === 412 || err.status === 409) {
|
|
522
|
+
attempt++;
|
|
523
|
+
await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
412
530
|
async tryAppendChanges(changes) {
|
|
413
531
|
// 1. Write Log File (Upload Data)
|
|
414
532
|
const fileId = await this.writeChangeFile(changes);
|
|
@@ -485,13 +603,22 @@ class DriveHandler {
|
|
|
485
603
|
// This is the one time we download everything if not cached.
|
|
486
604
|
// Optimization: We could reuse existing `snapshot-data` chunks and only append new data
|
|
487
605
|
// to a new chunk, but for simplicity: Merge All.
|
|
488
|
-
const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted);
|
|
606
|
+
const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted && !id.startsWith('_local/'));
|
|
489
607
|
const allDocs = await this.getMulti(allIds);
|
|
490
608
|
const snapshotData = { docs: {} };
|
|
609
|
+
const missingDocs = [];
|
|
491
610
|
allIds.forEach((id, i) => {
|
|
492
|
-
if (allDocs[i])
|
|
611
|
+
if (allDocs[i]) {
|
|
493
612
|
snapshotData.docs[id] = allDocs[i];
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
missingDocs.push(id);
|
|
616
|
+
}
|
|
494
617
|
});
|
|
618
|
+
if (missingDocs.length > 0) {
|
|
619
|
+
this.log('Compaction ABORTED: Failed to fetch documents', missingDocs);
|
|
620
|
+
throw new Error(`Compaction failed: missing ${missingDocs.length} documents. Aborting to prevent data loss.`);
|
|
621
|
+
}
|
|
495
622
|
// 2. Upload Data File
|
|
496
623
|
const dataContent = JSON.stringify(snapshotData);
|
|
497
624
|
const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
|
|
@@ -543,7 +670,7 @@ class DriveHandler {
|
|
|
543
670
|
const metaFile = await this.findFile('_meta.json');
|
|
544
671
|
if (!metaFile)
|
|
545
672
|
throw new Error('Meta missing');
|
|
546
|
-
const validMeta = await this.downloadJson(metaFile.
|
|
673
|
+
const validMeta = await this.downloadJson(metaFile.fileId, true); // No cache
|
|
547
674
|
const newMeta = modifier(validMeta);
|
|
548
675
|
await this.saveMeta(newMeta, metaFile.etag);
|
|
549
676
|
this.meta = newMeta;
|
|
@@ -570,29 +697,42 @@ class DriveHandler {
|
|
|
570
697
|
return createRes.id;
|
|
571
698
|
}
|
|
572
699
|
async findFile(name) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
700
|
+
const pending = this.pendingFinds.get(name);
|
|
701
|
+
if (pending) {
|
|
702
|
+
this.log('findFile reuse pending search', name);
|
|
703
|
+
return await pending;
|
|
704
|
+
}
|
|
705
|
+
const findPromise = (async () => {
|
|
706
|
+
const safeName = this.escapeQuery(name);
|
|
707
|
+
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
708
|
+
try {
|
|
709
|
+
const files = await this.client.listFiles(q);
|
|
710
|
+
if (files.length > 0) {
|
|
711
|
+
let file = files[0];
|
|
712
|
+
if (!file.etag) {
|
|
713
|
+
// Robustness: Fetch metadata for the file if etag is missing from list
|
|
714
|
+
try {
|
|
715
|
+
file = await this.client.getFileMetadata(file.id);
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
this.log('Failed to fetch file metadata for etag', file.id, e);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
fileId: file.id,
|
|
723
|
+
etag: file.etag,
|
|
724
|
+
md5Checksum: file.md5Checksum,
|
|
725
|
+
modifiedTime: file.modifiedTime
|
|
726
|
+
};
|
|
587
727
|
}
|
|
728
|
+
return null;
|
|
588
729
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
return null;
|
|
730
|
+
finally {
|
|
731
|
+
this.pendingFinds.delete(name);
|
|
732
|
+
}
|
|
733
|
+
})();
|
|
734
|
+
this.pendingFinds.set(name, findPromise);
|
|
735
|
+
return await findPromise;
|
|
596
736
|
}
|
|
597
737
|
async downloadJson(fileId, skipCache = false) {
|
|
598
738
|
return await this.fetchFile(fileId, skipCache);
|
|
@@ -615,14 +755,16 @@ class DriveHandler {
|
|
|
615
755
|
const content = JSON.stringify(meta);
|
|
616
756
|
const metaFile = await this.findFile('_meta.json');
|
|
617
757
|
if (metaFile) {
|
|
618
|
-
const res = await this.client.updateFile(metaFile.
|
|
758
|
+
const res = await this.client.updateFile(metaFile.fileId, content, expectedEtag || undefined);
|
|
619
759
|
this.metaEtag = res.etag;
|
|
760
|
+
this.metaMd5 = res.md5Checksum || null;
|
|
620
761
|
this.metaModifiedTime = res.modifiedTime;
|
|
621
|
-
this.fileCache.remove(metaFile.
|
|
762
|
+
this.fileCache.remove(metaFile.fileId); // Invalidate cache
|
|
622
763
|
}
|
|
623
764
|
else {
|
|
624
765
|
const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
|
|
625
766
|
this.metaEtag = res.etag;
|
|
767
|
+
this.metaMd5 = res.md5Checksum || null;
|
|
626
768
|
this.metaModifiedTime = res.modifiedTime;
|
|
627
769
|
}
|
|
628
770
|
}
|
|
@@ -682,11 +824,25 @@ class DriveHandler {
|
|
|
682
824
|
this.log('Polling: _meta.json not found');
|
|
683
825
|
return;
|
|
684
826
|
}
|
|
685
|
-
// Compare etags, falling back to modifiedTime
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
827
|
+
// Compare etags, falling back to md5Checksum or modifiedTime
|
|
828
|
+
const remoteEtag = metaFile.etag;
|
|
829
|
+
const remoteMd5 = metaFile.md5Checksum;
|
|
830
|
+
const remoteModified = metaFile.modifiedTime;
|
|
831
|
+
this.log('Polling: comparing etag', remoteEtag, 'with', this.metaEtag, 'md5', remoteMd5, 'with', this.metaMd5);
|
|
832
|
+
let changed = false;
|
|
833
|
+
if (remoteEtag && this.metaEtag) {
|
|
834
|
+
if (remoteEtag !== this.metaEtag)
|
|
835
|
+
changed = true;
|
|
836
|
+
}
|
|
837
|
+
else if (remoteMd5 && this.metaMd5) {
|
|
838
|
+
if (remoteMd5 !== this.metaMd5)
|
|
839
|
+
changed = true;
|
|
840
|
+
}
|
|
841
|
+
else if (remoteModified !== this.metaModifiedTime) {
|
|
842
|
+
changed = true;
|
|
843
|
+
}
|
|
844
|
+
if (changed) {
|
|
845
|
+
this.log('Polling detected change!', remoteEtag || remoteMd5 || remoteModified);
|
|
690
846
|
await this.load();
|
|
691
847
|
this.notifyListeners();
|
|
692
848
|
}
|
package/lib/types.d.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface ChangeEntry {
|
|
|
40
40
|
/** Location pointer for lazy loading */
|
|
41
41
|
export interface FilePointer {
|
|
42
42
|
fileId: string;
|
|
43
|
+
etag?: string;
|
|
44
|
+
md5Checksum?: string;
|
|
45
|
+
modifiedTime?: string;
|
|
43
46
|
/** Optional offset/length for future optimization (packed files) */
|
|
44
47
|
offset?: number;
|
|
45
48
|
length?: number;
|
|
@@ -87,6 +90,8 @@ export interface MetaData {
|
|
|
87
90
|
lastCompaction: number | null;
|
|
88
91
|
/** Database name */
|
|
89
92
|
dbName: string;
|
|
93
|
+
/** File ID for _local documents store (optimizes by avoiding app-log writes) */
|
|
94
|
+
localDocsId?: string | null;
|
|
90
95
|
/** Schema Version (for migration) */
|
|
91
96
|
version?: number;
|
|
92
97
|
}
|
package/package.json
CHANGED