@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/CHANGELOG.md +212 -0
- package/LICENSE +10 -0
- package/README.md +52 -1
- package/lib/adapter.js +339 -133
- package/lib/client.d.ts +2 -0
- package/lib/client.js +6 -4
- package/lib/drive.d.ts +139 -2
- package/lib/drive.js +739 -98
- package/lib/types.d.ts +34 -4
- package/package.json +7 -4
- package/.env.example +0 -9
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
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
574
|
+
id,
|
|
424
575
|
seq: entry.seq,
|
|
425
|
-
changes:
|
|
576
|
+
changes: buildChangesList(entry, opts.style)
|
|
426
577
|
};
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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:
|
|
677
|
+
changes: buildChangesList(entry, opts.style)
|
|
462
678
|
};
|
|
463
|
-
if (
|
|
464
|
-
|
|
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
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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) {
|