@docstack/pouchdb-adapter-googledrive 0.0.4 → 0.0.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.
@@ -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.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface DriveFile {
4
4
  mimeType: string;
5
5
  parents?: string[];
6
6
  etag?: string;
7
+ modifiedTime?: string;
7
8
  }
8
9
  export interface DriveClientOptions {
9
10
  accessToken: string | (() => Promise<string>);
@@ -19,10 +20,12 @@ export declare class GoogleDriveClient {
19
20
  createFile(name: string, parents: string[] | undefined, mimeType: string, content: string): Promise<{
20
21
  id: string;
21
22
  etag: string;
23
+ modifiedTime: string;
22
24
  }>;
23
25
  updateFile(fileId: string, content: string, expectedEtag?: string): Promise<{
24
26
  id: string;
25
27
  etag: string;
28
+ modifiedTime: string;
26
29
  }>;
27
30
  deleteFile(fileId: string): Promise<void>;
28
31
  private buildMultipart;
package/lib/client.js CHANGED
@@ -14,23 +14,44 @@ class GoogleDriveClient {
14
14
  return this.options.accessToken;
15
15
  }
16
16
  async fetch(url, init) {
17
+ const method = init.method || 'GET';
17
18
  const token = await this.getToken();
18
19
  const headers = new Headers(init.headers);
19
20
  headers.set('Authorization', `Bearer ${token}`);
20
- const res = await fetch(url, { ...init, headers });
21
+ let res;
22
+ try {
23
+ res = await fetch(url, { ...init, headers });
24
+ }
25
+ catch (networkErr) {
26
+ const err = new Error(`Network Error: ${networkErr.message} (${method} ${url})`);
27
+ err.code = 'network_error';
28
+ err.url = url;
29
+ err.method = method;
30
+ throw err;
31
+ }
21
32
  if (!res.ok) {
22
- // Basic error handling
23
33
  const text = await res.text();
24
- let errorMsg = `Drive API Error: ${res.status} ${res.statusText}`;
34
+ let errorMsg = `Drive API Error: ${res.status} ${res.statusText} (${method} ${url})`;
35
+ let reason = res.statusText;
25
36
  try {
26
37
  const json = JSON.parse(text);
27
- if (json.error && json.error.message) {
28
- errorMsg += ` - ${json.error.message}`;
38
+ const gError = json.error;
39
+ if (gError) {
40
+ errorMsg += ` - ${gError.message || 'Unknown Error'}`;
41
+ if (Array.isArray(gError.errors) && gError.errors.length > 0) {
42
+ reason = gError.errors[0].reason || reason;
43
+ if (gError.errors[0].message && gError.errors[0].message !== gError.message) {
44
+ errorMsg += ` (${gError.errors[0].message})`;
45
+ }
46
+ }
29
47
  }
30
48
  }
31
49
  catch { }
32
50
  const err = new Error(errorMsg);
33
51
  err.status = res.status;
52
+ err.code = reason;
53
+ err.url = url;
54
+ err.method = method;
34
55
  throw err;
35
56
  }
36
57
  return res;
@@ -38,11 +59,11 @@ class GoogleDriveClient {
38
59
  async listFiles(q) {
39
60
  const params = new URLSearchParams({
40
61
  q,
41
- fields: 'files(id, name, mimeType, parents, etag)',
42
- spaces: 'drive',
43
- pageSize: '1000' // Ensure we get enough
62
+ fields: 'files(id,name,mimeType,parents,modifiedTime)'
44
63
  });
45
- const res = await this.fetch(`${BASE_URL}?${params.toString()}`, { method: 'GET' });
64
+ // FIX: URLSearchParams uses '+', but Drive API is safer with '%20'
65
+ const queryString = params.toString().replace(/\+/g, '%20');
66
+ const res = await this.fetch(`${BASE_URL}?${queryString}`, { method: 'GET' });
46
67
  const data = await res.json();
47
68
  return data.files || [];
48
69
  }
@@ -50,7 +71,8 @@ class GoogleDriveClient {
50
71
  // Try getting media
51
72
  try {
52
73
  const params = new URLSearchParams({ alt: 'media' });
53
- const res = await this.fetch(`${BASE_URL}/${fileId}?${params.toString()}`, { method: 'GET' });
74
+ const queryString = params.toString().replace(/\+/g, '%20');
75
+ const res = await this.fetch(`${BASE_URL}/${fileId}?${queryString}`, { method: 'GET' });
54
76
  // Standard fetch handles JSON/Text transparency?
55
77
  // We expect JSON mostly, but sometimes we might want text.
56
78
  // PouchDB adapter flow: downloadJson, downloadNdjson
@@ -69,8 +91,9 @@ class GoogleDriveClient {
69
91
  }
70
92
  // Single metadata get (for etag check)
71
93
  async getFileMetadata(fileId) {
72
- const params = new URLSearchParams({ fields: 'id, name, mimeType, parents, etag' });
73
- const res = await this.fetch(`${BASE_URL}/${fileId}?${params.toString()}`, { method: 'GET' });
94
+ const params = new URLSearchParams({ fields: 'id,name,mimeType,parents,modifiedTime' });
95
+ const queryString = params.toString().replace(/\+/g, '%20');
96
+ const res = await this.fetch(`${BASE_URL}/${fileId}?${queryString}`, { method: 'GET' });
74
97
  return await res.json();
75
98
  }
76
99
  async createFile(name, parents, mimeType, content) {
@@ -79,25 +102,49 @@ class GoogleDriveClient {
79
102
  mimeType,
80
103
  parents
81
104
  };
105
+ // Folders or empty content can use simple metadata-only POST
106
+ if (!content && mimeType === 'application/vnd.google-apps.folder') {
107
+ const res = await this.fetch(`${BASE_URL}?fields=id,modifiedTime`, {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify(metadata)
111
+ });
112
+ const data = await res.json();
113
+ return {
114
+ id: data.id,
115
+ etag: data.etag || '',
116
+ modifiedTime: data.modifiedTime || ''
117
+ };
118
+ }
82
119
  const multipartBody = this.buildMultipart(metadata, content, mimeType);
83
- const res = await this.fetch(`${UPLOAD_URL}?uploadType=multipart&fields=id,etag`, {
120
+ const res = await this.fetch(`${UPLOAD_URL}?uploadType=multipart&fields=id,modifiedTime`, {
84
121
  method: 'POST',
85
122
  headers: {
86
123
  'Content-Type': `multipart/related; boundary=${multipartBody.boundary}`
87
124
  },
88
125
  body: multipartBody.body
89
126
  });
90
- return await res.json();
127
+ const data = await res.json();
128
+ return {
129
+ id: data.id,
130
+ etag: data.etag || '',
131
+ modifiedTime: data.modifiedTime || ''
132
+ };
91
133
  }
92
134
  async updateFile(fileId, content, expectedEtag) {
93
135
  // Update content (media) usually, but sometimes meta?
94
136
  // In our usage (saveMeta), we update body.
95
- const res = await this.fetch(`${UPLOAD_URL}/${fileId}?uploadType=media&fields=id,etag`, {
137
+ const res = await this.fetch(`${UPLOAD_URL}/${fileId}?uploadType=media&fields=id,modifiedTime`, {
96
138
  method: 'PATCH',
97
139
  headers: expectedEtag ? { 'If-Match': expectedEtag, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' },
98
140
  body: content
99
141
  });
100
- return await res.json();
142
+ const data = await res.json();
143
+ return {
144
+ id: data.id,
145
+ etag: data.etag || '',
146
+ modifiedTime: data.modifiedTime || ''
147
+ };
101
148
  }
102
149
  async deleteFile(fileId) {
103
150
  await this.fetch(`${BASE_URL}/${fileId}`, { method: 'DELETE' });
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;
@@ -18,6 +19,7 @@ export declare class DriveHandler {
18
19
  private compactionSizeThreshold;
19
20
  private meta;
20
21
  private metaEtag;
22
+ private metaModifiedTime;
21
23
  private index;
22
24
  private docCache;
23
25
  private pendingChanges;
@@ -64,6 +66,7 @@ export declare class DriveHandler {
64
66
  private notifyListeners;
65
67
  onChange(cb: any): void;
66
68
  stopPolling(): void;
69
+ private escapeQuery;
67
70
  deleteFolder(): Promise<void>;
68
71
  getNextSeq(): number;
69
72
  }
package/lib/drive.js CHANGED
@@ -27,6 +27,7 @@ class DriveHandler {
27
27
  dbName: ''
28
28
  };
29
29
  this.metaEtag = null;
30
+ this.metaModifiedTime = null;
30
31
  // In-Memory Index: ID -> Metadata/Pointer
31
32
  this.index = {};
32
33
  this.pendingChanges = [];
@@ -34,6 +35,7 @@ class DriveHandler {
34
35
  this.listeners = [];
35
36
  this.pollingInterval = null;
36
37
  this.client = new client_1.GoogleDriveClient(options);
38
+ this.options = options;
37
39
  this.folderId = options.folderId || null;
38
40
  this.folderName = options.folderName || dbName;
39
41
  this.parents = options.parents || [];
@@ -41,9 +43,7 @@ class DriveHandler {
41
43
  this.compactionSizeThreshold = options.compactionSizeThreshold || DEFAULT_SIZE_THRESHOLD;
42
44
  this.meta.dbName = dbName;
43
45
  this.docCache = new cache_1.LRUCache(options.cacheSize || DEFAULT_CACHE_SIZE);
44
- if (options.pollingIntervalMs) {
45
- this.startPolling(options.pollingIntervalMs);
46
- }
46
+ // Polling will be started in load() after folderId is resolved
47
47
  }
48
48
  // Public getter for Sequence (used by adapter)
49
49
  get seq() {
@@ -58,6 +58,7 @@ class DriveHandler {
58
58
  if (metaFile) {
59
59
  this.meta = await this.downloadJson(metaFile.id);
60
60
  this.metaEtag = metaFile.etag || null;
61
+ this.metaModifiedTime = metaFile.modifiedTime || null;
61
62
  }
62
63
  else {
63
64
  await this.saveMeta(this.meta);
@@ -109,6 +110,10 @@ class DriveHandler {
109
110
  }
110
111
  }
111
112
  }
113
+ // 3. Start Polling (if enabled)
114
+ if (this.options.pollingIntervalMs) {
115
+ this.startPolling(this.options.pollingIntervalMs);
116
+ }
112
117
  }
113
118
  // Migration helper
114
119
  filesFromLegacySnapshot(snapshot) {
@@ -420,7 +425,8 @@ class DriveHandler {
420
425
  }
421
426
  // Reused helpers
422
427
  async findOrCreateFolder() {
423
- const q = `name = '${this.folderName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`;
428
+ const safeName = this.escapeQuery(this.folderName);
429
+ const q = `name = '${safeName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`;
424
430
  const files = await this.client.listFiles(q);
425
431
  if (files.length > 0)
426
432
  return files[0].id;
@@ -428,10 +434,17 @@ class DriveHandler {
428
434
  return createRes.id;
429
435
  }
430
436
  async findFile(name) {
431
- const q = `name = '${name}' and '${this.folderId}' in parents and trashed = false`;
437
+ if (!this.folderId)
438
+ return null;
439
+ const safeName = this.escapeQuery(name);
440
+ const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
432
441
  const files = await this.client.listFiles(q);
433
442
  if (files.length > 0)
434
- return { id: files[0].id, etag: files[0].etag || '' };
443
+ return {
444
+ id: files[0].id,
445
+ etag: files[0].etag || '',
446
+ modifiedTime: files[0].modifiedTime || ''
447
+ };
435
448
  return null;
436
449
  }
437
450
  async downloadJson(fileId) {
@@ -465,10 +478,12 @@ class DriveHandler {
465
478
  if (metaFile) {
466
479
  const res = await this.client.updateFile(metaFile.id, content, expectedEtag || undefined);
467
480
  this.metaEtag = res.etag;
481
+ this.metaModifiedTime = res.modifiedTime;
468
482
  }
469
483
  else {
470
484
  const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
471
485
  this.metaEtag = res.etag;
486
+ this.metaModifiedTime = res.modifiedTime;
472
487
  }
473
488
  }
474
489
  async countTotalChanges() {
@@ -499,8 +514,8 @@ class DriveHandler {
499
514
  const metaFile = await this.findFile('_meta.json');
500
515
  if (!metaFile)
501
516
  return;
502
- // Etag check
503
- if (metaFile.etag !== this.metaEtag) {
517
+ // Use modifiedTime for polling as it's readable in projections
518
+ if (metaFile.modifiedTime !== this.metaModifiedTime) {
504
519
  await this.load();
505
520
  this.notifyListeners();
506
521
  }
@@ -528,6 +543,9 @@ class DriveHandler {
528
543
  onChange(cb) { this.listeners.push(cb); }
529
544
  stopPolling() { if (this.pollingInterval)
530
545
  clearInterval(this.pollingInterval); }
546
+ escapeQuery(value) {
547
+ return value.replace(/'/g, "\\'");
548
+ }
531
549
  async deleteFolder() { if (this.folderId)
532
550
  await this.client.deleteFile(this.folderId); }
533
551
  getNextSeq() { return this.meta.seq + 1; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docstack/pouchdb-adapter-googledrive",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "PouchDB adapter for Google Drive",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",