@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 +32 -0
- package/lib/adapter.js +36 -21
- package/lib/drive.d.ts +3 -0
- package/lib/drive.js +97 -13
- package/package.json +4 -3
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
|
-
|
|
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 (const { id, entry } of batch) {
|
|
560
573
|
const change = {
|
|
561
|
-
id
|
|
574
|
+
id,
|
|
562
575
|
seq: entry.seq,
|
|
563
576
|
changes: buildChangesList(entry, opts.style)
|
|
564
577
|
};
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
253
|
-
|
|
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
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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.
|
|
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
|
+
}
|