@docstack/pouchdb-adapter-googledrive 0.1.4 → 0.1.6

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.
@@ -193,74 +262,72 @@ function GoogleDriveAdapter(PouchDB) {
193
262
  opts = {};
194
263
  }
195
264
  const promise = (async () => {
196
- const keys = await db.getIndexKeys();
197
- const total = keys.length; // Total keys (including deleted?)
198
- let startIndex = opts.skip || 0;
199
- let limit = typeof opts.limit === 'number' ? opts.limit : keys.length;
200
- let filteredKeys = keys;
201
- if (opts.startkey)
202
- filteredKeys = filteredKeys.filter(k => k >= opts.startkey);
203
- if (opts.endkey)
204
- filteredKeys = filteredKeys.filter(k => k <= opts.endkey);
205
- if (opts.key)
206
- filteredKeys = filteredKeys.filter(k => k === opts.key);
207
- if (opts.keys)
208
- filteredKeys = opts.keys;
209
- filteredKeys.sort();
210
- if (opts.descending)
211
- filteredKeys.reverse();
212
- const sliced = filteredKeys.slice(startIndex, startIndex + limit);
213
- // Fetch actual docs if needed
214
- if (opts.include_docs) {
215
- const docs = await db.getMulti(sliced);
216
- const rows = sliced.map((id, i) => {
217
- const doc = docs[i];
218
- const entry = db.getIndexEntry(id);
219
- if (!doc && (!entry || entry.deleted))
220
- return { key: id, error: 'not_found' };
221
- if (!doc && entry) {
222
- // This implies fetch failed but exists in index? Or null result.
223
- return { key: id, error: 'not_found' };
224
- }
225
- const row = {
226
- id,
227
- key: id,
228
- value: { rev: entry?.rev || doc._rev }
229
- };
230
- row.doc = doc;
231
- return row;
232
- });
233
- const result = {
234
- total_rows: total,
235
- offset: startIndex,
236
- rows: rows.filter(r => !r.error || !opts.keys) // Filter errored unless specifically asked via keys?
237
- // CouchDB usually returns error row if distinct keys requested.
238
- };
239
- if (opts.update_seq)
240
- result.update_seq = db.seq;
241
- return result;
265
+ // _local/* docs live in a separate namespace and never appear in allDocs,
266
+ // same as CouchDB - they aren't part of the B-tree of regular documents.
267
+ const indexKeys = (await db.getIndexKeys()).filter(k => !k.startsWith('_local/'));
268
+ // total_rows reflects live (non-deleted) documents, matching db.info().doc_count -
269
+ // deleted entries stay in the index forever but were never meant to count here.
270
+ let total = 0;
271
+ for (const k of indexKeys) {
272
+ const entry = db.getIndexEntry(k);
273
+ if (entry && !entry.deleted)
274
+ total++;
275
+ }
276
+ const startIndex = opts.skip || 0;
277
+ let candidateKeys;
278
+ if (opts.keys) {
279
+ // Explicit keys: return exactly these, in the given order. Deleted docs are
280
+ // included here (flagged via value.deleted), not treated as errors - only
281
+ // keys absent from the index entirely are 'not_found'.
282
+ candidateKeys = opts.keys;
242
283
  }
243
284
  else {
244
- // Index only (Fast!)
245
- const rows = sliced.map(id => {
246
- const entry = db.getIndexEntry(id);
247
- if (!entry || entry.deleted)
248
- return { key: id, error: 'not_found' };
249
- return {
250
- id,
251
- key: id,
252
- value: { rev: entry.rev }
253
- };
285
+ // Default listing: deleted docs never appear, same as a real CouchDB/PouchDB
286
+ // allDocs() with no keys specified.
287
+ candidateKeys = indexKeys.filter(k => {
288
+ const entry = db.getIndexEntry(k);
289
+ return entry && !entry.deleted;
254
290
  });
255
- const result = {
256
- total_rows: total,
257
- offset: startIndex,
258
- rows
259
- };
260
- if (opts.update_seq)
261
- result.update_seq = db.seq;
262
- return result;
291
+ if (opts.startkey)
292
+ candidateKeys = candidateKeys.filter(k => k >= opts.startkey);
293
+ if (opts.endkey)
294
+ candidateKeys = candidateKeys.filter(k => k <= opts.endkey);
295
+ if (opts.key)
296
+ candidateKeys = candidateKeys.filter(k => k === opts.key);
297
+ candidateKeys.sort();
298
+ if (opts.descending)
299
+ candidateKeys.reverse();
263
300
  }
301
+ const limit = typeof opts.limit === 'number' ? opts.limit : candidateKeys.length;
302
+ const sliced = opts.keys ? candidateKeys : candidateKeys.slice(startIndex, startIndex + limit);
303
+ const docs = opts.include_docs ? await db.getMulti(sliced) : null;
304
+ const rows = sliced.map((id, i) => {
305
+ const entry = db.getIndexEntry(id);
306
+ if (!entry)
307
+ return { key: id, error: 'not_found' };
308
+ if (entry.deleted) {
309
+ const row = { id, key: id, value: { rev: entry.rev, deleted: true } };
310
+ if (opts.include_docs)
311
+ row.doc = null;
312
+ return row;
313
+ }
314
+ const row = { id, key: id, value: { rev: entry.rev } };
315
+ if (opts.include_docs) {
316
+ const doc = docs[i];
317
+ if (!doc)
318
+ return { key: id, error: 'not_found' };
319
+ row.doc = doc;
320
+ }
321
+ return row;
322
+ });
323
+ const result = {
324
+ total_rows: total,
325
+ offset: startIndex,
326
+ rows
327
+ };
328
+ if (opts.update_seq)
329
+ result.update_seq = db.seq;
330
+ return result;
264
331
  })();
265
332
  if (callback) {
266
333
  promise.then(res => callback(null, res)).catch(err => callback(err));
@@ -274,32 +341,35 @@ function GoogleDriveAdapter(PouchDB) {
274
341
  api._bulkGet = function (opts, callback) {
275
342
  const docs = opts.docs;
276
343
  const ids = docs.map((d) => d.id);
277
- return db.getMulti(ids).then(results => {
278
- const response = {
279
- results: ids.map((id, i) => {
280
- const doc = results[i];
281
- const requestedRev = docs[i].rev;
282
- const entry = db.getIndexEntry(id);
283
- let docResult;
284
- if (!doc || (requestedRev && doc._rev !== requestedRev)) {
285
- docResult = {
286
- error: {
287
- status: 404,
288
- error: true,
289
- name: 'not_found',
290
- message: 'missing'
291
- }
292
- };
293
- }
294
- else {
295
- docResult = { ok: doc };
296
- }
297
- return {
298
- id,
299
- 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
+ }
300
365
  };
301
- })
302
- };
366
+ }
367
+ else {
368
+ docResult = { ok: doc };
369
+ }
370
+ return { id, docs: [docResult] };
371
+ }));
372
+ const response = { results: rows };
303
373
  if (callback)
304
374
  callback(null, response);
305
375
  return response;
@@ -310,76 +380,145 @@ function GoogleDriveAdapter(PouchDB) {
310
380
  });
311
381
  };
312
382
  api.bulkGet = api._bulkGet;
313
- // 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.
314
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;
315
408
  const docs = req.docs;
316
- const results = [];
317
- const newEdits = opts.new_edits !== false;
409
+ const results = new Array(docs.length);
410
+ const revsLimit = adapterOpts.revs_limit || 1000;
318
411
  const changes = [];
319
- // We need to validate revisions against Index
320
- // This does NOT require fetching bodies usually
321
- for (const doc of docs) {
412
+ for (let i = 0; i < docs.length; i++) {
413
+ const doc = docs[i];
322
414
  const id = doc._id;
323
- const seq = db.getNextSeq() + changes.length;
324
415
  const entry = db.getIndexEntry(id);
325
- if (doc._deleted) {
326
- if (!entry || entry.deleted) {
327
- results.push({
328
- ok: false,
329
- id,
330
- error: 'not_found',
331
- reason: 'missing'
332
- });
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' };
333
433
  continue;
334
434
  }
335
- // Check rev
336
- const oldRev = entry.rev || '0-0'; // Index has latest
337
- // If mismatch? PouchDB handles conflict logic before calling us sometimes?
338
- // But we should verify.
339
- // If doc._rev matches entry.rev, we are good.
340
- const revNum = parseInt(oldRev.split('-')[0], 10) + 1;
341
- const newRev = revNum + '-' + generateRevId();
342
- changes.push({
343
- seq,
344
- id,
345
- rev: newRev,
346
- deleted: true,
347
- timestamp: Date.now()
348
- });
349
- 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
350
442
  }
351
443
  else {
352
- let newRev;
353
- let savedDoc;
354
- if (newEdits) {
355
- const oldRev = entry?.rev || '0-0';
356
- const revNum = parseInt(oldRev.split('-')[0], 10) + 1;
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
- }
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;
368
462
  }
463
+ savedDocInfo = reparsed;
464
+ deleted = savedDocInfo.metadata.deleted !== undefined ? savedDocInfo.metadata.deleted : false;
369
465
  }
370
- else {
371
- newRev = doc._rev;
372
- 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;
474
+ }
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 };
373
499
  }
