@docstack/pouchdb-adapter-googledrive 0.1.6 → 0.1.9

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,97 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8/9
4
+
5
+ ### Fixed
6
+
7
+ - **A connectivity blip on the read that verifies a metadata commit no longer
8
+ deletes the committed change log.** The commit had landed; the failed read made
9
+ the writer treat it as unpublished and clean up a log the metadata referenced — a
10
+ dangling reference, and the document behind it gone despite a successful write. A
11
+ landed write is now reported as committed even when its verification read fails.
12
+ A hard kill at the same point survived only by accident: severed connectivity made
13
+ the cleanup delete fail too. A blip restores connectivity in time for the delete
14
+ to succeed, which is what made this the worse case.
15
+
16
+ - **One missing change log no longer stops every future write.** A reference to a
17
+ change log that answers 404 - damage the verify-blip bug above left in real
18
+ folders - kept the log permanently unprocessed, and every write burned its whole
19
+ catch-up budget re-reading it, then threw a message blaming concurrent writers.
20
+ A folder in that state accepted no writes from any device, indefinitely, while
21
+ reporting active and converging on schedule. A 404 is now an answer: the log is
22
+ written off, the reference pruned and tombstoned on the first load that proves it
23
+ dead, and the catch-up error names unreadable logs instead of guessing at
24
+ writers. Transient failures keep their retry. (ADR-0008 / finding 0007)
25
+
26
+ ### Added
27
+
28
+ - **Sync progress for consumers.** The changes feed now reports `pending` — the
29
+ CouchDB field PouchDB's progress pipeline already understands — so replication
30
+ `change` events and `activeTasks` can drive a progress bar as
31
+ `docs_written / (docs_written + pending)`. The count was already being computed
32
+ to cut each batch, then thrown away. It travels two ways, because PouchDB reads
33
+ them differently: per change through `onChange(change, pending, lastSeq)` — the
34
+ only form pouchdb-replication actually consumes for its events — and on the
35
+ complete response, CouchDB style. Verified end to end: a replication against a
36
+ memory target reports a decreasing `pending` on every change event. This is also
37
+ the only percentage source left:
38
+ `update_seq` ratios stopped meaning anything when sequence numbers went sparse.
39
+
40
+ - **`onSyncProgress` adapter option** — reports change-log replay during `load()`
41
+ (`{ phase: 'replay', done, total }`), the cold-connect phase where a busy folder
42
+ (72 logs in one field report) previously looked frozen. Always reaches `total`
43
+ even when a straggler download fails; a throwing callback cannot fail the load.
44
+
45
+ - `tests/dead_log.test.ts` — five cases around the write outage: writes continue
46
+ past a dangling reference, the folder heals on first load, the log's own writer
47
+ does not resurrect a pruned reference, transient failures keep their retry, and
48
+ the error names the unreadable log.
49
+
50
+ - `tests/sync_progress.test.ts` — seven cases for the progress feedback, including
51
+ the end-to-end check that `pending` survives the trip through pouchdb-replication
52
+ onto its 'change' events.
53
+
54
+ - `tests/interruption_recovery.test.ts` — six scenarios for a tab killed mid-write
55
+ (after the log upload; after the metadata commit), a connectivity blip on exactly
56
+ the verification read, a connectivity pause and recovery, a frozen client
57
+ resuming against a folder that moved on, and polling through an outage. The
58
+ invariant throughout: an acknowledged write survives any interruption that
59
+ follows; a rejected one may vanish or become visible later, but never takes an
60
+ acknowledged write with it.
61
+
62
+
63
+ ## 0.1.7 — 2026-08-27
64
+
65
+ ### Fixed
66
+
67
+ - **A re-replayed old change log no longer regresses the reader's index.** A log
68
+ download clipped by the rate limiter was retried on a later load — after higher
69
+ logs had applied — and the replay path replaced index entries blindly, rewriting
70
+ rev/seq/location to older state. The regressed entry then failed the changes
71
+ feed's gate, the newer revision was never emitted, and the puller's checkpoint
72
+ sealed the loss: both devices idle and up to date while holding different data.
73
+ Replay now merges into the existing entry and never moves a document backwards;
74
+ winners are decided by revision generation (a stale revision echoed at a fresh
75
+ sequence number loses too), and losing revisions stay reachable as conflicts.
76
+ Field report and reasoning: ADR-0004 / finding 0006, in the repository.
77
+
78
+ - **Cold-boot change-log downloads are bounded to 8 in flight**, not one burst of
79
+ everything pending — the burst was what invited the rate limiting that created
80
+ out-of-order retries in the first place.
81
+
82
+ - **The live changes listener emits each batch in sequence order** and advances its
83
+ checkpoint as it emits, instead of gating each document against a bar that other
84
+ documents in the same batch had already raised — the initial pass's own fix,
85
+ applied to the listener.
86
+
87
+ ### Added
88
+
89
+ - `tests/production.concurrency.test.ts` (`npm run test:prod:concurrency`) —
90
+ re-checks the multi-writer invariants (no orphaned logs, no duplicate sequence
91
+ numbers, lost metadata updates cost nothing, acknowledged writes readable) against
92
+ the real Drive API rather than the test fake.
93
+
94
+
3
95
  ## 0.1.6 — 2026-08-27
