@docstack/pouchdb-adapter-googledrive 0.0.6 → 0.0.9
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/.env.example +3 -0
- package/README.md +72 -72
- package/compaction_test.log +725 -0
- package/docs/DEBUG_BIDIRECTIONAL_REPLICATION.ipynb +481 -0
- package/{DOCUMENTATION.md → docs/DOCUMENTATION.md} +61 -0
- package/docs/PROMISE_SUPPORT.md +75 -0
- package/docs/REPLICATION_ISSUE.md +63 -0
- package/emulation-test.log +163 -0
- package/failing_test.log +31 -0
- package/final_test.log +2620 -0
- package/jest.config.js +8 -8
- package/lib/adapter.d.ts +3 -0
- package/lib/adapter.js +373 -163
- package/lib/cache.d.ts +0 -0
- package/lib/cache.js +0 -0
- package/lib/client.d.ts +5 -0
- package/lib/client.js +43 -20
- package/lib/drive.d.ts +12 -2
- package/lib/drive.js +373 -189
- package/lib/index.d.ts +0 -0
- package/lib/index.js +4 -3
- package/lib/types.d.ts +6 -0
- package/lib/types.js +0 -0
- package/live_test.log +1209 -0
- package/package.json +47 -40
- package/test_output.log +563 -0
- package/test_output_2.log +1408 -0
- package/test_output_3.log +2445 -0
package/lib/drive.js
CHANGED
|
@@ -17,6 +17,9 @@ const DEFAULT_CACHE_SIZE = 1000; // Number of docs
|
|
|
17
17
|
* └── changes-*.ndjson # Append logs
|
|
18
18
|
*/
|
|
19
19
|
class DriveHandler {
|
|
20
|
+
log(...args) {
|
|
21
|
+
console.log(`[googledrive-drive] [${this.meta.dbName}]`, ...args);
|
|
22
|
+
}
|
|
20
23
|
constructor(options, dbName) {
|
|
21
24
|
this.folderId = null;
|
|
22
25
|
this.meta = {
|
|
@@ -34,7 +37,35 @@ class DriveHandler {
|
|
|
34
37
|
this.currentLogSizeEstimate = 0;
|
|
35
38
|
this.listeners = [];
|
|
36
39
|
this.pollingInterval = null;
|
|
37
|
-
this.
|
|
40
|
+
this.loadingPromise = null;
|
|
41
|
+
this.isPollingActive = false;
|
|
42
|
+
this.processedLogIds = new Set();
|
|
43
|
+
this.currentSnapshotIndexId = null;
|
|
44
|
+
this.debug = false;
|
|
45
|
+
this.isCompacting = false;
|
|
46
|
+
const clientOptions = { ...options };
|
|
47
|
+
if (options.testMode) {
|
|
48
|
+
const serverUrl = options.testServerUrl || 'http://localhost:3000';
|
|
49
|
+
// @ts-ignore - baseUrl/uploadUrl might not be in the strict type if we didn't update types.ts definition for DriveClientOptions in client.ts yet?
|
|
50
|
+
// We did update DriveClientOptions in client.ts.
|
|
51
|
+
// But GoogleDriveAdapterOptions extends DriveClientOptions?
|
|
52
|
+
// types.ts: export interface GoogleDriveAdapterOptions extends DriveClientOptions
|
|
53
|
+
// client.ts: export interface DriveClientOptions { accessToken: ...; baseUrl?: string; uploadUrl?: string; }
|
|
54
|
+
// So typescript should be happy.
|
|
55
|
+
// Using /drive/v3/files as base for the test server if simplified?
|
|
56
|
+
// The TestServer mounts at /drive/v3/files.
|
|
57
|
+
// But the client appends /drive/v3/files to BASE_URL?
|
|
58
|
+
// In client.ts default is `https://www.googleapis.com/drive/v3/files`.
|
|
59
|
+
// Our TestServer mounts `/drive/v3/files`.
|
|
60
|
+
// So testUrl should be `http://localhost:3000/drive/v3/files`.
|
|
61
|
+
const testBase = `${serverUrl}/drive/v3/files`;
|
|
62
|
+
const testUpload = `${serverUrl}/upload/drive/v3/files`;
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
clientOptions.baseUrl = testBase;
|
|
65
|
+
// @ts-ignore
|
|
66
|
+
clientOptions.uploadUrl = testUpload;
|
|
67
|
+
}
|
|
68
|
+
this.client = new client_1.GoogleDriveClient(clientOptions);
|
|
38
69
|
this.options = options;
|
|
39
70
|
this.folderId = options.folderId || null;
|
|
40
71
|
this.folderName = options.folderName || dbName;
|
|
@@ -42,7 +73,9 @@ class DriveHandler {
|
|
|
42
73
|
this.compactionThreshold = options.compactionThreshold || DEFAULT_COMPACTION_THRESHOLD;
|
|
43
74
|
this.compactionSizeThreshold = options.compactionSizeThreshold || DEFAULT_SIZE_THRESHOLD;
|
|
44
75
|
this.meta.dbName = dbName;
|
|
76
|
+
this.debug = !!options.debug;
|
|
45
77
|
this.docCache = new cache_1.LRUCache(options.cacheSize || DEFAULT_CACHE_SIZE);
|
|
78
|
+
this.fileCache = new cache_1.LRUCache(100); // Cache for last 100 files
|
|
46
79
|
// Polling will be started in load() after folderId is resolved
|
|
47
80
|
}
|
|
48
81
|
// Public getter for Sequence (used by adapter)
|
|
@@ -51,69 +84,104 @@ class DriveHandler {
|
|
|
51
84
|
}
|
|
52
85
|
/** Load the database (Index Only) */
|
|
53
86
|
async load() {
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const metaFile = await this.findFile('_meta.json');
|
|
58
|
-
if (metaFile) {
|
|
59
|
-
this.meta = await this.downloadJson(metaFile.id);
|
|
60
|
-
this.metaEtag = metaFile.etag || null;
|
|
61
|
-
this.metaModifiedTime = metaFile.modifiedTime || null;
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
await this.saveMeta(this.meta);
|
|
65
|
-
}
|
|
66
|
-
// Initialize Index
|
|
67
|
-
this.index = {};
|
|
68
|
-
// 1. Load Snapshot Index
|
|
69
|
-
if (this.meta.snapshotIndexId) {
|
|
87
|
+
if (this.loadingPromise)
|
|
88
|
+
return this.loadingPromise;
|
|
89
|
+
this.loadingPromise = (async () => {
|
|
70
90
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
this.log('Loading database, options', { options: this.options });
|
|
92
|
+
if (!this.folderId) {
|
|
93
|
+
this.folderId = await this.findOrCreateFolder();
|
|
94
|
+
this.log('Retrieved folder', { folderId: this.folderId });
|
|
95
|
+
}
|
|
96
|
+
const metaFile = await this.findFile('_meta.json');
|
|
97
|
+
if (metaFile) {
|
|
98
|
+
this.log('Retrieved meta file', { fileId: metaFile.id });
|
|
99
|
+
this.meta = await this.downloadJson(metaFile.id, true); // No cache for meta
|
|
100
|
+
this.metaEtag = metaFile.etag || null;
|
|
101
|
+
this.metaModifiedTime = metaFile.modifiedTime || null;
|
|
77
102
|
}
|
|
78
103
|
else {
|
|
79
|
-
this.
|
|
80
|
-
|
|
104
|
+
this.log('Meta file not found, creating new');
|
|
105
|
+
await this.saveMeta(this.meta);
|
|
106
|
+
}
|
|
107
|
+
if (this.meta.snapshotIndexId !== this.currentSnapshotIndexId) {
|
|
108
|
+
this.log('Snapshot index changed, loading index', {
|
|
109
|
+
snapshotIndexId: this.meta.snapshotIndexId,
|
|
110
|
+
currentSnapshotIndexId: this.currentSnapshotIndexId
|
|
111
|
+
});
|
|
112
|
+
// Compaction occurred or initial load
|
|
113
|
+
this.index = {};
|
|
114
|
+
this.processedLogIds.clear();
|
|
115
|
+
this.currentSnapshotIndexId = this.meta.snapshotIndexId;
|
|
116
|
+
if (this.meta.snapshotIndexId) {
|
|
117
|
+
try {
|
|
118
|
+
const snapshotIdx = await this.downloadJson(this.meta.snapshotIndexId);
|
|
119
|
+
if (snapshotIdx.docs) {
|
|
120
|
+
this.filesFromLegacySnapshot(snapshotIdx);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.index = snapshotIdx.entries || {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
console.warn('Failed to load snapshot index', e);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (this.meta.snapshotId) {
|
|
131
|
+
this.log('Legacy snapshot found, loading index', {
|
|
132
|
+
snapshotId: this.meta.snapshotId
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
const legacySnapshot = await this.downloadJson(this.meta.snapshotId);
|
|
136
|
+
this.filesFromLegacySnapshot(legacySnapshot);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
console.warn('Failed to load legacy snapshot', e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// 2. Replay NEW Change Logs (Metadata only updates)
|
|
144
|
+
this.log('Replaying change logs');
|
|
145
|
+
for (const logId of this.meta.changeLogIds) {
|
|
146
|
+
if (this.processedLogIds.has(logId))
|
|
147
|
+
continue;
|
|
148
|
+
this.log('Replaying change log', logId);
|
|
149
|
+
let changes = await this.downloadNdjson(logId);
|
|
150
|
+
if (!Array.isArray(changes)) {
|
|
151
|
+
this.log("Unexpected changes", { changes, logId });
|
|
152
|
+
this.log('Downloaded changes not an array, wrapping/ignoring', typeof changes);
|
|
153
|
+
changes = changes ? [changes] : []; // Fallback
|
|
154
|
+
}
|
|
155
|
+
this.currentLogSizeEstimate += 100 * changes.length;
|
|
156
|
+
this.log('Replayed change log', logId);
|
|
157
|
+
for (const change of changes) {
|
|
158
|
+
this.log('Processing change, sequence', change.seq);
|
|
159
|
+
this.updateIndex(change, logId);
|
|
160
|
+
if (this.docCache.get(change.id)) {
|
|
161
|
+
this.docCache.remove(change.id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
this.processedLogIds.add(logId);
|
|
165
|
+
this.log('Processed log', logId);
|
|
166
|
+
}
|
|
167
|
+
// 3. Start Polling (if enabled)
|
|
168
|
+
if (this.options.pollingIntervalMs) {
|
|
169
|
+
this.log('Starting polling with interval', this.options.pollingIntervalMs);
|
|
170
|
+
this.startPolling(Number(this.options.pollingIntervalMs));
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
this.log('Polling disabled (no interval provided)');
|
|
81
174
|
}
|
|
82
175
|
}
|
|
83
176
|
catch (e) {
|
|
84
|
-
console.
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
else if (this.meta.snapshotId) {
|
|
89
|
-
// Legacy support: field was renamed
|
|
90
|
-
try {
|
|
91
|
-
const legacySnapshot = await this.downloadJson(this.meta.snapshotId);
|
|
92
|
-
this.filesFromLegacySnapshot(legacySnapshot);
|
|
93
|
-
}
|
|
94
|
-
catch (e) {
|
|
95
|
-
console.warn('Failed to load legacy snapshot', e);
|
|
177
|
+
console.error('Failed to load database', e);
|
|
178
|
+
throw e;
|
|
96
179
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.pendingChanges = [];
|
|
100
|
-
this.currentLogSizeEstimate = 0;
|
|
101
|
-
for (const logId of this.meta.changeLogIds) {
|
|
102
|
-
const changes = await this.downloadNdjson(logId);
|
|
103
|
-
this.currentLogSizeEstimate += 100 * changes.length;
|
|
104
|
-
for (const change of changes) {
|
|
105
|
-
this.updateIndex(change, logId);
|
|
106
|
-
// We do NOT load body into cache automatically
|
|
107
|
-
// But we must invalidate cache if we had old data
|
|
108
|
-
if (this.docCache.get(change.id)) {
|
|
109
|
-
this.docCache.remove(change.id);
|
|
110
|
-
}
|
|
180
|
+
finally {
|
|
181
|
+
this.loadingPromise = null;
|
|
111
182
|
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (this.options.pollingIntervalMs) {
|
|
115
|
-
this.startPolling(this.options.pollingIntervalMs);
|
|
116
|
-
}
|
|
183
|
+
})();
|
|
184
|
+
return this.loadingPromise;
|
|
117
185
|
}
|
|
118
186
|
// Migration helper
|
|
119
187
|
filesFromLegacySnapshot(snapshot) {
|
|
@@ -141,17 +209,12 @@ class DriveHandler {
|
|
|
141
209
|
return null;
|
|
142
210
|
if (entry.deleted)
|
|
143
211
|
return null;
|
|
144
|
-
// 1. Check Cache
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
return
|
|
148
|
-
// 2. Fetch from Drive
|
|
149
|
-
// If it's a legacy entry currently in memory (should have been cached), returns null if evicted?
|
|
212
|
+
// 1. Check Doc Cache
|
|
213
|
+
const cachedDoc = this.docCache.get(id);
|
|
214
|
+
if (cachedDoc)
|
|
215
|
+
return cachedDoc;
|
|
216
|
+
// 2. Fetch from Drive (via File Cache)
|
|
150
217
|
if (entry.location.fileId === 'LEGACY_MEMORY') {
|
|
151
|
-
// If evicted, we are in trouble unless we re-download the legacy snapshot.
|
|
152
|
-
// For robustness, let's say we reload the legacy snapshot if needed.
|
|
153
|
-
// OR simpler: we assume compaction will fix this soon.
|
|
154
|
-
// Let's implement fetch for safety.
|
|
155
218
|
if (this.meta.snapshotId) {
|
|
156
219
|
const legacy = await this.downloadJson(this.meta.snapshotId);
|
|
157
220
|
if (legacy.docs[id]) {
|
|
@@ -159,36 +222,83 @@ class DriveHandler {
|
|
|
159
222
|
return legacy.docs[id];
|
|
160
223
|
}
|
|
161
224
|
}
|
|
162
|
-
return null;
|
|
225
|
+
return null;
|
|
163
226
|
}
|
|
164
227
|
const fileId = entry.location.fileId;
|
|
165
|
-
|
|
166
|
-
// We can infer or we could have stored type.
|
|
167
|
-
// Usually, we just download the file.
|
|
168
|
-
// Optimization: If we have many docs in one file, we might want to cache that file's contents?
|
|
169
|
-
// For now, naive fetch: download file, find doc.
|
|
170
|
-
const content = await this.downloadFileAny(fileId);
|
|
228
|
+
const content = await this.fetchFile(fileId);
|
|
171
229
|
let doc = null;
|
|
172
230
|
if (Array.isArray(content)) {
|
|
173
|
-
// It's a change log (
|
|
174
|
-
|
|
175
|
-
const match = content.reverse().find((c) => c.id === id);
|
|
231
|
+
// It's a change log (NDJSON parsed as array)
|
|
232
|
+
const match = [...content].reverse().find((c) => c.id === id);
|
|
176
233
|
doc = match ? match.doc : null;
|
|
177
234
|
}
|
|
178
|
-
else if (content.docs) {
|
|
235
|
+
else if (content && content.docs) {
|
|
179
236
|
// It's a snapshot-data chunk
|
|
180
237
|
doc = content.docs[id];
|
|
181
238
|
}
|
|
182
|
-
else {
|
|
183
|
-
//
|
|
239
|
+
else if (content && content.id === id && content.doc) {
|
|
240
|
+
// It's a single ChangeEntry object (parsed from single-line NDJSON)
|
|
241
|
+
doc = content.doc;
|
|
242
|
+
}
|
|
243
|
+
else if (content && (content._id === id || content.id === id)) {
|
|
244
|
+
// Single doc file or raw doc body
|
|
184
245
|
doc = content;
|
|
185
246
|
}
|
|
186
247
|
if (doc) {
|
|
187
248
|
this.docCache.put(id, doc);
|
|
188
|
-
doc._rev = entry.rev;
|
|
249
|
+
doc._rev = entry.rev;
|
|
189
250
|
}
|
|
190
251
|
return doc;
|
|
191
252
|
}
|
|
253
|
+
/** Generic Download with Caching and Parsing */
|
|
254
|
+
async fetchFile(fileId, skipCache = false) {
|
|
255
|
+
if (!skipCache) {
|
|
256
|
+
const cached = this.fileCache.get(fileId);
|
|
257
|
+
if (cached) {
|
|
258
|
+
this.log('fetchFile cache hit', fileId);
|
|
259
|
+
return cached;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
this.log('fetchFile downloading', fileId);
|
|
263
|
+
const data = await this.client.getFile(fileId);
|
|
264
|
+
let parsed;
|
|
265
|
+
if (typeof data === 'string') {
|
|
266
|
+
const trimmed = data.trim();
|
|
267
|
+
if (trimmed.startsWith('{')) {
|
|
268
|
+
// Could be JSON or NDJSON
|
|
269
|
+
if (trimmed.includes('\n')) {
|
|
270
|
+
// Definitely NDJSON (multiple lines)
|
|
271
|
+
try {
|
|
272
|
+
const lines = trimmed.split('\n').filter(l => l);
|
|
273
|
+
parsed = lines.map(line => JSON.parse(line));
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
parsed = data;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// Single line. Try regular JSON first.
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(trimmed);
|
|
283
|
+
// Optional: if we know it was supposed to be NDJSON?
|
|
284
|
+
// Our change files are always NDJSON.
|
|
285
|
+
// But _meta.json and snapshot-*.json are regular JSON.
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
parsed = data;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
parsed = data;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
parsed = data;
|
|
298
|
+
}
|
|
299
|
+
this.fileCache.put(fileId, parsed);
|
|
300
|
+
return parsed;
|
|
301
|
+
}
|
|
192
302
|
/** Get multiple docs (Atomic-ish) used for _allDocs */
|
|
193
303
|
async getMulti(ids) {
|
|
194
304
|
// Naive parallel fetch
|
|
@@ -222,16 +332,20 @@ class DriveHandler {
|
|
|
222
332
|
// Fetch files
|
|
223
333
|
for (const [fileId, docIds] of Object.entries(byFile)) {
|
|
224
334
|
try {
|
|
225
|
-
const content = await this.
|
|
335
|
+
const content = await this.fetchFile(fileId);
|
|
226
336
|
for (const docId of docIds) {
|
|
227
337
|
let doc = null;
|
|
228
338
|
if (Array.isArray(content)) {
|
|
229
|
-
const match = content.reverse().find((c) => c.id === docId);
|
|
339
|
+
const match = [...content].reverse().find((c) => c.id === docId);
|
|
230
340
|
doc = match ? match.doc : null;
|
|
231
341
|
}
|
|
232
|
-
else if (content.docs) {
|
|
342
|
+
else if (content && content.docs) {
|
|
233
343
|
doc = content.docs[docId];
|
|
234
344
|
}
|
|
345
|
+
else if (content && content.id === docId && content.doc) {
|
|
346
|
+
// Single ChangeEntry object
|
|
347
|
+
doc = content.doc;
|
|
348
|
+
}
|
|
235
349
|
if (doc) {
|
|
236
350
|
// Add entry.rev to doc just in case
|
|
237
351
|
if (this.index[docId])
|
|
@@ -253,7 +367,9 @@ class DriveHandler {
|
|
|
253
367
|
return ids.map(id => results[id]);
|
|
254
368
|
}
|
|
255
369
|
/** Return all keys in Index */
|
|
256
|
-
getIndexKeys() {
|
|
370
|
+
async getIndexKeys() {
|
|
371
|
+
if (this.loadingPromise)
|
|
372
|
+
await this.loadingPromise;
|
|
257
373
|
return Object.keys(this.index);
|
|
258
374
|
}
|
|
259
375
|
/** Get metadata for a specific ID from Index */
|
|
@@ -296,29 +412,37 @@ class DriveHandler {
|
|
|
296
412
|
async tryAppendChanges(changes) {
|
|
297
413
|
// 1. Write Log File (Upload Data)
|
|
298
414
|
const fileId = await this.writeChangeFile(changes);
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
415
|
+
try {
|
|
416
|
+
// 2. Prepare speculative meta update
|
|
417
|
+
const nextMeta = { ...this.meta };
|
|
418
|
+
nextMeta.changeLogIds = [...nextMeta.changeLogIds, fileId];
|
|
419
|
+
nextMeta.seq = changes[changes.length - 1].seq;
|
|
420
|
+
// 3. Commit Lock
|
|
421
|
+
await this.saveMeta(nextMeta, this.metaEtag);
|
|
422
|
+
// 4. Update Local State
|
|
423
|
+
this.meta = nextMeta;
|
|
424
|
+
for (const change of changes) {
|
|
425
|
+
this.updateIndex(change, fileId);
|
|
426
|
+
if (change.doc) {
|
|
427
|
+
this.docCache.put(change.id, change.doc);
|
|
428
|
+
}
|
|
429
|
+
else if (change.deleted) {
|
|
430
|
+
this.docCache.remove(change.id);
|
|
431
|
+
}
|
|
311
432
|
}
|
|
312
|
-
|
|
313
|
-
|
|
433
|
+
// Notify local changes feed listeners about our own write
|
|
434
|
+
this.notifyListeners();
|
|
435
|
+
// 5. Compaction Check
|
|
436
|
+
const totalChanges = await this.countTotalChanges();
|
|
437
|
+
if (totalChanges >= this.compactionThreshold ||
|
|
438
|
+
this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
|
|
439
|
+
this.compact().catch(e => console.error('Compaction failed', e));
|
|
314
440
|
}
|
|
315
441
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
this.currentLogSizeEstimate >= this.compactionSizeThreshold) {
|
|
321
|
-
this.compact().catch(e => console.error('Compaction failed', e));
|
|
442
|
+
catch (err) {
|
|
443
|
+
// Cleanup orphaned log file on metadata update failure
|
|
444
|
+
this.client.deleteFile(fileId).catch(e => this.log('Failed to cleanup orphaned log', fileId, e));
|
|
445
|
+
throw err;
|
|
322
446
|
}
|
|
323
447
|
}
|
|
324
448
|
/** Update Index with a new change */
|
|
@@ -348,55 +472,67 @@ class DriveHandler {
|
|
|
348
472
|
}
|
|
349
473
|
/** Compact: Create SnapshotIndex + SnapshotData */
|
|
350
474
|
async compact() {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
475
|
+
if (this.isCompacting)
|
|
476
|
+
return;
|
|
477
|
+
this.isCompacting = true;
|
|
478
|
+
try {
|
|
479
|
+
this.log('Starting compaction');
|
|
480
|
+
const snapshotSeq = this.meta.seq;
|
|
481
|
+
const oldLogIds = [...this.meta.changeLogIds];
|
|
482
|
+
const oldIndexId = this.meta.snapshotIndexId;
|
|
483
|
+
// 1. Fetch ALL active documents
|
|
484
|
+
// We need them to build the new large snapshot-data file
|
|
485
|
+
// This is the one time we download everything if not cached.
|
|
486
|
+
// Optimization: We could reuse existing `snapshot-data` chunks and only append new data
|
|
487
|
+
// to a new chunk, but for simplicity: Merge All.
|
|
488
|
+
const allIds = Object.keys(this.index).filter(id => !this.index[id].deleted);
|
|
489
|
+
const allDocs = await this.getMulti(allIds);
|
|
490
|
+
const snapshotData = { docs: {} };
|
|
491
|
+
allIds.forEach((id, i) => {
|
|
492
|
+
if (allDocs[i])
|
|
493
|
+
snapshotData.docs[id] = allDocs[i];
|
|
494
|
+
});
|
|
495
|
+
// 2. Upload Data File
|
|
496
|
+
const dataContent = JSON.stringify(snapshotData);
|
|
497
|
+
const dataRes = await this.client.createFile(`snapshot-data-${Date.now()}.json`, [this.folderId], 'application/json', dataContent);
|
|
498
|
+
const dataFileId = dataRes.id;
|
|
499
|
+
// 3. Create Index pointing to this Data File
|
|
500
|
+
const newIndexEntries = {};
|
|
501
|
+
for (const id of Object.keys(snapshotData.docs)) {
|
|
502
|
+
newIndexEntries[id] = {
|
|
503
|
+
rev: this.index[id].rev,
|
|
504
|
+
seq: this.index[id].seq,
|
|
505
|
+
location: { fileId: dataFileId }
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const snapshotIndex = {
|
|
509
|
+
entries: newIndexEntries,
|
|
510
|
+
seq: snapshotSeq,
|
|
511
|
+
createdAt: Date.now()
|
|
377
512
|
};
|
|
513
|
+
const indexContent = JSON.stringify(snapshotIndex);
|
|
514
|
+
const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
|
|
515
|
+
const newIndexId = indexRes.id;
|
|
516
|
+
// 4. Update Meta
|
|
517
|
+
let filesToDelete = [];
|
|
518
|
+
await this.atomicUpdateMeta((latest) => {
|
|
519
|
+
const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
|
|
520
|
+
// Only delete files that were in oldLogIds but not in remainingLogs
|
|
521
|
+
filesToDelete = oldLogIds.filter(id => !remainingLogs.includes(id));
|
|
522
|
+
return {
|
|
523
|
+
...latest,
|
|
524
|
+
snapshotIndexId: newIndexId,
|
|
525
|
+
changeLogIds: remainingLogs,
|
|
526
|
+
lastCompaction: Date.now()
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
// 5. Cleanup - Only delete files that were confirmed removed from metadata
|
|
530
|
+
await this.cleanupOldFiles(oldIndexId, filesToDelete);
|
|
531
|
+
this.currentLogSizeEstimate = 0;
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
this.isCompacting = false;
|
|
378
535
|
}
|
|
379
|
-
const snapshotIndex = {
|
|
380
|
-
entries: newIndexEntries,
|
|
381
|
-
seq: snapshotSeq,
|
|
382
|
-
createdAt: Date.now()
|
|
383
|
-
};
|
|
384
|
-
const indexContent = JSON.stringify(snapshotIndex);
|
|
385
|
-
const indexRes = await this.client.createFile(`snapshot-index-${Date.now()}.json`, [this.folderId], 'application/json', indexContent);
|
|
386
|
-
const newIndexId = indexRes.id;
|
|
387
|
-
// 4. Update Meta
|
|
388
|
-
await this.atomicUpdateMeta((latest) => {
|
|
389
|
-
const remainingLogs = latest.changeLogIds.filter(id => !oldLogIds.includes(id));
|
|
390
|
-
return {
|
|
391
|
-
...latest,
|
|
392
|
-
snapshotIndexId: newIndexId,
|
|
393
|
-
changeLogIds: remainingLogs,
|
|
394
|
-
lastCompaction: Date.now()
|
|
395
|
-
};
|
|
396
|
-
});
|
|
397
|
-
// 5. Cleanup
|
|
398
|
-
this.cleanupOldFiles(oldIndexId, oldLogIds); // And potentially old data files if we tracked them
|
|
399
|
-
this.currentLogSizeEstimate = 0;
|
|
400
536
|
}
|
|
401
537
|
// ... Helpers (atomicUpdateMeta, saveMeta, writeChangeFile same as before) ...
|
|
402
538
|
async atomicUpdateMeta(modifier) {
|
|
@@ -407,7 +543,7 @@ class DriveHandler {
|
|
|
407
543
|
const metaFile = await this.findFile('_meta.json');
|
|
408
544
|
if (!metaFile)
|
|
409
545
|
throw new Error('Meta missing');
|
|
410
|
-
const validMeta = await this.downloadJson(metaFile.id);
|
|
546
|
+
const validMeta = await this.downloadJson(metaFile.id, true); // No cache
|
|
411
547
|
const newMeta = modifier(validMeta);
|
|
412
548
|
await this.saveMeta(newMeta, metaFile.etag);
|
|
413
549
|
this.meta = newMeta;
|
|
@@ -439,30 +575,33 @@ class DriveHandler {
|
|
|
439
575
|
const safeName = this.escapeQuery(name);
|
|
440
576
|
const q = `name = '${safeName}' and '${this.folderId}' in parents and trashed = false`;
|
|
441
577
|
const files = await this.client.listFiles(q);
|
|
442
|
-
if (files.length > 0)
|
|
578
|
+
if (files.length > 0) {
|
|
579
|
+
let file = files[0];
|
|
580
|
+
if (!file.etag) {
|
|
581
|
+
// Robustness: Fetch metadata for the file if etag is missing from list
|
|
582
|
+
try {
|
|
583
|
+
file = await this.client.getFileMetadata(file.id);
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
this.log('Failed to fetch file metadata for etag', file.id, e);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
443
589
|
return {
|
|
444
|
-
id:
|
|
445
|
-
etag:
|
|
446
|
-
modifiedTime:
|
|
590
|
+
id: file.id,
|
|
591
|
+
etag: file.etag,
|
|
592
|
+
modifiedTime: file.modifiedTime
|
|
447
593
|
};
|
|
594
|
+
}
|
|
448
595
|
return null;
|
|
449
596
|
}
|
|
450
|
-
async downloadJson(fileId) {
|
|
451
|
-
return await this.
|
|
597
|
+
async downloadJson(fileId, skipCache = false) {
|
|
598
|
+
return await this.fetchFile(fileId, skipCache);
|
|
452
599
|
}
|
|
453
600
|
async downloadFileAny(fileId) {
|
|
454
|
-
return await this.
|
|
601
|
+
return await this.fetchFile(fileId);
|
|
455
602
|
}
|
|
456
603
|
async downloadNdjson(fileId) {
|
|
457
|
-
|
|
458
|
-
// data will likely be a string if NDJSON is returned and getFile sees weird content-type
|
|
459
|
-
// Or if getFile auto-parsed standard "application/json" but NDJSON is just text.
|
|
460
|
-
// Google Drive might return application/json for everything if we aren't careful?
|
|
461
|
-
// Actually .ndjson is separate.
|
|
462
|
-
// Safest: Handle string or object.
|
|
463
|
-
const content = typeof data === 'string' ? data : JSON.stringify(data);
|
|
464
|
-
const lines = content.trim().split('\n').filter((l) => l);
|
|
465
|
-
return lines.map((line) => JSON.parse(line));
|
|
604
|
+
return await this.fetchFile(fileId);
|
|
466
605
|
}
|
|
467
606
|
async writeChangeFile(changes) {
|
|
468
607
|
const lines = changes.map(c => JSON.stringify(c)).join('\n') + '\n';
|
|
@@ -479,6 +618,7 @@ class DriveHandler {
|
|
|
479
618
|
const res = await this.client.updateFile(metaFile.id, content, expectedEtag || undefined);
|
|
480
619
|
this.metaEtag = res.etag;
|
|
481
620
|
this.metaModifiedTime = res.modifiedTime;
|
|
621
|
+
this.fileCache.remove(metaFile.id); // Invalidate cache
|
|
482
622
|
}
|
|
483
623
|
else {
|
|
484
624
|
const res = await this.client.createFile('_meta.json', [this.folderId], 'application/json', content);
|
|
@@ -487,41 +627,75 @@ class DriveHandler {
|
|
|
487
627
|
}
|
|
488
628
|
}
|
|
489
629
|
async countTotalChanges() {
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
|
|
630
|
+
// If no snapshot exists yet, total changes = meta.seq (all changes)
|
|
631
|
+
if (!this.meta.snapshotIndexId) {
|
|
632
|
+
return this.meta.seq;
|
|
633
|
+
}
|
|
634
|
+
// Each log file ID in changeLogIds represents some number of changes.
|
|
635
|
+
// For simplicity and to trigger compaction based on file count (which is what matters for Drive),
|
|
636
|
+
// we can return the number of log files.
|
|
637
|
+
// But since compactionThreshold is usually in ENTRIES, let's keep a rough estimate
|
|
638
|
+
// or just return the log file count if that's what the user expects.
|
|
639
|
+
// The previous "* 5" was too aggressive.
|
|
640
|
+
// Let's assume on average 1 change per log file in tests (worst case).
|
|
641
|
+
return this.meta.changeLogIds.length + this.pendingChanges.length;
|
|
496
642
|
}
|
|
497
643
|
async cleanupOldFiles(oldIndexId, oldLogIds) {
|
|
498
|
-
|
|
644
|
+
const deleteFile = async (fileId) => {
|
|
499
645
|
try {
|
|
500
|
-
await this.client.deleteFile(
|
|
646
|
+
await this.client.deleteFile(fileId);
|
|
647
|
+
this.log('Deleted file', fileId);
|
|
501
648
|
}
|
|
502
|
-
catch {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
649
|
+
catch (err) {
|
|
650
|
+
// 404 is ok - file already deleted or doesn't exist
|
|
651
|
+
if (err.status === 404 || err.code === 404) {
|
|
652
|
+
this.log('File already deleted or not found', fileId);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
// Log other errors but don't fail
|
|
656
|
+
this.log('Failed to delete file', fileId, err);
|
|
506
657
|
}
|
|
507
|
-
|
|
658
|
+
};
|
|
659
|
+
if (oldIndexId) {
|
|
660
|
+
await deleteFile(oldIndexId);
|
|
661
|
+
}
|
|
662
|
+
for (const id of oldLogIds) {
|
|
663
|
+
await deleteFile(id);
|
|
664
|
+
}
|
|
508
665
|
}
|
|
509
666
|
startPolling(intervalMs) {
|
|
667
|
+
this.log('Starting polling with interval', { intervalMs });
|
|
668
|
+
if (isNaN(intervalMs) || intervalMs <= 0)
|
|
669
|
+
return;
|
|
510
670
|
if (this.pollingInterval)
|
|
511
671
|
clearInterval(this.pollingInterval);
|
|
512
672
|
this.pollingInterval = setInterval(async () => {
|
|
673
|
+
this.log('Polling tick...');
|
|
674
|
+
if (this.isPollingActive) {
|
|
675
|
+
this.log('Polling already in progress, skipping tick');
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
this.isPollingActive = true;
|
|
513
679
|
try {
|
|
514
680
|
const metaFile = await this.findFile('_meta.json');
|
|
515
|
-
if (!metaFile)
|
|
681
|
+
if (!metaFile) {
|
|
682
|
+
this.log('Polling: _meta.json not found');
|
|
516
683
|
return;
|
|
517
|
-
|
|
518
|
-
|
|
684
|
+
}
|
|
685
|
+
// Compare etags, falling back to modifiedTime
|
|
686
|
+
this.log('Polling: comparing etag', metaFile.etag, 'with', this.metaEtag);
|
|
687
|
+
if ((metaFile.etag && this.metaEtag && metaFile.etag !== this.metaEtag) ||
|
|
688
|
+
(!metaFile.etag && metaFile.modifiedTime !== this.metaModifiedTime)) {
|
|
689
|
+
this.log('Polling detected change!', metaFile.etag || metaFile.modifiedTime);
|
|
519
690
|
await this.load();
|
|
520
691
|
this.notifyListeners();
|
|
521
692
|
}
|
|
522
693
|
}
|
|
523
694
|
catch (err) {
|
|
524
|
-
|
|
695
|
+
this.log('Polling error', err);
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
this.isPollingActive = false;
|
|
525
699
|
}
|
|
526
700
|
}, intervalMs);
|
|
527
701
|
}
|
|
@@ -534,13 +708,23 @@ class DriveHandler {
|
|
|
534
708
|
// Adapter needs to handle this.
|
|
535
709
|
const changes = {};
|
|
536
710
|
for (const [id, entry] of Object.entries(this.index)) {
|
|
537
|
-
changes[id] = {
|
|
711
|
+
changes[id] = {
|
|
712
|
+
_id: id,
|
|
713
|
+
_rev: entry.rev,
|
|
714
|
+
_deleted: !!entry.deleted,
|
|
715
|
+
seq: entry.seq // IMPORTANT: Missing previously, preventing filtered changes from working
|
|
716
|
+
};
|
|
538
717
|
}
|
|
539
|
-
for (const
|
|
540
|
-
|
|
718
|
+
for (const cb of this.listeners)
|
|
719
|
+
cb(changes);
|
|
541
720
|
}
|
|
542
721
|
// For tests/debug
|
|
543
|
-
onChange(cb) {
|
|
722
|
+
onChange(cb) {
|
|
723
|
+
this.listeners.push(cb);
|
|
724
|
+
return () => {
|
|
725
|
+
this.listeners = this.listeners.filter(l => l !== cb);
|
|
726
|
+
};
|
|
727
|
+
}
|
|
544
728
|
stopPolling() { if (this.pollingInterval)
|
|
545
729
|
clearInterval(this.pollingInterval); }
|
|
546
730
|
escapeQuery(value) {
|