@docstack/pouchdb-adapter-googledrive 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7 — unreleased
4
+
5
+ ### Fixed
6
+
7
+ - **A re-replayed old change log no longer regresses the reader's index.** A log
8
+ download clipped by the rate limiter was retried on a later load — after higher
9
+ logs had applied — and the replay path replaced index entries blindly, rewriting
10
+ rev/seq/location to older state. The regressed entry then failed the changes
11
+ feed's gate, the newer revision was never emitted, and the puller's checkpoint
12
+ sealed the loss: both devices idle and up to date while holding different data.
13
+ Replay now merges into the existing entry and never moves a document backwards;
14
+ winners are decided by revision generation (a stale revision echoed at a fresh
15
+ sequence number loses too), and losing revisions stay reachable as conflicts.
16
+ Field report and reasoning: ADR-0004 / finding 0006, in the repository.
17
+
18
+ - **Cold-boot change-log downloads are bounded to 8 in flight**, not one burst of
19
+ everything pending — the burst was what invited the rate limiting that created
20
+ out-of-order retries in the first place.
21
+
22
+ - **The live changes listener emits each batch in sequence order** and advances its
23
+ checkpoint as it emits, instead of gating each document against a bar that other
24
+ documents in the same batch had already raised — the initial pass's own fix,
25
+ applied to the listener.
26
+
27
+ ### Added
28
+
29
+ - `tests/production.concurrency.test.ts` (`npm run test:prod:concurrency`) —
30
+ re-checks the multi-writer invariants (no orphaned logs, no duplicate sequence
31
+ numbers, lost metadata updates cost nothing, acknowledged writes readable) against
32
+ the real Drive API rather than the test fake.
33
+
34
+
3
35
  ## 0.1.6 — 2026-08-27
4
36
 
5
37
  A data-loss fix. Every client sharing a folder should be upgraded together; see
