@docstack/pouchdb-adapter-googledrive 0.1.5 → 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 ADDED
@@ -0,0 +1,212 @@
1
+ # Changelog
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
+
35
+ ## 0.1.6 — 2026-08-27
36
+
37
+ A data-loss fix. Every client sharing a folder should be upgraded together; see
38
+ [Compatibility](#compatibility).
39
+
40
+ Background and reasoning, in order:
41
+ [ADR-0001](docs/adr/0001-metadata-writes-without-compare-and-swap.md) (metadata writes
42
+ without compare-and-swap), [ADR-0002](docs/adr/0002-the-folder-is-the-index.md) (the
43
+ folder is the index), [ADR-0003](docs/adr/0003-sequence-numbers-carry-a-writer-slot.md)
44
+ (sequence numbers carry a writer slot). The reports that drove it:
45
+ [docstack-gdrive-adapter-0.1.5.md](docs/adr/docstack-gdrive-adapter-0.1.5.md) and
46
+ [0005-orphaned-change-logs.md](docs/adr/0005-orphaned-change-logs.md). The ADRs live in
47
+ the repository, not in this package.
48
+
49
+ ### Fixed
50
+
51
+ - **A lost metadata update no longer costs a document.** Change logs are found by
52
+ listing the folder, not only by reading `changeLogIds` out of `_meta.json`. The
53
+ file's existence is the durable record that a change log exists; the metadata is a
54
+ cache of that. Reported from Tokido on 2026-08-27 and reproduced locally as
55
+ acknowledged writes going missing in roughly one stress run in five —
56
+ [ADR-0002](docs/adr/0002-the-folder-is-the-index.md).
57
+
58
+ This supersedes the reachability half of the fix below, which could only repair a
59
+ dropped reference if the writer that lost the race ran again. At the end of a
60
+ session it never does.
61
+
62
+ - **Change logs replay in sequence order.** Insertion order was not deterministic
63
+ across clients once metadata merges were in play, so two readers of one folder
64
+ could disagree about which revision of a document won.
65
+
66
+ - **Concurrent writers no longer orphan each other's change logs.** Two clients could
67
+ each read `_meta.json`, append a change log, and write back a `changeLogIds` list
68
+ that did not mention the other's. The losing log stayed in the folder referenced by
69
+ nothing — present in Drive, invisible to every reader, and its documents with it.
70
+
71
+ The adapter believed it was guarding this with an ETag compare-and-swap. Drive API
72
+ v3 has no ETags, so `If-Match` was never sent and would have been ignored if it
73
+ had been; the emulated test server *does* enforce it, which is why the existing
74
+ concurrency tests stayed green. Metadata writes now read fresh, merge instead of
75
+ replace, read back to confirm, and restore any of this writer's logs that go
76
+ missing.
77
+
78
+ - **Compaction no longer deletes change logs it has not confirmed de-referencing.**
79
+ It deleted them straight after a metadata write that nothing had verified. With no
80
+ compare-and-swap, a lost update there means the snapshot never lands *and* the logs
81
+ — still the only copy of everything in them — are gone. A lost update became lost
82
+ documents.
83
+
84
+ - **Two writers can no longer mint the same sequence number.** A sequence number is
85
+ now `tick * 1_000_000 + writerSlot`, the slot being a hash of the writer's id. Two
86
+ clients reading the same counter still derive the same tick — nothing stops that
87
+ without a lock — but they no longer collide on the number itself.
88
+
89
+ This matters because `_changes` filters on `seq > since`: two documents sharing a
90
+ number means the second is never emitted to a replication target again, having
91
+ already been checkpointed past. Allocating from Drive at write time narrowed the
92
+ window and did not close it, because "read the counter, then check it has not
93
+ moved" is not atomic without a compare-and-swap —
94
+ [ADR-0003](docs/adr/0003-sequence-numbers-carry-a-writer-slot.md).
95
+
96
+ Sequence numbers are consequently large and sparse. The first write to a fresh
97
+ folder gets about `1000000`, not `1`.
98
+
99
+ A writer that sees a rival id hashing to its own slot in the folder listing re-rolls
100
+ to a free slot before minting anything, closing the hash-collision residual down to
101
+ the window before the rival's first log is visible.
102
+
103
+ - **Two clients opening the same empty folder converge on one `_meta.json`.** Drive
104
+ allows duplicate names, and clients were choosing between the rivals at random.
105
+ Lowest file id wins; the loser deletes its own and adopts the winner.
106
+
107
+ - **A client no longer replays its own change logs on reload.** The log lines on
108
+ Drive have `nextIndexEntry` stripped, so replaying one overwrote a real merged
109
+ revision tree with a synthesized single-node one, losing ancestry.
110
+
111
+ - **`pollingIntervalMs` works again.** The call site was lost in a refactor in April
112
+ (`2105386`) and the option has done nothing since. Without it a client only hears
113
+ about its own writes, so `db.changes({ live: true })` never fires for a remote one —
114
+ connect-and-read worked, continuous sync between two connected clients did not.
115
+
116
+ Change detection is now by `md5Checksum`, falling back to `modifiedTime`. The ETag
117
+ comparison is gone: it led the chain, so it decided the outcome, and on Drive v3 it
118
+ could only compare `''` against `''`.
119
+
120
+ - **`createFile` and `updateFile` request `md5Checksum`.** They asked for
121
+ `id,modifiedTime`, so the cached checksum was null after every write and polling
122
+ silently degraded to comparing a timestamp the client had set itself.
123
+
124
+ ### Changed
125
+
126
+ - **`MetaData` gains `retiredLogIds`** — change logs a compaction has folded into the
127
+ snapshot and deleted. They stop the writer that produced a log from restoring it
128
+ after it is legitimately gone. Capped at 500 entries.
129
+
130
+ - **Change-log filenames carry a writer id**: `changes-<seq>-<writerId>-<random>.ndjson`,
131
+ previously `changes-<seq>-<random>.ndjson`. Nothing parses these names — this is so
132
+ two writers cannot produce the same one, and so an orphan can be traced to whoever
133
+ wrote it.
134
+
135
+ - **A writer catches up before it writes.** If the folder holds logs this client has
136
+ not replayed, it loads them first. For the low-level `appendChange()` API this also
137
+ restores conflict detection, which in production had never run — it was reached
138
+ only from a 412 that Drive never returns. `_bulkDocs` is exempt: it resolves
139
+ revisions through pouchdb-merge and expresses a collision as a conflict branch, so
140
+ failing its whole batch would be the wrong answer.
141
+
142
+ - **Appends cost more Drive calls — 4 became 9.** Reading metadata fresh before
143
+ allocating a sequence range, and reading it back after committing, are what the
144
+ missing compare-and-swap is bought with. Two of the nine are `findFile`'s ETag
145
+ backfill, which can never succeed on Drive v3; removing it takes an append to 7 and
146
+ is the cheapest remaining win.
147
+
148
+ - **`GoogleDriveClient.createFile` and `.updateFile` return `md5Checksum`.** Additive
149
+ — the returned object gained an optional field.
150
+
151
+ - **Loads cost one more `files.list`**, to discover change logs. A load happens on
152
+ connect, on a polling tick that sees a change, and on a write that has to catch up
153
+ with another writer.
154
+
155
+ - **An interrupted write can now become visible.** A change log uploaded by a client
156
+ that died before committing its metadata used to be invisible; it is now found by
157
+ the listing and adopted. Correct for an append-only log, but it is a change in
158
+ which writes survive.
159
+
160
+ ### Compatibility
161
+
162
+ - **Sequence numbers jump once, harmlessly.** A folder sitting at `seq: 23` yields a
163
+ next sequence number of about a million — above everything already in the index and
164
+ above any checkpoint a replication target holds. Nothing re-replicates, nothing is
165
+ skipped.
166
+ - **No migration.** `retiredLogIds` is optional and absent metadata reads as an empty
167
+ list. Existing folders work untouched.
168
+ - **A 0.1.5 client can still orphan a 0.1.6 client's logs**, because the bug is in the
169
+ writer, not the format. A 0.1.6 client restores its own logs on its next load or
170
+ commit, so the damage is repaired rather than prevented. Upgrade every writer.
171
+ - **A 0.1.5 client preserves `retiredLogIds`** as it passes through — it copies the
172
+ metadata it downloaded rather than rebuilding it — so a mixed fleet will not strip
173
+ the tombstones.
174
+
175
+ ### Known limitations
176
+
177
+ - **Slot collisions are detected and dodged, not impossible.** A writer that sees a
178
+ rival id hashing to its own slot in the folder listing re-rolls to a free slot
179
+ before minting anything. What remains is the window before the rival's first log
180
+ appears in a listing, combined with the 1-in-a-million hash collision itself.
181
+ - **A change that lands below a target's checkpoint is still skipped.** Uniqueness
182
+ stops two documents sharing a number; it does not stop a writer committing a tick it
183
+ allocated before the target checkpointed past it. That needs a serialization point,
184
+ which Drive does not offer.
185
+ - Concurrent writes to the *same document* resolve last-writer-wins in the index
186
+ rather than through pouchdb-merge. Both revisions are on Drive; the losing one does
187
+ not surface as a conflict branch.
188
+ - Two clients that each create a folder by `folderName` get two databases. Duplicate
189
+ folders are not reconciled, only duplicate `_meta.json` files within one.
190
+
191
+ ### Tests
192
+
193
+ - `tests/meta_concurrency.test.ts` — eight cases against a fake Drive that behaves
194
+ like the real one: never returns an ETag, never honours one. This is the clean
195
+ reproduction the 0.1.5 report asked for, and it needs neither a Drive account nor
196
+ the client-side id fix first.
197
+ - `tests/writer_stress.test.ts` — three writers, eight rounds, jittered latency, and
198
+ a writer that vanishes mid-write. The invariant is the load-bearing one: *an append
199
+ whose promise resolved must be readable by whoever comes next.* This is what caught
200
+ the defect ADR-0002 fixes.
201
+ - `tests/simultaneous_writers.test.ts` — overlapping appends, as opposed to the
202
+ stale-writer shape the other suites cover.
203
+ - `tests/polling.test.ts` — five cases, `pollingIntervalMs`'s first tests.
204
+ - `tests/fake-drive.ts` — the shared fake: real md5 hashing, latency jitter, a
205
+ `sever()` for simulating a context that goes away, and a `diagnose()` that tells an
206
+ orphaned log apart from a dangling reference.
207
+
208
+ ---
209
+
210
+ ## 0.1.5 and earlier
211
+
212
+ Not recorded. See `git log`.
package/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
2
+
3
+ Copyright (c) Onyx <hello@onyx.ac> (https://onyx.ac)
4
+
5
+ This work is licensed under the Creative Commons Attribution-ShareAlike 4.0
6
+ International License. You are free to share and adapt this work, including
7
+ commercially, provided you give appropriate attribution and distribute any
8
+ derivative works under the same license.
9
+
10
+ Full legal text: https://creativecommons.org/licenses/by-sa/4.0/legalcode
package/README.md CHANGED
@@ -6,7 +6,7 @@ A persistent, serverless PouchDB adapter that uses Google Drive as a backend sto
6
6
 
7
7
  - **🚀 Append-Only Log**: Uses an efficient append-only log pattern for fast, conflict-free writes.
8
8
  - **⚡ Lazy Loading**: Optimizes memory and bandwidth by loading only the **Index** into memory. Document bodies are fetched on-demand.
9
- - **🛡️ Optimistic Concurrency Control**: Uses ETag-based locking on metadata to prevent race conditions.
9
+ - **🛡️ Multi-writer safe**: Metadata writes merge rather than replace, and are read back before anything is deleted. See [Concurrent writers](#concurrent-writers).
10
10
  - **📦 Auto-Compaction**: Automatically merges logs for performance.
11
11
  - **🌍 Universal**: Works natively in Node.js 18+, Browsers, and Edge environments (no `googleapis` dependency).
12
12
 
@@ -60,6 +60,57 @@ const adapterPlugin = GoogleDriveAdapter({
60
60
  });
61
61
  ```
62
62
 
63
+ ### Live changes from other clients
64
+
65
+ Set `pollingIntervalMs` to have the adapter watch `_meta.json` and replay what other
66
+ clients write. Without it, a client only hears about its own writes — connect-and-read
67
+ works, continuous sync between two connected clients does not.
68
+
69
+ ```typescript
70
+ const adapterPlugin = GoogleDriveAdapter({
71
+ accessToken: 'YOUR_GOOGLE_ACCESS_TOKEN',
72
+ folderId: 'my-folder-id',
73
+ pollingIntervalMs: 2000 // check for remote writes every 2s
74
+ });
75
+
76
+ db.changes({ live: true, since: 'now' }).on('change', change => { /* ... */ });
77
+ ```
78
+
79
+ A tick costs one `files.list`, whatever has changed; only a tick that sees a new
80
+ `md5Checksum` (or, failing that, a new `modifiedTime`) goes on to fetch anything.
81
+ `db.close()` and `db.destroy()` stop it.
82
+
83
+ ## Concurrent writers
84
+
85
+ Several clients may share one folder. What that costs, and what it does not:
86
+
87
+ - **Writes from different clients do not overwrite each other.** `_meta.json` is the
88
+ only shared mutable state. Every write to it is built on a copy read from Drive
89
+ moments earlier, merges into that copy instead of replacing it, and is read back
90
+ afterwards to confirm it survived. A writer also remembers the change logs it
91
+ wrote and restores any that go missing on its next load.
92
+ - **Compaction deletes nothing until its metadata write is confirmed.** Until then,
93
+ the change logs are still the only copy of the changes in them.
94
+ - **Sequence numbers come from Drive, not from a local counter**, so two clients
95
+ writing minutes apart cannot mint the same one.
96
+ - **A writer catches up before it writes**, so it sees what its peers have appended.
97
+ A client that only *reads* needs `pollingIntervalMs` to notice anything.
98
+
99
+ Caveats worth knowing before you fan out:
100
+
101
+ - **There is no compare-and-swap.** Drive API v3 dropped ETags, so `If-Match` is
102
+ accepted and ignored - the adapter still sends it (the emulated test server does
103
+ enforce it), but nothing may assume it was honoured. Two clients writing metadata
104
+ within the same round trip can still produce a lost update; the read-back and the
105
+ restore-on-load above are what repair it, so a client that writes and immediately
106
+ disconnects forever is the one case that can leave a change log unreferenced.
107
+ - **Pass `folderId`, not just `folderName`, when several clients may start at once.**
108
+ Duplicate `_meta.json` files are detected and resolved; duplicate *folders* are
109
+ not, and two clients that each create "my-app-db-folder" get two databases.
110
+ - **A document written concurrently by two clients resolves last-writer-wins in the
111
+ index**, not through pouchdb-merge. Both revisions stay on Drive, but the losing
112
+ one will not show up as a conflict branch.
113
+
63
114
  ## Architecture
64
115
 
65
116
  The adapter implements a **"Remote-First"** architecture: