@docstack/pouchdb-adapter-googledrive 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/drive.js CHANGED
@@ -1,11 +1,65 @@
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");
8
+ const pouchdb_merge_1 = require("pouchdb-merge");
6
9
  const DEFAULT_COMPACTION_THRESHOLD = 100; // entries
7
10
  const DEFAULT_SIZE_THRESHOLD = 1024 * 1024; // 1MB
8
11
  const DEFAULT_CACHE_SIZE = 1000; // Number of docs
12
+ const META_COMMIT_RETRIES = 6; // read-modify-write attempts per _meta.json commit
13
+ const RETIRED_LOG_HISTORY = 500; // tombstones kept for change logs compaction deleted
14
+ const LOG_DOWNLOAD_CONCURRENCY = 8; // parallel change-log GETs during load()
15
+ const MERGE_DEPTH = 1000; // revs_limit for index-side tree merges
16
+ /**
17
+ * Sequence numbers are `tick * SEQ_SLOTS + writerSlot`.
18
+ *
19
+ * Writers mint them blind to one another - there is no lock and no compare-and-swap -
20
+ * so two clients reading the same counter will always be able to derive the same next
21
+ * tick. Reserving the low digits for a per-writer slot means they can share a tick and
22
+ * still never share a *sequence number*, which is the part that matters: `_changes`
23
+ * filters on `seq > since`, so a second document sharing a checkpointed sequence
24
+ * number is never emitted to a replication target again.
25
+ *
26
+ * A million slots keeps the chance that two concurrent writers hash to the same one
27
+ * near 1 in 20,000 for a ten-client fleet, and leaves room for 9e9 ticks inside
28
+ * Number.MAX_SAFE_INTEGER.
29
+ */
30
+ exports.SEQ_SLOTS = 1000000;
31
+ /** Stable slot for a writer id. FNV-1a, folded into the slot space. */
32
+ function writerSlotFor(writerId) {
33
+ let h = 2166136261;
34
+ for (let i = 0; i < writerId.length; i++) {
35
+ h ^= writerId.charCodeAt(i);
36
+ h = Math.imul(h, 16777619);
37
+ }
38
+ return Math.abs(h) % exports.SEQ_SLOTS;
39
+ }
40
+ function newWriterId() {
41
+ return Math.random().toString(36).substring(2, 10);
42
+ }
43
+ /** The writer id embedded in a change-log filename, if the name carries one.
44
+ * New format: changes-<seq>-<writerId>-<random>.ndjson (4 dash-parts).
45
+ * Old format: changes-<seq>-<random>.ndjson (3 parts) - no id to extract. */
46
+ function writerIdFromLogName(name) {
47
+ if (!name.startsWith('changes-') || !name.endsWith('.ndjson'))
48
+ return null;
49
+ const parts = name.slice(0, -'.ndjson'.length).split('-');
50
+ return parts.length === 4 ? parts[2] : null;
51
+ }
52
+ /** A trivial single-node pouchdb-merge tree for a bare rev string - used where a
53
+ * doc enters the index without going through _bulkDocs's real merge (local docs,
54
+ * legacy-snapshot migration, and the low-level DriveHandler.appendChange() API's
55
+ * raw callers). Correct as far as it goes (one leaf, no ancestry); these paths
56
+ * never participate in replication conflict resolution anyway. */
57
+ function synthesizeTree(rev, deleted) {
58
+ const dash = rev.indexOf('-');
59
+ const pos = dash >= 0 ? parseInt(rev.slice(0, dash), 10) : 0;
60
+ const hash = dash >= 0 ? rev.slice(dash + 1) : rev;
61
+ return JSON.stringify([{ pos, ids: [hash, { status: 'available', deleted: !!deleted }, []] }]);
62
+ }
9
63
  /**
10
64
  * DriveHandler - Lazy Loading Implementation
11
65
  *
@@ -33,6 +87,27 @@ class DriveHandler {
33
87
  this.metaMd5 = null;
34
88
  this.metaModifiedTime = null;
35
89
  this.localDocsEtag = null;
90
+ this.metaFileId = null;
91
+ /** Identifies this handler among the writers sharing a folder. Change-log
92
+ * filenames carry it, so two writers can never produce the same name and an
93
+ * orphaned log can be traced back to whoever wrote it. Not readonly: if the
94
+ * folder shows another writer whose id hashes to our sequence slot, we re-roll
95
+ * to a free one before minting anything (see rerollIfSlotContested). */
96
+ this.writerId = newWriterId();
97
+ /** Change-log file ids this handler wrote, minus any a compaction has retired.
98
+ * Drive API v3 has no compare-and-swap (ETags were dropped), so another
99
+ * writer's read-modify-write of _meta.json can drop a log id that landed in
100
+ * between. Anything still in here but missing from the remote changeLogIds was
101
+ * dropped that way and gets put back - see reconcileOwnLogs(). */
102
+ this.ownLogIds = new Set();
103
+ /** This writer's reservation in the low digits of every sequence number it mints.
104
+ * Derived from writerId; changes only when writerId is re-rolled off a
105
+ * contested slot. */
106
+ this.writerSlot = writerSlotFor(this.writerId);
107
+ /** Serializes this handler's own _meta.json read-modify-write cycles. Says
108
+ * nothing about other clients - that is what commitMeta's verify pass is for -
109
+ * but stops one handler racing itself when several writes are in flight. */
110
+ this.metaLock = Promise.resolve();
36
111
  // In-Memory Index: ID -> Metadata/Pointer
37
112
  this.index = {};
38
113
  this.pendingChanges = [];
@@ -97,17 +172,21 @@ class DriveHandler {
97
172
  this.folderId = await this.findOrCreateFolder();
98
173
  this.log('Retrieved folder', { folderId: this.folderId });
99
174
  }
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;
175
+ const current = await this.readRemoteMeta();
176
+ if (current) {
177
+ this.log('Retrieved meta file', { fileId: current.pointer.fileId });
178
+ this.adoptMeta(current.meta, current.pointer);
107
179
  }
108
180
  else {
109
181
  this.log('Meta file not found, creating new');
110
- await this.saveMeta(this.meta);
182
+ await this.ensureMetaFile();
183
+ }
184
+ // A change log of ours that has fallen out of changeLogIds without a
185
+ // compaction retiring it was dropped by another writer's
186
+ // read-modify-write. Put it back before replaying, so this load sees
187
+ // its own writes - and so the next reader does too.
188
+ if (this.hasOrphanedOwnLogs(this.meta)) {
189
+ await this.commitMeta((latest, repaired) => repaired ? latest : null);
111
190
  }
112
191
  if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
