@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/drive.js CHANGED
@@ -1,11 +1,62 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DriveHandler = void 0;
3
+ exports.DriveHandler = exports.SEQ_SLOTS = void 0;
4
+ exports.writerSlotFor = writerSlotFor;
5
+ exports.writerIdFromLogName = writerIdFromLogName;
4
6
  const cache_1 = require("./cache");
5
7
  const client_1 = require("./client");
6
8
  const DEFAULT_COMPACTION_THRESHOLD = 100; // entries
7
9
  const DEFAULT_SIZE_THRESHOLD = 1024 * 1024; // 1MB
8
10
  const DEFAULT_CACHE_SIZE = 1000; // Number of docs
11
+ const META_COMMIT_RETRIES = 6; // read-modify-write attempts per _meta.json commit
12
+ const RETIRED_LOG_HISTORY = 500; // tombstones kept for change logs compaction deleted
13
+ /**
14
+ * Sequence numbers are `tick * SEQ_SLOTS + writerSlot`.
15
+ *
16
+ * Writers mint them blind to one another - there is no lock and no compare-and-swap -
17
+ * so two clients reading the same counter will always be able to derive the same next
18
+ * tick. Reserving the low digits for a per-writer slot means they can share a tick and
19
+ * still never share a *sequence number*, which is the part that matters: `_changes`
20
+ * filters on `seq > since`, so a second document sharing a checkpointed sequence
21
+ * number is never emitted to a replication target again.
22
+ *
23
+ * A million slots keeps the chance that two concurrent writers hash to the same one
24
+ * near 1 in 20,000 for a ten-client fleet, and leaves room for 9e9 ticks inside
25
+ * Number.MAX_SAFE_INTEGER.
26
+ */
27
+ exports.SEQ_SLOTS = 1000000;
28
+ /** Stable slot for a writer id. FNV-1a, folded into the slot space. */
29
+ function writerSlotFor(writerId) {
30
+ let h = 2166136261;
31
+ for (let i = 0; i < writerId.length; i++) {
32
+ h ^= writerId.charCodeAt(i);
33
+ h = Math.imul(h, 16777619);
34
+ }
35
+ return Math.abs(h) % exports.SEQ_SLOTS;
36
+ }
37
+ function newWriterId() {
38
+ return Math.random().toString(36).substring(2, 10);
39
+ }
40
+ /** The writer id embedded in a change-log filename, if the name carries one.
41
+ * New format: changes-<seq>-<writerId>-<random>.ndjson (4 dash-parts).
42
+ * Old format: changes-<seq>-<random>.ndjson (3 parts) - no id to extract. */
43
+ function writerIdFromLogName(name) {
44
+ if (!name.startsWith('changes-') || !name.endsWith('.ndjson'))
45
+ return null;
46
+ const parts = name.slice(0, -'.ndjson'.length).split('-');
47
+ return parts.length === 4 ? parts[2] : null;
48
+ }
49
+ /** A trivial single-node pouchdb-merge tree for a bare rev string - used where a
50
+ * doc enters the index without going through _bulkDocs's real merge (local docs,
51
+ * legacy-snapshot migration, and the low-level DriveHandler.appendChange() API's
52
+ * raw callers). Correct as far as it goes (one leaf, no ancestry); these paths
53
+ * never participate in replication conflict resolution anyway. */
54
+ function synthesizeTree(rev, deleted) {
55
+ const dash = rev.indexOf('-');
56
+ const pos = dash >= 0 ? parseInt(rev.slice(0, dash), 10) : 0;
57
+ const hash = dash >= 0 ? rev.slice(dash + 1) : rev;
58
+ return JSON.stringify([{ pos, ids: [hash, { status: 'available', deleted: !!deleted }, []] }]);
59
+ }
9
60
  /**
10
61
  * DriveHandler - Lazy Loading Implementation
11
62
  *
@@ -33,6 +84,27 @@ class DriveHandler {
33
84
  this.metaMd5 = null;
34
85
  this.metaModifiedTime = null;
35
86
  this.localDocsEtag = null;
87
+ this.metaFileId = null;
88
+ /** Identifies this handler among the writers sharing a folder. Change-log
89
+ * filenames carry it, so two writers can never produce the same name and an
90
+ * orphaned log can be traced back to whoever wrote it. Not readonly: if the
91
+ * folder shows another writer whose id hashes to our sequence slot, we re-roll
92
+ * to a free one before minting anything (see rerollIfSlotContested). */
93
+ this.writerId = newWriterId();
94
+ /** Change-log file ids this handler wrote, minus any a compaction has retired.
95
+ * Drive API v3 has no compare-and-swap (ETags were dropped), so another
96
+ * writer's read-modify-write of _meta.json can drop a log id that landed in
97
+ * between. Anything still in here but missing from the remote changeLogIds was
98
+ * dropped that way and gets put back - see reconcileOwnLogs(). */
99
+ this.ownLogIds = new Set();
100
+ /** This writer's reservation in the low digits of every sequence number it mints.
101
+ * Derived from writerId; changes only when writerId is re-rolled off a
102
+ * contested slot. */
103
+ this.writerSlot = writerSlotFor(this.writerId);
104
+ /** Serializes this handler's own _meta.json read-modify-write cycles. Says
105
+ * nothing about other clients - that is what commitMeta's verify pass is for -
106
+ * but stops one handler racing itself when several writes are in flight. */
107
+ this.metaLock = Promise.resolve();
36
108
  // In-Memory Index: ID -> Metadata/Pointer
37
109
  this.index = {};
38
110
  this.pendingChanges = [];
@@ -97,17 +169,21 @@ class DriveHandler {
97
169
  this.folderId = await this.findOrCreateFolder();
98
170
  this.log('Retrieved folder', { folderId: this.folderId });
99
171
  }