4
96
 
5
97
  A data-loss fix. Every client sharing a folder should be upgraded together; see
package/README.md CHANGED
@@ -80,6 +80,44 @@ A tick costs one `files.list`, whatever has changed; only a tick that sees a new
80
80
  `md5Checksum` (or, failing that, a new `modifiedTime`) goes on to fetch anything.
81
81
  `db.close()` and `db.destroy()` stop it.
82
82
 
83
+ ### Sync progress
84
+
85
+ Two signals, one per phase where a UI would otherwise show nothing:
86
+
87
+ **Replication progress** rides PouchDB's standard pipeline. The adapter's changes
88
+ feed reports `pending` (the CouchDB field), so replication `change` events and
89
+ `activeTasks` carry it without any adapter-specific wiring:
90
+
91
+ ```typescript
92
+ const rep = PouchDB.replicate(remote, local);
93
+ rep.on('change', info => {
94
+ const done = info.docs_written;
95
+ const pct = Math.round(done / (done + info.pending) * 100);
96
+ // Freeze the denominator at cycle start, or clamp the bar monotone —
97
+ // other devices keep writing, so `pending` can grow mid-cycle.
98
+ });
99
+ ```
100
+
101
+ Do not derive progress from `update_seq` arithmetic: sequence numbers are sparse
102
+ (they carry a writer slot in the low digits), so ratios of them mean nothing.
103
+
104
+ **Connect progress** covers the cold load, where a busy folder replays dozens of
105
+ change logs before the database is usable:
106
+
107
+ ```typescript
108
+ GoogleDriveAdapter({
109
+ accessToken: '...',
110
+ folderId: 'my-folder-id',
111
+ onSyncProgress: ({ phase, done, total }) => {
112
+ // phase 'replay': applying change logs, done of total for this load.
113
+ }
114
+ });
115
+ ```
116
+
117
+ The callback is fire-and-forget: it always reaches `total` even when a log download
118
+ fails (that log is retried on a later load), and an exception it throws cannot fail
119
+ the load.
120
+
83
121
  ## Concurrent writers
84
122
 
85
123
  Several clients may share one folder. What that costs, and what it does not:
package/lib/adapter.js CHANGED
@@ -552,32 +552,50 @@ 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 (let i = 0; i < batch.length; i++) {
573
+ const { id, entry } = batch[i];
560
574
  const change = {
561
- id: id,
575
+ id,
562
576
  seq: entry.seq,
563
577
  changes: buildChangesList(entry, opts.style)
564
578
  };
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
- }
579
+ // Same tombstone rule as the initial pass: a `null` body
580
+ // makes a filtered replication drop the deletion.
581
+ if (bodies)
582
+ change.doc = bodies[id];
583
+ lastSeq = Math.max(lastSeq, entry.seq);
584
+ // Same progress contract as the initial pass; for a live
585
+ // batch the horizon is the batch itself.
586
+ if (opts.onChange)
587
+ opts.onChange(change, batch.length - (i + 1), lastSeq);
580
588
  }
589
+ };
590
+ if (opts.include_docs) {
591
+ // Bodies for the whole batch first, then emit in order -
592
+ // per-row fetches resolved in whatever order the network
593
+ // chose, which reordered emissions and raced the gate.
594
+ loadChangeBodies(batch).then(emit)
595
+ .catch(e => log('Live change body fetch error', e));
596
+ }
597
+ else {
598
+ emit(null);
581
599
  }
582
600
  };
583
601
  cancelLive = db.onChange(liveListener);
