@docstack/pouchdb-adapter-googledrive 0.1.5 → 0.1.7

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 CHANGED
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GoogleDriveAdapter = GoogleDriveAdapter;
4
4
  const drive_1 = require("./drive");
5
+ const pouchdb_adapter_utils_1 = require("pouchdb-adapter-utils");
6
+ const pouchdb_merge_1 = require("pouchdb-merge");
5
7
  /**
6
8
  * Schedule a function to run asynchronously.
7
9
  */
@@ -10,6 +12,50 @@ function nextTick(fn) {
10
12
  queueMicrotask(fn);
11
13
  }
12
14
  }
15
+ /** Substituted by DriveHandler.updateIndex() once the batch's change-log file
16
+ * actually has an id - lets adapter.ts build a complete IndexEntry (including
17
+ * pointers at revisions being written in *this* batch) before that file exists. */
18
+ const SELF_FILE = '__SELF__';
19
+ /** True when a newEdits write's parent rev isn't actually present in the tree
20
+ * (the caller claimed a rev that doesn't exist) - same check, same name,
21
+ * `pouchdb-adapter-native` uses (traced from pouchdb-adapter-utils's processDocs.js). */
22
+ function rootIsMissing(docInfo) {
23
+ return docInfo.metadata.rev_tree[0].ids[1].status === 'missing';
24
+ }
25
+ /**
26
+ * Builds a change's `changes` array.
27
+ *
28
+ * `style: 'all_docs'` - which is what `pouchdb-replication` asks for, and its default -
29
+ * means "list every leaf of the revision tree, not just the winner". Reporting only the
30
+ * winner hides conflict leaves from the changes feed, so they are never fetched, never
31
+ * pushed, and the two replicas quietly disagree about which revisions exist. The winner
32
+ * goes first, as CouchDB orders it.
33
+ *
34
+ * @param entry - The index entry for the document, carrying the serialized rev tree.
35
+ * @param style - The `style` the caller asked for; anything but `'all_docs'` yields the
36
+ * winning revision alone.
37
+ * @returns The `changes` array for the changes feed.
38
+ */
39
+ function buildChangesList(entry, style) {
40
+ const winner = [{ rev: entry.rev }];
41
+ if (style !== 'all_docs')
42
+ return winner;
43
+ try {
44
+ const leaves = (0, pouchdb_merge_1.collectLeaves)(JSON.parse(entry.tree));
45
+ if (!leaves || !leaves.length)
46
+ return winner;
47
+ const others = leaves
48
+ .map(leaf => leaf.rev)
49
+ .filter(rev => rev !== entry.rev)
50
+ .map(rev => ({ rev }));
51
+ return [...winner, ...others];
52
+ }
53
+ catch (e) {
54
+ // A tree that will not parse is a bigger problem than a missing conflict leaf;
55
+ // fall back to the winner rather than breaking the feed.
56
+ return winner;
57
+ }
58
+ }
13
59
  /**
14
60
  * TODO: Implement dynamic method signature, since PouchDB CRUD methods can be called as promise, callback or (?)
15
61
  */