100
- const metaFile = await this.findFile('_meta.json');
101
- if (metaFile) {
102
- this.log('Retrieved meta file', { fileId: metaFile.fileId });
103
- this.meta = await this.downloadJson(metaFile.fileId, true); // No cache for meta
104
- this.metaEtag = metaFile.etag || null;
105
- this.metaMd5 = metaFile.md5Checksum || null;
106
- this.metaModifiedTime = metaFile.modifiedTime || null;
172
+ const current = await this.readRemoteMeta();
173
+ if (current) {
174
+ this.log('Retrieved meta file', { fileId: current.pointer.fileId });
175
+ this.adoptMeta(current.meta, current.pointer);
107
176
  }
108
177
  else {
109
178
  this.log('Meta file not found, creating new');
110
- await this.saveMeta(this.meta);
179
+ await this.ensureMetaFile();
180
+ }
181
+ // A change log of ours that has fallen out of changeLogIds without a
182
+ // compaction retiring it was dropped by another writer's
183
+ // read-modify-write. Put it back before replaying, so this load sees
184
+ // its own writes - and so the next reader does too.
185
+ if (this.hasOrphanedOwnLogs(this.meta)) {
186
+ await this.commitMeta((latest, repaired) => repaired ? latest : null);
111
187
  }
112
188
  if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
113
189
  this.log('Snapshot index changed, loading index', {
@@ -147,7 +223,31 @@ class DriveHandler {
147
223
  }
148
224
  // 2. Replay NEW Change Logs (Metadata only updates)
149
225
  this.log('Replaying change logs');
150
- const pendingLogs = this.meta.changeLogIds.filter(id => !this.processedLogIds.has(id));
226
+ const retired = new Set(this.meta.retiredLogIds || []);
227
+ // Take the union of what the metadata references and what is actually
228
+ // in the folder. A log missing from changeLogIds but present in the
229
+ // folder was orphaned by a lost metadata update; a log referenced but
230
+ // absent was deleted after being referenced. Both are survivable if
231
+ // the folder gets the last word.
232
+ let discovered = [];
233
+ try {
234
+ const listed = await this.listChangeLogs();
235
+ // The same listing shows every writer's id - the moment to notice
236
+ // someone else is on our sequence slot and move off it.
237
+ this.rerollIfSlotContested(listed.map(f => f.name));
238
+ const referenced = new Set(this.meta.changeLogIds);
239
+ discovered = listed.map(f => f.id).filter(id => !referenced.has(id) && !retired.has(id));
240
+ if (discovered.length > 0) {
241
+ this.log('Found change logs the metadata does not reference', discovered);
242
+ }
243
+ }
244
+ catch (e) {
245
+ // Listing is an optimisation over the metadata, never a
246
+ // prerequisite - a failure here must not fail the load.
247
+ this.log('Failed to list change logs, falling back to metadata only', e);
248
+ }
249
+ const pendingLogs = [...this.meta.changeLogIds, ...discovered]
250
+ .filter(id => !this.processedLogIds.has(id) && !retired.has(id));
151
251
  if (pendingLogs.length > 0) {
152
252
  this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
153
253
  const logResults = await Promise.all(pendingLogs.map(async (id) => {
@@ -160,6 +260,18 @@ class DriveHandler {
160
260
  return { id, changes: null };
161
261
  }
162
262
  }));
263
+ // Replay in sequence order, not in the order the ids happened to
264
+ // be listed. Two clients can hold different changeLogIds orderings
265
+ // for the same folder once merges are in play, and updateIndex
266
+ // takes the last write for a document - so insertion order meant
267
+ // two readers of one folder could disagree about the winner.
268
+ logResults.sort((a, b) => {
269
+ const seqOf = (r) => r.changes && r.changes.length ? (Array.isArray(r.changes) ? r.changes[0].seq : r.changes.seq) : Number.MAX_SAFE_INTEGER;
270
+ const sa = seqOf(a), sb = seqOf(b);
271
+ if (sa !== sb)
272
+ return sa - sb;
273
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
274
+ });
163
275
  const foundNew = {};
164
276
  for (const { id, changes } of logResults) {
165
277
  if (!changes) {
@@ -190,6 +302,19 @@ class DriveHandler {
190
302
  // 2. Replay NEW Change Logs (Metadata only updates)
191
303
  // ... (previous logic for change logs)
192
304
  // (Already updated in previous turn, keep it)
305
+ // Put what we found back into the metadata, so the next reader
306
+ // does not have to rediscover it and compaction can see it. Any
307
+ // client repairs this, not only the writer that lost the race.
308
+ if (discovered.length > 0) {
309
+ await this.commitMeta((latest) => {
310
+ const referenced = new Set(latest.changeLogIds);
311
+ const stillRetired = new Set(latest.retiredLogIds || []);
312
+ const missing = discovered.filter(id => !referenced.has(id) && !stillRetired.has(id));
313
+ if (missing.length === 0)
314
+ return null;
315
+ return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
316
+ });
317
+ }
193
318
  // 2b. Load Local Documents Store (Pinned in meta)
194
319
  if (this.meta.localDocsId) {
195
320
  this.log('Loading local docs store', this.meta.localDocsId);
@@ -201,6 +326,7 @@ class DriveHandler {
201
326
  for (const [id, doc] of Object.entries(localDocsChunk.docs)) {
202
327
  this.log('Merging local doc', id);
203
328
  this.index[id] = {
329
+ tree: synthesizeTree(doc._rev),
204
330
  rev: doc._rev,
205
331
  seq: 0, // Local docs don't participate in shared sequences
206
332
  location: { fileId: this.meta.localDocsId }
@@ -213,7 +339,13 @@ class DriveHandler {
213
339
  this.log('Failed to load local docs store', e);
214
340
  }
215
341
  }
216
- // 3. Start Polling ...
342
+ // 3. Start Polling (if enabled). Idempotent on purpose: load()
343
+ // runs again on every catch-up and retry, and restarting the
344
+ // interval each time would push the next tick further out for
345
+ // exactly as long as the client stays busy.
346
+ if (this.options.pollingIntervalMs) {
347
+ this.startPolling(Number(this.options.pollingIntervalMs));
348
+ }
217
349
  }
218
350
  catch (e) {
219
351
  console.error('Failed to load database', e);
@@ -234,6 +366,7 @@ class DriveHandler {
234
366
  // We will cache them ALL now (since we downloaded them) and index them.
235
367
  for (const [id, doc] of Object.entries(snapshot.docs)) {
236
368
  this.index[id] = {
369
+ tree: synthesizeTree(doc._rev),
237
370
  rev: doc._rev,
238
371
  seq: snapshot.seq, // Approximate
239
372
  location: { fileId: 'LEGACY_MEMORY' } // Special validity marker
@@ -298,6 +431,40 @@ class DriveHandler {
298
431
  }
299
432
  return doc;
300
433
  }
434
+ /**
435
+ * Fetch a specific (possibly non-winning/conflicting) revision's body by its
436
+ * own tracked location - used by _get(opts.rev)/_bulkGet when the requested
437
+ * rev isn't the current winner. Deliberately doesn't touch docCache (keyed
438
+ * per-id, not per-rev - a conflict fetch is rare enough not to warrant a
439
+ * per-rev cache key) and doesn't force `_rev` to the index's winning rev the
440
+ * way get() does.
441
+ */
442
+ async getRevisionBody(id, rev, location) {
443
+ if (location.fileId === 'LEGACY_MEMORY' || location.fileId === '__SELF__')
444
+ return null;
445
+ const content = await this.fetchFile(location.fileId);
446
+ let doc = null;
447
+ if (Array.isArray(content)) {
448
+ const match = content.find((c) => c.id === id && c.rev === rev);
449
+ doc = match ? match.doc : null;
450
+ }
451
+ else if (content && content.conflicts && content.conflicts[id]) {
452
+ doc = content.conflicts[id][rev] ?? null;
453
+ }
454
+ else if (content && content.docs && content.docs[id]) {
455
+ // Winning body happened to be requested through this path (e.g. after
456
+ // a merge where the "conflict" turned out to be the new winner).
457
+ const candidate = content.docs[id];
458
+ if (candidate && candidate._rev === rev)
459
+ doc = candidate;
460
+ }
461
+ else if (content && content.id === id && content.rev === rev && content.doc) {
462
+ doc = content.doc;
463
+ }
464
+ if (doc)
465
+ doc._rev = rev;
466
+ return doc;
467
+ }
301
468
  /** Generic Download with Caching and Parsing */
302
469
  async fetchFile(fileId, skipCache = false) {
303
470
  if (!skipCache) {
@@ -463,18 +630,14 @@ class DriveHandler {
463
630
  }
464
631
  catch (err) {
465
632
  if (err.status === 412 || err.status === 409) {
466
- // Reload and RETRY
633
+ // Reload and RETRY. No resequencing here - tryAppendChanges
634
+ // stamps sequence numbers from a fresh read of _meta.json on
635
+ // every attempt of its own.
467
636
  await this.load();
468
637
  // Check conflicts against Index (Metadata sufficient)
469
638
  this.checkConflicts(remote);
470
- // Reseq
471
- let currentSeq = this.meta.seq;
472
- for (const change of remote) {
473
- currentSeq++;
474
- change.seq = currentSeq;
475
- }
476
639
  attemptNum++;
477
- await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
640
+ await this.backoff(attemptNum);
478
641
  continue;
479
642
  }
480
643
  throw err;
@@ -519,7 +682,8 @@ class DriveHandler {
519
682
  else {
520
683
  res = await this.client.createFile('_local_docs.json', [this.folderId], 'application/json', content);
521
684
  // Update Meta with new File ID
522
- await this.atomicUpdateMeta((latest) => ({ ...latest, localDocsId: res.id }));
685
+ const localRes = res;
686
+ await this.commitMeta((latest) => ({ ...latest, localDocsId: localRes.id }));
523
687
  }
524
688
  this.localDocsEtag = res.etag;
525
689
  // Update Index
@@ -551,17 +715,92 @@ class DriveHandler {
551
715
  }
552
716
  }
553
717
  async tryAppendChanges(changes) {
554
- // 1. Write Log File (Upload Data)
555
- const fileId = await this.writeChangeFile(changes);
556
- try {
557
- // 2. Prepare speculative meta update
558
- const nextMeta = { ...this.meta };
559
- nextMeta.changeLogIds = [...nextMeta.changeLogIds, fileId];
560
- nextMeta.seq = changes[changes.length - 1].seq;
561
- // 3. Commit Lock
562
- await this.saveMeta(nextMeta, this.metaEtag);
563
- // 4. Update Local State
564
- this.meta = nextMeta;
718
+ // Catching up on another writer's logs and failing to publish are separate
719
+ // failures, so they get separate budgets - a busy folder should not be able
720
+ // to spend the publish retries on catch-ups alone.
721
+ let catchUps = 0;
722
+ for (let attempt = 0; attempt < META_COMMIT_RETRIES;) {
723
+ // 1. Take the sequence range from what Drive holds right now, not from
724
+ // this handler's cached copy - that copy is only refreshed on load, so
725
+ // two clients allocating from their own stale copies is exactly how two
726
+ // change logs end up claiming the same sequence number.
727
+ const current = await this.readRemoteMeta();
728
+ // Catch up before writing. Another writer having appended since our last
729
+ // load means our index - and so the revision this write is built on - is
730
+ // out of date; replaying their logs first is what lets checkConflicts see
731
+ // the collision instead of us silently writing over them. (Previously
732
+ // this only happened when the metadata ETag came back 412, which Drive
733
+ // itself never does.)
734
+ if (current && this.hasUnprocessedLogs(current.meta)) {
735
+ if (++catchUps > META_COMMIT_RETRIES) {
736
+ throw new Error('Could not catch up with concurrent writers');
737
+ }
738
+ await this.load();
739
+ // Only the low-level appendChange() callers are checked here.
740
+ // _bulkDocs has already resolved revisions through pouchdb-merge and
741
+ // expresses a collision as a conflict branch, not as a thrown error;
742
+ // failing its whole batch would be the wrong answer.
743
+ this.checkConflicts(changes.filter(c => !c.nextIndexEntry));
744
+ continue;
745
+ }
746
+ // Take the tick from whatever counter is furthest along and stamp our own
747
+ // slot into it. Another writer working from the same counter lands on the
748
+ // same tick and a different sequence number, which is the point - there is
749
+ // no way to stop them sharing the tick, and no longer any need to.
750
+ const observedSeq = current ? current.meta.seq : this.meta.seq;
751
+ const baseTick = Math.floor(Math.max(observedSeq, this.meta.seq) / exports.SEQ_SLOTS);
752
+ changes.forEach((change, i) => {
753
+ change.seq = (baseTick + i + 1) * exports.SEQ_SLOTS + this.writerSlot;
754
+ });
755
+ const lastSeq = changes[changes.length - 1].seq;
756
+ // 2. Write Log File (Upload Data). `nextIndexEntry` (the merged tree, only
757
+ // needed transiently by updateIndex() below) is stripped first - it's
758
+ // already durably captured by the index/snapshot system, so writing it into
759
+ // every change-log line too would just redundantly bloat storage, growing
760
+ // with tree depth on every single write.
761
+ const fileId = await this.writeChangeFile(changes.map(({ nextIndexEntry, ...rest }) => rest));
762
+ // 3. Publish it. The modifier merges into whatever _meta.json holds at
763
+ // write time rather than replacing it, so a concurrent writer's logs
764
+ // survive; it abandons the commit if someone has taken the sequence
765
+ // range already stamped into our file, since those seqs would then
766
+ // collide with theirs.
767
+ let committed;
768
+ try {
769
+ committed = await this.commitMeta((latest) => ({
770
+ ...latest,
771
+ // Idempotent: commitMeta may run this more than once.
772
+ changeLogIds: latest.changeLogIds.includes(fileId)
773
+ ? latest.changeLogIds
774
+ : [...latest.changeLogIds, fileId],
775
+ // Never let the shared counter go backwards: another writer may
776
+ // have reached a higher tick while we were uploading, and
777
+ // rewinding it would hand our tick out a second time.
778
+ seq: Math.max(latest.seq, lastSeq)
779
+ }), { verify: (m) => m.changeLogIds.includes(fileId) });
780
+ }
781
+ catch (err) {
782
+ this.discardLog(fileId);
783
+ throw err;
784
+ }
785
+ if (!committed) {
786
+ // commitMeta ran out of attempts. Nothing references the log we just
787
+ // uploaded, so drop it and start over against fresh metadata. This no
788
+ // longer fires for a sequence collision - slots make those impossible -
789
+ // only for a metadata write that would not stick.
790
+ this.log('Change log not published, retrying', { fileId, attempt });
791
+ this.discardLog(fileId);
792
+ await this.backoff(attempt++);
793
+ continue;
794
+ }
795
+ // Published: from here on this log is ours to defend if another writer
796
+ // drops it from the metadata.
797
+ this.ownLogIds.add(fileId);
798
+ // 4. Update Local State. Marking our own log processed keeps a later
799
+ // load() from replaying it: the entries we apply here carry the merged
800
+ // rev tree computed by _bulkDocs, while the log lines on Drive have it
801
+ // stripped, so a replay would overwrite real ancestry with a synthesized
802
+ // single-node tree.
803
+ this.processedLogIds.add(fileId);
565
804
  const changedDocs = {};
566
805
  for (const change of changes) {
567
806
  this.updateIndex(change, fileId);
@@ -586,16 +825,42 @@ class DriveHandler {
586
825
  this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
587
826
  this.compact().catch(e => console.error('Compaction failed', e));
588
827
  }
828
+ return;
589
829
  }
590
- catch (err) {
591
- // Cleanup orphaned log file on metadata update failure
592
- this.client.deleteFile(fileId).catch(e => this.log('Failed to cleanup orphaned log', fileId, e));
593
- throw err;
594
- }
830
+ throw new Error(`Failed to publish change log after ${META_COMMIT_RETRIES} attempts`);
831
+ }
832
+ /** Forget a change log we uploaded but never managed to reference from
833
+ * _meta.json. Nothing points at it, so it is safe to remove. */
834
+ discardLog(fileId) {
835
+ this.ownLogIds.delete(fileId);
836
+ this.client.deleteFile(fileId).catch(e => this.log('Failed to clean up unreferenced log', fileId, e));
595
837
  }
596
838
  /** Update Index with a new change */
597
839
  updateIndex(change, fileId) {
840
+ if (change.nextIndexEntry) {
841
+ // adapter.ts already computed the full merged tree/winner/conflicts via
842
+ // pouchdb-merge - just substitute the '__SELF__' placeholder(s) with the
843
+ // fileId this batch actually landed in (unknown until upload completed).
844
+ const entry = { ...change.nextIndexEntry, seq: change.seq };
845
+ if (entry.location.fileId === '__SELF__')
846
+ entry.location = { fileId };
847
+ if (entry.conflictLocations) {
848
+ const resolved = {};
849
+ for (const rev of Object.keys(entry.conflictLocations)) {
850
+ const loc = entry.conflictLocations[rev];
851
+ resolved[rev] = loc.fileId === '__SELF__' ? { fileId } : loc;
852
+ }
853
+ entry.conflictLocations = resolved;
854
+ }
855
+ this.index[change.id] = entry;
856
+ return;
857
+ }
858
+ // Legacy path: no computed tree (the low-level DriveHandler.appendChange()
859
+ // API's raw callers - e.g. concurrency tests - never set nextIndexEntry).
860
+ // Synthesize a trivial single-node tree so the index entry shape stays
861
+ // consistent for anything reading `.tree` (e.g. _getRevisionTree).
598
862
  this.index[change.id] = {
863
+ tree: synthesizeTree(change.rev, change.deleted),
599
864
  rev: change.rev,
600
865
  seq: change.seq,
601
866
  deleted: !!change.deleted,
@@ -628,6 +893,27 @@ class DriveHandler {
628
893
  const snapshotSeq = this.meta.seq;
629
894
  const oldLogIds = [...this.meta.changeLogIds];
630
895
  const oldIndexId = this.meta.snapshotIndexId;
896
+ // Resolve which snapshot-data file(s) the OLD index points to, so we can
897
+ // delete them too once the new snapshot is safely in place. Without this,
898
+ // every compaction leaves its predecessor's data file behind as orphaned
899
+ // clutter (the index gets cleaned up, but the (often much larger) data
900
+ // file it pointed to never does).
901
+ let oldDataFileIds = [];
902
+ if (oldIndexId) {
903
+ try {
904
+ const oldIndex = await this.downloadJson(oldIndexId, true);
905
+ const ids = new Set();
906
+ for (const entry of Object.values(oldIndex.entries || {})) {
907
+ const fid = entry.location?.fileId;
908
+ if (fid && fid !== 'LEGACY_MEMORY')
909
+ ids.add(fid);
910
+ }
911
+ oldDataFileIds = [...ids];
912
+ }
913
+ catch (e) {
914
+ this.log('Failed to load old snapshot index for data-file cleanup', oldIndexId, e);
915
+ }
916
+ }
631
917
  // 1. Fetch ALL active documents
632
918
  // We need them to build the new large snapshot-data file
633
919
  // This is the one time we download everything if not cached.
@@ -649,6 +935,39 @@ class DriveHandler {
649
935
  this.log('Compaction ABORTED: Failed to fetch documents', missingDocs);
650
936
  throw new Error(`Compaction failed: missing ${missingDocs.length} documents. Aborting to prevent data loss.`);
651
937
  }
938
+ // 1b. Carry forward conflict-branch bodies too. Without this, the first
939
+ // compaction after a real conflict exists would silently drop the losing
940
+ // revision forever - snapshotData only ever had room for one body per id
941
+ // before conflict tracking existed. A body that fails to fetch here is
942
+ // logged and dropped rather than aborting the whole compaction (unlike
943
+ // missingDocs above): losing one stale conflict branch is far less bad
944
+ // than losing a doc's current data, and shouldn't block reclaiming space.
945
+ const conflictFetches = [];
946
+ for (const id of allIds) {
947
+ const conflicts = this.index[id].conflictLocations;
948
+ if (!conflicts)
949
+ continue;
950
+ for (const rev of Object.keys(conflicts)) {
951
+ conflictFetches.push({ id, rev, location: conflicts[rev] });
952
+ }
953
+ }
954
+ const carriedConflicts = {};
955
+ if (conflictFetches.length > 0) {
956
+ snapshotData.conflicts = {};
957
+ for (const { id, rev, location } of conflictFetches) {
958
+ const body = await this.getRevisionBody(id, rev, location);
959
+ if (!body) {
960
+ this.log('Compaction WARNING: could not fetch conflict body, dropping', id, rev);
961
+ continue;
962
+ }
963
+ if (!snapshotData.conflicts[id])
964
+ snapshotData.conflicts[id] = {};
965
+ snapshotData.conflicts[id][rev] = body;
966
+ if (!carriedConflicts[id])
967
+ carriedConflicts[id] = {};
968
+ carriedConflicts[id][rev] = true;
969
+ }
970
+ }
652
971
  // 2. Upload Data File
653
972
  const dataContent = JSON.stringify(snapshotData);
654
973
  const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
@@ -656,11 +975,19 @@ class DriveHandler {
656
975
  // 3. Create Index pointing to this Data File
657
976
  const newIndexEntries = {};
658
977
  for (const id of Object.keys(snapshotData.docs)) {
659
- newIndexEntries[id] = {
978
+ const entry = {
979
+ tree: this.index[id].tree,
660
980
  rev: this.index[id].rev,
661
981
  seq: this.index[id].seq,
662
982
  location: { fileId: dataFileId }
663
983
  };
984
+ if (carriedConflicts[id]) {
985
+ entry.conflictLocations = {};
986
+ for (const rev of Object.keys(carriedConflicts[id])) {
987
+ entry.conflictLocations[rev] = { fileId: dataFileId };
988
+ }
989
+ }
990
+ newIndexEntries[id] = entry;
664
991
  }
665
992
  const snapshotIndex = {
666
993
  entries: newIndexEntries,
@@ -670,51 +997,225 @@ class DriveHandler {
670
997
  const indexContent = JSON.stringify(snapshotIndex);
671
998
  const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
672
999
  const newIndexId = indexRes.id;
673
- // 4. Update Meta
1000
+ // 4. Update Meta. Only the logs THIS handler had already replayed are
1001
+ // retired; anything another writer appended in the meantime stays in
1002
+ // changeLogIds and gets replayed on top of the new snapshot.
674
1003
  let filesToDelete = [];
675
- await this.atomicUpdateMeta((latest) => {
1004
+ const committed = await this.commitMeta((latest) => {
676
1005
  const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
677
1006
  // Only delete files that were in oldLogIds but not in remainingLogs
678
1007
  filesToDelete = oldLogIds.filter(id => !remainingLogs.includes(id));
1008
+ const retired = [...(latest.retiredLogIds || []), ...filesToDelete];
679
1009
  return {
680
1010
  ...latest,
681
1011
  snapshotIndexId: newIndexId,
682
1012
  changeLogIds: remainingLogs,
1013
+ // Tombstones, so the writers of those logs don't put them back.
1014
+ retiredLogIds: [...new Set(retired)].slice(-RETIRED_LOG_HISTORY),
683
1015
  lastCompaction: Date.now()
684
1016
  };
1017
+ }, {
1018
+ verify: (m) => m.snapshotIndexId === newIndexId &&
1019
+ !filesToDelete.some(id => m.changeLogIds.includes(id))
685
1020
  });
686
- // 5. Cleanup - Only delete files that were confirmed removed from metadata
687
- await this.cleanupOldFiles(oldIndexId, filesToDelete);
1021
+ // 5. Cleanup - ONLY once we have read back the metadata that de-references
1022
+ // these files. Until that write is confirmed, the change logs are still
1023
+ // the only copy of everything in them, and deleting on the strength of an
1024
+ // unverified write is how a lost update turns into lost documents. If the
1025
+ // commit never stuck, the new snapshot files are left behind unreferenced
1026
+ // rather than deleted - a reader may already have picked them up.
1027
+ if (!committed) {
1028
+ this.log('Compaction: metadata never committed, keeping every change log', {
1029
+ snapshotIndexId: newIndexId,
1030
+ logs: oldLogIds.length
1031
+ });
1032
+ return;
1033
+ }
1034
+ for (const id of filesToDelete)
1035
+ this.ownLogIds.delete(id);
1036
+ const staleDataFileIds = oldDataFileIds.filter(id => id !== dataFileId);
1037
+ await this.cleanupOldFiles(oldIndexId, [...filesToDelete, ...staleDataFileIds]);
688
1038
  this.currentLogSizeEstimate = 0;
689
1039
  }
690
1040
  finally {
691
1041
  this.isCompacting = false;
692
1042
  }
693
1043
  }
694
- // ... Helpers (atomicUpdateMeta, saveMeta, writeChangeFile same as before) ...
695
- async atomicUpdateMeta(modifier) {
696
- const MAX_RETRIES = 5;
697
- let attempt = 0;
698
- while (attempt < MAX_RETRIES) {
699
- try {
700
- const metaFile = await this.findFile('_meta.json');
701
- if (!metaFile)
702
- throw new Error('Meta missing');
703
- const validMeta = await this.downloadJson(metaFile.fileId, true); // No cache
704
- const newMeta = modifier(validMeta);
705
- await this.saveMeta(newMeta, metaFile.etag);
706
- this.meta = newMeta;
707
- return;
1044
+ // --- _meta.json: the one piece of shared mutable state --------------------
1045
+ //
1046
+ // Drive API v3 has no compare-and-swap. ETags are gone from the API, so the
1047
+ // If-Match header saveMeta() still sends is honoured by the emulated server and
1048
+ // silently ignored by Drive itself - which is what let two clients read the same
1049
+ // metadata, append their own change log, and each write back a changeLogIds list
1050
+ // that did not mention the other's. The losing log stayed in the folder,
1051
+ // referenced by nothing, invisible to every reader.
1052
+ //
1053
+ // Four things stand in for the missing CAS:
1054
+ //
1055
+ // 1. every commit builds on metadata read from Drive moments earlier, never on
1056
+ // this handler's cached copy, so the window in which a concurrent writer can
1057
+ // be clobbered is one round trip instead of the client's whole lifetime;
1058
+ // 2. modifiers merge into that copy rather than replacing it, so whatever
1059
+ // another writer added survives;
1060
+ // 3. commits that matter re-read afterwards and retry if what they wrote is not
1061
+ // there, which catches the writer who read just before we wrote;
1062
+ // 4. and a writer remembers the logs it wrote (ownLogIds), so anything dropped
1063
+ // despite all of the above is restored on its next load or commit.
1064
+ /** Run `fn` after every meta mutation this handler has already queued has
1065
+ * settled. Never call this from inside a commitMeta modifier - it would wait on
1066
+ * itself. */
1067
+ withMetaLock(fn) {
1068
+ const run = this.metaLock.then(fn, fn);
1069
+ this.metaLock = run.then(() => undefined, () => undefined);
1070
+ return run;
1071
+ }
1072
+ async backoff(attempt) {
1073
+ const ceiling = Math.min(100 * Math.pow(2, attempt), 2000);
1074
+ await new Promise(r => setTimeout(r, ceiling * (0.5 + Math.random())));
1075
+ }
1076
+ /** Locate _meta.json and read its body straight from Drive, past every cache. */
1077
+ async readRemoteMeta() {
1078
+ const pointer = await this.findFile('_meta.json');
1079
+ if (!pointer) {
1080
+ this.metaFileId = null;
1081
+ return null;
1082
+ }
1083
+ this.metaFileId = pointer.fileId;
1084
+ const meta = await this.readMetaBody(pointer.fileId);
1085
+ return meta ? { meta, pointer } : null;
1086
+ }
1087
+ /** Read a known _meta.json by id - no folder listing, no cache. Used by the
1088
+ * post-write verification pass, which only needs the body. */
1089
+ async readMetaBody(fileId) {
1090
+ this.fileCache.remove(fileId);
1091
+ const raw = await this.client.getFile(fileId);
1092
+ if (typeof raw === 'string')
1093
+ return JSON.parse(raw);
1094
+ if (raw && typeof raw === 'object')
1095
+ return JSON.parse(JSON.stringify(raw));
1096
+ return null;
1097
+ }
1098
+ adoptMeta(meta, pointer) {
1099
+ this.meta = meta;
1100
+ this.metaFileId = pointer.fileId;
1101
+ this.metaEtag = pointer.etag || null;
1102
+ this.metaMd5 = pointer.md5Checksum || null;
1103
+ this.metaModifiedTime = pointer.modifiedTime || null;
1104
+ }
1105
+ /** True when the folder holds changes this handler has not replayed - another
1106
+ * writer appended, or compacted, since our last load. */
1107
+ hasUnprocessedLogs(meta) {
1108
+ if (meta.snapshotIndexId !== this.currentSnapshotIndexId)
1109
+ return true;
1110
+ return meta.changeLogIds.some(id => !this.processedLogIds.has(id));
1111
+ }
1112
+ /** True when a change log we wrote has fallen out of `changeLogIds` without a
1113
+ * compaction retiring it - i.e. someone else's write dropped it. */
1114
+ hasOrphanedOwnLogs(meta) {
1115
+ if (this.ownLogIds.size === 0)
1116
+ return false;
1117
+ const present = new Set(meta.changeLogIds);
1118
+ const retired = new Set(meta.retiredLogIds || []);
1119
+ for (const id of this.ownLogIds) {
1120
+ if (!present.has(id) && !retired.has(id))
1121
+ return true;
1122
+ }
1123
+ return false;
1124
+ }
1125
+ /** Put back any such log. Returns `latest` by identity when there is nothing to
1126
+ * repair, so callers can tell the two cases apart. */
1127
+ reconcileOwnLogs(latest) {
1128
+ if (this.ownLogIds.size === 0)
1129
+ return latest;
1130
+ const present = new Set(latest.changeLogIds);
1131
+ const retired = new Set(latest.retiredLogIds || []);
1132
+ const missing = [];
1133
+ for (const id of [...this.ownLogIds]) {
1134
+ if (retired.has(id)) {
1135
+ this.ownLogIds.delete(id); // folded into a snapshot - not ours to defend
1136
+ continue;
708
1137
  }
709
- catch (err) {
710
- if (err.status === 412 || err.status === 409) {
711
- attempt++;
712
- await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
713
- continue;
1138
+ if (!present.has(id))
1139
+ missing.push(id);
1140
+ }
1141
+ if (missing.length === 0)
1142
+ return latest;
1143
+ this.log('Restoring change logs dropped by another writer', missing);
1144
+ return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
1145
+ }
1146
+ /**
1147
+ * Read-modify-write `_meta.json`.
1148
+ *
1149
+ * `modify` receives the current remote metadata (with any of our dropped logs
1150
+ * already restored, which `repaired` reports) and returns the value to write, or
1151
+ * null to abandon the commit because its assumptions no longer hold. This method
1152
+ * also returns null when it runs out of attempts. Either way nothing durable has
1153
+ * changed and the caller must not act as though it had.
1154
+ */
1155
+ commitMeta(modify, opts = {}) {
1156
+ return this.withMetaLock(async () => {
1157
+ for (let attempt = 0; attempt < META_COMMIT_RETRIES; attempt++) {
1158
+ const current = await this.readRemoteMeta();
1159
+ if (!current)
1160
+ throw new Error('Meta missing');
1161
+ const reconciled = this.reconcileOwnLogs(current.meta);
1162
+ const next = modify(reconciled, reconciled !== current.meta);
1163
+ if (!next)
1164
+ return null;
1165
+ try {
1166
+ await this.saveMeta(next, current.pointer);
714
1167
  }
715
- throw err;
1168
+ catch (err) {
1169
+ if (err.status === 412 || err.status === 409) {
1170
+ await this.backoff(attempt);
1171
+ continue;
1172
+ }
1173
+ throw err;
1174
+ }
1175
+ if (!opts.verify) {
1176
+ this.meta = next;
1177
+ return next;
1178
+ }
1179
+ const after = await this.readMetaBody(current.pointer.fileId);
1180
+ if (after && opts.verify(after)) {
1181
+ // Adopt the remote view rather than our own - it carries whatever
1182
+ // else landed alongside us.
1183
+ this.meta = after;
1184
+ return after;
1185
+ }
1186
+ this.log('Meta commit did not survive, retrying', { attempt });
1187
+ await this.backoff(attempt);
716
1188
  }
1189
+ this.log('Meta commit abandoned after', META_COMMIT_RETRIES, 'attempts');
1190
+ return null;
1191
+ });
1192
+ }
1193
+ /** Create _meta.json for a folder that has none. Two clients opening the same
1194
+ * empty folder both end up here and Drive will happily keep two files with the
1195
+ * same name, after which every client picks between them at random. Settle it
1196
+ * deterministically instead: lowest file id wins, the loser deletes its own and
1197
+ * adopts the winner. */
1198
+ async ensureMetaFile() {
1199
+ await this.saveMeta(this.meta, null);
1200
+ const mine = this.metaFileId;
1201
+ const rivals = await this.findFiles('_meta.json');
1202
+ if (rivals.length < 2 || !mine)
1203
+ return;
1204
+ const winner = rivals.map(f => f.fileId).sort()[0];
1205
+ if (winner === mine) {
1206
+ this.log('Won the _meta.json creation race', { mine, rivals: rivals.length });
1207
+ return;
717
1208
  }
1209
+ this.log('Lost the _meta.json creation race, adopting', winner);
1210
+ try {
1211
+ await this.client.deleteFile(mine);
1212
+ }
1213
+ catch (e) {
1214
+ this.log('Failed to remove duplicate _meta.json', mine, e);
1215
+ }
1216
+ const adopted = await this.readRemoteMeta();
1217
+ if (adopted)
1218
+ this.adoptMeta(adopted.meta, adopted.pointer);
718
1219
  }
719
1220
  // Reused helpers
720
1221
  async findOrCreateFolder() {
@@ -726,6 +1227,72 @@ class DriveHandler {
726
1227
  const createRes = await this.client.createFile(this.folderName, this.parents.length ? this.parents : undefined, 'application/vnd.google-apps.folder', '');
727
1228
  return createRes.id;
728
1229
  }
1230
+ /** Every change log in the folder, whatever _meta.json has to say about them.
1231
+ *
1232
+ * The folder is the authority on which change logs exist; _meta.json is only a
1233
+ * cache of that, and a lossy one - it is a whole-file read-modify-write with no
1234
+ * compare-and-swap behind it, so a writer whose metadata lands and is then
1235
+ * overwritten by a slower writer loses its reference. The file is still right
1236
+ * here. Listing for it is what stops a lost update from becoming a lost
1237
+ * document. */
1238
+ async listChangeLogs() {
1239
+ const q = `name contains 'changes-' and '${this.folderId}' in parents and trashed = false`;
1240
+ const files = await this.client.listFiles(q);
1241
+ return files.filter(f => f.name.startsWith('changes-')).map(f => ({ id: f.id, name: f.name }));
1242
+ }
1243
+ /**
1244
+ * Give up a sequence slot another writer is already using.
1245
+ *
1246
+ * Slots make sequence collisions structurally impossible only between writers on
1247
+ * *different* slots; two ids hashing to the same slot are back to the dense
1248
+ * allocation this scheme replaced. The filenames the folder listing hands us
1249
+ * carry every writer's id, so a contested slot is visible - and since this
1250
+ * handler re-rolls before minting anything against what it just saw, the
1251
+ * exposure shrinks from "the whole session" to "rival's first log not yet
1252
+ * visible in a listing".
1253
+ *
1254
+ * Logs already written keep their old name and numbers; ownLogIds tracks file
1255
+ * ids, not names, so nothing else cares.
1256
+ */
1257
+ rerollIfSlotContested(logNames) {
1258
+ const rivalSlots = new Set();
1259
+ for (const name of logNames) {
1260
+ const id = writerIdFromLogName(name);
1261
+ if (id && id !== this.writerId)
1262
+ rivalSlots.add(writerSlotFor(id));
1263
+ }
1264
+ if (!rivalSlots.has(this.writerSlot))
1265
+ return;
1266
+ for (let attempt = 0; attempt < 50; attempt++) {
1267
+ const candidateId = newWriterId();
1268
+ const candidateSlot = writerSlotFor(candidateId);
1269
+ if (!rivalSlots.has(candidateSlot)) {
1270
+ this.log('Sequence slot contested, re-rolling writer id', {
1271
+ from: { writerId: this.writerId, slot: this.writerSlot },
1272
+ to: { writerId: candidateId, slot: candidateSlot }
1273
+ });
1274
+ this.writerId = candidateId;
1275
+ this.writerSlot = candidateSlot;
1276
+ return;
1277
+ }
1278
+ }
1279
+ // ~50 rivals colliding with 50 fresh rolls in a million-slot space does not
1280
+ // happen by chance; leave the slot alone rather than loop forever.
1281
+ this.log('Could not find a free sequence slot, keeping', this.writerSlot);
1282
+ }
1283
+ /** Every file in the folder with this name. Drive allows duplicates, so this is
1284
+ * how the callers that care (see ensureMetaFile) find out there are any. */
1285
+ async findFiles(name) {
1286
+ const safeName = this.escapeQuery(name);
1287
+ const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
1288
+ const files = await this.client.listFiles(q);
1289
+ return files.map(file => ({
1290
+ fileId: file.id,
1291
+ etag: file.etag,
1292
+ md5Checksum: file.md5Checksum,
1293
+ modifiedTime: file.modifiedTime
1294
+ }));
1295
+ }
729
1296
  async findFile(name) {
730
1297
  const safeName = this.escapeQuery(name);
731
1298
  const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
@@ -761,16 +1328,25 @@ class DriveHandler {
761
1328
  async writeChangeFile(changes) {
762
1329
  const lines = changes.map(c => JSON.stringify(c)).join('\n') + '\n';
763
1330
  const startSeq = changes[0].seq;
764
- const name = `changes-${startSeq}-${Math.random().toString(36).substring(7)}.ndjson`;
1331
+ // The writer id makes the name unique even when two clients do manage to
1332
+ // stamp the same starting sequence number, and says who wrote it.
1333
+ const name = `changes-${startSeq}-${this.writerId}-${Math.random().toString(36).substring(7)}.ndjson`;
765
1334
  const res = await this.client.createFile(name, [this.folderId], 'application/x-ndjson', lines);
766
1335
  this.currentLogSizeEstimate += new Blob([lines]).size;
767
1336
  return res.id;
768
1337
  }
769
- async saveMeta(meta, expectedEtag = null) {
1338
+ /** Write `meta` to _meta.json. `target` is the file to write, as already
1339
+ * located by the caller; pass null to force creation, or omit it to look the
1340
+ * file up. */
1341
+ async saveMeta(meta, target) {
770
1342
  const content = JSON.stringify(meta);
771
- const metaFile = await this.findFile('_meta.json');
1343
+ const metaFile = target !== undefined ? target : await this.findFile('_meta.json');
772
1344
  if (metaFile) {
773
- const res = await this.client.updateFile(metaFile.fileId, content, expectedEtag || undefined);
1345
+ // If-Match is a no-op against Drive v3, which dropped ETags - it still
1346
+ // guards the emulated server and costs nothing, but nothing here may
1347
+ // assume it was enforced. See the commitMeta block above.
1348
+ const res = await this.client.updateFile(metaFile.fileId, content, metaFile.etag || undefined);
1349
+ this.metaFileId = metaFile.fileId;
774
1350
  this.metaEtag = res.etag;
775
1351
  this.metaMd5 = res.md5Checksum || null;
776
1352
  this.metaModifiedTime = res.modifiedTime;
@@ -778,16 +1354,18 @@ class DriveHandler {
778
1354
  }
779
1355
  else {
780
1356
  const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
1357
+ this.metaFileId = res.id;
781
1358
  this.metaEtag = res.etag;
782
1359
  this.metaMd5 = res.md5Checksum || null;
783
1360
  this.metaModifiedTime = res.modifiedTime;
784
1361
  }
785
1362
  }
786
1363
  async countTotalChanges() {
787
- // If no snapshot exists yet, total changes = meta.seq (all changes)
788
- if (!this.meta.snapshotIndexId) {
789
- return this.meta.seq;
790
- }
1364
+ // Count change-log files, not changes. This used to return meta.seq when there
1365
+ // was no snapshot yet, on the reasoning that the counter and the change count
1366
+ // were the same number. Sequence numbers now carry a writer slot in their low
1367
+ // digits (see SEQ_SLOTS), so meta.seq is about a million times the tick and
1368
+ // would trigger a compaction on the very first write.
791
1369
  // Each log file ID in changeLogIds represents some number of changes.
792
1370
  // For simplicity and to trigger compaction based on file count (which is what matters for Drive),
793
1371
  // we can return the number of log files.
@@ -820,14 +1398,28 @@ class DriveHandler {
820
1398
  await deleteFile(id);
821
1399
  }
822
1400
  }
1401
+ /**
1402
+ * Watch _meta.json for writes by other clients.
1403
+ *
1404
+ * This is the only thing that makes `db.changes({ live: true })` fire for a
1405
+ * *remote* write: on a change it calls load(), which replays the newly
1406
+ * referenced logs and emits exactly those through notifyListeners. Without it a
1407
+ * client only ever hears about what it wrote itself, so connect-and-read works
1408
+ * and continuous sync between two connected clients does not.
1409
+ *
1410
+ * Change is detected by md5Checksum, falling back to modifiedTime. There is
1411
+ * deliberately no ETag comparison: Drive API v3 has none (see
1412
+ * docs/adr/0001-metadata-writes-without-compare-and-swap.md), so that branch
1413
+ * could only ever compare '' against '' - and sitting first in the chain, it
1414
+ * shadowed the two comparisons that do work.
1415
+ */
823
1416
  startPolling(intervalMs) {
824
- this.log('Starting polling with interval', { intervalMs });
825
1417
  if (isNaN(intervalMs) || intervalMs <= 0)
826
1418
  return;
827
1419
  if (this.pollingInterval)
828
- clearInterval(this.pollingInterval);
1420
+ return; // already watching
1421
+ this.log('Starting polling with interval', { intervalMs });
829
1422
  this.pollingInterval = setInterval(async () => {
830
- this.log('Polling tick...');
831
1423
  if (this.isPollingActive) {
832
1424
  this.log('Polling already in progress, skipping tick');
833
1425
  return;
@@ -839,27 +1431,16 @@ class DriveHandler {
839
1431
  this.log('Polling: _meta.json not found');
840
1432
  return;
841
1433
  }
842
- // Compare etags, falling back to md5Checksum or modifiedTime
843
- const remoteEtag = metaFile.etag;
844
1434
  const remoteMd5 = metaFile.md5Checksum;
845
1435
  const remoteModified = metaFile.modifiedTime;
846
- this.log('Polling: comparing etag', remoteEtag, 'with', this.metaEtag, 'md5', remoteMd5, 'with', this.metaMd5);
847
- let changed = false;
848
- if (remoteEtag && this.metaEtag) {
849
- if (remoteEtag !== this.metaEtag)
850
- changed = true;
851
- }
852
- else if (remoteMd5 && this.metaMd5) {
853
- if (remoteMd5 !== this.metaMd5)
854
- changed = true;
855
- }
856
- else if (remoteModified !== this.metaModifiedTime) {
857
- changed = true;
858
- }
1436
+ const changed = remoteMd5 && this.metaMd5
1437
+ ? remoteMd5 !== this.metaMd5
1438
+ : remoteModified !== this.metaModifiedTime;
859
1439
  if (changed) {
860
- this.log('Polling detected change!', remoteEtag || remoteMd5 || remoteModified);
1440
+ this.log('Polling detected change', { remoteMd5, remoteModified });
1441
+ // load() emits precisely what it replayed. Announcing the whole
1442
+ // index instead is what used to send PouchDB sync in circles.
861
1443
  await this.load();
862
- this.notifyListeners();
863
1444
  }
864
1445
  }
865
1446
  catch (err) {