@@ -653,9 +671,10 @@ function GoogleDriveAdapter(PouchDB) {
653
671
  const bodies = opts.include_docs
654
672
  ? await loadChangeBodies(batch)
655
673
  : null;
656
- for (const { id, entry } of batch) {
674
+ for (let i = 0; i < batch.length; i++) {
657
675
  if (complete)
658
676
  break;
677
+ const { id, entry } = batch[i];
659
678
  const change = {
660
679
  id: id,
661
680
  seq: entry.seq,
@@ -663,17 +682,32 @@ function GoogleDriveAdapter(PouchDB) {
663
682
  };
664
683
  if (bodies)
665
684
  change.doc = bodies[id];
685
+ lastSeq = Math.max(lastSeq, entry.seq);
686
+ // The second and third arguments are how progress actually
687
+ // reaches a consumer: pouchdb-replication reads `pending` from
688
+ // onChange's arguments (never from the complete response) and
689
+ // surfaces it on its own 'change' events. `pending` here is
690
+ // CouchDB's meaning - eligible changes remaining AFTER this row,
691
+ // both the rest of this batch and everything beyond the limit.
666
692
  if (opts.onChange)
667
- opts.onChange(change);
693
+ opts.onChange(change, pending.length - (i + 1), lastSeq);
668
694
  if (returnDocs)
669
695
  results.push(change);
670
- lastSeq = Math.max(lastSeq, entry.seq);
671
696
  }
672
697
  // ✅ Call opts.complete() ONLY for non-live modes
673
698
  // PouchDB replication will infinite-loop reconnect if we call complete() on a live feed
674
699
  if (opts.complete && !complete && !opts.live) {
675
- log('_changes calling complete callback with', { results_count: results.length, last_seq: lastSeq });
676
- opts.complete(null, { results, last_seq: lastSeq });
700
+ // `pending` is what CouchDB reports and what PouchDB's whole
701
+ // progress pipeline runs on: replication surfaces it on 'change'
702
+ // events and in activeTasks' total_items. The number was already
703
+ // computed to cut the batch; consumers turn it into a progress
704
+ // bar as docs_written / (docs_written + pending). It is also the
705
+ // only percentage source left standing - update_seq arithmetic
706
+ // stopped meaning anything when sequence numbers went sparse
707
+ // (ADR-0003).
708
+ const remaining = Math.max(0, pending.length - batch.length);
709
+ log('_changes calling complete callback with', { results_count: results.length, last_seq: lastSeq, pending: remaining });
710
+ opts.complete(null, { results, last_seq: lastSeq, pending: remaining });
677
711
  }
678
712
  }
679
713
  // Start processing (async) which will wait for load() via getIndexKeys()
package/lib/drive.d.ts CHANGED
@@ -56,6 +56,13 @@ export declare class DriveHandler {
56
56
  * between. Anything still in here but missing from the remote changeLogIds was
57
57
  * dropped that way and gets put back - see reconcileOwnLogs(). */
58
58
  private ownLogIds;
59
+ /** Change logs proven gone: the metadata references them and Drive answers 404.
60
+ * A dangling reference is damage some earlier defect already did (the 0.1.8
61
+ * verify-blip bug deleted committed logs); what it must not do is compound -
62
+ * a log that can only 404 for ever kept hasUnprocessedLogs() true for ever,
63
+ * and every write spent its whole catch-up budget re-reading it and threw.
64
+ * One lost change became a permanent, silent write outage. See finding 0007. */
65
+ private deadLogIds;
59
66
  /** This writer's reservation in the low digits of every sequence number it mints.
60
67
  * Derived from writerId; changes only when writerId is re-rolled off a
61
68
  * contested slot. */
@@ -80,6 +87,8 @@ export declare class DriveHandler {
80
87
  private pendingDownloads;
81
88
  private pendingFinds;
82
89
  private log;
90
+ /** Feed the consumer's progress callback, if any. Its failures are its own. */
91
+ private reportProgress;
83
92
  constructor(options: GoogleDriveAdapterOptions, dbName: string);
84
93
  get seq(): number;
85
94
  /** Load the database (Index Only) */
@@ -118,6 +127,9 @@ export declare class DriveHandler {
118
127
  private discardLog;
119
128
  /** Update Index with a new change */
120
129
  private updateIndex;
130
+ /** Run `fn` over `items` with at most `limit` in flight at once, preserving
131
+ * result order. */
132
+ private mapBounded;
121
133
  private checkConflicts;
122
134
  /** Compact: Create SnapshotIndex + SnapshotData */
123
135
  compact(): Promise<void>;
@@ -141,6 +153,13 @@ export declare class DriveHandler {
141
153
  /** Put back any such log. Returns `latest` by identity when there is nothing to
142
154
  * repair, so callers can tell the two cases apart. */
143
155
  private reconcileOwnLogs;
156
+ /** Drop references this client has proven dead from `changeLogIds`, moving them
157
+ * into `retiredLogIds` - the tombstone that stops the log's own writer from
158
+ * restoring them (its reconcile respects retirement) and any client from
159
+ * re-adopting them. Without the tombstone, prune-and-restore would ping-pong
160
+ * between the pruning reader and the writer that still remembers the log.
161
+ * Returns `latest` by identity when there is nothing to prune. */
162
+ private pruneDeadLogs;
144
163
  /**
145
164
  * Read-modify-write `_meta.json`.
146
165
  *
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
  *
@@ -71,6 +74,18 @@ class DriveHandler {
71
74
  log(...args) {
72
75
  console.log(`[googledrive-drive] [${this.meta.dbName}]`, ...args);
73
76
  }
77
+ /** Feed the consumer's progress callback, if any. Its failures are its own. */
78
+ reportProgress(phase, done, total) {
79
+ const cb = this.options.onSyncProgress;
80
+ if (!cb)
81
+ return;
82
+ try {
83
+ cb({ phase, done, total });
84
+ }
85
+ catch (e) {
86
+ this.log('onSyncProgress callback threw', e);
87
+ }
88
+ }
74
89
  constructor(options, dbName) {
75
90
  this.folderId = null;
76
91
  this.meta = {
@@ -97,6 +112,13 @@ class DriveHandler {
97
112
  * between. Anything still in here but missing from the remote changeLogIds was
98
113
  * dropped that way and gets put back - see reconcileOwnLogs(). */
99
114
  this.ownLogIds = new Set();
115
+ /** Change logs proven gone: the metadata references them and Drive answers 404.
116
+ * A dangling reference is damage some earlier defect already did (the 0.1.8
117
+ * verify-blip bug deleted committed logs); what it must not do is compound -
118
+ * a log that can only 404 for ever kept hasUnprocessedLogs() true for ever,
119
+ * and every write spent its whole catch-up budget re-reading it and threw.
120
+ * One lost change became a permanent, silent write outage. See finding 0007. */
121
+ this.deadLogIds = new Set();
100
122
  /** This writer's reservation in the low digits of every sequence number it mints.
101
123
  * Derived from writerId; changes only when writerId is re-rolled off a
102
124
  * contested slot. */
@@ -182,7 +204,8 @@ class DriveHandler {
182
204
  // compaction retiring it was dropped by another writer's
183
205
  // read-modify-write. Put it back before replaying, so this load sees
184
206
  // its own writes - and so the next reader does too.
185
- if (this.hasOrphanedOwnLogs(this.meta)) {
207
+ if (this.hasOrphanedOwnLogs(this.meta) ||
208
+ this.meta.changeLogIds.some(id => this.deadLogIds.has(id))) {
186
209
  await this.commitMeta((latest, repaired) => repaired ? latest : null);
187
210
  }
188
211
  if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
@@ -249,17 +272,40 @@ class DriveHandler {
249
272
  const pendingLogs = [...this.meta.changeLogIds, ...discovered]
250
273
  .filter(id => !this.processedLogIds.has(id) && !retired.has(id));
251
274
  if (pendingLogs.length > 0) {
252
- this.log(`Downloading ${pendingLogs.length} change logs in parallel`);
253
- const logResults = await Promise.all(pendingLogs.map(async (id) => {
275
+ this.reportProgress('replay', 0, pendingLogs.length);
276
+ this.log(`Downloading ${pendingLogs.length} change logs, ${LOG_DOWNLOAD_CONCURRENCY} at a time`);
277
+ // Bounded, not unbounded: a cold boot can hold dozens of pending
278
+ // logs, and firing them all at once is exactly the burst Drive's
279
+ // rate limiter clips. A clipped download is skipped and retried on
280
+ // a later load - out of order, which used to regress the index
281
+ // (see updateIndex); the guard there is the fix, this is the
282
+ // prevention.
283
+ const logResults = await this.mapBounded(pendingLogs, LOG_DOWNLOAD_CONCURRENCY, async (id) => {
254
284
  try {
255
285
  const changes = await this.downloadNdjson(id);
256
286
  return { id, changes };
257
287
  }
258
288
  catch (e) {
259
- this.log(`Failed to download change log ${id}`, e);
289
+ // A 404 is an answer, not an outage: the file is gone and
290
+ // no retry will bring it back. Recording it as processed
291
+ // costs the changes it held - already lost - and keeps
292
+ // that loss from also stopping every future write. Any
293
+ // other failure stays retryable, as it should: retrying
294
+ // is right when the file is there and the network was not.
295
+ if (e?.status === 404) {
296
+ this.log(`Change log ${id} is gone for good, writing it off`, e?.message);
297
+ this.deadLogIds.add(id);
298
+ this.processedLogIds.add(id);
299
+ // Not ours to defend any more either - reconcile
300
+ // would resurrect the reference for ever.
301
+ this.ownLogIds.delete(id);
302
+ }
303
+ else {
304
+ this.log(`Failed to download change log ${id}`, e);
305
+ }
260
306
  return { id, changes: null };
261
307
  }
262
- }));
308
+ });
263
309
  // Replay in sequence order, not in the order the ids happened to
264
310
  // be listed. Two clients can hold different changeLogIds orderings
265
311
  // for the same folder once merges are in play, and updateIndex
@@ -273,9 +319,15 @@ class DriveHandler {
273
319
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
274
320
  });
275
321
  const foundNew = {};
322
+ let replayed = 0;
276
323
  for (const { id, changes } of logResults) {
324
+ // A failed download still counts toward the cycle - the bar
325
+ // must reach total even when a straggler is left for the next
326
+ // load, or it sits at 71/72 looking stuck.
327
+ replayed++;
277
328
  if (!changes) {
278
329
  this.log(`Skipping failed log ${id}`);
330
+ this.reportProgress('replay', replayed, logResults.length);
279
331
  continue;
280
332
  }
281
333
  let changesArray = Array.isArray(changes) ? changes : [changes];
@@ -293,6 +345,7 @@ class DriveHandler {
293
345
  };
294
346
  }
295
347
  this.processedLogIds.add(id);
348
+ this.reportProgress('replay', replayed, logResults.length);
296
349
  }
297
350
  if (Object.keys(foundNew).length > 0) {
298
351
  this.log('Load complete, notifying of changes', Object.keys(foundNew).length);
@@ -315,6 +368,14 @@ class DriveHandler {
315
368
  return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
316
369
  });
317
370
  }
371
+ // A reference proven dead during THIS replay (the 404s land in the
372
+ // download loop above, after the pre-replay repair has run) is
373
+ // pruned now, so a damaged folder heals on the first load that
374
+ // notices - by any client, reader or writer - rather than carrying
375
+ // a permanent 404 and a permanent log line until someone writes.
376
+ if (this.meta.changeLogIds.some(id => this.deadLogIds.has(id))) {
377
+ await this.commitMeta((latest, repaired) => repaired ? latest : null);
378
+ }
318
379
  // 2b. Load Local Documents Store (Pinned in meta)
319
380
  if (this.meta.localDocsId) {
320
381
  this.log('Loading local docs store', this.meta.localDocsId);
@@ -733,7 +794,15 @@ class DriveHandler {
733
794
  // itself never does.)
734
795
  if (current && this.hasUnprocessedLogs(current.meta)) {
735
796
  if (++catchUps > META_COMMIT_RETRIES) {
736
- throw new Error('Could not catch up with concurrent writers');
797
+ // Naming what actually blocked us. The old message blamed
798
+ // concurrent writers unconditionally, and sent a real
799
+ // investigation looking for a second writer when the cause was
800
+ // a change log that could not be read.
801
+ const unreadable = current.meta.changeLogIds
802
+ .filter(id => !this.processedLogIds.has(id));
803
+ throw new Error(unreadable.length > 0
804
+ ? `Could not catch up: change log(s) ${unreadable.join(', ')} could not be read after ${META_COMMIT_RETRIES} attempts`
805
+ : 'Could not catch up with concurrent writers');
737
806
  }
738
807
  await this.load();
739
808
  // Only the low-level appendChange() callers are checked here.
@@ -855,17 +924,92 @@ class DriveHandler {
855
924
  this.index[change.id] = entry;
856
925
  return;
857
926
  }
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 }
927
+ // Legacy path: no computed tree. Every row a reader replays lands here,
928
+ // because `nextIndexEntry` is stripped before upload - which makes this the
929
+ // path that decides what a replica believes.
930
+ //
931
+ // It used to replace the entry blindly, and a row replayed out of order
932
+ // rewrote rev, seq and location to an older state. Out-of-order replay is
933
+ // routine, not exotic: a change-log download clipped by the rate limiter is
934
+ // skipped and retried on a later load, after higher logs have applied. The
935
+ // regressed entry then fails the changes feed's `seq > since` gate, so the
936
+ // newer revision is never emitted, and the puller's checkpoint advances past
937
+ // it - silent, permanent loss on the reading side while the folder holds
938
+ // everything. A writer echoing a stale revision at a fresh seq regressed the
939
+ // winner the same way, no retry needed.
940
+ //
941
+ // So: merge instead of replace, and never let a replay move a document
942
+ // backwards. The winner is decided by revision generation (hash tie-break),
943
+ // not pouchdb-merge's winningRev - synthesized nodes carry no ancestry, so
944
+ // every rev is a leaf to winningRev, and its live-leaf preference would let
945
+ // an old live rev beat a genuine deletion at a higher generation.
946
+ const existing = this.index[change.id];
947
+ if (!existing) {
948
+ this.index[change.id] = {
949
+ tree: synthesizeTree(change.rev, change.deleted),
950
+ rev: change.rev,
951
+ seq: change.seq,
952
+ deleted: !!change.deleted,
953
+ location: { fileId }
954
+ };
955
+ return;
956
+ }
957
+ // Never regress the seq, whatever else happens - the feed gates on it.
958
+ const seq = Math.max(existing.seq, change.seq);
959
+ let existingTree;
960
+ try {
961
+ existingTree = JSON.parse(existing.tree);
962
+ }
963
+ catch {
964
+ existingTree = JSON.parse(synthesizeTree(existing.rev, existing.deleted));
965
+ }
966
+ if (change.rev === existing.rev || (0, pouchdb_merge_1.revExists)(existingTree, change.rev)) {
967
+ // A re-replay of something already known: nothing to change but the seq.
968
+ if (seq !== existing.seq)
969
+ this.index[change.id] = { ...existing, seq };
970
+ return;
971
+ }
972
+ const incomingPath = JSON.parse(synthesizeTree(change.rev, change.deleted))[0];
973
+ const mergedTree = (0, pouchdb_merge_1.merge)(existingTree, incomingPath, MERGE_DEPTH).tree;
974
+ const genOf = (rev) => parseInt(rev.split('-')[0], 10) || 0;
975
+ const hashOf = (rev) => rev.slice(rev.indexOf('-') + 1);
976
+ const incomingWins = genOf(change.rev) !== genOf(existing.rev)
977
+ ? genOf(change.rev) > genOf(existing.rev)
978
+ : hashOf(change.rev) > hashOf(existing.rev); // CouchDB's deterministic tie-break
979
+ const entry = {
980
+ tree: JSON.stringify(mergedTree),
981
+ rev: incomingWins ? change.rev : existing.rev,
982
+ seq,
983
+ deleted: incomingWins ? !!change.deleted : !!existing.deleted,
984
+ location: incomingWins ? { fileId } : existing.location
868
985
  };
986
+ // The losing revision stays reachable as a conflict, matching what the
987
+ // nextIndexEntry path does for real merges.
988
+ const conflicts = { ...(existing.conflictLocations || {}) };
989
+ if (incomingWins) {
990
+ conflicts[existing.rev] = existing.location;
991
+ }
992
+ else {
993
+ conflicts[change.rev] = { fileId };
994
+ }
995
+ delete conflicts[entry.rev];
996
+ if (Object.keys(conflicts).length > 0)
997
+ entry.conflictLocations = conflicts;
998
+ this.index[change.id] = entry;
999
+ }
1000
+ /** Run `fn` over `items` with at most `limit` in flight at once, preserving
1001
+ * result order. */
1002
+ async mapBounded(items, limit, fn) {
1003
+ const results = new Array(items.length);
1004
+ let next = 0;
1005
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1006
+ while (next < items.length) {
1007
+ const i = next++;
1008
+ results[i] = await fn(items[i]);
1009
+ }
1010
+ });
1011
+ await Promise.all(workers);
1012
+ return results;
869
1013
  }
870
1014
  checkConflicts(changes) {
871
1015
  for (const change of changes) {
@@ -1143,6 +1287,26 @@ class DriveHandler {
1143
1287
  this.log('Restoring change logs dropped by another writer', missing);
1144
1288
  return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
1145
1289
  }
1290
+ /** Drop references this client has proven dead from `changeLogIds`, moving them
1291
+ * into `retiredLogIds` - the tombstone that stops the log's own writer from
1292
+ * restoring them (its reconcile respects retirement) and any client from
1293
+ * re-adopting them. Without the tombstone, prune-and-restore would ping-pong
1294
+ * between the pruning reader and the writer that still remembers the log.
1295
+ * Returns `latest` by identity when there is nothing to prune. */
1296
+ pruneDeadLogs(latest) {
1297
+ if (this.deadLogIds.size === 0)
1298
+ return latest;
1299
+ const dead = latest.changeLogIds.filter(id => this.deadLogIds.has(id));
1300
+ if (dead.length === 0)
1301
+ return latest;
1302
+ this.log('Pruning dead change-log references', dead);
1303
+ const retired = [...new Set([...(latest.retiredLogIds || []), ...dead])];
1304
+ return {
1305
+ ...latest,
1306
+ changeLogIds: latest.changeLogIds.filter(id => !this.deadLogIds.has(id)),
1307
+ retiredLogIds: retired.slice(-RETIRED_LOG_HISTORY)
1308
+ };
1309
+ }
1146
1310
  /**
1147
1311
  * Read-modify-write `_meta.json`.
1148
1312
  *
@@ -1158,7 +1322,7 @@ class DriveHandler {
1158
1322
  const current = await this.readRemoteMeta();
1159
1323
  if (!current)
1160
1324
  throw new Error('Meta missing');
1161
- const reconciled = this.reconcileOwnLogs(current.meta);
1325
+ const reconciled = this.pruneDeadLogs(this.reconcileOwnLogs(current.meta));
1162
1326
  const next = modify(reconciled, reconciled !== current.meta);
1163
1327
  if (!next)
1164
1328
  return null;
@@ -1176,7 +1340,21 @@ class DriveHandler {
1176
1340
  this.meta = next;
1177
1341
  return next;
1178
1342
  }
1179
- const after = await this.readMetaBody(current.pointer.fileId);
1343
+ // From here on the write HAS landed. Whatever the verify read does,
1344
+ // this attempt must never surface as "not committed" - a caller told
1345
+ // that would clean up a change log the metadata now references,
1346
+ // leaving a dangling reference and losing the document behind it. A
1347
+ // read failure is a read failure, not a failed commit: report the
1348
+ // commit as landed, unverified.
1349
+ let after;
1350
+ try {
1351
+ after = await this.readMetaBody(current.pointer.fileId);
1352
+ }
1353
+ catch (e) {
1354
+ this.log('Verify read failed after a landed write, reporting the commit as-is', e);
1355
+ this.meta = next;
1356
+ return next;
1357
+ }
1180
1358
  if (after && opts.verify(after)) {
1181
1359
  // Adopt the remote view rather than our own - it carries whatever
1182
1360
  // else landed alongside us.
package/lib/types.d.ts CHANGED
@@ -17,6 +17,19 @@ export interface GoogleDriveAdapterOptions extends DriveClientOptions {
17
17
  cacheSize?: number;
18
18
  /** Enable debug logging */
19
19
  debug?: boolean;
20
+ /**
21
+ * Called as load() replays change logs, the phase where a cold connect to a
22
+ * busy folder can otherwise look frozen (a real-world boot has been seen with
23
+ * 72 logs pending). `done` counts logs applied so far out of `total` for THIS
24
+ * load; a later load that finds new logs starts a fresh cycle at 0. Errors
25
+ * thrown by the callback are swallowed - progress reporting must never be able
26
+ * to fail a load.
27
+ */
28
+ onSyncProgress?: (progress: {
29
+ phase: 'replay';
30
+ done: number;
31
+ total: number;
32
+ }) => void;
20
33
  /** Enable test mode (emulates Google Drive API) */
21
34
  testMode?: boolean;
22
35
  /** Test server URL (defaults to http://localhost:3000) */
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.9",
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
+ }