374
- changes.push({
375
- seq,
376
- id,
377
- rev: newRev,
378
- doc: savedDoc,
379
- timestamp: Date.now()
380
- });
381
- results.push({ ok: true, id, rev: newRev });
382
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;
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 };
383
522
  }
384
523
  log('_bulkDocs flushing', changes.length, 'changes');
385
524
  // Append changes to log
@@ -421,11 +560,13 @@ function GoogleDriveAdapter(PouchDB) {
421
560
  const change = {
422
561
  id: id,
423
562
  seq: entry.seq,
424
- changes: [{ rev: entry.rev }]
563
+ changes: buildChangesList(entry, opts.style)
425
564
  };
426
565
  if (opts.include_docs) {
427
- db.get(id).then(doc => {
428
- change.doc = doc;
566
+ // Same tombstone rule as the initial pass: a `null` body
567
+ // makes a filtered replication drop the deletion.
568
+ loadChangeBodies([{ id, entry }]).then(bodies => {
569
+ change.doc = bodies[id];
429
570
  if (opts.onChange)
430
571
  opts.onChange(change);
431
572
  lastSeq = Math.max(lastSeq, change.seq);
@@ -441,37 +582,91 @@ function GoogleDriveAdapter(PouchDB) {
441
582
  };
442
583
  cancelLive = db.onChange(liveListener);
443
584
  }
585
+ /**
586
+ * Fetches the document bodies for a whole batch in one pass.
587
+ *
588
+ * `getMulti` groups ids by the file that holds them, so a batch drawn from a
589
+ * single change-log file costs one download rather than one per document -
590
+ * which is what a serial `get()` per change was costing. This path is only
591
+ * ever taken because something asked for `include_docs`, and the one thing
592
+ * that always asks is a filtered replication: `pouchdb-replication` forces
593
+ * `include_docs: true` so it can run its filter over `change.doc`.
594
+ *
595
+ * Deleted documents have no body to fetch, and a `null` there is not
596
+ * harmless: `pouchdb-replication`'s `filterChange` substitutes `{}` for a
597
+ * missing `change.doc`, and a filter given `{}` has no id to judge, so it
598
+ * drops the change. A filtered replication would then never propagate a
599
+ * deletion. Emit the tombstone the rest of PouchDB expects instead.
600
+ */
601
+ async function loadChangeBodies(batch) {
602
+ const bodies = {};
603
+ if (!batch.length)
604
+ return bodies;
605
+ const tombstone = (row) => ({
606
+ _id: row.id,
607
+ _rev: row.entry.rev,
608
+ _deleted: true
609
+ });
610
+ const live = batch.filter(row => !row.entry.deleted);
611
+ for (const row of batch) {
612
+ if (row.entry.deleted)
613
+ bodies[row.id] = tombstone(row);
614
+ }
615
+ if (!live.length)
616
+ return bodies;
617
+ try {
618
+ const docs = await db.getMulti(live.map(row => row.id));
619
+ live.forEach((row, index) => {
620
+ bodies[row.id] = docs[index] || tombstone(row);
621
+ });
622
+ }
623
+ catch (e) {
624
+ log('_changes include_docs error', e);
625
+ for (const row of live)
626
+ bodies[row.id] = tombstone(row);
627
+ }
628
+ return bodies;
629
+ }
444
630
  // Process initial changes
445
631
  async function processChangesAsync() {
446
632
  log('_changes processing since', since, 'limit', limit, 'live', !!opts.live);
447
633
  const keys = await db.getIndexKeys();
448
- let processed = 0;
449
- for (const id of keys) {
450
- if (complete || processed >= limit)
634
+ // The index is a plain object keyed by document id, so iterating it
635
+ // yields insertion order, not sequence order. Replication checkpoints on
636
+ // the highest seq in each batch and `limit` makes every batch a partial
637
+ // one, so an unordered batch can checkpoint past a change it never
638
+ // emitted - and that change is then never replicated again. Order by seq
639
+ // before the batch is cut.
640
+ const pending = keys
641
+ .filter((id) => !id.startsWith('_local/'))
642
+ .map((id) => ({ id, entry: db.getIndexEntry(id) }))
643
+ .filter((row) => Boolean(row.entry) && row.entry.seq > since)
644
+ .sort((a, b) => a.entry.seq - b.entry.seq);
645
+ // `descending` reverses the order the changes come back in. Its `since`
646
+ // semantics are CouchDB's (walk down from the given seq) and are not
647
+ // implemented here; replication never asks for it.
648
+ if (opts.descending)
649
+ pending.reverse();
650
+ // Cut the batch before fetching anything: `include_docs` costs a download
651
+ // per *file*, and there is no reason to pay it for changes beyond `limit`.
652
+ const batch = pending.slice(0, limit);
653
+ const bodies = opts.include_docs
654
+ ? await loadChangeBodies(batch)
655
+ : null;
656
+ for (const { id, entry } of batch) {
657
+ if (complete)
451
658
  break;
452
- if (id.startsWith('_local/'))
453
- continue;
454
- const entry = db.getIndexEntry(id);
455
- if (!entry || entry.seq <= since)
456
- continue;
457
659
  const change = {
458
660
  id: id,
459
661
  seq: entry.seq,
460
- changes: [{ rev: entry.rev }]
662
+ changes: buildChangesList(entry, opts.style)
461
663
  };
462
- if (opts.include_docs) {
463
- try {
464
- change.doc = await db.get(id);
465
- }
466
- catch (e) {
467
- log('_changes include_docs error', e);
468
- }
469
- }
664
+ if (bodies)
665
+ change.doc = bodies[id];
470
666
  if (opts.onChange)
471
667
  opts.onChange(change);
472
668
  if (returnDocs)
473
669
  results.push(change);
474
- processed++;
475
670
  lastSeq = Math.max(lastSeq, entry.seq);
476
671
  }
477
672
  // ✅ Call opts.complete() ONLY for non-live modes
@@ -498,7 +693,11 @@ function GoogleDriveAdapter(PouchDB) {
498
693
  };
499
694
  };
500
695
  // Manual compaction trigger
501
- api._compact = function (callback) {
696
+ api._compact = function (opts, callback) {
697
+ if (typeof opts === 'function') {
698
+ callback = opts;
699
+ opts = {};
700
+ }
502
701
  const promise = db.compact().then(() => {
503
702
  const result = { ok: true };
504
703
  if (callback)
@@ -516,14 +715,11 @@ function GoogleDriveAdapter(PouchDB) {
516
715
  if (!entry) {
517
716
  return callback({ status: 404, error: true, name: 'not_found', message: 'missing' });
518
717
  }
519
- // Return a minimal tree based on the known winning revision
520
- const revNum = parseInt(entry.rev.split('-')[0], 10);
521
- const revHash = entry.rev.split('-')[1];
522
- const revTree = [{
523
- pos: revNum,
524
- ids: [revHash, { status: 'available' }, []]
525
- }];
526
- callback(null, revTree);
718
+ // The real tree, as maintained by _bulkDocs's merge() calls - not a
719
+ // synthesized single-leaf stand-in. This is what makes PouchDB core's
720
+ // default revsDiff (which walks this) actually see conflicting revisions
721
+ // instead of only ever knowing about "whatever's currently winning".
722
+ callback(null, JSON.parse(entry.tree));
527
723
  };
528
724
  api._close = function (callback) {
529
725
  db.stopPolling();