@docstack/pouchdb-adapter-googledrive 0.1.7 → 0.2.0
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 +61 -1
- package/README.md +50 -0
- package/lib/adapter.js +27 -8
- package/lib/drive.d.ts +16 -0
- package/lib/drive.js +99 -5
- package/lib/types.d.ts +13 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
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
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/@docstack/pouchdb-adapter-googledrive)
|
|
2
|
+
[](https://onyx.ac/products/docstack/docs)
|
|
3
|
+
[](#license)
|
|
4
|
+
|
|
1
5
|
# PouchDB Adapter for Google Drive
|
|
2
6
|
|
|
3
7
|
A persistent, serverless PouchDB adapter that uses Google Drive as a backend storage. Designed for high concurrency, large datasets (via lazy loading), and offline resilience.
|
|
4
8
|
|
|
9
|
+
Part of [DocStack](https://onyx.ac/products/docstack): it is what makes a DocStack stack sync to the user's *own* Drive, so an application gets multi-device sync and real backup with no server, no storage bill and no custody of anyone's data. It works with plain PouchDB just as well.
|
|
10
|
+
|
|
5
11
|
## Features
|
|
6
12
|
|
|
7
13
|
- **🚀 Append-Only Log**: Uses an efficient append-only log pattern for fast, conflict-free writes.
|
|
@@ -80,6 +86,44 @@ A tick costs one `files.list`, whatever has changed; only a tick that sees a new
|
|
|
80
86
|
`md5Checksum` (or, failing that, a new `modifiedTime`) goes on to fetch anything.
|
|
81
87
|
`db.close()` and `db.destroy()` stop it.
|
|
82
88
|
|
|
89
|
+
### Sync progress
|
|
90
|
+
|
|
91
|
+
Two signals, one per phase where a UI would otherwise show nothing:
|
|
92
|
+
|
|
93
|
+
**Replication progress** rides PouchDB's standard pipeline. The adapter's changes
|
|
94
|
+
feed reports `pending` (the CouchDB field), so replication `change` events and
|
|
95
|
+
`activeTasks` carry it without any adapter-specific wiring:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
const rep = PouchDB.replicate(remote, local);
|
|
99
|
+
rep.on('change', info => {
|
|
100
|
+
const done = info.docs_written;
|
|
101
|
+
const pct = Math.round(done / (done + info.pending) * 100);
|
|
102
|
+
// Freeze the denominator at cycle start, or clamp the bar monotone —
|
|
103
|
+
// other devices keep writing, so `pending` can grow mid-cycle.
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Do not derive progress from `update_seq` arithmetic: sequence numbers are sparse
|
|
108
|
+
(they carry a writer slot in the low digits), so ratios of them mean nothing.
|
|
109
|
+
|
|
110
|
+
**Connect progress** covers the cold load, where a busy folder replays dozens of
|
|
111
|
+
change logs before the database is usable:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
GoogleDriveAdapter({
|
|
115
|
+
accessToken: '...',
|
|
116
|
+
folderId: 'my-folder-id',
|
|
117
|
+
onSyncProgress: ({ phase, done, total }) => {
|
|
118
|
+
// phase 'replay': applying change logs, done of total for this load.
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The callback is fire-and-forget: it always reaches `total` even when a log download
|
|
124
|
+
fails (that log is retried on a later load), and an exception it throws cannot fail
|
|
125
|
+
the load.
|
|
126
|
+
|
|
83
127
|
## Concurrent writers
|
|
84
128
|
|
|
85
129
|
Several clients may share one folder. What that costs, and what it does not:
|
|
@@ -118,6 +162,12 @@ The adapter implements a **"Remote-First"** architecture:
|
|
|
118
162
|
- **Caching**: Changes are indexed locally but bodies are cached in an LRU cache.
|
|
119
163
|
- **Resilience**: Writes use optimistic locking to handle multi-client concurrency safer.
|
|
120
164
|
|
|
165
|
+
## Documentation
|
|
166
|
+
|
|
167
|
+
- [Sync to Google Drive](https://onyx.ac/products/docstack/docs/guides/google-drive/) — registering the adapter, OAuth scope, one folder per database, restoring on a new device
|
|
168
|
+
- [Sync to a remote](https://onyx.ac/products/docstack/docs/guides/sync/) — the replication lifecycle this adapter plugs into
|
|
169
|
+
- [DocStack](https://onyx.ac/products/docstack) — the document data layer it was built for
|
|
170
|
+
|
|
121
171
|
## License
|
|
122
172
|
|
|
123
173
|
CC-BY-SA-4.0
|
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 (
|
|
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 (
|
|
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
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "PouchDB adapter for Google Drive",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"bugs": {
|
|
27
27
|
"url": "https://github.com/onyx-ac/docstack-pouchdb-adapter-gdrive/issues"
|
|
28
28
|
},
|
|
29
|
-
"homepage": "https://onyx.ac/docstack",
|
|
29
|
+
"homepage": "https://onyx.ac/products/docstack",
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=18"
|
|
32
32
|
},
|