@docstack/pouchdb-adapter-googledrive 0.1.4 → 0.1.6

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,180 @@
1
+ # Changelog
2
+
3
+ ## 0.1.6 — 2026-08-27
4
+
5
+ A data-loss fix. Every client sharing a folder should be upgraded together; see
6
+ [Compatibility](#compatibility).
7
+
8
+ Background and reasoning, in order:
9
+ [ADR-0001](docs/adr/0001-metadata-writes-without-compare-and-swap.md) (metadata writes
10
+ without compare-and-swap), [ADR-0002](docs/adr/0002-the-folder-is-the-index.md) (the
11
+ folder is the index), [ADR-0003](docs/adr/0003-sequence-numbers-carry-a-writer-slot.md)
12
+ (sequence numbers carry a writer slot). The reports that drove it:
13
+ [docstack-gdrive-adapter-0.1.5.md](docs/adr/docstack-gdrive-adapter-0.1.5.md) and
14
+ [0005-orphaned-change-logs.md](docs/adr/0005-orphaned-change-logs.md). The ADRs live in
15
+ the repository, not in this package.
16
+
17
+ ### Fixed
18
+
19
+ - **A lost metadata update no longer costs a document.** Change logs are found by
20
+ listing the folder, not only by reading `changeLogIds` out of `_meta.json`. The
21
+ file's existence is the durable record that a change log exists; the metadata is a
22
+ cache of that. Reported from Tokido on 2026-08-27 and reproduced locally as
23
+ acknowledged writes going missing in roughly one stress run in five —
24
+ [ADR-0002](docs/adr/0002-the-folder-is-the-index.md).
25
+
26
+ This supersedes the reachability half of the fix below, which could only repair a
27
+ dropped reference if the writer that lost the race ran again. At the end of a
28
+ session it never does.
29
+
30
+ - **Change logs replay in sequence order.** Insertion order was not deterministic
31
+ across clients once metadata merges were in play, so two readers of one folder
32
+ could disagree about which revision of a document won.
33
+
34
+ - **Concurrent writers no longer orphan each other's change logs.** Two clients could
35
+ each read `_meta.json`, append a change log, and write back a `changeLogIds` list
36
+ that did not mention the other's. The losing log stayed in the folder referenced by
37
+ nothing — present in Drive, invisible to every reader, and its documents with it.
38
+
39
+ The adapter believed it was guarding this with an ETag compare-and-swap. Drive API
40
+ v3 has no ETags, so `If-Match` was never sent and would have been ignored if it
41
+ had been; the emulated test server *does* enforce it, which is why the existing
42
+ concurrency tests stayed green. Metadata writes now read fresh, merge instead of
43
+ replace, read back to confirm, and restore any of this writer's logs that go
44
+ missing.
45
+
46
+ - **Compaction no longer deletes change logs it has not confirmed de-referencing.**
47
+ It deleted them straight after a metadata write that nothing had verified. With no
48
+ compare-and-swap, a lost update there means the snapshot never lands *and* the logs
49
+ — still the only copy of everything in them — are gone. A lost update became lost
50
+ documents.
51
+
52
+ - **Two writers can no longer mint the same sequence number.** A sequence number is
53
+ now `tick * 1_000_000 + writerSlot`, the slot being a hash of the writer's id. Two
54
+ clients reading the same counter still derive the same tick — nothing stops that
55
+ without a lock — but they no longer collide on the number itself.
56
+
57
+ This matters because `_changes` filters on `seq > since`: two documents sharing a
58
+ number means the second is never emitted to a replication target again, having
59
+ already been checkpointed past. Allocating from Drive at write time narrowed the
60
+ window and did not close it, because "read the counter, then check it has not
61
+ moved" is not atomic without a compare-and-swap —
62
+ [ADR-0003](docs/adr/0003-sequence-numbers-carry-a-writer-slot.md).
63
+
64
+ Sequence numbers are consequently large and sparse. The first write to a fresh
65
+ folder gets about `1000000`, not `1`.
66
+
67
+ A writer that sees a rival id hashing to its own slot in the folder listing re-rolls
68
+ to a free slot before minting anything, closing the hash-collision residual down to
69
+ the window before the rival's first log is visible.
70
+
71
+ - **Two clients opening the same empty folder converge on one `_meta.json`.** Drive
72
+ allows duplicate names, and clients were choosing between the rivals at random.
73
+ Lowest file id wins; the loser deletes its own and adopts the winner.
74
+
75
+ - **A client no longer replays its own change logs on reload.** The log lines on
76
+ Drive have `nextIndexEntry` stripped, so replaying one overwrote a real merged
77
+ revision tree with a synthesized single-node one, losing ancestry.
78
+
79
+ - **`pollingIntervalMs` works again.** The call site was lost in a refactor in April
80
+ (`2105386`) and the option has done nothing since. Without it a client only hears
81
+ about its own writes, so `db.changes({ live: true })` never fires for a remote one —
82
+ connect-and-read worked, continuous sync between two connected clients did not.
83
+
84
+ Change detection is now by `md5Checksum`, falling back to `modifiedTime`. The ETag
85
+ comparison is gone: it led the chain, so it decided the outcome, and on Drive v3 it
86
+ could only compare `''` against `''`.
87
+
88
+ - **`createFile` and `updateFile` request `md5Checksum`.** They asked for
89
+ `id,modifiedTime`, so the cached checksum was null after every write and polling
90
+ silently degraded to comparing a timestamp the client had set itself.
91
+
92
+ ### Changed
93
+
94
+ - **`MetaData` gains `retiredLogIds`** — change logs a compaction has folded into the
95
+ snapshot and deleted. They stop the writer that produced a log from restoring it
96
+ after it is legitimately gone. Capped at 500 entries.
97
+
98
+ - **Change-log filenames carry a writer id**: `changes-<seq>-<writerId>-<random>.ndjson`,
99
+ previously `changes-<seq>-<random>.ndjson`. Nothing parses these names — this is so
100
+ two writers cannot produce the same one, and so an orphan can be traced to whoever
101
+ wrote it.
102
+
103
+ - **A writer catches up before it writes.** If the folder holds logs this client has
104
+ not replayed, it loads them first. For the low-level `appendChange()` API this also
105
+ restores conflict detection, which in production had never run — it was reached
106
+ only from a 412 that Drive never returns. `_bulkDocs` is exempt: it resolves
107
+ revisions through pouchdb-merge and expresses a collision as a conflict branch, so
108
+ failing its whole batch would be the wrong answer.
109
+
110
+ - **Appends cost more Drive calls — 4 became 9.** Reading metadata fresh before
111
+ allocating a sequence range, and reading it back after committing, are what the
112
+ missing compare-and-swap is bought with. Two of the nine are `findFile`'s ETag
113
+ backfill, which can never succeed on Drive v3; removing it takes an append to 7 and
114
+ is the cheapest remaining win.
115
+
116
+ - **`GoogleDriveClient.createFile` and `.updateFile` return `md5Checksum`.** Additive
117
+ — the returned object gained an optional field.
118
+
119
+ - **Loads cost one more `files.list`**, to discover change logs. A load happens on
120
+ connect, on a polling tick that sees a change, and on a write that has to catch up
121
+ with another writer.
122
+
123
+ - **An interrupted write can now become visible.** A change log uploaded by a client
124
+ that died before committing its metadata used to be invisible; it is now found by
125
+ the listing and adopted. Correct for an append-only log, but it is a change in
126
+ which writes survive.
127
+
128
+ ### Compatibility
129
+
130
+ - **Sequence numbers jump once, harmlessly.** A folder sitting at `seq: 23` yields a
131
+ next sequence number of about a million — above everything already in the index and
132
+ above any checkpoint a replication target holds. Nothing re-replicates, nothing is
133
+ skipped.
134
+ - **No migration.** `retiredLogIds` is optional and absent metadata reads as an empty
135
+ list. Existing folders work untouched.
136
+ - **A 0.1.5 client can still orphan a 0.1.6 client's logs**, because the bug is in the
137
+ writer, not the format. A 0.1.6 client restores its own logs on its next load or
138
+ commit, so the damage is repaired rather than prevented. Upgrade every writer.
139
+ - **A 0.1.5 client preserves `retiredLogIds`** as it passes through — it copies the
140
+ metadata it downloaded rather than rebuilding it — so a mixed fleet will not strip
141
+ the tombstones.
142
+
143
+ ### Known limitations
144
+
145
+ - **Slot collisions are detected and dodged, not impossible.** A writer that sees a
146
+ rival id hashing to its own slot in the folder listing re-rolls to a free slot
147
+ before minting anything. What remains is the window before the rival's first log
148
+ appears in a listing, combined with the 1-in-a-million hash collision itself.
149
+ - **A change that lands below a target's checkpoint is still skipped.** Uniqueness
150
+ stops two documents sharing a number; it does not stop a writer committing a tick it
151
+ allocated before the target checkpointed past it. That needs a serialization point,
152
+ which Drive does not offer.
153
+ - Concurrent writes to the *same document* resolve last-writer-wins in the index
154
+ rather than through pouchdb-merge. Both revisions are on Drive; the losing one does
155
+ not surface as a conflict branch.
156
+ - Two clients that each create a folder by `folderName` get two databases. Duplicate
157
+ folders are not reconciled, only duplicate `_meta.json` files within one.
158
+
159
+ ### Tests
160
+
161
+ - `tests/meta_concurrency.test.ts` — eight cases against a fake Drive that behaves
162
+ like the real one: never returns an ETag, never honours one. This is the clean
163
+ reproduction the 0.1.5 report asked for, and it needs neither a Drive account nor
164
+ the client-side id fix first.
165
+ - `tests/writer_stress.test.ts` — three writers, eight rounds, jittered latency, and
166
+ a writer that vanishes mid-write. The invariant is the load-bearing one: *an append
167
+ whose promise resolved must be readable by whoever comes next.* This is what caught
168
+ the defect ADR-0002 fixes.
169
+ - `tests/simultaneous_writers.test.ts` — overlapping appends, as opposed to the
170
+ stale-writer shape the other suites cover.
171
+ - `tests/polling.test.ts` — five cases, `pollingIntervalMs`'s first tests.
172
+ - `tests/fake-drive.ts` — the shared fake: real md5 hashing, latency jitter, a
173
+ `sever()` for simulating a context that goes away, and a `diagnose()` that tells an
174
+ orphaned log apart from a dangling reference.
175
+
176
+ ---
177
+
178
+ ## 0.1.5 and earlier
179
+
180
+ 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:
package/lib/adapter.d.ts CHANGED
File without changes