package/lib/adapter.js CHANGED
@@ -552,32 +552,47 @@ function GoogleDriveAdapter(PouchDB) {
552
552
  liveListener = (changedDocs) => {
553
553
  if (complete)
554
554
  return;
555
- for (const id of Object.keys(changedDocs)) {
556
- if (id.startsWith('_local/'))
557
- continue;
558
- const entry = db.getIndexEntry(id);
559
- if (entry && entry.seq > lastSeq) {
555
+ // Gate the whole batch against where the feed stood BEFORE it,
556
+ // then emit in seq order and advance once at the end. The old
557
+ // shape - unordered iteration, lastSeq bumped per emission - is
558
+ // the initial pass's bug in another spot (its own comment: "an
559
+ // unordered batch can checkpoint past a change it never
560
+ // emitted"): a higher-seq doc processed first raised the bar,
561
+ // and a lower-seq sibling in the same batch failed `> lastSeq`
562
+ // and was never delivered, while the checkpoint moved past it.
563
+ const gate = lastSeq;
564
+ const batch = Object.keys(changedDocs)
565
+ .filter(id => !id.startsWith('_local/'))
566
+ .map(id => ({ id, entry: db.getIndexEntry(id) }))
567
+ .filter((row) => Boolean(row.entry) && row.entry.seq > gate)
568
+ .sort((a, b) => a.entry.seq - b.entry.seq);
569
+ if (batch.length === 0)
570
+ return;
571
+ const emit = (bodies) => {
572
+ for (const { id, entry } of batch) {
560
573
  const change = {
561
- id: id,
574
+ id,
562
575
  seq: entry.seq,
563
576
  changes: buildChangesList(entry, opts.style)
564
577
  };
565
- if (opts.include_docs) {
566
- // Same tombstone rule as the initial pass: a `null` body
567
- // makes a filtered replication drop the deletion.
568
- loadChangeBodies([{ id, entry }]).then(bodies => {
569
- change.doc = bodies[id];
570
- if (opts.onChange)
571
- opts.onChange(change);
572
- lastSeq = Math.max(lastSeq, change.seq);
573
- }).catch(e => log('Live change body fetch error', e));
574
- }
575
- else {
576
- if (opts.onChange)
577
- opts.onChange(change);
578
- lastSeq = Math.max(lastSeq, change.seq);
579
- }
578
+ // Same tombstone rule as the initial pass: a `null` body
579
+ // makes a filtered replication drop the deletion.
580
+ if (bodies)
581
+ change.doc = bodies[id];
582
+ if (opts.onChange)
583
+ opts.onChange(change);
584
+ lastSeq = Math.max(lastSeq, entry.seq);
580
585
  }
586
+ };
587
+ if (opts.include_docs) {
588
+ // Bodies for the whole batch first, then emit in order -
589
+ // per-row fetches resolved in whatever order the network
590
+ // chose, which reordered emissions and raced the gate.
591
+ loadChangeBodies(batch).then(emit)
592
+ .catch(e => log('Live change body fetch error', e));
593
+ }
594
+ else {
595
+ emit(null);
581
596
  }
582
597
  };
583
598
  cancelLive = db.onChange(liveListener);
package/lib/drive.d.ts CHANGED
@@ -118,6 +118,9 @@ export declare class DriveHandler {
118
118
  private discardLog;
119
119
  /** Update Index with a new change */
120
120
  private updateIndex;
121
+ /** Run `fn` over `items` with at most `limit` in flight at once, preserving
122
+ * result order. */
123
+ private mapBounded;
121
124
  private checkConflicts;
122
125
  /** Compact: Create SnapshotIndex + SnapshotData */
123
126
  compact(): Promise<void>;
package/lib/drive.js CHANGED
@@ -5,11 +5,14 @@ exports.writerSlotFor = writerSlotFor;
5
5
  exports.writerIdFromLogName = writerIdFromLogName;
6
6
  const cache_1 = require("./cache");
7
7
  const client_1 = require("./client");
8
+ const pouchdb_merge_1 = require("pouchdb-merge");
8
9
  const DEFAULT_COMPACTION_THRESHOLD = 100; // entries
9
10
  const DEFAULT_SIZE_THRESHOLD = 1024 * 1024; // 1MB
10
11
  const DEFAULT_CACHE_SIZE = 1000; // Number of docs
11
12
  const META_COMMIT_RETRIES = 6; // read-modify-write attempts per _meta.json commit
12
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
13
16
  /**
14
17
  * Sequence numbers are `tick * SEQ_SLOTS + writerSlot`.
15
18
  *
@@ -249,8 +252,14 @@ class DriveHandler {
249
252
  const pendingLogs = [...this.meta.changeLogIds, ...discovered]
250
253
  .filter(id => !this.processedLogIds.has(id) && !retired.has(id));
251
254
  if (pendingLogs.length > 0) {
252
- this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
253
- 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) => {
254
263
  try {
255
264
  const changes = await this.downloadNdjson(id);
256
265
  return { id, changes };
@@ -259,7 +268,7 @@ class DriveHandler {
259
268
  this.log(`Failed to download change log ${id}`, e);
260
269
  return { id, changes: null };
261
270
  }
262
- }));
271
+ });
263
272
  // Replay in sequence order, not in the order the ids happened to
264
273
  // be listed. Two clients can hold different changeLogIds orderings
265
274
  // for the same folder once merges are in play, and updateIndex
@@ -855,17 +864,92 @@ class DriveHandler {
855
864
  this.index[change.id] = entry;
856
865
  return;
857
866
  }
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).
862
- this.index[change.id] = {
863
- tree: synthesizeTree(change.rev, change.deleted),
864
- rev: change.rev,
865
- seq: change.seq,
866
- deleted: !!change.deleted,
867
- location: { fileId }
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
868
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;
869
953
  }
870
954
  checkConflicts(changes) {
871
955
  for (const change of changes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docstack/pouchdb-adapter-googledrive",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "PouchDB adapter for Google Drive",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -9,7 +9,8 @@
9
9
  "test": "jest",
10
10
  "test:prod": "TEST_ENV=production jest",
11
11
  "test:prod:explore": "TEST_ENV=production jest tests/production.explore.test.ts",
12
- "test:prod:replication": "TEST_ENV=production jest tests/production.replication.test.ts"
12
+ "test:prod:replication": "TEST_ENV=production jest tests/production.replication.test.ts",
13
+ "test:prod:concurrency": "TEST_ENV=production jest tests/production.concurrency.test.ts"
13
14
  },
14
15
  "keywords": [
15
16
  "pouchdb",
@@ -49,4 +50,4 @@
49
50
  "ts-jest": "^29.4.6",
50
51
  "typescript": "^5.0.0"
51
52
  }
52
- }
53
+ }