@augmentd-labs/canvas-edge 0.0.0-stage → 0.4.0
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/LICENSE +661 -0
- package/NOTICE +114 -0
- package/README.md +76 -2
- package/bin/canvas-edge +2 -0
- package/bin/healthcheck.mjs +9 -0
- package/node_modules/canvas-stored/LICENSE +661 -0
- package/node_modules/canvas-stored/NOTICE +85 -0
- package/node_modules/canvas-stored/README.md +472 -0
- package/node_modules/canvas-stored/package.json +26 -0
- package/node_modules/canvas-stored/src/backends/BackendManager.js +60 -0
- package/node_modules/canvas-stored/src/backends/StorageBackend.js +72 -0
- package/node_modules/canvas-stored/src/backends/cacache/index.js +130 -0
- package/node_modules/canvas-stored/src/backends/canvas/index.js +722 -0
- package/node_modules/canvas-stored/src/backends/file/index.js +658 -0
- package/node_modules/canvas-stored/src/backends/gdrive/index.js +924 -0
- package/node_modules/canvas-stored/src/backends/http/index.js +47 -0
- package/node_modules/canvas-stored/src/backends/s3/index.js +25 -0
- package/node_modules/canvas-stored/src/cache/index.js +65 -0
- package/node_modules/canvas-stored/src/extractors/audio.js +53 -0
- package/node_modules/canvas-stored/src/extractors/image.js +90 -0
- package/node_modules/canvas-stored/src/extractors/index.js +41 -0
- package/node_modules/canvas-stored/src/extractors/text.js +65 -0
- package/node_modules/canvas-stored/src/index/index.js +360 -0
- package/node_modules/canvas-stored/src/index.js +2058 -0
- package/node_modules/canvas-stored/src/sync/JobQueue.js +0 -0
- package/node_modules/canvas-stored/src/sync/Ledger.js +138 -0
- package/node_modules/canvas-stored/src/sync/Mirror.js +995 -0
- package/node_modules/canvas-stored/src/sync/SyncQueue.js +111 -0
- package/node_modules/canvas-stored/src/sync/keys.js +140 -0
- package/node_modules/canvas-stored/src/sync/worker.js +52 -0
- package/node_modules/canvas-stored/src/utils/checksum.js +32 -0
- package/node_modules/canvas-stored/src/utils/common.js +37 -0
- package/node_modules/canvas-stored/src/utils/mime.js +107 -0
- package/node_modules/canvas-stored/src/utils/mount.js +99 -0
- package/package.json +49 -4
- package/src/EdgeClient.js +129 -0
- package/src/control.js +48 -0
- package/src/daemon.js +88 -0
- package/src/env.js +182 -0
- package/src/fuse-runtime.js +173 -0
- package/src/index.js +26 -0
- package/src/mirror-runtime.js +232 -0
- package/src/remote-config.js +69 -0
- package/src/runtime.js +61 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
import { createReadStream, promises as fsp } from 'fs';
|
|
3
|
+
import Debug from 'debug';
|
|
4
|
+
import StorageBackend from '../StorageBackend.js';
|
|
5
|
+
import { checksumStream } from '../../utils/checksum.js';
|
|
6
|
+
|
|
7
|
+
const debug = Debug('stored:backend:gdrive');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Google Drive storage backend.
|
|
11
|
+
*
|
|
12
|
+
* One backend = one Drive folder subtree (`config.folderId`, default `root` =
|
|
13
|
+
* the account's My Drive) reached with an offline-access OAuth refresh token
|
|
14
|
+
* (`clientId`, `clientSecret`, `refreshToken` — the same grant the gcal
|
|
15
|
+
* connector uses, with the `drive` scope). Plain `fetch`, no googleapis dep.
|
|
16
|
+
*
|
|
17
|
+
* Key model: Drive is id-addressed, stored is path-addressed. A key is the
|
|
18
|
+
* '/'-joined chain of Drive *names* from the configured root down to the file.
|
|
19
|
+
* Drive allows duplicate names in one folder and '/' inside names — siblings
|
|
20
|
+
* that collide get a ` (<id-prefix>)` suffix, '/' becomes '∕' (U+2215), so
|
|
21
|
+
* every key is unique and round-trips. The key→id map is rebuilt by every
|
|
22
|
+
* walk (scan/list/shape) and kept current by put/delete/watch; a cold lookup
|
|
23
|
+
* resolves the path component-by-component against the API.
|
|
24
|
+
*
|
|
25
|
+
* Identity: Drive serves sha256/sha1/md5 for every binary file, so a scan
|
|
26
|
+
* never downloads bytes unless an algorithm the API lacks is requested. Native
|
|
27
|
+
* Google Docs/Sheets/Slides have no bytes and no checksums — skipped (an
|
|
28
|
+
* export path is a follow-up).
|
|
29
|
+
*
|
|
30
|
+
* `type: 'remote'` — writes land in stored's cache and reach Drive through the
|
|
31
|
+
* SyncQueue (`commit(key, srcPath)`), reads stream straight from the API.
|
|
32
|
+
* `watch()` polls `changes.list` with a page token (true incremental sync;
|
|
33
|
+
* renames/moves surface as unlink+add sharing `ino` = fileId, which stored's
|
|
34
|
+
* rename window collapses into an in-place URL rewrite).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
38
|
+
const API = 'https://www.googleapis.com/drive/v3';
|
|
39
|
+
const UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3';
|
|
40
|
+
const FOLDER_MIME = 'application/vnd.google-apps.folder';
|
|
41
|
+
const NATIVE_PREFIX = 'application/vnd.google-apps.';
|
|
42
|
+
const FILE_FIELDS = 'id,name,mimeType,size,modifiedTime,createdTime,md5Checksum,sha1Checksum,sha256Checksum,parents,trashed';
|
|
43
|
+
const PAGE_SIZE = 1000;
|
|
44
|
+
// Resumable-upload chunk: Drive requires non-final chunks to be a multiple of
|
|
45
|
+
// 256 KiB; 8 MiB keeps request count low without holding much in RAM.
|
|
46
|
+
const UPLOAD_CHUNK = 8 * 1024 * 1024;
|
|
47
|
+
const DEFAULT_POLL_INTERVAL = 60_000;
|
|
48
|
+
// Walks are memoized briefly: a resync calls shape() then scan() back to back
|
|
49
|
+
// and must not enumerate a large Drive twice.
|
|
50
|
+
const WALK_CACHE_MS = 30_000;
|
|
51
|
+
const DEV = 'gdrive';
|
|
52
|
+
const API_HASH_FIELDS = { sha256: 'sha256Checksum', sha1: 'sha1Checksum', md5: 'md5Checksum' };
|
|
53
|
+
const RETRY_DELAYS_MS = [500, 1500, 4000];
|
|
54
|
+
|
|
55
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
const qEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
57
|
+
const safeName = (name) => String(name).replace(/\//g, '∕');
|
|
58
|
+
const splitKey = (key) => {
|
|
59
|
+
const k = String(key || '').replace(/\/{2,}/g, '/').replace(/^\/+|\/+$/g, '');
|
|
60
|
+
const slash = k.lastIndexOf('/');
|
|
61
|
+
return slash < 0 ? { dir: '', name: k, key: k } : { dir: k.slice(0, slash), name: k.slice(slash + 1), key: k };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
class DriveError extends Error {
|
|
65
|
+
constructor(message, { code = 'unreachable', status = null } = {}) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'DriveError';
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.status = status;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default class GdriveBackend extends StorageBackend {
|
|
74
|
+
#fetch;
|
|
75
|
+
#rootRef;
|
|
76
|
+
#rootId = null;
|
|
77
|
+
#accessToken = null;
|
|
78
|
+
#tokenExpiresAt = 0;
|
|
79
|
+
#defaultAlgorithms;
|
|
80
|
+
|
|
81
|
+
// key → row { key, id, size, modified, created, mimeType, checksums, dev, ino }
|
|
82
|
+
#files = new Map();
|
|
83
|
+
// id → key (files only)
|
|
84
|
+
#keyById = new Map();
|
|
85
|
+
// folder path ('' = root) ↔ folder id
|
|
86
|
+
#folderIdByPath = new Map();
|
|
87
|
+
#folderPathById = new Map();
|
|
88
|
+
#lastWalk = null;
|
|
89
|
+
|
|
90
|
+
#pollTimer = null;
|
|
91
|
+
#polling = false;
|
|
92
|
+
#pageToken = null;
|
|
93
|
+
|
|
94
|
+
constructor(name, config = {}) {
|
|
95
|
+
super(name, config);
|
|
96
|
+
this.type = 'remote';
|
|
97
|
+
this.#fetch = typeof config.fetch === 'function' ? config.fetch : (...args) => globalThis.fetch(...args);
|
|
98
|
+
this.#rootRef = String(config.folderId || 'root').trim() || 'root';
|
|
99
|
+
this.#defaultAlgorithms = config.algorithms || ['sha256'];
|
|
100
|
+
debug(`GdriveBackend "${name}" initialized (folder=${this.#rootRef})`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get capabilities() { return { read: true, write: true, delete: true, canEnumerate: true, remote: true }; }
|
|
104
|
+
get remote() { return true; }
|
|
105
|
+
get transport() { return DEV; }
|
|
106
|
+
get watching() { return !!this.#pollTimer; }
|
|
107
|
+
get rootFolderId() { return this.#rootId; }
|
|
108
|
+
|
|
109
|
+
nativeUrl(key) {
|
|
110
|
+
const row = this.#files.get(splitKey(key).key);
|
|
111
|
+
return row ? `https://drive.google.com/file/d/${row.id}/view` : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
115
|
+
// Auth + transport
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async #token({ force = false } = {}) {
|
|
119
|
+
if (!force && this.#accessToken && Date.now() < this.#tokenExpiresAt - 60_000) return this.#accessToken;
|
|
120
|
+
const { clientId, clientSecret, refreshToken } = this.config;
|
|
121
|
+
if (!clientId || !clientSecret || !refreshToken) {
|
|
122
|
+
throw new DriveError('gdrive backend requires clientId, clientSecret and refreshToken', { code: 'unauthorized' });
|
|
123
|
+
}
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await this.#fetch(TOKEN_URL, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
129
|
+
body: new URLSearchParams({
|
|
130
|
+
client_id: clientId,
|
|
131
|
+
client_secret: clientSecret,
|
|
132
|
+
refresh_token: refreshToken,
|
|
133
|
+
grant_type: 'refresh_token',
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new DriveError(`gdrive token endpoint unreachable: ${err.message}`, { code: 'unreachable' });
|
|
138
|
+
}
|
|
139
|
+
const json = await res.json().catch(() => null);
|
|
140
|
+
if (!res.ok || !json?.access_token) {
|
|
141
|
+
const detail = json?.error_description || json?.error || `HTTP ${res.status}`;
|
|
142
|
+
throw new DriveError(`gdrive token refresh failed: ${detail}`, { code: 'unauthorized', status: res.status });
|
|
143
|
+
}
|
|
144
|
+
this.#accessToken = json.access_token;
|
|
145
|
+
this.#tokenExpiresAt = Date.now() + (Number(json.expires_in) || 3600) * 1000;
|
|
146
|
+
return this.#accessToken;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Authenticated request with one forced token refresh on 401 and bounded
|
|
151
|
+
* backoff on 429/5xx. Returns the Response; `okStatuses` widens success
|
|
152
|
+
* (resumable uploads answer 308 between chunks). Throws DriveError.
|
|
153
|
+
*/
|
|
154
|
+
async #call(url, init = {}, { okStatuses = [], allow404 = false } = {}) {
|
|
155
|
+
let refreshed = false;
|
|
156
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
157
|
+
const token = await this.#token();
|
|
158
|
+
let res;
|
|
159
|
+
try {
|
|
160
|
+
res = await this.#fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.headers || {}) } });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (attempt < RETRY_DELAYS_MS.length) { await sleep(RETRY_DELAYS_MS[attempt]); continue; }
|
|
163
|
+
throw new DriveError(`gdrive request failed: ${err.message}`, { code: 'unreachable' });
|
|
164
|
+
}
|
|
165
|
+
if (res.ok || okStatuses.includes(res.status)) return res;
|
|
166
|
+
if (res.status === 404 && allow404) return res;
|
|
167
|
+
if (res.status === 401 && !refreshed) {
|
|
168
|
+
refreshed = true;
|
|
169
|
+
await this.#token({ force: true });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if ((res.status === 429 || res.status >= 500) && attempt < RETRY_DELAYS_MS.length) {
|
|
173
|
+
const retryAfter = Number(res.headers?.get?.('retry-after')) * 1000;
|
|
174
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : RETRY_DELAYS_MS[attempt]);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const body = await res.json().catch(() => null);
|
|
178
|
+
const detail = body?.error?.message || body?.error_description || `HTTP ${res.status}`;
|
|
179
|
+
const code = res.status === 401 || res.status === 403 ? 'unauthorized' : (res.status === 404 ? 'not-found' : 'unreachable');
|
|
180
|
+
throw new DriveError(`gdrive ${init.method || 'GET'} ${new URL(url).pathname}: ${detail}`, { code, status: res.status });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#url(path, query = {}) {
|
|
185
|
+
const u = new URL(path.startsWith('http') ? path : `${API}${path}`);
|
|
186
|
+
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null) u.searchParams.set(k, String(v));
|
|
187
|
+
return u.toString();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async #json(path, { query = {}, ...init } = {}) {
|
|
191
|
+
const res = await this.#call(this.#url(path, query), init);
|
|
192
|
+
return res.status === 204 ? null : res.json();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
196
|
+
// Root + liveness
|
|
197
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
async #root() {
|
|
200
|
+
if (this.#rootId) return this.#rootId;
|
|
201
|
+
const res = await this.#call(this.#url(`/files/${encodeURIComponent(this.#rootRef)}`, { fields: 'id,name,mimeType,trashed', supportsAllDrives: true }), {}, { allow404: true });
|
|
202
|
+
if (res.status === 404) throw new DriveError(`gdrive root folder not found: ${this.#rootRef}`, { code: 'root-missing', status: 404 });
|
|
203
|
+
const f = await res.json();
|
|
204
|
+
if (f.trashed) throw new DriveError(`gdrive root folder is trashed: ${this.#rootRef}`, { code: 'root-missing' });
|
|
205
|
+
if (f.mimeType !== FOLDER_MIME) throw new DriveError(`gdrive root is not a folder: ${this.#rootRef}`, { code: 'root-missing' });
|
|
206
|
+
this.#rootId = f.id;
|
|
207
|
+
this.#rememberFolder('', f.id);
|
|
208
|
+
return f.id;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Liveness gate (same contract as the file driver's): token grant works and
|
|
213
|
+
* the root folder exists. `fsid` is null — Drive has no filesystem identity
|
|
214
|
+
* to snapshot; the folder id already is one.
|
|
215
|
+
*/
|
|
216
|
+
async verifyRoot() {
|
|
217
|
+
try {
|
|
218
|
+
await this.#root();
|
|
219
|
+
return { ok: true, fsid: null };
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return { ok: false, reason: err.code || 'unreachable', error: err.message, fsid: null };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// Path ↔ id bookkeeping
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
#rememberFolder(path, id) {
|
|
230
|
+
this.#folderIdByPath.set(path, id);
|
|
231
|
+
this.#folderPathById.set(id, path);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#remember(row) {
|
|
235
|
+
this.#files.set(row.key, row);
|
|
236
|
+
this.#keyById.set(row.id, row.key);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#forget(key) {
|
|
240
|
+
const row = this.#files.get(key);
|
|
241
|
+
if (!row) return null;
|
|
242
|
+
this.#files.delete(key);
|
|
243
|
+
if (this.#keyById.get(row.id) === key) this.#keyById.delete(row.id);
|
|
244
|
+
return row;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#apiChecksums(f) {
|
|
248
|
+
const out = {};
|
|
249
|
+
for (const [algo, field] of Object.entries(API_HASH_FIELDS)) if (f[field]) out[algo] = String(f[field]).toLowerCase();
|
|
250
|
+
return Object.keys(out).length ? out : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#row(key, f) {
|
|
254
|
+
return {
|
|
255
|
+
key,
|
|
256
|
+
id: f.id,
|
|
257
|
+
size: Number(f.size) || 0,
|
|
258
|
+
modified: f.modifiedTime ? Date.parse(f.modifiedTime) : null,
|
|
259
|
+
created: f.createdTime ? Date.parse(f.createdTime) : null,
|
|
260
|
+
mimeType: f.mimeType || null,
|
|
261
|
+
checksums: this.#apiChecksums(f),
|
|
262
|
+
// Inode-equivalent: the Drive id survives renames/moves, so stored
|
|
263
|
+
// can pair an unlink+add into a rename instead of delete+create.
|
|
264
|
+
dev: DEV,
|
|
265
|
+
ino: f.id,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Every non-trashed child of a folder, all pages.
|
|
270
|
+
async #children(folderId, { foldersOnly = false } = {}) {
|
|
271
|
+
const rows = [];
|
|
272
|
+
let pageToken;
|
|
273
|
+
const q = `'${qEscape(folderId)}' in parents and trashed = false${foldersOnly ? ` and mimeType = '${FOLDER_MIME}'` : ''}`;
|
|
274
|
+
do {
|
|
275
|
+
const res = await this.#json('/files', {
|
|
276
|
+
query: {
|
|
277
|
+
q,
|
|
278
|
+
fields: `nextPageToken,files(${FILE_FIELDS})`,
|
|
279
|
+
pageSize: PAGE_SIZE,
|
|
280
|
+
pageToken,
|
|
281
|
+
supportsAllDrives: true,
|
|
282
|
+
includeItemsFromAllDrives: true,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
rows.push(...(res?.files || []));
|
|
286
|
+
pageToken = res?.nextPageToken;
|
|
287
|
+
} while (pageToken);
|
|
288
|
+
return rows;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Deterministic sibling naming: sorted by (name, id); a repeated name gets
|
|
292
|
+
// an id-derived suffix so keys stay unique and stable across walks.
|
|
293
|
+
#nameSiblings(entries) {
|
|
294
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
|
|
295
|
+
const seen = new Map();
|
|
296
|
+
return sorted.map((f) => {
|
|
297
|
+
const base = safeName(f.name);
|
|
298
|
+
const taken = seen.get(base) || 0;
|
|
299
|
+
seen.set(base, taken + 1);
|
|
300
|
+
if (!taken) return { f, name: base };
|
|
301
|
+
const dot = base.lastIndexOf('.');
|
|
302
|
+
const tag = ` (${f.id.slice(0, 6)})`;
|
|
303
|
+
return { f, name: dot > 0 ? `${base.slice(0, dot)}${tag}${base.slice(dot)}` : `${base}${tag}` };
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Enumerate the whole subtree (breadth-first, one list call per folder) and
|
|
309
|
+
* rebuild the path maps from it. Unreadable folders are reported through
|
|
310
|
+
* `onError(prefix, err)` and skipped — never fatal, never "empty".
|
|
311
|
+
*/
|
|
312
|
+
async #walk({ onError = null, maxAge = 0 } = {}) {
|
|
313
|
+
if (maxAge > 0 && this.#lastWalk && Date.now() - this.#lastWalk.at < maxAge) return this.#lastWalk.result;
|
|
314
|
+
const rootId = await this.#root();
|
|
315
|
+
const files = new Map();
|
|
316
|
+
const keyById = new Map();
|
|
317
|
+
const folderIdByPath = new Map([['', rootId]]);
|
|
318
|
+
const folderPathById = new Map([[rootId, '']]);
|
|
319
|
+
const dirs = [];
|
|
320
|
+
const queue = [{ prefix: '', id: rootId }];
|
|
321
|
+
while (queue.length) {
|
|
322
|
+
const { prefix, id } = queue.shift();
|
|
323
|
+
let entries;
|
|
324
|
+
try {
|
|
325
|
+
entries = await this.#children(id);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (typeof onError === 'function') { onError(prefix, err); continue; }
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
for (const { f, name } of this.#nameSiblings(entries)) {
|
|
331
|
+
const key = prefix ? `${prefix}/${name}` : name;
|
|
332
|
+
if (f.mimeType === FOLDER_MIME) {
|
|
333
|
+
folderIdByPath.set(key, f.id);
|
|
334
|
+
folderPathById.set(f.id, key);
|
|
335
|
+
dirs.push(key);
|
|
336
|
+
queue.push({ prefix: key, id: f.id });
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// Native Google documents have no bytes (and no checksums).
|
|
340
|
+
if (f.mimeType && f.mimeType.startsWith(NATIVE_PREFIX)) continue;
|
|
341
|
+
const row = this.#row(key, f);
|
|
342
|
+
files.set(key, row);
|
|
343
|
+
keyById.set(f.id, key);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
this.#files = files;
|
|
347
|
+
this.#keyById = keyById;
|
|
348
|
+
this.#folderIdByPath = folderIdByPath;
|
|
349
|
+
this.#folderPathById = folderPathById;
|
|
350
|
+
const result = { files, dirs };
|
|
351
|
+
this.#lastWalk = { at: Date.now(), result };
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
#invalidateWalk() { this.#lastWalk = null; }
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Folder id for a '/'-path under the root. Walks down component by
|
|
359
|
+
* component against the API on a cache miss; `create` mkdir -p's the chain.
|
|
360
|
+
* Returns null when a component is missing and `create` is false.
|
|
361
|
+
*/
|
|
362
|
+
async #folderId(dirPath, { create = false } = {}) {
|
|
363
|
+
const clean = String(dirPath || '').replace(/\/{2,}/g, '/').replace(/^\/+|\/+$/g, '');
|
|
364
|
+
if (!clean) return this.#root();
|
|
365
|
+
const cached = this.#folderIdByPath.get(clean);
|
|
366
|
+
if (cached) return cached;
|
|
367
|
+
const { dir, name } = splitKey(clean);
|
|
368
|
+
const parentId = await this.#folderId(dir, { create });
|
|
369
|
+
if (!parentId) return null;
|
|
370
|
+
const found = await this.#json('/files', {
|
|
371
|
+
query: {
|
|
372
|
+
q: `'${qEscape(parentId)}' in parents and name = '${qEscape(name)}' and mimeType = '${FOLDER_MIME}' and trashed = false`,
|
|
373
|
+
fields: 'files(id,name)',
|
|
374
|
+
pageSize: 10,
|
|
375
|
+
supportsAllDrives: true,
|
|
376
|
+
includeItemsFromAllDrives: true,
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
const match = (found?.files || []).sort((a, b) => a.id.localeCompare(b.id))[0];
|
|
380
|
+
if (match) {
|
|
381
|
+
this.#rememberFolder(clean, match.id);
|
|
382
|
+
return match.id;
|
|
383
|
+
}
|
|
384
|
+
if (!create) return null;
|
|
385
|
+
const created = await this.#json('/files', {
|
|
386
|
+
method: 'POST',
|
|
387
|
+
query: { fields: 'id', supportsAllDrives: true },
|
|
388
|
+
headers: { 'Content-Type': 'application/json' },
|
|
389
|
+
body: JSON.stringify({ name, mimeType: FOLDER_MIME, parents: [parentId] }),
|
|
390
|
+
});
|
|
391
|
+
this.#rememberFolder(clean, created.id);
|
|
392
|
+
this.#invalidateWalk();
|
|
393
|
+
debug(`MKDIR ${clean} (${created.id})`);
|
|
394
|
+
return created.id;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Path of a folder id relative to the root ('' = root), or null when the
|
|
399
|
+
* folder is not under it (shared-with-me, another drive, trashed).
|
|
400
|
+
* Resolved upward through `parents`, memoized per folder.
|
|
401
|
+
*/
|
|
402
|
+
async #folderPath(folderId) {
|
|
403
|
+
if (folderId === (await this.#root())) return '';
|
|
404
|
+
if (this.#folderPathById.has(folderId)) return this.#folderPathById.get(folderId);
|
|
405
|
+
const res = await this.#call(this.#url(`/files/${encodeURIComponent(folderId)}`, { fields: 'id,name,parents,trashed,mimeType', supportsAllDrives: true }), {}, { allow404: true });
|
|
406
|
+
if (res.status === 404) return null;
|
|
407
|
+
const f = await res.json();
|
|
408
|
+
if (f.trashed || !f.parents?.length) return null;
|
|
409
|
+
const parentPath = await this.#folderPath(f.parents[0]);
|
|
410
|
+
if (parentPath === null) return null;
|
|
411
|
+
const path = parentPath ? `${parentPath}/${safeName(f.name)}` : safeName(f.name);
|
|
412
|
+
this.#rememberFolder(path, f.id);
|
|
413
|
+
return path;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Key of a file resource, or null when it is outside the root subtree.
|
|
417
|
+
async #keyFor(f) {
|
|
418
|
+
const parent = f.parents?.[0];
|
|
419
|
+
if (!parent) return null;
|
|
420
|
+
const dir = await this.#folderPath(parent);
|
|
421
|
+
if (dir === null) return null;
|
|
422
|
+
return dir ? `${dir}/${safeName(f.name)}` : safeName(f.name);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Cold lookup of one key against the API (no walk). Caches the row.
|
|
426
|
+
async #lookup(key) {
|
|
427
|
+
const { dir, name, key: clean } = splitKey(key);
|
|
428
|
+
if (!clean) return null;
|
|
429
|
+
const cached = this.#files.get(clean);
|
|
430
|
+
if (cached) return cached;
|
|
431
|
+
const parentId = await this.#folderId(dir, { create: false });
|
|
432
|
+
if (!parentId) return null;
|
|
433
|
+
const found = await this.#json('/files', {
|
|
434
|
+
query: {
|
|
435
|
+
q: `'${qEscape(parentId)}' in parents and name = '${qEscape(name)}' and mimeType != '${FOLDER_MIME}' and trashed = false`,
|
|
436
|
+
fields: `files(${FILE_FIELDS})`,
|
|
437
|
+
pageSize: 10,
|
|
438
|
+
supportsAllDrives: true,
|
|
439
|
+
includeItemsFromAllDrives: true,
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
const match = (found?.files || [])
|
|
443
|
+
.filter((f) => !(f.mimeType || '').startsWith(NATIVE_PREFIX))
|
|
444
|
+
.sort((a, b) => a.id.localeCompare(b.id))[0];
|
|
445
|
+
if (!match) return null;
|
|
446
|
+
const row = this.#row(clean, match);
|
|
447
|
+
this.#remember(row);
|
|
448
|
+
return row;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async #ensureChecksums(row, algorithms) {
|
|
452
|
+
const have = row.checksums || {};
|
|
453
|
+
if (algorithms.every((a) => have[a])) {
|
|
454
|
+
return Object.fromEntries(algorithms.map((a) => [a, have[a]]));
|
|
455
|
+
}
|
|
456
|
+
const stream = await this.#download(row.id, { stream: true });
|
|
457
|
+
if (!stream) return null;
|
|
458
|
+
const hashed = await checksumStream(stream, algorithms);
|
|
459
|
+
return { ...have, ...hashed };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
463
|
+
// CRUD
|
|
464
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
async #download(id, { stream = false, range = null } = {}) {
|
|
467
|
+
const headers = range ? { Range: `bytes=${range.start}-${range.end}` } : {};
|
|
468
|
+
const res = await this.#call(
|
|
469
|
+
this.#url(`/files/${encodeURIComponent(id)}`, { alt: 'media', supportsAllDrives: true }),
|
|
470
|
+
{ headers },
|
|
471
|
+
{ allow404: true },
|
|
472
|
+
);
|
|
473
|
+
if (res.status === 404) return null;
|
|
474
|
+
if (range && res.status !== 206) {
|
|
475
|
+
// Server ignored the Range — drain and report "not ranged".
|
|
476
|
+
await res.arrayBuffer().catch(() => {});
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
if (stream) return Readable.fromWeb(res.body);
|
|
480
|
+
return Buffer.from(await res.arrayBuffer());
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async get(key, options = {}) {
|
|
484
|
+
const row = await this.#lookup(key);
|
|
485
|
+
if (!row) return null;
|
|
486
|
+
return this.#download(row.id, { stream: !!options.stream });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Ranged read for HTTP Range / media streaming; null → caller falls back
|
|
490
|
+
// to a full stream.
|
|
491
|
+
async getRange(key, { start, end }) {
|
|
492
|
+
const row = await this.#lookup(key);
|
|
493
|
+
if (!row) return null;
|
|
494
|
+
return this.#download(row.id, { stream: true, range: { start, end } });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async stat(key) {
|
|
498
|
+
const row = await this.#lookup(key);
|
|
499
|
+
return row ? { ...row } : null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Upload `data` (Buffer | string | Readable) to `key`, creating missing
|
|
504
|
+
* folders. An existing file at the key is updated in place (same id →
|
|
505
|
+
* sharing/links survive). Resumable upload throughout; `options.size` lets
|
|
506
|
+
* a stream be announced up front but is not required.
|
|
507
|
+
*/
|
|
508
|
+
async put(key, data, options = {}) {
|
|
509
|
+
const { dir, name, key: clean } = splitKey(key);
|
|
510
|
+
if (!clean) throw new Error('gdrive put: key is required');
|
|
511
|
+
const parentId = await this.#folderId(dir, { create: true });
|
|
512
|
+
const existing = await this.#lookup(clean);
|
|
513
|
+
const mimeType = options.mimeType || 'application/octet-stream';
|
|
514
|
+
const source = Buffer.isBuffer(data) ? data : (typeof data === 'string' ? Buffer.from(data) : data);
|
|
515
|
+
const size = Buffer.isBuffer(source) ? source.length : (Number.isFinite(options.size) ? options.size : null);
|
|
516
|
+
|
|
517
|
+
const initUrl = existing
|
|
518
|
+
? this.#url(`${UPLOAD_API}/files/${encodeURIComponent(existing.id)}`, { uploadType: 'resumable', supportsAllDrives: true, fields: FILE_FIELDS })
|
|
519
|
+
: this.#url(`${UPLOAD_API}/files`, { uploadType: 'resumable', supportsAllDrives: true, fields: FILE_FIELDS });
|
|
520
|
+
const init = await this.#call(initUrl, {
|
|
521
|
+
method: existing ? 'PATCH' : 'POST',
|
|
522
|
+
headers: {
|
|
523
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
524
|
+
'X-Upload-Content-Type': mimeType,
|
|
525
|
+
...(size != null ? { 'X-Upload-Content-Length': String(size) } : {}),
|
|
526
|
+
},
|
|
527
|
+
body: JSON.stringify(existing ? {} : { name, parents: [parentId] }),
|
|
528
|
+
});
|
|
529
|
+
const session = init.headers.get('location');
|
|
530
|
+
if (!session) throw new DriveError('gdrive upload: no resumable session URL returned');
|
|
531
|
+
|
|
532
|
+
const file = await this.#uploadChunks(session, source, mimeType);
|
|
533
|
+
const row = this.#row(clean, file);
|
|
534
|
+
this.#remember(row);
|
|
535
|
+
this.#invalidateWalk();
|
|
536
|
+
debug(`PUT ${clean} (${row.id}, ${row.size} bytes)`);
|
|
537
|
+
return { key: clean, id: row.id, size: row.size };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async #putChunk(session, buf, offset, total, mimeType) {
|
|
541
|
+
const last = total != null;
|
|
542
|
+
const range = buf.length
|
|
543
|
+
? `bytes ${offset}-${offset + buf.length - 1}/${last ? total : '*'}`
|
|
544
|
+
: `bytes */${last ? total : '*'}`;
|
|
545
|
+
const res = await this.#call(session, {
|
|
546
|
+
method: 'PUT',
|
|
547
|
+
headers: { 'Content-Length': String(buf.length), 'Content-Range': range, 'Content-Type': mimeType },
|
|
548
|
+
body: buf,
|
|
549
|
+
}, { okStatuses: [308] });
|
|
550
|
+
if (last) {
|
|
551
|
+
if (res.status === 308) throw new DriveError('gdrive upload: session incomplete after final chunk');
|
|
552
|
+
return res.json();
|
|
553
|
+
}
|
|
554
|
+
if (res.status !== 308) throw new DriveError(`gdrive upload: unexpected ${res.status} on intermediate chunk`);
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async #uploadChunks(session, source, mimeType) {
|
|
559
|
+
if (Buffer.isBuffer(source)) return this.#putChunk(session, source, 0, source.length, mimeType);
|
|
560
|
+
// Stream of unknown length: buffer one chunk ahead so the final chunk
|
|
561
|
+
// can carry the total (Drive only learns the size at the end).
|
|
562
|
+
let offset = 0;
|
|
563
|
+
let pending = [];
|
|
564
|
+
let pendingLen = 0;
|
|
565
|
+
for await (const chunk of source) {
|
|
566
|
+
const c = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
567
|
+
pending.push(c);
|
|
568
|
+
pendingLen += c.length;
|
|
569
|
+
while (pendingLen > UPLOAD_CHUNK) {
|
|
570
|
+
const all = Buffer.concat(pending, pendingLen);
|
|
571
|
+
const head = all.subarray(0, UPLOAD_CHUNK);
|
|
572
|
+
const rest = all.subarray(UPLOAD_CHUNK);
|
|
573
|
+
await this.#putChunk(session, head, offset, null, mimeType);
|
|
574
|
+
offset += head.length;
|
|
575
|
+
pending = rest.length ? [rest] : [];
|
|
576
|
+
pendingLen = rest.length;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const tail = Buffer.concat(pending, pendingLen);
|
|
580
|
+
return this.#putChunk(session, tail, offset, offset + tail.length, mimeType);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// SyncQueue entry point: stream an already-written file (stored cache
|
|
584
|
+
// content) to Drive. Size is known, so the upload is announced up front.
|
|
585
|
+
async commit(key, srcPath) {
|
|
586
|
+
const { size } = await fsp.stat(srcPath);
|
|
587
|
+
return this.put(key, createReadStream(srcPath), { size });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Remove the bytes at `key`. Trashes by default — a user's Drive is shared
|
|
592
|
+
* with their other tools and the trash is their safety net; set
|
|
593
|
+
* `config.permanentDelete: true` to bypass it. Trashed files are invisible
|
|
594
|
+
* to every listing here, so stored's semantics hold either way.
|
|
595
|
+
*/
|
|
596
|
+
async delete(key) {
|
|
597
|
+
const row = await this.#lookup(key);
|
|
598
|
+
if (!row) return false;
|
|
599
|
+
await this.#trashOrDelete(row.id);
|
|
600
|
+
this.#forget(row.key);
|
|
601
|
+
this.#invalidateWalk();
|
|
602
|
+
debug(`DELETE ${row.key} (${row.id})`);
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async #trashOrDelete(id) {
|
|
607
|
+
if (this.config.permanentDelete === true) {
|
|
608
|
+
await this.#call(this.#url(`/files/${encodeURIComponent(id)}`, { supportsAllDrives: true }), { method: 'DELETE' }, { allow404: true });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
await this.#call(this.#url(`/files/${encodeURIComponent(id)}`, { supportsAllDrives: true, fields: 'id' }), {
|
|
612
|
+
method: 'PATCH',
|
|
613
|
+
headers: { 'Content-Type': 'application/json' },
|
|
614
|
+
body: JSON.stringify({ trashed: true }),
|
|
615
|
+
}, { allow404: true });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async *list(options = {}) {
|
|
619
|
+
const prefix = String(options.prefix || '').replace(/^\/+|\/+$/g, '');
|
|
620
|
+
const { files } = await this.#walk({ onError: options.onError, maxAge: options.maxAge ?? 0 });
|
|
621
|
+
for (const row of files.values()) {
|
|
622
|
+
if (prefix && row.key !== prefix && !row.key.startsWith(`${prefix}/`)) continue;
|
|
623
|
+
yield { ...row };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
628
|
+
// Containers (folders)
|
|
629
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
#dropSubtree(prefix) {
|
|
632
|
+
for (const key of [...this.#files.keys()]) {
|
|
633
|
+
if (key === prefix || key.startsWith(`${prefix}/`)) this.#forget(key);
|
|
634
|
+
}
|
|
635
|
+
for (const [path, id] of [...this.#folderIdByPath]) {
|
|
636
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) {
|
|
637
|
+
this.#folderIdByPath.delete(path);
|
|
638
|
+
this.#folderPathById.delete(id);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
#moveSubtree(from, to) {
|
|
644
|
+
const rekey = (k) => (k === from ? to : `${to}${k.slice(from.length)}`);
|
|
645
|
+
const moved = [];
|
|
646
|
+
for (const [key, row] of [...this.#files]) {
|
|
647
|
+
if (key !== from && !key.startsWith(`${from}/`)) continue;
|
|
648
|
+
this.#forget(key);
|
|
649
|
+
const next = { ...row, key: rekey(key) };
|
|
650
|
+
this.#remember(next);
|
|
651
|
+
moved.push({ from: key, row: next });
|
|
652
|
+
}
|
|
653
|
+
for (const [path, id] of [...this.#folderIdByPath]) {
|
|
654
|
+
if (path !== from && !path.startsWith(`${from}/`)) continue;
|
|
655
|
+
this.#folderIdByPath.delete(path);
|
|
656
|
+
this.#rememberFolder(rekey(path), id);
|
|
657
|
+
}
|
|
658
|
+
return moved;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async createContainer(key) {
|
|
662
|
+
const clean = splitKey(key).key;
|
|
663
|
+
if (!clean) throw new Error('Container key is required');
|
|
664
|
+
await this.#folderId(clean, { create: true });
|
|
665
|
+
return { key: clean };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async deleteContainer(key) {
|
|
669
|
+
const clean = splitKey(key).key;
|
|
670
|
+
if (!clean) throw new Error('Cannot delete the backend root');
|
|
671
|
+
const id = await this.#folderId(clean, { create: false });
|
|
672
|
+
if (!id) return false;
|
|
673
|
+
await this.#trashOrDelete(id);
|
|
674
|
+
this.#dropSubtree(clean);
|
|
675
|
+
this.#invalidateWalk();
|
|
676
|
+
debug(`RMDIR ${clean} (${id})`);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async renameContainer(fromKey, toKey) {
|
|
681
|
+
const from = splitKey(fromKey).key;
|
|
682
|
+
const { dir: toDir, name: toName, key: to } = splitKey(toKey);
|
|
683
|
+
if (!from || !to) throw new Error('Container keys are required');
|
|
684
|
+
const id = await this.#folderId(from, { create: false });
|
|
685
|
+
if (!id) throw new Error(`Container not found: ${fromKey}`);
|
|
686
|
+
if (await this.#folderId(to, { create: false })) throw new Error(`Container already exists: ${toKey}`);
|
|
687
|
+
const oldParent = await this.#folderId(splitKey(from).dir, { create: false });
|
|
688
|
+
const newParent = await this.#folderId(toDir, { create: true });
|
|
689
|
+
await this.#call(this.#url(`/files/${encodeURIComponent(id)}`, {
|
|
690
|
+
supportsAllDrives: true,
|
|
691
|
+
fields: 'id',
|
|
692
|
+
...(oldParent !== newParent ? { addParents: newParent, removeParents: oldParent } : {}),
|
|
693
|
+
}), {
|
|
694
|
+
method: 'PATCH',
|
|
695
|
+
headers: { 'Content-Type': 'application/json' },
|
|
696
|
+
body: JSON.stringify({ name: toName }),
|
|
697
|
+
});
|
|
698
|
+
this.#moveSubtree(from, to);
|
|
699
|
+
this.#invalidateWalk();
|
|
700
|
+
debug(`MV ${from} -> ${to}`);
|
|
701
|
+
return { from, to };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
705
|
+
// Shape / scan / watch
|
|
706
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
async shape() {
|
|
709
|
+
const { files, dirs } = await this.#walk({ onError: () => {}, maxAge: WALK_CACHE_MS });
|
|
710
|
+
return { dirs: [...dirs], files: files.size };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Full-snapshot scan, same contract as the file driver:
|
|
715
|
+
* { files, complete, errors:{root,dirs,files} }. Checksums come from the
|
|
716
|
+
* API (sha256/sha1/md5) — bytes are only pulled for algorithms Drive does
|
|
717
|
+
* not serve, and never when `knownChecksums` already vouches for the key.
|
|
718
|
+
*/
|
|
719
|
+
async scan(options = {}) {
|
|
720
|
+
const algorithms = options.algorithms || this.#defaultAlgorithms;
|
|
721
|
+
const known = typeof options.knownChecksums === 'function' ? options.knownChecksums : null;
|
|
722
|
+
const onFile = typeof options.onFile === 'function' ? options.onFile : null;
|
|
723
|
+
const errors = { root: null, dirs: [], files: [] };
|
|
724
|
+
const results = [];
|
|
725
|
+
this.emit('scan:start', { backend: this.name });
|
|
726
|
+
|
|
727
|
+
let walk;
|
|
728
|
+
try {
|
|
729
|
+
walk = await this.#walk({
|
|
730
|
+
maxAge: WALK_CACHE_MS,
|
|
731
|
+
onError: (prefix, err) => errors.dirs.push({ prefix, code: err?.code || 'EUNKNOWN' }),
|
|
732
|
+
});
|
|
733
|
+
} catch (err) {
|
|
734
|
+
errors.root = err.code || 'unreachable';
|
|
735
|
+
debug(`Scan aborted: ${err.message}`);
|
|
736
|
+
this.emit('scan:complete', { backend: this.name, count: 0, complete: false, errors });
|
|
737
|
+
return { files: [], complete: false, errors, fsid: null };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
for (const row of walk.files.values()) {
|
|
741
|
+
let checksums = null;
|
|
742
|
+
const have = row.checksums || {};
|
|
743
|
+
if (algorithms.every((a) => have[a])) {
|
|
744
|
+
checksums = Object.fromEntries(algorithms.map((a) => [a, have[a]]));
|
|
745
|
+
} else {
|
|
746
|
+
const cached = known ? known(row.key, { size: row.size, mtime: row.modified, dev: row.dev, ino: row.ino }) : null;
|
|
747
|
+
if (cached) {
|
|
748
|
+
checksums = cached.checksums;
|
|
749
|
+
} else {
|
|
750
|
+
checksums = await this.#ensureChecksums(row, algorithms).catch(() => null);
|
|
751
|
+
if (!checksums) errors.files.push({ key: row.key });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const out = { ...row, checksums, backend: this.name };
|
|
755
|
+
results.push(out);
|
|
756
|
+
if (onFile) await onFile(out);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const complete = errors.dirs.length === 0;
|
|
760
|
+
this.emit('scan:complete', { backend: this.name, count: results.length, complete, errors });
|
|
761
|
+
debug(`Scan complete: ${results.length} files (${complete ? 'full' : `partial — ${errors.dirs.length} unreadable folders`})`);
|
|
762
|
+
return { files: results, complete, errors, fsid: null };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Incremental change feed: `changes.list` from a start page token, polled
|
|
767
|
+
* every `config.pollInterval` ms (default 60s). Emits file:add /
|
|
768
|
+
* file:change / file:unlink with the same payload shape as the file
|
|
769
|
+
* driver's watcher. Returns false (and emits 'error') when the feed can't
|
|
770
|
+
* be opened — a watch that looks live but sees nothing is worse than none.
|
|
771
|
+
*/
|
|
772
|
+
async watch() {
|
|
773
|
+
if (this.#pollTimer) return true;
|
|
774
|
+
try {
|
|
775
|
+
await this.#root();
|
|
776
|
+
if (!this.#pageToken) {
|
|
777
|
+
const res = await this.#json('/changes/startPageToken', { query: { supportsAllDrives: true } });
|
|
778
|
+
this.#pageToken = res?.startPageToken || null;
|
|
779
|
+
}
|
|
780
|
+
if (!this.#pageToken) throw new DriveError('gdrive changes feed returned no start token');
|
|
781
|
+
} catch (err) {
|
|
782
|
+
this.emit('error', err);
|
|
783
|
+
return false;
|
|
784
|
+
}
|
|
785
|
+
const interval = Math.max(5_000, Number(this.config.pollInterval) || DEFAULT_POLL_INTERVAL);
|
|
786
|
+
this.#pollTimer = setInterval(() => {
|
|
787
|
+
this.poll().catch((err) => this.emit('error', err));
|
|
788
|
+
}, interval);
|
|
789
|
+
this.#pollTimer.unref?.();
|
|
790
|
+
debug(`Watching ${this.#rootRef} (poll ${interval}ms)`);
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** One pass over pending changes. Public so a host can trigger it on demand. */
|
|
795
|
+
async poll() {
|
|
796
|
+
if (this.#polling || !this.#pageToken) return false;
|
|
797
|
+
this.#polling = true;
|
|
798
|
+
try {
|
|
799
|
+
let token = this.#pageToken;
|
|
800
|
+
while (token) {
|
|
801
|
+
const res = await this.#json('/changes', {
|
|
802
|
+
query: {
|
|
803
|
+
pageToken: token,
|
|
804
|
+
pageSize: PAGE_SIZE,
|
|
805
|
+
fields: `nextPageToken,newStartPageToken,changes(changeType,removed,fileId,file(${FILE_FIELDS}))`,
|
|
806
|
+
supportsAllDrives: true,
|
|
807
|
+
includeItemsFromAllDrives: true,
|
|
808
|
+
includeRemoved: true,
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
for (const change of res?.changes || []) {
|
|
812
|
+
try { await this.#applyChange(change); }
|
|
813
|
+
catch (err) { this.emit('error', err); }
|
|
814
|
+
}
|
|
815
|
+
if (res?.newStartPageToken) { this.#pageToken = res.newStartPageToken; break; }
|
|
816
|
+
token = res?.nextPageToken || null;
|
|
817
|
+
}
|
|
818
|
+
return true;
|
|
819
|
+
} finally {
|
|
820
|
+
this.#polling = false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async #applyChange(change) {
|
|
825
|
+
if (change.changeType && change.changeType !== 'file') return;
|
|
826
|
+
const id = change.fileId;
|
|
827
|
+
const f = change.file;
|
|
828
|
+
const oldKey = this.#keyById.get(id);
|
|
829
|
+
const gone = change.removed || !f || f.trashed;
|
|
830
|
+
|
|
831
|
+
if (gone) {
|
|
832
|
+
if (oldKey) {
|
|
833
|
+
this.#forget(oldKey);
|
|
834
|
+
this.#invalidateWalk();
|
|
835
|
+
this.emit('file:unlink', { backend: this.name, key: oldKey, dev: DEV, ino: id });
|
|
836
|
+
} else if (this.#folderPathById.has(id)) {
|
|
837
|
+
const prefix = this.#folderPathById.get(id);
|
|
838
|
+
const rows = [...this.#files.values()].filter((r) => r.key.startsWith(`${prefix}/`));
|
|
839
|
+
this.#dropSubtree(prefix);
|
|
840
|
+
this.#invalidateWalk();
|
|
841
|
+
for (const row of rows) this.emit('file:unlink', { backend: this.name, key: row.key, dev: DEV, ino: row.id });
|
|
842
|
+
}
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (f.mimeType === FOLDER_MIME) {
|
|
847
|
+
// A folder rename/move re-keys everything under it. Emit the pairs
|
|
848
|
+
// (unlink old + add new, same ino) so stored rewrites in place.
|
|
849
|
+
const oldPath = this.#folderPathById.get(id);
|
|
850
|
+
if (oldPath === undefined || oldPath === '') return;
|
|
851
|
+
this.#folderPathById.delete(id);
|
|
852
|
+
this.#folderIdByPath.delete(oldPath);
|
|
853
|
+
const newPath = await this.#keyFor(f);
|
|
854
|
+
if (newPath === null) {
|
|
855
|
+
const rows = [...this.#files.values()].filter((r) => r.key.startsWith(`${oldPath}/`));
|
|
856
|
+
this.#dropSubtree(oldPath);
|
|
857
|
+
this.#invalidateWalk();
|
|
858
|
+
for (const row of rows) this.emit('file:unlink', { backend: this.name, key: row.key, dev: DEV, ino: row.id });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
this.#rememberFolder(oldPath, id); // restore for #moveSubtree's rekey pass
|
|
862
|
+
if (newPath === oldPath) return;
|
|
863
|
+
const moved = this.#moveSubtree(oldPath, newPath);
|
|
864
|
+
this.#invalidateWalk();
|
|
865
|
+
for (const { from, row } of moved) {
|
|
866
|
+
this.emit('file:unlink', { backend: this.name, key: from, dev: DEV, ino: row.id });
|
|
867
|
+
this.emit('file:add', this.#eventPayload(row));
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (f.mimeType && f.mimeType.startsWith(NATIVE_PREFIX)) return;
|
|
873
|
+
|
|
874
|
+
const newKey = await this.#keyFor(f);
|
|
875
|
+
if (newKey === null) {
|
|
876
|
+
if (oldKey) {
|
|
877
|
+
this.#forget(oldKey);
|
|
878
|
+
this.#invalidateWalk();
|
|
879
|
+
this.emit('file:unlink', { backend: this.name, key: oldKey, dev: DEV, ino: id });
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const prev = oldKey === newKey ? this.#files.get(oldKey) : null;
|
|
885
|
+
const row = this.#row(newKey, f);
|
|
886
|
+
row.checksums = await this.#ensureChecksums(row, this.#defaultAlgorithms).catch(() => row.checksums);
|
|
887
|
+
if (oldKey && oldKey !== newKey) {
|
|
888
|
+
this.#forget(oldKey);
|
|
889
|
+
this.emit('file:unlink', { backend: this.name, key: oldKey, dev: DEV, ino: id });
|
|
890
|
+
}
|
|
891
|
+
this.#remember(row);
|
|
892
|
+
this.#invalidateWalk();
|
|
893
|
+
|
|
894
|
+
if (prev) {
|
|
895
|
+
const same = prev.modified === row.modified
|
|
896
|
+
&& JSON.stringify(prev.checksums || null) === JSON.stringify(row.checksums || null);
|
|
897
|
+
if (same) return;
|
|
898
|
+
this.emit('file:change', this.#eventPayload(row));
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
this.emit('file:add', this.#eventPayload(row));
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
#eventPayload(row) {
|
|
905
|
+
return {
|
|
906
|
+
backend: this.name,
|
|
907
|
+
key: row.key,
|
|
908
|
+
checksums: row.checksums,
|
|
909
|
+
mimeType: row.mimeType,
|
|
910
|
+
size: row.size,
|
|
911
|
+
modified: row.modified,
|
|
912
|
+
dev: DEV,
|
|
913
|
+
ino: row.id,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async stop() {
|
|
918
|
+
if (this.#pollTimer) {
|
|
919
|
+
clearInterval(this.#pollTimer);
|
|
920
|
+
this.#pollTimer = null;
|
|
921
|
+
debug(`Stopped watching ${this.#rootRef}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|