@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 +92 -0
- package/README.md +38 -0
- package/lib/adapter.js +60 -26
- package/lib/drive.d.ts +19 -0
- package/lib/drive.js +196 -18
- package/lib/types.d.ts +13 -0
- package/package.json +4 -3
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
575
|
+
id,
|
|
562
576
|
seq: entry.seq,
|
|
563
577
|
changes: buildChangesList(entry, opts.style)
|
|
564
578
|
};
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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 (
|
|
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
|
-
|
|
676
|
-
|
|
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.
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|