@@ -140,9 +186,27 @@ function GoogleDriveAdapter(PouchDB) {
140
186
  if (!opts || typeof opts !== 'object') {
141
187
  opts = {};
142
188
  }
143
- // PouchDB sometimes asks for metadata only (revs, revs_info)
189
+ // PouchDB sometimes asks for metadata only (revs, revs_info)
144
190
  log('_get id:', id, 'opts:', JSON.stringify(opts));
145
- const promise = db.get(id).then(doc => {
191
+ // A specific non-winning rev was requested (e.g. to inspect a conflict
192
+ // via db.get(id, {rev, conflicts: true})'s follow-up fetch) - look it up
193
+ // among known conflict locations instead of always returning the current
194
+ // winner. The common case (no rev, or rev === the winner) is unaffected.
195
+ // api.get = api._get below means this replaces the whole public `get()`
196
+ // method (same "no _-prefixed hook, override the public method instead"
197
+ // situation @docstack/pouchdb-adapter-native's own revsDiff/bulkGet ran
198
+ // into) - AbstractPouchDB.prototype.get's own opts.conflicts handling
199
+ // (pouchdbMerge.collectConflicts(metadata)) is bypassed entirely, so it
200
+ // has to be done here instead.
201
+ const entry = db.getIndexEntry(id);
202
+ const fetchPromise = (async () => {
203
+ if (opts.rev && entry && entry.rev !== opts.rev) {
204
+ const loc = entry.conflictLocations && entry.conflictLocations[opts.rev];
205
+ return loc ? db.getRevisionBody(id, opts.rev, loc) : null;
206
+ }
207
+ return db.get(id);
208
+ })();
209
+ const promise = fetchPromise.then(doc => {
146
210
  if (!doc) {
147
211
  log('_get missing', id);
148
212
  const err = {
@@ -166,6 +230,11 @@ function GoogleDriveAdapter(PouchDB) {
166
230
  callback(null, result);
167
231
  return result;
168
232
  }
233
+ if (opts.conflicts && entry) {
234
+ const conflicts = (0, pouchdb_merge_1.collectConflicts)({ rev_tree: JSON.parse(entry.tree) });
235
+ if (conflicts.length)
236
+ doc._conflicts = conflicts;
237
+ }
169
238
  log('_get returning standard doc for:', id);
170
239
  // If only rev was requested? (Internal optimization)
171
240
  // PouchDB core handles this if we return the full doc.
@@ -272,32 +341,35 @@ function GoogleDriveAdapter(PouchDB) {
272
341
  api._bulkGet = function (opts, callback) {
273
342
  const docs = opts.docs;
274
343
  const ids = docs.map((d) => d.id);
275
- return db.getMulti(ids).then(results => {
276
- const response = {
277
- results: ids.map((id, i) => {
278
- const doc = results[i];
279
- const requestedRev = docs[i].rev;
280
- const entry = db.getIndexEntry(id);
281
- let docResult;
282
- if (!doc || (requestedRev && doc._rev !== requestedRev)) {
283
- docResult = {
284
- error: {
285
- status: 404,
286
- error: true,
287
- name: 'not_found',
288
- message: 'missing'
289
- }
290
- };
291
- }
292
- else {
293
- docResult = { ok: doc };
294
- }
295
- return {
296
- id,
297
- docs: [docResult]
344
+ return db.getMulti(ids).then(async (results) => {
345
+ const rows = await Promise.all(ids.map(async (id, i) => {
346
+ let doc = results[i];
347
+ const requestedRev = docs[i].rev;
348
+ const entry = db.getIndexEntry(id);
349
+ // Winning body didn't match the specific rev requested - it may
350
+ // still be a known conflict leaf, not necessarily missing.
351
+ if (requestedRev && (!doc || doc._rev !== requestedRev) && entry) {
352
+ const loc = entry.conflictLocations && entry.conflictLocations[requestedRev];
353
+ if (loc)
354
+ doc = await db.getRevisionBody(id, requestedRev, loc);
355
+ }
356
+ let docResult;
357
+ if (!doc || (requestedRev && doc._rev !== requestedRev)) {
358
+ docResult = {
359
+ error: {
360
+ status: 404,
361
+ error: true,
362
+ name: 'not_found',
363
+ message: 'missing'
364
+ }
298
365
  };
299
- })
300
- };
366
+ }
367
+ else {
368
+ docResult = { ok: doc };
369
+ }
370
+ return { id, docs: [docResult] };
371
+ }));
372
+ const response = { results: rows };
301
373
  if (callback)
302
374
  callback(null, response);
303
375
  return response;
@@ -308,79 +380,145 @@ function GoogleDriveAdapter(PouchDB) {
308
380
  });
309
381
  };
310
382
  api.bulkGet = api._bulkGet;
311
- // Bulk document operations
383
+ // Bulk document operations. Merges every write into a real pouchdb-merge
384
+ // rev tree (mirroring @docstack/pouchdb-adapter-native's own, already-proven
385
+ // _bulkDocs, traced from pouchdb-adapter-utils's processDocs.js/updateDoc.js)
386
+ // instead of blindly overwriting the index - the previous version had no
387
+ // conflict detection at all on the new_edits:false (replication) path, so
388
+ // two peers syncing a concurrent edit through this adapter would each just
389
+ // keep whichever write landed last, silently disagreeing with each other.
312
390
  api._bulkDocs = function (req, opts, callback) {
391
+ // `api.bulkDocs = api._bulkDocs` below means callers reach this directly and
392
+ // AbstractPouchDB.prototype.bulkDocs never runs, so its two normalizations
393
+ // have to happen here instead.
394
+ //
395
+ // First, the request shape: `db.bulkDocs([doc])` is the common call style and
396
+ // core turns the array into an envelope before any adapter sees it.
397
+ if (Array.isArray(req))
398
+ req = { docs: req };
399
+ // Second, `new_edits`, which arrives on either side. `pouchdb-replication`
400
+ // puts it on `req` (the CouchDB _bulk_docs body shape); `bulkDocs(docs,
401
+ // { new_edits: false })` and `put(doc, { new_edits: false })` - which core
402
+ // routes through `bulkDocs` - put it on `opts`. Core consults `opts` first and
403
+ // falls back to `req`; reading only one side silently mints fresh revisions
404
+ // for writes that were meant to land verbatim.
405
+ const newEdits = (opts && typeof opts === 'object' && 'new_edits' in opts)
406
+ ? opts.new_edits !== false
407
+ : req.new_edits !== false;
313
408
  const docs = req.docs;
314
- const results = [];
315
- // new_edits lives on `req` (the CouchDB _bulk_docs request body shape), not `opts`.
316
- // api.bulkDocs = api._bulkDocs below means callers reach this directly, bypassing
317
- // AbstractPouchDB.prototype.bulkDocs's req->opts normalization - so read it from req.
318
- const newEdits = req.new_edits !== false;
409
+ const results = new Array(docs.length);
410
+ const revsLimit = adapterOpts.revs_limit || 1000;
319
411
  const changes = [];
320
- // We need to validate revisions against Index
321
- // This does NOT require fetching bodies usually
322
- for (const doc of docs) {
412
+ for (let i = 0; i < docs.length; i++) {
413
+ const doc = docs[i];
323
414
  const id = doc._id;
324
- const seq = db.getNextSeq() + changes.length;
325
415
  const entry = db.getIndexEntry(id);
326
- if (doc._deleted) {
327
- if (!entry || entry.deleted) {
328
- results.push({
329
- ok: false,
330
- id,
331
- error: 'not_found',
332
- reason: 'missing'
333
- });
416
+ const docInfo = (0, pouchdb_adapter_utils_1.parseDoc)(Object.assign({}, doc), newEdits);
417
+ if (!docInfo || !docInfo.metadata) {
418
+ results[i] = Object.assign({ id }, docInfo);
419
+ continue;
420
+ }
421
+ let mergedTree;
422
+ let stemmedRevs;
423
+ let winning;
424
+ let winningDeleted;
425
+ let selfIsWinner;
426
+ let incomingRev;
427
+ let savedDocInfo = docInfo;
428
+ const conflictLocations = {};
429
+ if (!entry) {
430
+ // Brand new doc.
431
+ if (newEdits && rootIsMissing(docInfo)) {
432
+ results[i] = { ok: false, id, error: 'conflict', reason: 'Document update conflict' };
334
433
  continue;
335
434
  }
336
- // Check rev
337
- const oldRev = entry.rev || '0-0'; // Index has latest
338
- // If mismatch? PouchDB handles conflict logic before calling us sometimes?
339
- // But we should verify.
340
- // If doc._rev matches entry.rev, we are good.
341
- const revNum = parseInt(oldRev.split('-')[0], 10) + 1;
342
- const newRev = revNum + '-' + generateRevId();
343
- changes.push({
344
- seq,
345
- id,
346
- rev: newRev,
347
- deleted: true,
348
- timestamp: Date.now()
349
- });
350
- results.push({ ok: true, id, rev: newRev });
435
+ const merged = (0, pouchdb_merge_1.merge)([], docInfo.metadata.rev_tree[0], revsLimit);
436
+ mergedTree = merged.tree;
437
+ stemmedRevs = merged.stemmedRevs;
438
+ winning = (0, pouchdb_merge_1.winningRev)({ rev_tree: mergedTree });
439
+ winningDeleted = (0, pouchdb_merge_1.isDeleted)({ rev_tree: mergedTree }, winning);
440
+ incomingRev = docInfo.metadata.rev;
441
+ selfIsWinner = true; // an empty starting tree can only ever produce one leaf
351
442
  }
352
443
  else {
353
- let newRev;
354
- let savedDoc;
355
- if (newEdits) {
356
- const oldRev = entry?.rev || '0-0';
357
- const revNum = parseInt(oldRev.split('-')[0], 10) + 1;
358
- const revHash = generateRevId();
359
- newRev = revNum + '-' + revHash;
360
- savedDoc = Object.assign({}, doc, { _rev: newRev });
361
- if (doc._revisions) {
362
- savedDoc._revisions = {
363
- start: revNum,
364
- ids: [revHash, ...(doc._revisions.ids || [])]
365
- };
366
- if (savedDoc._revisions.ids.length > 500) {
367
- savedDoc._revisions.ids = savedDoc._revisions.ids.slice(0, 500);
368
- }
444
+ const existingTree = JSON.parse(entry.tree);
445
+ if ((0, pouchdb_merge_1.revExists)(existingTree, docInfo.metadata.rev) && !newEdits) {
446
+ // Replication redelivering a rev we already have - a no-op.
447
+ results[i] = { ok: true, id, rev: docInfo.metadata.rev };
448
+ continue;
449
+ }
450
+ const previousWinningRev = entry.rev;
451
+ const previouslyDeleted = !!entry.deleted;
452
+ let deleted = docInfo.metadata.deleted !== undefined ? docInfo.metadata.deleted : false;
453
+ const isRoot = /^1-/.test(docInfo.metadata.rev);
454
+ // Undeleting via a fresh newEdits put re-parents onto the tombstone
455
+ // rev instead of conflicting (CouchDB "resurrection").
456
+ if (previouslyDeleted && !deleted && newEdits && isRoot) {
457
+ const resurrected = Object.assign({}, docInfo.data, { _id: id, _rev: previousWinningRev });
458
+ const reparsed = (0, pouchdb_adapter_utils_1.parseDoc)(resurrected, newEdits);
459
+ if (!reparsed || !reparsed.metadata) {
460
+ results[i] = Object.assign({ id }, reparsed);
461
+ continue;
369
462
  }
463
+ savedDocInfo = reparsed;
464
+ deleted = savedDocInfo.metadata.deleted !== undefined ? savedDocInfo.metadata.deleted : false;
370
465
  }
371
- else {
372
- newRev = doc._rev;
373
- savedDoc = Object.assign({}, doc, { _rev: newRev });
466
+ const merged = (0, pouchdb_merge_1.merge)(existingTree, savedDocInfo.metadata.rev_tree[0], revsLimit);
467
+ const inConflict = newEdits &&
468
+ ((previouslyDeleted && deleted && merged.conflicts !== 'new_leaf') ||
469
+ (!previouslyDeleted && merged.conflicts !== 'new_leaf') ||
470
+ (previouslyDeleted && !deleted && merged.conflicts === 'new_branch'));
471
+ if (inConflict) {
472
+ results[i] = { ok: false, id, error: 'conflict', reason: 'Document update conflict' };
473
+ continue;
374
474
  }
375
- changes.push({
376
- seq,
377
- id,
378
- rev: newRev,
379
- doc: savedDoc,
380
- timestamp: Date.now()
381
- });
382
- results.push({ ok: true, id, rev: newRev });
475
+ mergedTree = merged.tree;
476
+ stemmedRevs = merged.stemmedRevs;
477
+ winning = (0, pouchdb_merge_1.winningRev)({ rev_tree: mergedTree });
478
+ winningDeleted = (0, pouchdb_merge_1.isDeleted)({ rev_tree: mergedTree }, winning);
479
+ incomingRev = savedDocInfo.metadata.rev;
480
+ selfIsWinner = incomingRev === winning;
481
+ // Carry forward existing conflicts, minus anything just stemmed
482
+ // past revs_limit (pouchdb-adapter-native's own stemBodies
483
+ // handles the equivalent body cleanup for its own storage;
484
+ // compact() here does the same, see drive.ts).
485
+ if (entry.conflictLocations) {
486
+ for (const rev of Object.keys(entry.conflictLocations)) {
487
+ if (!stemmedRevs.includes(rev))
488
+ conflictLocations[rev] = entry.conflictLocations[rev];
489
+ }
490
+ }
491
+ if (selfIsWinner) {
492
+ // The old winner becomes a conflict leaf, unless it just got stemmed.
493
+ if (previousWinningRev !== winning && !stemmedRevs.includes(previousWinningRev)) {
494
+ conflictLocations[previousWinningRev] = entry.location;
495
+ }
496
+ }
497
+ else if (!stemmedRevs.includes(incomingRev)) {
498
+ conflictLocations[incomingRev] = { fileId: SELF_FILE };
499
+ }
500
+ }
501
+ const seq = db.getNextSeq() + changes.length;
502
+ const savedDoc = Object.assign({}, savedDocInfo.data, { _id: id, _rev: incomingRev });
503
+ const nextIndexEntry = {
504
+ tree: JSON.stringify(mergedTree),
505
+ rev: winning,
506
+ deleted: winningDeleted,
507
+ location: selfIsWinner ? { fileId: SELF_FILE } : entry.location,
508
+ };
509
+ if (Object.keys(conflictLocations).length > 0) {
510
+ nextIndexEntry.conflictLocations = conflictLocations;
383
511
  }
512
+ changes.push({
513
+ seq,
514
+ id,
515
+ rev: incomingRev,
516
+ deleted: savedDocInfo.metadata.deleted,
517
+ doc: savedDoc,
518
+ timestamp: Date.now(),
519
+ nextIndexEntry,
520
+ });
521
+ results[i] = { ok: true, id, rev: incomingRev };
384
522
  }
385
523
  log('_bulkDocs flushing', changes.length, 'changes');
386
524
  // Append changes to log
@@ -414,65 +552,136 @@ function GoogleDriveAdapter(PouchDB) {
414
552
  liveListener = (changedDocs) => {
415
553
  if (complete)
416
554
  return;
417
- for (const id of Object.keys(changedDocs)) {
418
- if (id.startsWith('_local/'))
419
- continue;
420
- const entry = db.getIndexEntry(id);
421
- if (entry && entry.seq > lastSeq) {
555
+ // Gate the whole batch against where the feed stood BEFORE it,
556
+ // then emit in seq order and advance once at the end. The old
557
+ // shape - unordered iteration, lastSeq bumped per emission - is
558
+ // the initial pass's bug in another spot (its own comment: "an
559
+ // unordered batch can checkpoint past a change it never
560
+ // emitted"): a higher-seq doc processed first raised the bar,
561
+ // and a lower-seq sibling in the same batch failed `> lastSeq`
562
+ // and was never delivered, while the checkpoint moved past it.
563
+ const gate = lastSeq;
564
+ const batch = Object.keys(changedDocs)
565
+ .filter(id => !id.startsWith('_local/'))
566
+ .map(id => ({ id, entry: db.getIndexEntry(id) }))
567
+ .filter((row) => Boolean(row.entry) && row.entry.seq > gate)
568
+ .sort((a, b) => a.entry.seq - b.entry.seq);
569
+ if (batch.length === 0)
570
+ return;
571
+ const emit = (bodies) => {
572
+ for (const { id, entry } of batch) {
422
573
  const change = {
423
- id: id,
574
+ id,
424
575
  seq: entry.seq,
425
- changes: [{ rev: entry.rev }]
576
+ changes: buildChangesList(entry, opts.style)
426
577
  };
427
- if (opts.include_docs) {
428
- db.get(id).then(doc => {
429
- change.doc = doc;
430
- if (opts.onChange)
431
- opts.onChange(change);
432
- lastSeq = Math.max(lastSeq, change.seq);
433
- }).catch(e => log('Live change body fetch error', e));
434
- }
435
- else {
436
- if (opts.onChange)
437
- opts.onChange(change);
438
- lastSeq = Math.max(lastSeq, change.seq);
439
- }
578
+ // Same tombstone rule as the initial pass: a `null` body
579
+ // makes a filtered replication drop the deletion.
580
+ if (bodies)
581
+ change.doc = bodies[id];
582
+ if (opts.onChange)
583
+ opts.onChange(change);
584
+ lastSeq = Math.max(lastSeq, entry.seq);
440
585
  }
586
+ };
587
+ if (opts.include_docs) {
588
+ // Bodies for the whole batch first, then emit in order -
589
+ // per-row fetches resolved in whatever order the network
590
+ // chose, which reordered emissions and raced the gate.
591
+ loadChangeBodies(batch).then(emit)
592
+ .catch(e => log('Live change body fetch error', e));
593
+ }
594
+ else {
595
+ emit(null);
441
596
  }
442
597
  };
443
598
  cancelLive = db.onChange(liveListener);
444
599
  }
600
+ /**
601
+ * Fetches the document bodies for a whole batch in one pass.
602
+ *
603
+ * `getMulti` groups ids by the file that holds them, so a batch drawn from a
604
+ * single change-log file costs one download rather than one per document -
605
+ * which is what a serial `get()` per change was costing. This path is only
606
+ * ever taken because something asked for `include_docs`, and the one thing
607
+ * that always asks is a filtered replication: `pouchdb-replication` forces
608
+ * `include_docs: true` so it can run its filter over `change.doc`.
609
+ *
610
+ * Deleted documents have no body to fetch, and a `null` there is not
611
+ * harmless: `pouchdb-replication`'s `filterChange` substitutes `{}` for a
612
+ * missing `change.doc`, and a filter given `{}` has no id to judge, so it
613
+ * drops the change. A filtered replication would then never propagate a
614
+ * deletion. Emit the tombstone the rest of PouchDB expects instead.
615
+ */
616
+ async function loadChangeBodies(batch) {
617
+ const bodies = {};
618
+ if (!batch.length)
619
+ return bodies;
620
+ const tombstone = (row) => ({
621
+ _id: row.id,
622
+ _rev: row.entry.rev,
623
+ _deleted: true
624
+ });
625
+ const live = batch.filter(row => !row.entry.deleted);
626
+ for (const row of batch) {
627
+ if (row.entry.deleted)
628
+ bodies[row.id] = tombstone(row);
629
+ }
630
+ if (!live.length)
631
+ return bodies;
632
+ try {
633
+ const docs = await db.getMulti(live.map(row => row.id));
634
+ live.forEach((row, index) => {
635
+ bodies[row.id] = docs[index] || tombstone(row);
636
+ });
637
+ }
638
+ catch (e) {
639
+ log('_changes include_docs error', e);
640
+ for (const row of live)
641
+ bodies[row.id] = tombstone(row);
642
+ }
643
+ return bodies;
644
+ }
445
645
  // Process initial changes
446
646
  async function processChangesAsync() {
447
647
  log('_changes processing since', since, 'limit', limit, 'live', !!opts.live);
448
648
  const keys = await db.getIndexKeys();
449
- let processed = 0;
450
- for (const id of keys) {
451
- if (complete || processed >= limit)
649
+ // The index is a plain object keyed by document id, so iterating it
650
+ // yields insertion order, not sequence order. Replication checkpoints on
651
+ // the highest seq in each batch and `limit` makes every batch a partial
652
+ // one, so an unordered batch can checkpoint past a change it never
653
+ // emitted - and that change is then never replicated again. Order by seq
654
+ // before the batch is cut.
655
+ const pending = keys
656
+ .filter((id) => !id.startsWith('_local/'))
657
+ .map((id) => ({ id, entry: db.getIndexEntry(id) }))
658
+ .filter((row) => Boolean(row.entry) && row.entry.seq > since)
659
+ .sort((a, b) => a.entry.seq - b.entry.seq);
660
+ // `descending` reverses the order the changes come back in. Its `since`
661
+ // semantics are CouchDB's (walk down from the given seq) and are not
662
+ // implemented here; replication never asks for it.
663
+ if (opts.descending)
664
+ pending.reverse();
665
+ // Cut the batch before fetching anything: `include_docs` costs a download
666
+ // per *file*, and there is no reason to pay it for changes beyond `limit`.
667
+ const batch = pending.slice(0, limit);
668
+ const bodies = opts.include_docs
669
+ ? await loadChangeBodies(batch)
670
+ : null;
671
+ for (const { id, entry } of batch) {
672
+ if (complete)
452
673
  break;
453
- if (id.startsWith('_local/'))
454
- continue;
455
- const entry = db.getIndexEntry(id);
456
- if (!entry || entry.seq <= since)
457
- continue;
458
674
  const change = {
459
675
  id: id,
460
676
  seq: entry.seq,
461
- changes: [{ rev: entry.rev }]
677
+ changes: buildChangesList(entry, opts.style)
462
678
  };
463
- if (opts.include_docs) {
464
- try {
465
- change.doc = await db.get(id);
466
- }
467
- catch (e) {
468
- log('_changes include_docs error', e);
469
- }
470
- }
679
+ if (bodies)
680
+ change.doc = bodies[id];
471
681
  if (opts.onChange)
472
682
  opts.onChange(change);
473
683
  if (returnDocs)
474
684
  results.push(change);
475
- processed++;
476
685
  lastSeq = Math.max(lastSeq, entry.seq);
477
686
  }
478
687
  // ✅ Call opts.complete() ONLY for non-live modes
@@ -521,14 +730,11 @@ function GoogleDriveAdapter(PouchDB) {
521
730
  if (!entry) {
522
731
  return callback({ status: 404, error: true, name: 'not_found', message: 'missing' });
523
732
  }
524
- // Return a minimal tree based on the known winning revision
525
- const revNum = parseInt(entry.rev.split('-')[0], 10);
526
- const revHash = entry.rev.split('-')[1];
527
- const revTree = [{
528
- pos: revNum,
529
- ids: [revHash, { status: 'available' }, []]
530
- }];
531
- callback(null, revTree);
733
+ // The real tree, as maintained by _bulkDocs's merge() calls - not a
734
+ // synthesized single-leaf stand-in. This is what makes PouchDB core's
735
+ // default revsDiff (which walks this) actually see conflicting revisions
736
+ // instead of only ever knowing about "whatever's currently winning".
737
+ callback(null, JSON.parse(entry.tree));
532
738
  };
533
739
  api._close = function (callback) {
534
740
  db.stopPolling();
package/lib/client.d.ts CHANGED
@@ -25,11 +25,13 @@ export declare class GoogleDriveClient {
25
25
  id: string;
26
26
  etag: string;
27
27
  modifiedTime: string;
28
+ md5Checksum?: string;
28
29
  }>;
29
30
  updateFile(fileId: string, content: string, expectedEtag?: string): Promise<{
30
31
  id: string;
31
32
  etag: string;
32
33
  modifiedTime: string;
34
+ md5Checksum?: string;
33
35
  }>;
34
36
  private extractEtag;
35
37
  deleteFile(fileId: string): Promise<void>;
package/lib/client.js CHANGED
@@ -133,7 +133,7 @@ class GoogleDriveClient {
133
133
  };
134
134
  }
135
135
  const multipartBody = this.buildMultipart(metadata, content, mimeType);
136
- const res = await this.fetch(`${this.uploadUrl}?uploadType=multipart&fields=id,modifiedTime`, {
136
+ const res = await this.fetch(`${this.uploadUrl}?uploadType=multipart&fields=id,modifiedTime,md5Checksum`, {
137
137
  method: 'POST',
138
138
  headers: {
139
139
  'Content-Type': `multipart/related; boundary=${multipartBody.boundary}`
@@ -144,13 +144,14 @@ class GoogleDriveClient {
144
144
  return {
145
145
  id: data.id,
146
146
  etag: this.extractEtag(res, data),
147
- modifiedTime: data.modifiedTime || res.headers.get('Last-Modified') || ''
147
+ modifiedTime: data.modifiedTime || res.headers.get('Last-Modified') || '',
148
+ md5Checksum: data.md5Checksum
148
149
  };
149
150
  }
150
151
  async updateFile(fileId, content, expectedEtag) {
151
152
  // Update content (media) usually, but sometimes meta?
152
153
  // In our usage (saveMeta), we update body.
153
- const res = await this.fetch(`${this.uploadUrl}/${fileId}?uploadType=media&fields=id,modifiedTime`, {
154
+ const res = await this.fetch(`${this.uploadUrl}/${fileId}?uploadType=media&fields=id,modifiedTime,md5Checksum`, {
154
155
  method: 'PATCH',
155
156
  headers: expectedEtag ? { 'If-Match': `"${expectedEtag}"`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' },
156
157
  body: content
@@ -159,7 +160,8 @@ class GoogleDriveClient {
159
160
  return {
160
161
  id: data.id,
161
162
  etag: this.extractEtag(res, data),
162
- modifiedTime: data.modifiedTime || res.headers.get('Last-Modified') || ''
163
+ modifiedTime: data.modifiedTime || res.headers.get('Last-Modified') || '',
164
+ md5Checksum: data.md5Checksum
163
165
  };
164
166
  }
165
167
  extractEtag(res, data) {