@docstack/pouchdb-adapter-googledrive 0.1.7 → 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,6 +1,66 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.7 — unreleased
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
4
64
 
5
65
  ### Fixed
6
66
 
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
@@ -569,7 +569,8 @@ function GoogleDriveAdapter(PouchDB) {
569
569
  if (batch.length === 0)
570
570
  return;
571
571
  const emit = (bodies) => {
572
- for (const { id, entry } of batch) {
572
+ for (let i = 0; i < batch.length; i++) {
573
+ const { id, entry } = batch[i];
573
574
  const change = {
574
575
  id,
575
576
  seq: entry.seq,
@@ -579,9 +580,11 @@ function GoogleDriveAdapter(PouchDB) {
579
580
  // makes a filtered replication drop the deletion.
580
581
  if (bodies)
581
582
  change.doc = bodies[id];
582
- if (opts.onChange)
583
- opts.onChange(change);
584
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);
585
588
  }
586
589
  };
587
590
  if (opts.include_docs) {
@@ -668,9 +671,10 @@ function GoogleDriveAdapter(PouchDB) {
668
671
  const bodies = opts.include_docs
669
672
  ? await loadChangeBodies(batch)
670
673
  : null;
671
- for (const { id, entry } of batch) {
674
+ for (let i = 0; i < batch.length; i++) {
672
675
  if (complete)
673
676
  break;
677
+ const { id, entry } = batch[i];
674
678
  const change = {
675
679
  id: id,
676
680
  seq: entry.seq,
@@ -678,17 +682,32 @@ function GoogleDriveAdapter(PouchDB) {
678
682
  };
679
683
  if (bodies)
680
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.
681
692
  if (opts.onChange)
682
- opts.onChange(change);
693
+ opts.onChange(change, pending.length - (i + 1), lastSeq);
683
694
  if (returnDocs)
684
695
  results.push(change);
685
- lastSeq = Math.max(lastSeq, entry.seq);
686
696
  }
687
697
  // ✅ Call opts.complete() ONLY for non-live modes
688
698
  // PouchDB replication will infinite-loop reconnect if we call complete() on a live feed
689
699
  if (opts.complete && !complete && !opts.live) {
690
- log('_changes calling complete callback with', { results_count: results.length, last_seq: lastSeq });
691
- 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 });
692
711
  }
693
712
  }
694
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) */
@@ -144,6 +153,13 @@ export declare class DriveHandler {
144
153
  /** Put back any such log. Returns `latest` by identity when there is nothing to
145
154
  * repair, so callers can tell the two cases apart. */
146
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;
147
163
  /**
148
164
  * Read-modify-write `_meta.json`.
149
165
  *
package/lib/drive.js CHANGED
@@ -74,6 +74,18 @@ class DriveHandler {
74
74
  log(...args) {
75
75
  console.log(`[googledrive-drive] [${this.meta.dbName}]`, ...args);
76
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
+ }
77
89
  constructor(options, dbName) {
78
90
  this.folderId = null;
79
91
  this.meta = {
@@ -100,6 +112,13 @@ class DriveHandler {
100
112
  * between. Anything still in here but missing from the remote changeLogIds was
101
113
  * dropped that way and gets put back - see reconcileOwnLogs(). */
102
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();
103
122
  /** This writer's reservation in the low digits of every sequence number it mints.
104
123
  * Derived from writerId; changes only when writerId is re-rolled off a
105
124
  * contested slot. */
@@ -185,7 +204,8 @@ class DriveHandler {
185
204
  // compaction retiring it was dropped by another writer's
186
205
  // read-modify-write. Put it back before replaying, so this load sees
187
206
  // its own writes - and so the next reader does too.
188
- if (this.hasOrphanedOwnLogs(this.meta)) {
207
+ if (this.hasOrphanedOwnLogs(this.meta) ||
208
+ this.meta.changeLogIds.some(id => this.deadLogIds.has(id))) {
189
209
  await this.commitMeta((latest, repaired) => repaired ? latest : null);
190
210
  }
191
211
  if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
@@ -252,6 +272,7 @@ class DriveHandler {
252
272
  const pendingLogs = [...this.meta.changeLogIds, ...discovered]
253
273
  .filter(id => !this.processedLogIds.has(id) && !retired.has(id));
254
274
  if (pendingLogs.length > 0) {
275
+ this.reportProgress('replay', 0, pendingLogs.length);
255
276
  this.log(`Downloading ${pendingLogs.length} change logs, ${LOG_DOWNLOAD_CONCURRENCY} at a time`);
256
277
  // Bounded, not unbounded: a cold boot can hold dozens of pending
257
278
  // logs, and firing them all at once is exactly the burst Drive's
@@ -265,7 +286,23 @@ class DriveHandler {
265
286
  return { id, changes };
266
287
  }
267
288
  catch (e) {
268
- 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
+ }
269
306
  return { id, changes: null };
270
307
  }
271
308
  });
@@ -282,9 +319,15 @@ class DriveHandler {
282
319
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
283
320
  });
284
321
  const foundNew = {};
322
+ let replayed = 0;
285
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++;
286
328
  if (!changes) {
287
329
  this.log(`Skipping failed log ${id}`);
330
+ this.reportProgress('replay', replayed, logResults.length);
288
331
  continue;
289
332
  }
290
333
  let changesArray = Array.isArray(changes) ? changes : [changes];
@@ -302,6 +345,7 @@ class DriveHandler {
302
345
  };
303
346
  }
304
347
  this.processedLogIds.add(id);
348
+ this.reportProgress('replay', replayed, logResults.length);
305
349
  }
306
350
  if (Object.keys(foundNew).length > 0) {
307
351
  this.log('Load complete, notifying of changes', Object.keys(foundNew).length);
@@ -324,6 +368,14 @@ class DriveHandler {
324
368
  return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
325
369
  });
326
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
+ }
327
379
  // 2b. Load Local Documents Store (Pinned in meta)
328
380
  if (this.meta.localDocsId) {
329
381
  this.log('Loading local docs store', this.meta.localDocsId);
@@ -742,7 +794,15 @@ class DriveHandler {
742
794
  // itself never does.)
743
795
  if (current && this.hasUnprocessedLogs(current.meta)) {
744
796
  if (++catchUps > META_COMMIT_RETRIES) {
745
- 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');
746
806
  }
747
807
  await this.load();
748
808
  // Only the low-level appendChange() callers are checked here.
@@ -1227,6 +1287,26 @@ class DriveHandler {
1227
1287
  this.log('Restoring change logs dropped by another writer', missing);
1228
1288
  return { ...latest, changeLogIds: [...latest.changeLogIds, ...missing] };
1229
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
+ }
1230
1310
  /**
1231
1311
  * Read-modify-write `_meta.json`.
1232
1312
  *
@@ -1242,7 +1322,7 @@ class DriveHandler {
1242
1322
  const current = await this.readRemoteMeta();
1243
1323
  if (!current)
1244
1324
  throw new Error('Meta missing');
1245
- const reconciled = this.reconcileOwnLogs(current.meta);
1325
+ const reconciled = this.pruneDeadLogs(this.reconcileOwnLogs(current.meta));
1246
1326
  const next = modify(reconciled, reconciled !== current.meta);
1247
1327
  if (!next)
1248
1328
  return null;
@@ -1260,7 +1340,21 @@ class DriveHandler {
1260
1340
  this.meta = next;
1261
1341
  return next;
1262
1342
  }
1263
- 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
+ }
1264
1358
  if (after && opts.verify(after)) {
1265
1359
  // Adopt the remote view rather than our own - it carries whatever
1266
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.7",
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",