113
192
  this.log('Snapshot index changed, loading index', {
@@ -147,10 +226,40 @@ class DriveHandler {
147
226
  }
148
227
  // 2. Replay NEW Change Logs (Metadata only updates)
149
228
  this.log('Replaying change logs');
150
- const pendingLogs = this.meta.changeLogIds.filter(id => !this.processedLogIds.has(id));
229
+ const retired = new Set(this.meta.retiredLogIds || []);
230
+ // Take the union of what the metadata references and what is actually
231
+ // in the folder. A log missing from changeLogIds but present in the
232
+ // folder was orphaned by a lost metadata update; a log referenced but
233
+ // absent was deleted after being referenced. Both are survivable if
234
+ // the folder gets the last word.
235
+ let discovered = [];
236
+ try {
237
+ const listed = await this.listChangeLogs();
238
+ // The same listing shows every writer's id - the moment to notice
239
+ // someone else is on our sequence slot and move off it.
240
+ this.rerollIfSlotContested(listed.map(f => f.name));
241
+ const referenced = new Set(this.meta.changeLogIds);
242
+ discovered = listed.map(f => f.id).filter(id => !referenced.has(id) && !retired.has(id));
243
+ if (discovered.length > 0) {
244
+ this.log('Found change logs the metadata does not reference', discovered);
245
+ }
246
+ }
247
+ catch (e) {
248
+ // Listing is an optimisation over the metadata, never a
249
+ // prerequisite - a failure here must not fail the load.
250
+ this.log('Failed to list change logs, falling back to metadata only', e);
251
+ }
252
+ const pendingLogs = [...this.meta.changeLogIds, ...discovered]
253
+ .filter(id => !this.processedLogIds.has(id) && !retired.has(id));
151
254
  if (pendingLogs.length > 0) {
152
- this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
153
- const logResults = await Promise.all(pendingLogs.map(async (id) => {
255
+ this.log(`Downloading ${pendingLogs.length} change logs, ${LOG_DOWNLOAD_CONCURRENCY} at a time`);
256
+ // Bounded, not unbounded: a cold boot can hold dozens of pending
257
+ // logs, and firing them all at once is exactly the burst Drive's
258
+ // rate limiter clips. A clipped download is skipped and retried on
259
+ // a later load - out of order, which used to regress the index
260
+ // (see updateIndex); the guard there is the fix, this is the
261
+ // prevention.
262
+ const logResults = await this.mapBounded(pendingLogs, LOG_DOWNLOAD_CONCURRENCY, async (id) => {
154
263
  try {
155
264
  const changes = await this.downloadNdjson(id);
156
265
  return { id, changes };
@@ -159,7 +268,19 @@ class DriveHandler {
159
268
  this.log(`Failed to download change log ${id}`, e);
160
269
  return { id, changes: null };
161
270
  }
162
- }));
271
+ });
272
+ // Replay in sequence order, not in the order the ids happened to
273
+ // be listed. Two clients can hold different changeLogIds orderings
274
+ // for the same folder once merges are in play, and updateIndex
275
+ // takes the last write for a document - so insertion order meant
276
+ // two readers of one folder could disagree about the winner.
277
+ logResults.sort((a, b) => {
278
+ const seqOf = (r) => r.changes && r.changes.length ? (Array.isArray(r.changes) ? r.changes[0].seq : r.changes.seq) : Number.MAX_SAFE_INTEGER;
279
+ const sa = seqOf(a), sb = seqOf(b);
280
+ if (sa !== sb)
281
+ return sa - sb;
282
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
283
+ });
163
284
  const foundNew = {};
164
285
  for (const { id, changes } of logResults) {
165
286
  if (!changes) {
@@ -190,6 +311,19 @@ class DriveHandler {
190
311
  // 2. Replay NEW Change Logs (Metadata only updates)
191
312
  // ... (previous logic for change logs)
192
313
  // (Already updated in previous turn, keep it)
314
+ // Put what we found back into the metadata, so the next reader
315
+ // does not have to rediscover it and compaction can see it. Any
316
+ // client repairs this, not only the writer that lost the race.
317
+ if (discovered.length > 0) {
318
+ await this.commitMeta((latest) => {
319
+ const referenced = new Set(latest.changeLogIds);
320
+ const stillRetired = new Set(latest.retiredLogIds || []);
321
+ const missing = discovered.filter(id => !referenced.has(id) && !stillRetired.has(id));
322
+ if (missing.length === 0)
323
+ return null;
324
+ return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
325
+ });
326
+ }
193
327
  // 2b. Load Local Documents Store (Pinned in meta)
194
328
  if (this.meta.localDocsId) {
195
329
  this.log('Loading local docs store', this.meta.localDocsId);
@@ -201,6 +335,7 @@ class DriveHandler {
201
335
  for (const [id, doc] of Object.entries(localDocsChunk.docs)) {
202
336
  this.log('Merging local doc', id);
203
337
  this.index[id] = {
338
+ tree: synthesizeTree(doc._rev),
204
339
  rev: doc._rev,
205
340
  seq: 0, // Local docs don't participate in shared sequences
206
341
  location: { fileId: this.meta.localDocsId }
@@ -213,7 +348,13 @@ class DriveHandler {
213
348
  this.log('Failed to load local docs store', e);
214
349
  }
215
350
  }
216
- // 3. Start Polling ...
351
+ // 3. Start Polling (if enabled). Idempotent on purpose: load()
352
+ // runs again on every catch-up and retry, and restarting the
353
+ // interval each time would push the next tick further out for
354
+ // exactly as long as the client stays busy.
355
+ if (this.options.pollingIntervalMs) {
356
+ this.startPolling(Number(this.options.pollingIntervalMs));
357
+ }
217
358
  }
218
359
  catch (e) {
219
360
  console.error('Failed to load database', e);
@@ -234,6 +375,7 @@ class DriveHandler {
234
375
  // We will cache them ALL now (since we downloaded them) and index them.
235
376
  for (const [id, doc] of Object.entries(snapshot.docs)) {
236
377
  this.index[id] = {
378
+ tree: synthesizeTree(doc._rev),
237
379
  rev: doc._rev,
238
380
  seq: snapshot.seq, // Approximate
239
381
  location: { fileId: 'LEGACY_MEMORY' } // Special validity marker
@@ -298,6 +440,40 @@ class DriveHandler {
298
440
  }
299
441
  return doc;
300
442
  }
443
+ /**
444
+ * Fetch a specific (possibly non-winning/conflicting) revision's body by its
445
+ * own tracked location - used by _get(opts.rev)/_bulkGet when the requested
446
+ * rev isn't the current winner. Deliberately doesn't touch docCache (keyed
447
+ * per-id, not per-rev - a conflict fetch is rare enough not to warrant a
448
+ * per-rev cache key) and doesn't force `_rev` to the index's winning rev the
449
+ * way get() does.
450
+ */
451
+ async getRevisionBody(id, rev, location) {
452
+ if (location.fileId === 'LEGACY_MEMORY' || location.fileId === '__SELF__')
453
+ return null;
454
+ const content = await this.fetchFile(location.fileId);
455
+ let doc = null;
456
+ if (Array.isArray(content)) {
457
+ const match = content.find((c) => c.id === id && c.rev === rev);
458
+ doc = match ? match.doc : null;
459
+ }
460
+ else if (content && content.conflicts && content.conflicts[id]) {
461
+ doc = content.conflicts[id][rev] ?? null;
462
+ }
463
+ else if (content && content.docs && content.docs[id]) {
464
+ // Winning body happened to be requested through this path (e.g. after
465
+ // a merge where the "conflict" turned out to be the new winner).
466
+ const candidate = content.docs[id];
467
+ if (candidate && candidate._rev === rev)
468
+ doc = candidate;
469
+ }
470
+ else if (content && content.id === id && content.rev === rev && content.doc) {
471
+ doc = content.doc;
472
+ }
473
+ if (doc)
474
+ doc._rev = rev;
475
+ return doc;
476
+ }
301
477
  /** Generic Download with Caching and Parsing */
302
478
  async fetchFile(fileId, skipCache = false) {
303
479
  if (!skipCache) {
@@ -463,18 +639,14 @@ class DriveHandler {
463
639
  }
464
640
  catch (err) {
465
641
  if (err.status === 412 || err.status === 409) {
466
- // Reload and RETRY
642
+ // Reload and RETRY. No resequencing here - tryAppendChanges
643
+ // stamps sequence numbers from a fresh read of _meta.json on
644
+ // every attempt of its own.
467
645
  await this.load();
468
646
  // Check conflicts against Index (Metadata sufficient)
469
647
  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
648
  attemptNum++;
477
- await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
649
+ await this.backoff(attemptNum);
478
650
  continue;
479
651
  }
480
652
  throw err;
@@ -519,7 +691,8 @@ class DriveHandler {
519
691
  else {
520
692
  res = await this.client.createFile('_local_docs.json', [this.folderId], 'application/json', content);
521
693
  // Update Meta with new File ID
522
- await this.atomicUpdateMeta((latest) => ({ ...latest, localDocsId: res.id }));
694
+ const localRes = res;
695
+ await this.commitMeta((latest) => ({ ...latest, localDocsId: localRes.id }));
523
696
  }
524
697
  this.localDocsEtag = res.etag;
525
698
  // Update Index
@@ -551,17 +724,92 @@ class DriveHandler {
551
724
  }
552
725
  }
553
726
  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;
727
+ // Catching up on another writer's logs and failing to publish are separate
728
+ // failures, so they get separate budgets - a busy folder should not be able
729
+ // to spend the publish retries on catch-ups alone.
730
+ let catchUps = 0;
731
+ for (let attempt = 0; attempt < META_COMMIT_RETRIES;) {
732
+ // 1. Take the sequence range from what Drive holds right now, not from
733
+ // this handler's cached copy - that copy is only refreshed on load, so
734
+ // two clients allocating from their own stale copies is exactly how two
735
+ // change logs end up claiming the same sequence number.
736
+ const current = await this.readRemoteMeta();
737
+ // Catch up before writing. Another writer having appended since our last
738
+ // load means our index - and so the revision this write is built on - is
739
+ // out of date; replaying their logs first is what lets checkConflicts see
740
+ // the collision instead of us silently writing over them. (Previously
741
+ // this only happened when the metadata ETag came back 412, which Drive
742
+ // itself never does.)
743
+ if (current && this.hasUnprocessedLogs(current.meta)) {
744
+ if (++catchUps > META_COMMIT_RETRIES) {
745
+ throw new Error('Could not catch up with concurrent writers');
746
+ }
747
+ await this.load();
748
+ // Only the low-level appendChange() callers are checked here.
749
+ // _bulkDocs has already resolved revisions through pouchdb-merge and
750
+ // expresses a collision as a conflict branch, not as a thrown error;
751
+ // failing its whole batch would be the wrong answer.
752
+ this.checkConflicts(changes.filter(c => !c.nextIndexEntry));
753
+ continue;
754
+ }
755
+ // Take the tick from whatever counter is furthest along and stamp our own
756
+ // slot into it. Another writer working from the same counter lands on the
757
+ // same tick and a different sequence number, which is the point - there is
758
+ // no way to stop them sharing the tick, and no longer any need to.
759
+ const observedSeq = current ? current.meta.seq : this.meta.seq;
760
+ const baseTick = Math.floor(Math.max(observedSeq, this.meta.seq) / exports.SEQ_SLOTS);
761
+ changes.forEach((change, i) => {
762
+ change.seq = (baseTick + i + 1) * exports.SEQ_SLOTS + this.writerSlot;
763
+ });
764
+ const lastSeq = changes[changes.length - 1].seq;
765
+ // 2. Write Log File (Upload Data). `nextIndexEntry` (the merged tree, only
766
+ // needed transiently by updateIndex() below) is stripped first - it's
767
+ // already durably captured by the index/snapshot system, so writing it into
768
+ // every change-log line too would just redundantly bloat storage, growing
769
+ // with tree depth on every single write.
770
+ const fileId = await this.writeChangeFile(changes.map(({ nextIndexEntry, ...rest }) => rest));
771
+ // 3. Publish it. The modifier merges into whatever _meta.json holds at
772
+ // write time rather than replacing it, so a concurrent writer's logs
773
+ // survive; it abandons the commit if someone has taken the sequence
774
+ // range already stamped into our file, since those seqs would then
775
+ // collide with theirs.
776
+ let committed;
777
+ try {
778
+ committed = await this.commitMeta((latest) => ({
779
+ ...latest,
780
+ // Idempotent: commitMeta may run this more than once.
781
+ changeLogIds: latest.changeLogIds.includes(fileId)
782
+ ? latest.changeLogIds
783
+ : [...latest.changeLogIds, fileId],
784
+ // Never let the shared counter go backwards: another writer may
785
+ // have reached a higher tick while we were uploading, and
786
+ // rewinding it would hand our tick out a second time.
787
+ seq: Math.max(latest.seq, lastSeq)
788
+ }), { verify: (m) => m.changeLogIds.includes(fileId) });
789
+ }
790
+ catch (err) {
791
+ this.discardLog(fileId);
792
+ throw err;
793
+ }
794
+ if (!committed) {
795
+ // commitMeta ran out of attempts. Nothing references the log we just
796
+ // uploaded, so drop it and start over against fresh metadata. This no
797
+ // longer fires for a sequence collision - slots make those impossible -
798
+ // only for a metadata write that would not stick.
799
+ this.log('Change log not published, retrying', { fileId, attempt });
800
+ this.discardLog(fileId);
801
+ await this.backoff(attempt++);
802
+ continue;
803
+ }
804
+ // Published: from here on this log is ours to defend if another writer
805
+ // drops it from the metadata.
806
+ this.ownLogIds.add(fileId);
807
+ // 4. Update Local State. Marking our own log processed keeps a later
808
+ // load() from replaying it: the entries we apply here carry the merged
809
+ // rev tree computed by _bulkDocs, while the log lines on Drive have it
810
+ // stripped, so a replay would overwrite real ancestry with a synthesized
811
+ // single-node tree.
812
+ this.processedLogIds.add(fileId);
565
813
  const changedDocs = {};
566
814
  for (const change of changes) {
567
815
  this.updateIndex(change, fileId);
@@ -586,21 +834,122 @@ class DriveHandler {
586
834
  this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
587
835
  this.compact().catch(e => console.error('Compaction failed', e));
588
836
  }
837
+ return;
589
838
  }
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
- }
839
+ throw new Error(`Failed to publish change log after ${META_COMMIT_RETRIES} attempts`);
840
+ }
841
+ /** Forget a change log we uploaded but never managed to reference from
842
+ * _meta.json. Nothing points at it, so it is safe to remove. */
843
+ discardLog(fileId) {
844
+ this.ownLogIds.delete(fileId);
845
+ this.client.deleteFile(fileId).catch(e => this.log('Failed to clean up unreferenced log', fileId, e));
595
846
  }
596
847
  /** Update Index with a new change */
597
848
  updateIndex(change, fileId) {
598
- this.index[change.id] = {
599
- rev: change.rev,
600
- seq: change.seq,
601
- deleted: !!change.deleted,
602
- location: { fileId }
849
+ if (change.nextIndexEntry) {
850
+ // adapter.ts already computed the full merged tree/winner/conflicts via
851
+ // pouchdb-merge - just substitute the '__SELF__' placeholder(s) with the
852
+ // fileId this batch actually landed in (unknown until upload completed).
853
+ const entry = { ...change.nextIndexEntry, seq: change.seq };
854
+ if (entry.location.fileId === '__SELF__')
855
+ entry.location = { fileId };
856
+ if (entry.conflictLocations) {
857
+ const resolved = {};
858
+ for (const rev of Object.keys(entry.conflictLocations)) {
859
+ const loc = entry.conflictLocations[rev];
860
+ resolved[rev] = loc.fileId === '__SELF__' ? { fileId } : loc;
861
+ }
862
+ entry.conflictLocations = resolved;
863
+ }
864
+ this.index[change.id] = entry;
865
+ return;
866
+ }
867
+ // Legacy path: no computed tree. Every row a reader replays lands here,
868
+ // because `nextIndexEntry` is stripped before upload - which makes this the
869
+ // path that decides what a replica believes.
870
+ //
871
+ // It used to replace the entry blindly, and a row replayed out of order
872
+ // rewrote rev, seq and location to an older state. Out-of-order replay is
873
+ // routine, not exotic: a change-log download clipped by the rate limiter is
874
+ // skipped and retried on a later load, after higher logs have applied. The
875
+ // regressed entry then fails the changes feed's `seq > since` gate, so the
876
+ // newer revision is never emitted, and the puller's checkpoint advances past
877
+ // it - silent, permanent loss on the reading side while the folder holds
878
+ // everything. A writer echoing a stale revision at a fresh seq regressed the
879
+ // winner the same way, no retry needed.
880
+ //
881
+ // So: merge instead of replace, and never let a replay move a document
882
+ // backwards. The winner is decided by revision generation (hash tie-break),
883
+ // not pouchdb-merge's winningRev - synthesized nodes carry no ancestry, so
884
+ // every rev is a leaf to winningRev, and its live-leaf preference would let
885
+ // an old live rev beat a genuine deletion at a higher generation.
886
+ const existing = this.index[change.id];
887
+ if (!existing) {
888
+ this.index[change.id] = {
889
+ tree: synthesizeTree(change.rev, change.deleted),
890
+ rev: change.rev,
891
+ seq: change.seq,
892
+ deleted: !!change.deleted,
893
+ location: { fileId }
894
+ };
895
+ return;
896
+ }
897
+ // Never regress the seq, whatever else happens - the feed gates on it.
898
+ const seq = Math.max(existing.seq, change.seq);
899
+ let existingTree;
900
+ try {
901
+ existingTree = JSON.parse(existing.tree);
902
+ }
903
+ catch {
904
+ existingTree = JSON.parse(synthesizeTree(existing.rev, existing.deleted));
905
+ }
906
+ if (change.rev === existing.rev || (0, pouchdb_merge_1.revExists)(existingTree, change.rev)) {
907
+ // A re-replay of something already known: nothing to change but the seq.
908
+ if (seq !== existing.seq)
909
+ this.index[change.id] = { ...existing, seq };
910
+ return;
911
+ }
912
+ const incomingPath = JSON.parse(synthesizeTree(change.rev, change.deleted))[0];
913
+ const mergedTree = (0, pouchdb_merge_1.merge)(existingTree, incomingPath, MERGE_DEPTH).tree;
914
+ const genOf = (rev) => parseInt(rev.split('-')[0], 10) || 0;
915
+ const hashOf = (rev) => rev.slice(rev.indexOf('-') + 1);
916
+ const incomingWins = genOf(change.rev) !== genOf(existing.rev)
917
+ ? genOf(change.rev) > genOf(existing.rev)
918
+ : hashOf(change.rev) > hashOf(existing.rev); // CouchDB's deterministic tie-break
919
+ const entry = {
920
+ tree: JSON.stringify(mergedTree),
921
+ rev: incomingWins ? change.rev : existing.rev,
922
+ seq,
923
+ deleted: incomingWins ? !!change.deleted : !!existing.deleted,
924
+ location: incomingWins ? { fileId } : existing.location
603
925
  };
926
+ // The losing revision stays reachable as a conflict, matching what the
927
+ // nextIndexEntry path does for real merges.
928
+ const conflicts = { ...(existing.conflictLocations || {}) };
929
+ if (incomingWins) {
930
+ conflicts[existing.rev] = existing.location;
931
+ }
932
+ else {
933
+ conflicts[change.rev] = { fileId };
934
+ }
935
+ delete conflicts[entry.rev];
936
+ if (Object.keys(conflicts).length > 0)
937
+ entry.conflictLocations = conflicts;
938
+ this.index[change.id] = entry;
939
+ }
940
+ /** Run `fn` over `items` with at most `limit` in flight at once, preserving
941
+ * result order. */
942
+ async mapBounded(items, limit, fn) {
943
+ const results = new Array(items.length);
944
+ let next = 0;
945
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
946
+ while (next < items.length) {
947
+ const i = next++;
948
+ results[i] = await fn(items[i]);
949
+ }
950
+ });
951
+ await Promise.all(workers);
952
+ return results;
604
953
  }
605
954
  checkConflicts(changes) {
606
955
  for (const change of changes) {
@@ -670,6 +1019,39 @@ class DriveHandler {
670
1019
  this.log('Compaction ABORTED: Failed to fetch documents', missingDocs);
671
1020
  throw new Error(`Compaction failed: missing ${missingDocs.length} documents. Aborting to prevent data loss.`);
672
1021
  }
1022
+ // 1b. Carry forward conflict-branch bodies too. Without this, the first
1023
+ // compaction after a real conflict exists would silently drop the losing
1024
+ // revision forever - snapshotData only ever had room for one body per id
1025
+ // before conflict tracking existed. A body that fails to fetch here is
1026
+ // logged and dropped rather than aborting the whole compaction (unlike
1027
+ // missingDocs above): losing one stale conflict branch is far less bad
1028
+ // than losing a doc's current data, and shouldn't block reclaiming space.
1029
+ const conflictFetches = [];
1030
+ for (const id of allIds) {
1031
+ const conflicts = this.index[id].conflictLocations;
1032
+ if (!conflicts)
1033
+ continue;
1034
+ for (const rev of Object.keys(conflicts)) {
1035
+ conflictFetches.push({ id, rev, location: conflicts[rev] });
1036
+ }
1037
+ }
1038
+ const carriedConflicts = {};
1039
+ if (conflictFetches.length > 0) {
1040
+ snapshotData.conflicts = {};
1041
+ for (const { id, rev, location } of conflictFetches) {
1042
+ const body = await this.getRevisionBody(id, rev, location);
1043
+ if (!body) {
1044
+ this.log('Compaction WARNING: could not fetch conflict body, dropping', id, rev);
1045
+ continue;
1046
+ }
1047
+ if (!snapshotData.conflicts[id])
1048
+ snapshotData.conflicts[id] = {};
1049
+ snapshotData.conflicts[id][rev] = body;
1050
+ if (!carriedConflicts[id])
1051
+ carriedConflicts[id] = {};
1052
+ carriedConflicts[id][rev] = true;
1053
+ }
1054
+ }
673
1055
  // 2. Upload Data File
674
1056
  const dataContent = JSON.stringify(snapshotData);
675
1057
  const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
@@ -677,11 +1059,19 @@ class DriveHandler {
677
1059
  // 3. Create Index pointing to this Data File
678
1060
  const newIndexEntries = {};
679
1061
  for (const id of Object.keys(snapshotData.docs)) {
680
- newIndexEntries[id] = {
1062
+ const entry = {
1063
+ tree: this.index[id].tree,
681
1064
  rev: this.index[id].rev,
682
1065
  seq: this.index[id].seq,
683
1066
  location: { fileId: dataFileId }
684
1067
  };
1068
+ if (carriedConflicts[id]) {
1069
+ entry.conflictLocations = {};
1070
+ for (const rev of Object.keys(carriedConflicts[id])) {
1071
+ entry.conflictLocations[rev] = { fileId: dataFileId };
1072
+ }
1073
+ }
1074
+ newIndexEntries[id] = entry;
685
1075
  }
686
1076
  const snapshotIndex = {
687
1077
  entries: newIndexEntries,
@@ -691,22 +1081,42 @@ class DriveHandler {
691
1081
  const indexContent = JSON.stringify(snapshotIndex);
692
1082
  const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
693
1083
  const newIndexId = indexRes.id;
694
- // 4. Update Meta
1084
+ // 4. Update Meta. Only the logs THIS handler had already replayed are
1085
+ // retired; anything another writer appended in the meantime stays in
1086
+ // changeLogIds and gets replayed on top of the new snapshot.
695
1087
  let filesToDelete = [];
696
- await this.atomicUpdateMeta((latest) => {
1088
+ const committed = await this.commitMeta((latest) => {
697
1089
  const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
698
1090
  // Only delete files that were in oldLogIds but not in remainingLogs
699
1091
  filesToDelete = oldLogIds.filter(id => !remainingLogs.includes(id));
1092
+ const retired = [...(latest.retiredLogIds || []), ...filesToDelete];
700
1093
  return {
701
1094
  ...latest,
702
1095
  snapshotIndexId: newIndexId,
703
1096
  changeLogIds: remainingLogs,
1097
+ // Tombstones, so the writers of those logs don't put them back.
1098
+ retiredLogIds: [...new Set(retired)].slice(-RETIRED_LOG_HISTORY),
704
1099
  lastCompaction: Date.now()
705
1100
  };
1101
+ }, {
1102
+ verify: (m) => m.snapshotIndexId === newIndexId &&
1103
+ !filesToDelete.some(id => m.changeLogIds.includes(id))
706
1104
  });
707
- // 5. Cleanup - Only delete files that were confirmed removed from metadata.
708
- // Also delete the old snapshot-data file(s) the old index pointed to (excluding
709
- // the freshly-created one, which can't appear here since it's brand new).
1105
+ // 5. Cleanup - ONLY once we have read back the metadata that de-references
1106
+ // these files. Until that write is confirmed, the change logs are still
1107
+ // the only copy of everything in them, and deleting on the strength of an
1108
+ // unverified write is how a lost update turns into lost documents. If the
1109
+ // commit never stuck, the new snapshot files are left behind unreferenced
1110
+ // rather than deleted - a reader may already have picked them up.
1111
+ if (!committed) {
1112
+ this.log('Compaction: metadata never committed, keeping every change log', {
1113
+ snapshotIndexId: newIndexId,
1114
+ logs: oldLogIds.length
1115
+ });
1116
+ return;
1117
+ }
1118
+ for (const id of filesToDelete)
1119
+ this.ownLogIds.delete(id);
710
1120
  const staleDataFileIds = oldDataFileIds.filter(id => id !== dataFileId);
711
1121
  await this.cleanupOldFiles(oldIndexId, [...filesToDelete, ...staleDataFileIds]);
712
1122
  this.currentLogSizeEstimate = 0;
@@ -715,30 +1125,181 @@ class DriveHandler {
715
1125
  this.isCompacting = false;
716
1126
  }
717
1127
  }
718
- // ... Helpers (atomicUpdateMeta, saveMeta, writeChangeFile same as before) ...
719
- async atomicUpdateMeta(modifier) {
720
- const MAX_RETRIES = 5;
721
- let attempt = 0;
722
- while (attempt < MAX_RETRIES) {
723
- try {
724
- const metaFile = await this.findFile('_meta.json');
725
- if (!metaFile)
726
- throw new Error('Meta missing');
727
- const validMeta = await this.downloadJson(metaFile.fileId, true); // No cache
728
- const newMeta = modifier(validMeta);
729
- await this.saveMeta(newMeta, metaFile.etag);
730
- this.meta = newMeta;
731
- return;
1128
+ // --- _meta.json: the one piece of shared mutable state --------------------
1129
+ //
1130
+ // Drive API v3 has no compare-and-swap. ETags are gone from the API, so the
1131
+ // If-Match header saveMeta() still sends is honoured by the emulated server and
1132
+ // silently ignored by Drive itself - which is what let two clients read the same
1133
+ // metadata, append their own change log, and each write back a changeLogIds list
1134
+ // that did not mention the other's. The losing log stayed in the folder,
1135
+ // referenced by nothing, invisible to every reader.
1136
+ //
1137
+ // Four things stand in for the missing CAS:
1138
+ //
1139
+ // 1. every commit builds on metadata read from Drive moments earlier, never on
1140
+ // this handler's cached copy, so the window in which a concurrent writer can
1141
+ // be clobbered is one round trip instead of the client's whole lifetime;
1142
+ // 2. modifiers merge into that copy rather than replacing it, so whatever
1143
+ // another writer added survives;
1144
+ // 3. commits that matter re-read afterwards and retry if what they wrote is not
1145
+ // there, which catches the writer who read just before we wrote;
1146
+ // 4. and a writer remembers the logs it wrote (ownLogIds), so anything dropped
1147
+ // despite all of the above is restored on its next load or commit.
1148
+ /** Run `fn` after every meta mutation this handler has already queued has
1149
+ * settled. Never call this from inside a commitMeta modifier - it would wait on
1150
+ * itself. */
1151
+ withMetaLock(fn) {
1152
+ const run = this.metaLock.then(fn, fn);
1153
+ this.metaLock = run.then(() => undefined, () => undefined);
1154
+ return run;
1155
+ }
1156
+ async backoff(attempt) {
1157
+ const ceiling = Math.min(100 * Math.pow(2, attempt), 2000);
1158
+ await new Promise(r => setTimeout(r, ceiling * (0.5 + Math.random())));
1159
+ }
1160
+ /** Locate _meta.json and read its body straight from Drive, past every cache. */
1161
+ async readRemoteMeta() {
1162
+ const pointer = await this.findFile('_meta.json');
1163
+ if (!pointer) {
1164
+ this.metaFileId = null;
1165
+ return null;
1166
+ }
1167
+ this.metaFileId = pointer.fileId;
1168
+ const meta = await this.readMetaBody(pointer.fileId);
1169
+ return meta ? { meta, pointer } : null;
1170
+ }
1171
+ /** Read a known _meta.json by id - no folder listing, no cache. Used by the
1172
+ * post-write verification pass, which only needs the body. */
1173
+ async readMetaBody(fileId) {
1174
+ this.fileCache.remove(fileId);
1175
+ const raw = await this.client.getFile(fileId);
1176
+ if (typeof raw === 'string')
1177
+ return JSON.parse(raw);
1178
+ if (raw && typeof raw === 'object')
1179
+ return JSON.parse(JSON.stringify(raw));
1180
+ return null;
1181
+ }
1182
+ adoptMeta(meta, pointer) {
1183
+ this.meta = meta;
1184
+ this.metaFileId = pointer.fileId;
1185
+ this.metaEtag = pointer.etag || null;
1186
+ this.metaMd5 = pointer.md5Checksum || null;
1187
+ this.metaModifiedTime = pointer.modifiedTime || null;
1188
+ }
1189
+ /** True when the folder holds changes this handler has not replayed - another
1190
+ * writer appended, or compacted, since our last load. */
1191
+ hasUnprocessedLogs(meta) {
1192
+ if (meta.snapshotIndexId !== this.currentSnapshotIndexId)
1193
+ return true;
1194
+ return meta.changeLogIds.some(id => !this.processedLogIds.has(id));
1195
+ }
1196
+ /** True when a change log we wrote has fallen out of `changeLogIds` without a
1197
+ * compaction retiring it - i.e. someone else's write dropped it. */
1198
+ hasOrphanedOwnLogs(meta) {
1199
+ if (this.ownLogIds.size === 0)
1200
+ return false;
1201
+ const present = new Set(meta.changeLogIds);
1202
+ const retired = new Set(meta.retiredLogIds || []);
1203
+ for (const id of this.ownLogIds) {
1204
+ if (!present.has(id) && !retired.has(id))
1205
+ return true;
1206
+ }
1207
+ return false;
1208
+ }
1209
+ /** Put back any such log. Returns `latest` by identity when there is nothing to
1210
+ * repair, so callers can tell the two cases apart. */
1211
+ reconcileOwnLogs(latest) {
1212
+ if (this.ownLogIds.size === 0)
1213
+ return latest;
1214
+ const present = new Set(latest.changeLogIds);
1215
+ const retired = new Set(latest.retiredLogIds || []);
1216
+ const missing = [];
1217
+ for (const id of [...this.ownLogIds]) {
1218
+ if (retired.has(id)) {
1219
+ this.ownLogIds.delete(id); // folded into a snapshot - not ours to defend
1220
+ continue;
732
1221
  }
733
- catch (err) {
734
- if (err.status === 412 || err.status === 409) {
735
- attempt++;
736
- await new Promise(r => setTimeout(r, Math.random() * 500 + 100));
737
- continue;
1222
+ if (!present.has(id))
1223
+ missing.push(id);
1224
+ }
1225
+ if (missing.length === 0)
1226
+ return latest;
1227
+ this.log('Restoring change logs dropped by another writer', missing);
1228
+ return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
1229
+ }
1230
+ /**
1231
+ * Read-modify-write `_meta.json`.
1232
+ *
1233
+ * `modify` receives the current remote metadata (with any of our dropped logs
1234
+ * already restored, which `repaired` reports) and returns the value to write, or
1235
+ * null to abandon the commit because its assumptions no longer hold. This method
1236
+ * also returns null when it runs out of attempts. Either way nothing durable has
1237
+ * changed and the caller must not act as though it had.
1238
+ */
1239
+ commitMeta(modify, opts = {}) {
1240
+ return this.withMetaLock(async () => {
1241
+ for (let attempt = 0; attempt < META_COMMIT_RETRIES; attempt++) {
1242
+ const current = await this.readRemoteMeta();
1243
+ if (!current)
1244
+ throw new Error('Meta missing');
1245
+ const reconciled = this.reconcileOwnLogs(current.meta);
1246
+ const next = modify(reconciled, reconciled !== current.meta);
1247
+ if (!next)
1248
+ return null;
1249
+ try {
1250
+ await this.saveMeta(next, current.pointer);
738
1251
  }
739
- throw err;
1252
+ catch (err) {
1253
+ if (err.status === 412 || err.status === 409) {
1254
+ await this.backoff(attempt);
1255
+ continue;
1256
+ }
1257
+ throw err;
1258
+ }
1259
+ if (!opts.verify) {
1260
+ this.meta = next;
1261
+ return next;
1262
+ }
1263
+ const after = await this.readMetaBody(current.pointer.fileId);
1264
+ if (after && opts.verify(after)) {
1265
+ // Adopt the remote view rather than our own - it carries whatever
1266
+ // else landed alongside us.
1267
+ this.meta = after;
1268
+ return after;
1269
+ }
1270
+ this.log('Meta commit did not survive, retrying', { attempt });
1271
+ await this.backoff(attempt);
740
1272
  }
1273
+ this.log('Meta commit abandoned after', META_COMMIT_RETRIES, 'attempts');
1274
+ return null;
1275
+ });
1276
+ }
1277
+ /** Create _meta.json for a folder that has none. Two clients opening the same
1278
+ * empty folder both end up here and Drive will happily keep two files with the
1279
+ * same name, after which every client picks between them at random. Settle it
1280
+ * deterministically instead: lowest file id wins, the loser deletes its own and
1281
+ * adopts the winner. */
1282
+ async ensureMetaFile() {
1283
+ await this.saveMeta(this.meta, null);
1284
+ const mine = this.metaFileId;
1285
+ const rivals = await this.findFiles('_meta.json');
1286
+ if (rivals.length < 2 || !mine)
1287
+ return;
1288
+ const winner = rivals.map(f => f.fileId).sort()[0];
1289
+ if (winner === mine) {
1290
+ this.log('Won the _meta.json creation race', { mine, rivals: rivals.length });
1291
+ return;
1292
+ }
1293
+ this.log('Lost the _meta.json creation race, adopting', winner);
1294
+ try {
1295
+ await this.client.deleteFile(mine);
1296
+ }
1297
+ catch (e) {
1298
+ this.log('Failed to remove duplicate _meta.json', mine, e);
741
1299
  }
1300
+ const adopted = await this.readRemoteMeta();
1301
+ if (adopted)
1302
+ this.adoptMeta(adopted.meta, adopted.pointer);
742
1303
  }
743
1304
  // Reused helpers
744
1305
  async findOrCreateFolder() {
@@ -750,6 +1311,72 @@ class DriveHandler {
750
1311
  const createRes = await this.client.createFile(this.folderName, this.parents.length ? this.parents : undefined, 'application/vnd.google-apps.folder', '');
751
1312
  return createRes.id;
752
1313
  }
1314
+ /** Every change log in the folder, whatever _meta.json has to say about them.
1315
+ *
1316
+ * The folder is the authority on which change logs exist; _meta.json is only a
1317
+ * cache of that, and a lossy one - it is a whole-file read-modify-write with no
1318
+ * compare-and-swap behind it, so a writer whose metadata lands and is then
1319
+ * overwritten by a slower writer loses its reference. The file is still right
1320
+ * here. Listing for it is what stops a lost update from becoming a lost
1321
+ * document. */
1322
+ async listChangeLogs() {
1323
+ const q = `name contains 'changes-' and '${this.folderId}' in parents and trashed = false`;
1324
+ const files = await this.client.listFiles(q);
1325
+ return files.filter(f => f.name.startsWith('changes-')).map(f => ({ id: f.id, name: f.name }));
1326
+ }
1327
+ /**
1328
+ * Give up a sequence slot another writer is already using.
1329
+ *
1330
+ * Slots make sequence collisions structurally impossible only between writers on
1331
+ * *different* slots; two ids hashing to the same slot are back to the dense
1332
+ * allocation this scheme replaced. The filenames the folder listing hands us
1333
+ * carry every writer's id, so a contested slot is visible - and since this
1334
+ * handler re-rolls before minting anything against what it just saw, the
1335
+ * exposure shrinks from "the whole session" to "rival's first log not yet
1336
+ * visible in a listing".
1337
+ *
1338
+ * Logs already written keep their old name and numbers; ownLogIds tracks file
1339
+ * ids, not names, so nothing else cares.
1340
+ */
1341
+ rerollIfSlotContested(logNames) {
1342
+ const rivalSlots = new Set();
1343
+ for (const name of logNames) {
1344
+ const id = writerIdFromLogName(name);
1345
+ if (id && id !== this.writerId)
1346
+ rivalSlots.add(writerSlotFor(id));
1347
+ }
1348
+ if (!rivalSlots.has(this.writerSlot))
1349
+ return;
1350
+ for (let attempt = 0; attempt < 50; attempt++) {
1351
+ const candidateId = newWriterId();
1352
+ const candidateSlot = writerSlotFor(candidateId);
1353
+ if (!rivalSlots.has(candidateSlot)) {
1354
+ this.log('Sequence slot contested, re-rolling writer id', {
1355
+ from: { writerId: this.writerId, slot: this.writerSlot },
1356
+ to: { writerId: candidateId, slot: candidateSlot }
1357
+ });
1358
+ this.writerId = candidateId;
1359
+ this.writerSlot = candidateSlot;
1360
+ return;
1361
+ }
1362
+ }
1363
+ // ~50 rivals colliding with 50 fresh rolls in a million-slot space does not
1364
+ // happen by chance; leave the slot alone rather than loop forever.
1365
+ this.log('Could not find a free sequence slot, keeping', this.writerSlot);
1366
+ }
1367
+ /** Every file in the folder with this name. Drive allows duplicates, so this is
1368
+ * how the callers that care (see ensureMetaFile) find out there are any. */
1369
+ async findFiles(name) {
1370
+ const safeName = this.escapeQuery(name);
1371
+ const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
1372
+ const files = await this.client.listFiles(q);
1373
+ return files.map(file => ({
1374
+ fileId: file.id,
1375
+ etag: file.etag,
1376
+ md5Checksum: file.md5Checksum,
1377
+ modifiedTime: file.modifiedTime
1378
+ }));
1379
+ }
753
1380
  async findFile(name) {
754
1381
  const safeName = this.escapeQuery(name);
755
1382
  const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
@@ -785,16 +1412,25 @@ class DriveHandler {
785
1412
  async writeChangeFile(changes) {
786
1413
  const lines = changes.map(c => JSON.stringify(c)).join('\n') + '\n';
787
1414
  const startSeq = changes[0].seq;
788
- const name = `changes-${startSeq}-${Math.random().toString(36).substring(7)}.ndjson`;
1415
+ // The writer id makes the name unique even when two clients do manage to
1416
+ // stamp the same starting sequence number, and says who wrote it.
1417
+ const name = `changes-${startSeq}-${this.writerId}-${Math.random().toString(36).substring(7)}.ndjson`;
789
1418
  const res = await this.client.createFile(name, [this.folderId], 'application/x-ndjson', lines);
790
1419
  this.currentLogSizeEstimate += new Blob([lines]).size;
791
1420
  return res.id;
792
1421
  }
793
- async saveMeta(meta, expectedEtag = null) {
1422
+ /** Write `meta` to _meta.json. `target` is the file to write, as already
1423
+ * located by the caller; pass null to force creation, or omit it to look the
1424
+ * file up. */
1425
+ async saveMeta(meta, target) {
794
1426
  const content = JSON.stringify(meta);
795
- const metaFile = await this.findFile('_meta.json');
1427
+ const metaFile = target !== undefined ? target : await this.findFile('_meta.json');
796
1428
  if (metaFile) {
797
- const res = await this.client.updateFile(metaFile.fileId, content, expectedEtag || undefined);
1429
+ // If-Match is a no-op against Drive v3, which dropped ETags - it still
1430
+ // guards the emulated server and costs nothing, but nothing here may
1431
+ // assume it was enforced. See the commitMeta block above.
1432
+ const res = await this.client.updateFile(metaFile.fileId, content, metaFile.etag || undefined);
1433
+ this.metaFileId = metaFile.fileId;
798
1434
  this.metaEtag = res.etag;
799
1435
  this.metaMd5 = res.md5Checksum || null;
800
1436
  this.metaModifiedTime = res.modifiedTime;
@@ -802,16 +1438,18 @@ class DriveHandler {
802
1438
  }
803
1439
  else {
804
1440
  const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
1441
+ this.metaFileId = res.id;
805
1442
  this.metaEtag = res.etag;
806
1443
  this.metaMd5 = res.md5Checksum || null;
807
1444
  this.metaModifiedTime = res.modifiedTime;
808
1445
  }
809
1446
  }
810
1447
  async countTotalChanges() {
811
- // If no snapshot exists yet, total changes = meta.seq (all changes)
812
- if (!this.meta.snapshotIndexId) {
813
- return this.meta.seq;
814
- }
1448
+ // Count change-log files, not changes. This used to return meta.seq when there
1449
+ // was no snapshot yet, on the reasoning that the counter and the change count
1450
+ // were the same number. Sequence numbers now carry a writer slot in their low
1451
+ // digits (see SEQ_SLOTS), so meta.seq is about a million times the tick and
1452
+ // would trigger a compaction on the very first write.
815
1453
  // Each log file ID in changeLogIds represents some number of changes.
816
1454
  // For simplicity and to trigger compaction based on file count (which is what matters for Drive),
817
1455
  // we can return the number of log files.
@@ -844,14 +1482,28 @@ class DriveHandler {
844
1482
  await deleteFile(id);
845
1483
  }
846
1484
  }
1485
+ /**
1486
+ * Watch _meta.json for writes by other clients.
1487
+ *
1488
+ * This is the only thing that makes `db.changes({ live: true })` fire for a
1489
+ * *remote* write: on a change it calls load(), which replays the newly
1490
+ * referenced logs and emits exactly those through notifyListeners. Without it a
1491
+ * client only ever hears about what it wrote itself, so connect-and-read works
1492
+ * and continuous sync between two connected clients does not.
1493
+ *
1494
+ * Change is detected by md5Checksum, falling back to modifiedTime. There is
1495
+ * deliberately no ETag comparison: Drive API v3 has none (see
1496
+ * docs/adr/0001-metadata-writes-without-compare-and-swap.md), so that branch
1497
+ * could only ever compare '' against '' - and sitting first in the chain, it
1498
+ * shadowed the two comparisons that do work.
1499
+ */
847
1500
  startPolling(intervalMs) {
848
- this.log('Starting polling with interval', { intervalMs });
849
1501
  if (isNaN(intervalMs) || intervalMs <= 0)
850
1502
  return;
851
1503
  if (this.pollingInterval)
852
- clearInterval(this.pollingInterval);
1504
+ return; // already watching
1505
+ this.log('Starting polling with interval', { intervalMs });
853
1506
  this.pollingInterval = setInterval(async () => {
854
- this.log('Polling tick...');
855
1507
  if (this.isPollingActive) {
856
1508
  this.log('Polling already in progress, skipping tick');
857
1509
  return;
@@ -863,27 +1515,16 @@ class DriveHandler {
863
1515
  this.log('Polling: _meta.json not found');
864
1516
  return;
865
1517
  }
866
- // Compare etags, falling back to md5Checksum or modifiedTime
867
- const remoteEtag = metaFile.etag;
868
1518
  const remoteMd5 = metaFile.md5Checksum;
869
1519
  const remoteModified = metaFile.modifiedTime;
870
- this.log('Polling: comparing etag', remoteEtag, 'with', this.metaEtag, 'md5', remoteMd5, 'with', this.metaMd5);
871
- let changed = false;
872
- if (remoteEtag && this.metaEtag) {
873
- if (remoteEtag !== this.metaEtag)
874
- changed = true;
875
- }
876
- else if (remoteMd5 && this.metaMd5) {
877
- if (remoteMd5 !== this.metaMd5)
878
- changed = true;
879
- }
880
- else if (remoteModified !== this.metaModifiedTime) {
881
- changed = true;
882
- }
1520
+ const changed = remoteMd5 && this.metaMd5
1521
+ ? remoteMd5 !== this.metaMd5
1522
+ : remoteModified !== this.metaModifiedTime;
883
1523
  if (changed) {
884
- this.log('Polling detected change!', remoteEtag || remoteMd5 || remoteModified);
1524
+ this.log('Polling detected change', { remoteMd5, remoteModified });
1525
+ // load() emits precisely what it replayed. Announcing the whole
1526
+ // index instead is what used to send PouchDB sync in circles.
885
1527
  await this.load();
886
- this.notifyListeners();
887
1528
  }
888
1529
  }
889
1530
  catch (err) {