@docstack/pouchdb-adapter-googledrive 0.0.4 → 0.0.5
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/DOCUMENTATION.md +54 -0
- package/lib/client.js +11 -1
- package/lib/drive.d.ts +2 -0
- package/lib/drive.js +15 -5
- package/package.json +1 -1
package/DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Architecture & Design Documentation
|
|
2
|
+
|
|
3
|
+
## 1. Core Principles
|
|
4
|
+
The `pouchdb-adapter-googledrive` implementation is built on three core pillars to ensure data integrity and performance on a file-based remote storage system.
|
|
5
|
+
|
|
6
|
+
### A. Append-Only Log (Storage)
|
|
7
|
+
Instead of modifying a single database file (which is prone to conflicts), we use an **Append-Only** strategy.
|
|
8
|
+
- **Changes**: Every write operation (or batch of writes) creates a **new, immutable file** (e.g., `changes-{seq}-{uuid}.ndjson`).
|
|
9
|
+
- **Snapshots**: Periodically, the log is compacted into a `snapshot` file.
|
|
10
|
+
- **Benefit**: Historical data is preserved until compaction, and file-write conflicts are minimized.
|
|
11
|
+
|
|
12
|
+
### B. Optimistic Concurrency Control (OCC)
|
|
13
|
+
To prevent race conditions (two clients writing simultaneously), we use **ETag-based locking** on a single entry point: `_meta.json`.
|
|
14
|
+
- **The Lock**: `_meta.json` holds the current Sequence Number and the list of active log files.
|
|
15
|
+
- **The Protocol**:
|
|
16
|
+
1. Reader fetches `_meta.json` and its `ETag`.
|
|
17
|
+
2. Writer prepares a new change file and uploads it (orphaned initially).
|
|
18
|
+
3. Writer attempts to update `_meta.json` with the new file reference, sending `If-Match: <Old-ETag>`.
|
|
19
|
+
4. **Success**: The change is now officially part of the DB.
|
|
20
|
+
5. **Failure (412/409)**: Another client updated the DB. The writer deletes its orphaned file, pulls the new state, and retries the logical operation.
|
|
21
|
+
|
|
22
|
+
### C. Remote-First "Lazy" Loading (Memory Optimization)
|
|
23
|
+
To support large databases without exhausting client memory, we separate **Metadata** from **Content**.
|
|
24
|
+
|
|
25
|
+
#### Storage Structure
|
|
26
|
+
- `_meta.json`: Root pointer. Small.
|
|
27
|
+
- `snapshot-index.json`: A map of `{ docId: { rev, filePointer } }`. Medium size (~100 bytes/doc). Loaded at startup.
|
|
28
|
+
- `snapshot-data.json`: The actual document bodies. Large. **Never fully loaded.**
|
|
29
|
+
- `changes-*.ndjson`: Recent updates.
|
|
30
|
+
|
|
31
|
+
#### Client Startup Sequence
|
|
32
|
+
1. **Fetch Meta**: Download `_meta.json` and get the `snapshotIndexId`.
|
|
33
|
+
2. **Fetch Index**: Download `snapshot-index.json`. This builds the "Revision Tree" in memory.
|
|
34
|
+
3. **Replay Logs**: Download and parse only the small `changes-*.ndjson` files created since the snapshot to update the in-memory Index.
|
|
35
|
+
4. **Ready**: The client is now ready to query keys. No document content has been downloaded yet.
|
|
36
|
+
|
|
37
|
+
#### On-Demand Usage
|
|
38
|
+
- **`db.get(id)`**:
|
|
39
|
+
1. Look up `id` in the **Memory Index** to find the `filePointer`.
|
|
40
|
+
2. Check **LRU Cache**.
|
|
41
|
+
3. If missing, fetch the specific file/range from Google Drive.
|
|
42
|
+
- **`db.allDocs({ keys: [...] })`**: Efficiently looks up pointers and fetches only requested docs.
|
|
43
|
+
|
|
44
|
+
## 2. Technical Patterns
|
|
45
|
+
|
|
46
|
+
### Atomic Compaction
|
|
47
|
+
Compaction is a critical maintenance task that merges the `snapshot-data` with recent `changes` to create a new baseline.
|
|
48
|
+
- **Safe**: Limits memory usage by streaming/batching.
|
|
49
|
+
- **Atomic**: Uploads the new snapshot as a new file. Swaps the pointer in `_meta.json` using OCC.
|
|
50
|
+
- **Zero-Downtime**: Clients can continue reading/writing to the old logs while compaction runs. Writes that happen *during* compaction are detected via the ETag check, causing the compaction to abort/retry safeley.
|
|
51
|
+
|
|
52
|
+
### Conflict Handling
|
|
53
|
+
- **PouchDB Level**: Standard CouchDB revision conflicts (409) are preserved. A "winner" is chosen deterministically, but conflicting revisions are kept in the tree (requires `snapshot-index` to store the full revision tree, not just the winner).
|
|
54
|
+
- **Adapter Level**: Drive API 409s handling (retry logic) ensures the transport layer is reliable.
|
package/lib/client.js
CHANGED
|
@@ -18,10 +18,11 @@ class GoogleDriveClient {
|
|
|
18
18
|
const headers = new Headers(init.headers);
|
|
19
19
|
headers.set('Authorization', `Bearer ${token}`);
|
|
20
20
|
const res = await fetch(url, { ...init, headers });
|
|
21
|
+
const method = init.method || 'GET';
|
|
21
22
|
if (!res.ok) {
|
|
22
23
|
// Basic error handling
|
|
23
24
|
const text = await res.text();
|
|
24
|
-
let errorMsg = `Drive API Error: ${res.status} ${res.statusText}`;
|
|
25
|
+
let errorMsg = `Drive API Error: ${res.status} ${res.statusText} (${method} ${url})`;
|
|
25
26
|
try {
|
|
26
27
|
const json = JSON.parse(text);
|
|
27
28
|
if (json.error && json.error.message) {
|
|
@@ -79,6 +80,15 @@ class GoogleDriveClient {
|
|
|
79
80
|
mimeType,
|
|
80
81
|
parents
|
|
81
82
|
};
|
|
83
|
+
// Folders or empty content can use simple metadata-only POST
|
|
84
|
+
if (!content && mimeType === 'application/vnd.google-apps.folder') {
|
|
85
|
+
const res = await this.fetch(`${BASE_URL}?fields=id,etag`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify(metadata)
|
|
89
|
+
});
|
|
90
|
+
return await res.json();
|
|
91
|
+
}
|
|
82
92
|
const multipartBody = this.buildMultipart(metadata, content, mimeType);
|
|
83
93
|
const res = await this.fetch(`${UPLOAD_URL}?uploadType=multipart&fields=id,etag`, {
|
|
84
94
|
method: 'POST',
|
package/lib/drive.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { GoogleDriveAdapterOptions, ChangeEntry, IndexEntry } from './types';
|
|
|
11
11
|
*/
|
|
12
12
|
export declare class DriveHandler {
|
|
13
13
|
private client;
|
|
14
|
+
private options;
|
|
14
15
|
private folderId;
|
|
15
16
|
private folderName;
|
|
16
17
|
private parents;
|
|
@@ -64,6 +65,7 @@ export declare class DriveHandler {
|
|
|
64
65
|
private notifyListeners;
|
|
65
66
|
onChange(cb: any): void;
|
|
66
67
|
stopPolling(): void;
|
|
68
|
+
private escapeQuery;
|
|
67
69
|
deleteFolder(): Promise<void>;
|
|
68
70
|
getNextSeq(): number;
|
|
69
71
|
}
|
package/lib/drive.js
CHANGED
|
@@ -34,6 +34,7 @@ class DriveHandler {
|
|
|
34
34
|
this.listeners = [];
|
|
35
35
|
this.pollingInterval = null;
|
|
36
36
|
this.client = new client_1.GoogleDriveClient(options);
|
|
37
|
+
this.options = options;
|
|
37
38
|
this.folderId = options.folderId || null;
|
|
38
39
|
this.folderName = options.folderName || dbName;
|
|
39
40
|
this.parents = options.parents || [];
|
|
@@ -41,9 +42,7 @@ class DriveHandler {
|
|
|
41
42
|
this.compactionSizeThreshold = options.compactionSizeThreshold || DEFAULT_SIZE_THRESHOLD;
|
|
42
43
|
this.meta.dbName = dbName;
|
|
43
44
|
this.docCache = new cache_1.LRUCache(options.cacheSize || DEFAULT_CACHE_SIZE);
|
|
44
|
-
|
|
45
|
-
this.startPolling(options.pollingIntervalMs);
|
|
46
|
-
}
|
|
45
|
+
// Polling will be started in load() after folderId is resolved
|
|
47
46
|
}
|
|
48
47
|
// Public getter for Sequence (used by adapter)
|
|
49
48
|
get seq() {
|
|
@@ -109,6 +108,10 @@ class DriveHandler {
|
|
|
109
108
|
}
|
|
110
109
|
}
|
|
111
110
|
}
|
|
111
|
+
// 3. Start Polling (if enabled)
|
|
112
|
+
if (this.options.pollingIntervalMs) {
|
|
113
|
+
this.startPolling(this.options.pollingIntervalMs);
|
|
114
|
+
}
|
|
112
115
|
}
|
|
113
116
|
// Migration helper
|
|
114
117
|
filesFromLegacySnapshot(snapshot) {
|
|
@@ -420,7 +423,8 @@ class DriveHandler {
|
|
|
420
423
|
}
|
|
421
424
|
// Reused helpers
|
|
422
425
|
async findOrCreateFolder() {
|
|
423
|
-
const
|
|
426
|
+
const safeName = this.escapeQuery(this.folderName);
|
|
427
|
+
const q = `name = '${safeName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`;
|
|
424
428
|
const files = await this.client.listFiles(q);
|
|
425
429
|
if (files.length > 0)
|
|
426
430
|
return files[0].id;
|
|
@@ -428,7 +432,10 @@ class DriveHandler {
|
|
|
428
432
|
return createRes.id;
|
|
429
433
|
}
|
|
430
434
|
async findFile(name) {
|
|
431
|
-
|
|
435
|
+
if (!this.folderId)
|
|
436
|
+
return null;
|
|
437
|
+
const safeName = this.escapeQuery(name);
|
|
438
|
+
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
432
439
|
const files = await this.client.listFiles(q);
|
|
433
440
|
if (files.length > 0)
|
|
434
441
|
return { id: files[0].id, etag: files[0].etag || '' };
|
|
@@ -528,6 +535,9 @@ class DriveHandler {
|
|
|
528
535
|
onChange(cb) { this.listeners.push(cb); }
|
|
529
536
|
stopPolling() { if (this.pollingInterval)
|
|
530
537
|
clearInterval(this.pollingInterval); }
|
|
538
|
+
escapeQuery(value) {
|
|
539
|
+
return value.replace(/'/g, "\\'");
|
|
540
|
+
}
|
|
531
541
|
async deleteFolder() { if (this.folderId)
|
|
532
542
|
await this.client.deleteFile(this.folderId); }
|
|
533
543
|
getNextSeq() { return this.meta.seq + 1; }
|