@tdxvolt/volt-drive-mcp 0.0.0-stage → 0.1.1
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/README.md +289 -2
- package/bin/http.js +78 -0
- package/bin/stdio.js +72 -0
- package/package.json +54 -3
- package/src/blob.js +192 -0
- package/src/bytes.js +73 -0
- package/src/config.js +116 -0
- package/src/connect.js +43 -0
- package/src/doc-state.js +131 -0
- package/src/drive-index.js +370 -0
- package/src/drive-service.js +1083 -0
- package/src/drive-session.js +283 -0
- package/src/errors.js +23 -0
- package/src/http.js +229 -0
- package/src/index.js +4 -0
- package/src/logger.js +38 -0
- package/src/paths.js +145 -0
- package/src/server.js +338 -0
- package/src/sync-doc.js +268 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { blobHash, looksBinary, toBytes } from "./bytes.js";
|
|
3
|
+
import { ensureBlob, getBlob } from "./blob.js";
|
|
4
|
+
import { waitForDocumentReady } from "./doc-state.js";
|
|
5
|
+
import {
|
|
6
|
+
findEntry,
|
|
7
|
+
findFileAncestor,
|
|
8
|
+
hasChildren,
|
|
9
|
+
listDirectory,
|
|
10
|
+
pathKind,
|
|
11
|
+
planMove,
|
|
12
|
+
schemaToMetadata,
|
|
13
|
+
searchEntriesByPath
|
|
14
|
+
} from "./drive-index.js";
|
|
15
|
+
import { DriveSession } from "./drive-session.js";
|
|
16
|
+
import {
|
|
17
|
+
DriveError,
|
|
18
|
+
conflict,
|
|
19
|
+
forbidden,
|
|
20
|
+
invalid,
|
|
21
|
+
notFound,
|
|
22
|
+
tooLarge
|
|
23
|
+
} from "./errors.js";
|
|
24
|
+
import { ancestorsOf, isWithin, normalizeDrivePath } from "./paths.js";
|
|
25
|
+
import {
|
|
26
|
+
readLegacyArrayContent,
|
|
27
|
+
readTextContent,
|
|
28
|
+
writeTextContent
|
|
29
|
+
} from "./sync-doc.js";
|
|
30
|
+
|
|
31
|
+
export const DRIVE_RESOURCE_KIND = "volt:drive-database";
|
|
32
|
+
|
|
33
|
+
const ATTR_INCLUDE = "volt:drive-database-include";
|
|
34
|
+
const ATTR_EXCLUDE = "volt:drive-database-exclude";
|
|
35
|
+
const ATTR_MAX_FILE_SIZE = "volt:drive-database-max-file-size";
|
|
36
|
+
const ATTR_IGNORE_BINARY = "volt:drive-database-ignore-binary-files";
|
|
37
|
+
const ATTR_INCLUDE_HIDDEN = "volt:drive-database-include-hidden";
|
|
38
|
+
const ATTR_NO_COMPACTION = "volt:sync-database-no-compaction";
|
|
39
|
+
const ATTR_VERSION_MODE = "volt:sync-database-version-mode";
|
|
40
|
+
|
|
41
|
+
const MIB = 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
/** Defaults for every tunable limit; override via the constructor options. */
|
|
44
|
+
export const DEFAULT_OPTIONS = Object.freeze({
|
|
45
|
+
/** Refuse every write tool. */
|
|
46
|
+
readOnly: false,
|
|
47
|
+
/** Only expose drives matching one of these ids, aliases or names (empty = all). */
|
|
48
|
+
drives: [],
|
|
49
|
+
/** Largest file that will be read or written. */
|
|
50
|
+
maxFileBytes: 10 * MIB,
|
|
51
|
+
/** Most characters returned from a single read. */
|
|
52
|
+
maxResponseChars: 100_000,
|
|
53
|
+
/** Most bytes of a binary file returned (base64) from a single read. */
|
|
54
|
+
maxBinaryReturnBytes: 1 * MIB,
|
|
55
|
+
/** How long to wait for a document to sync with the Volt. */
|
|
56
|
+
syncTimeoutMs: 30_000,
|
|
57
|
+
/** Content search skips text files larger than this. */
|
|
58
|
+
contentSearchMaxFileBytes: 512 * 1024,
|
|
59
|
+
/** Content search scans at most this many files per call. */
|
|
60
|
+
contentSearchMaxFiles: 500,
|
|
61
|
+
/** Content search stops after this long. */
|
|
62
|
+
contentSearchTimeoutMs: 60_000,
|
|
63
|
+
/** How long the list of drives is cached. */
|
|
64
|
+
driveListTtlMs: 30_000
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
68
|
+
|
|
69
|
+
function attributeValue(resource, attributeId) {
|
|
70
|
+
const attributes = Array.isArray(resource?.attribute) ? resource.attribute : [];
|
|
71
|
+
for (const attribute of attributes) {
|
|
72
|
+
if (String(attribute?.attribute_id || "") !== attributeId) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const value = Array.isArray(attribute?.value) ? attribute.value[0] : null;
|
|
76
|
+
if (!value || typeof value !== "object") {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
for (const key of ["string", "integer", "real", "boolean"]) {
|
|
80
|
+
if (value[key] !== undefined) {
|
|
81
|
+
return value[key];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function booleanAttribute(resource, attributeId, defaultValue) {
|
|
89
|
+
const raw = attributeValue(resource, attributeId);
|
|
90
|
+
if (raw === undefined) {
|
|
91
|
+
return defaultValue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof raw === "string") {
|
|
94
|
+
return ["true", "1", "yes"].includes(raw.trim().toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
return Boolean(raw);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function positiveIntegerAttribute(resource, attributeId) {
|
|
100
|
+
const value = Number(attributeValue(resource, attributeId));
|
|
101
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isoTime(ms) {
|
|
105
|
+
return typeof ms === "number" && Number.isFinite(ms) && ms > 0
|
|
106
|
+
? new Date(ms).toISOString()
|
|
107
|
+
: null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Run `task` over `items`, `limit` at a time; rejects after all have settled. */
|
|
111
|
+
async function forEachLimited(items, limit, task) {
|
|
112
|
+
let next = 0;
|
|
113
|
+
let firstError = null;
|
|
114
|
+
const worker = async () => {
|
|
115
|
+
while (next < items.length) {
|
|
116
|
+
const item = items[next++];
|
|
117
|
+
try {
|
|
118
|
+
await task(item);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
firstError ??= err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
await Promise.all(
|
|
125
|
+
Array.from({ length: Math.min(limit, items.length) }, worker)
|
|
126
|
+
);
|
|
127
|
+
if (firstError) {
|
|
128
|
+
throw firstError;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatVersionError(err) {
|
|
133
|
+
const message = err?.message ?? String(err);
|
|
134
|
+
if (
|
|
135
|
+
message.includes(ATTR_NO_COMPACTION) ||
|
|
136
|
+
message.includes("document versioning is not supported")
|
|
137
|
+
) {
|
|
138
|
+
return new DriveError(
|
|
139
|
+
"File versions are disabled on this drive. Enable file versions in the drive settings.",
|
|
140
|
+
"unsupported"
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return err instanceof Error ? err : new DriveError(message);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Everything the MCP tools can do with a VoltDrive, with no MCP dependency.
|
|
148
|
+
*
|
|
149
|
+
* Talks to the Volt through `volt-client-grpc` and mirrors drive structure
|
|
150
|
+
* through the Volt's Yjs sync, exactly as the VoltDrive web Explorer does. The
|
|
151
|
+
* client, Yjs and SyncProvider are injected so the service can be tested
|
|
152
|
+
* against an in-memory fake.
|
|
153
|
+
*/
|
|
154
|
+
export class DriveService {
|
|
155
|
+
/**
|
|
156
|
+
* @param {object} deps
|
|
157
|
+
* @param {object} deps.client - connected `VoltClient`.
|
|
158
|
+
* @param {object} deps.Y - the `yjs` module.
|
|
159
|
+
* @param {Function} deps.SyncProvider - Volt `SyncProvider` class.
|
|
160
|
+
* @param {() => Promise<void>} [deps.connect] - establishes the connection of
|
|
161
|
+
* `client`. When given, it is run lazily before the first use and again
|
|
162
|
+
* after a failure, so the service can be created (and an MCP server started)
|
|
163
|
+
* before the Volt is reachable.
|
|
164
|
+
* @param {Partial<typeof DEFAULT_OPTIONS>} [deps.options]
|
|
165
|
+
* @param {{debug?: Function, info?: Function, error?: Function}} [deps.logger]
|
|
166
|
+
*/
|
|
167
|
+
constructor({ client, Y, SyncProvider, connect, options = {}, logger }) {
|
|
168
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
169
|
+
this.ctx = { client, Y, SyncProvider, logger };
|
|
170
|
+
this.client = client;
|
|
171
|
+
this.logger = logger;
|
|
172
|
+
this.sessions = new Map();
|
|
173
|
+
this._connect = connect ?? null;
|
|
174
|
+
this._connecting = null;
|
|
175
|
+
this._driveList = null;
|
|
176
|
+
this._driveListAt = 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Make sure the Volt connection is up. Safe to call concurrently and often;
|
|
181
|
+
* a failed attempt is forgotten so that the next call tries again.
|
|
182
|
+
*/
|
|
183
|
+
ensureConnected() {
|
|
184
|
+
if (!this._connect) {
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
}
|
|
187
|
+
if (!this._connecting) {
|
|
188
|
+
this._connecting = this._connect().catch((err) => {
|
|
189
|
+
this._connecting = null;
|
|
190
|
+
throw new DriveError(
|
|
191
|
+
`could not connect to the Volt: ${err?.message ?? String(err)}`,
|
|
192
|
+
"unavailable"
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return this._connecting;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get readOnly() {
|
|
200
|
+
return this.options.readOnly;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_assertWritable() {
|
|
204
|
+
if (this.options.readOnly) {
|
|
205
|
+
throw forbidden("this connector is running in read-only mode");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------- drives
|
|
210
|
+
|
|
211
|
+
_allowed(resource) {
|
|
212
|
+
const allow = this.options.drives.map((item) => String(item).toLowerCase());
|
|
213
|
+
if (allow.length === 0) {
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
const names = [resource.id, resource.name, ...(resource.alias ?? [])]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.map((item) => String(item).toLowerCase());
|
|
219
|
+
return names.some((name) => allow.includes(name));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async _driveResources(force = false) {
|
|
223
|
+
await this.ensureConnected();
|
|
224
|
+
const fresh = Date.now() - this._driveListAt < this.options.driveListTtlMs;
|
|
225
|
+
if (!force && this._driveList && fresh) {
|
|
226
|
+
return this._driveList;
|
|
227
|
+
}
|
|
228
|
+
const response = await this.client.GetResources({
|
|
229
|
+
kind: [DRIVE_RESOURCE_KIND],
|
|
230
|
+
include_attributes: false,
|
|
231
|
+
include_protobuf: false
|
|
232
|
+
});
|
|
233
|
+
const resources = Array.isArray(response?.resource) ? response.resource : [];
|
|
234
|
+
this._driveList = resources
|
|
235
|
+
.filter((resource) => resource?.id)
|
|
236
|
+
.filter((resource) => this._allowed(resource));
|
|
237
|
+
this._driveListAt = Date.now();
|
|
238
|
+
return this._driveList;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The drives this connector may use. */
|
|
242
|
+
async listDrives() {
|
|
243
|
+
const resources = await this._driveResources(true);
|
|
244
|
+
return resources
|
|
245
|
+
.map((resource) => ({
|
|
246
|
+
id: String(resource.id),
|
|
247
|
+
name: String(resource.name || resource.id),
|
|
248
|
+
aliases: (resource.alias ?? []).map(String).filter(Boolean)
|
|
249
|
+
}))
|
|
250
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async _resolveDrive(driveRef) {
|
|
254
|
+
const ref = String(driveRef ?? "").trim();
|
|
255
|
+
if (!ref) {
|
|
256
|
+
throw invalid("drive is required; use list_drives to see the available drives");
|
|
257
|
+
}
|
|
258
|
+
const lower = ref.toLowerCase();
|
|
259
|
+
|
|
260
|
+
let resources = await this._driveResources();
|
|
261
|
+
const find = (list) => {
|
|
262
|
+
const byId = list.filter(
|
|
263
|
+
(r) => String(r.id) === ref || (r.alias ?? []).includes(ref)
|
|
264
|
+
);
|
|
265
|
+
if (byId.length === 1) {
|
|
266
|
+
return byId;
|
|
267
|
+
}
|
|
268
|
+
return list.filter((r) => String(r.name || "").toLowerCase() === lower);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
let matches = find(resources);
|
|
272
|
+
if (matches.length === 0) {
|
|
273
|
+
// The cache may be stale (a drive was created since it was filled).
|
|
274
|
+
resources = await this._driveResources(true);
|
|
275
|
+
matches = find(resources);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (matches.length === 0) {
|
|
279
|
+
throw notFound(
|
|
280
|
+
`no drive matches '${ref}'; use list_drives to see the available drives`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (matches.length > 1) {
|
|
284
|
+
const options = matches.map((r) => `${r.name} (${r.id})`).join(", ");
|
|
285
|
+
throw invalid(`'${ref}' matches more than one drive: ${options}; use the id`);
|
|
286
|
+
}
|
|
287
|
+
return matches[0];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async _session(driveRef) {
|
|
291
|
+
const resource = await this._resolveDrive(driveRef);
|
|
292
|
+
let session = this.sessions.get(resource.id);
|
|
293
|
+
if (!session) {
|
|
294
|
+
session = new DriveSession(this.ctx, resource, {
|
|
295
|
+
readOnly: this.options.readOnly,
|
|
296
|
+
syncTimeoutMs: this.options.syncTimeoutMs
|
|
297
|
+
});
|
|
298
|
+
this.sessions.set(resource.id, session);
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
await session.ensureReady();
|
|
302
|
+
} catch (err) {
|
|
303
|
+
this.sessions.delete(resource.id);
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
return session;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async _driveWithAttributes(driveId) {
|
|
310
|
+
const response = await this.client.GetResource({
|
|
311
|
+
resource_id: driveId,
|
|
312
|
+
include_attributes: true
|
|
313
|
+
});
|
|
314
|
+
return response?.resource ?? {};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Summary and settings of a drive, and the state of its local sync. */
|
|
318
|
+
async getDriveStatus(driveRef) {
|
|
319
|
+
const session = await this._session(driveRef);
|
|
320
|
+
const resource = await this._driveWithAttributes(session.id);
|
|
321
|
+
const entries = session.entries;
|
|
322
|
+
|
|
323
|
+
const status = {
|
|
324
|
+
id: session.id,
|
|
325
|
+
name: String(resource.name || session.resource.name || session.id),
|
|
326
|
+
aliases: (resource.alias ?? session.resource.alias ?? []).map(String),
|
|
327
|
+
fileCount: entries.length,
|
|
328
|
+
folderCount: session.folders.length,
|
|
329
|
+
totalBytes: entries.reduce((sum, entry) => sum + entry.size, 0),
|
|
330
|
+
metadataSynced: session.metaSynced,
|
|
331
|
+
writable: !this.options.readOnly,
|
|
332
|
+
settings: {
|
|
333
|
+
includePatterns: String(attributeValue(resource, ATTR_INCLUDE) ?? ""),
|
|
334
|
+
excludePatterns: String(attributeValue(resource, ATTR_EXCLUDE) ?? ""),
|
|
335
|
+
maxFileSizeBytes: positiveIntegerAttribute(resource, ATTR_MAX_FILE_SIZE),
|
|
336
|
+
includeBinaryFiles: !booleanAttribute(resource, ATTR_IGNORE_BINARY, true),
|
|
337
|
+
includeHidden: booleanAttribute(resource, ATTR_INCLUDE_HIDDEN, false),
|
|
338
|
+
versioningEnabled: booleanAttribute(resource, ATTR_NO_COMPACTION, false),
|
|
339
|
+
versionMode: String(attributeValue(resource, ATTR_VERSION_MODE) ?? "")
|
|
340
|
+
},
|
|
341
|
+
localSync: await this._instanceStatus(session.id)
|
|
342
|
+
};
|
|
343
|
+
return status;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* State of any VoltDrive daemon instance hosted by the connected Volt
|
|
348
|
+
* (null when the Volt cannot say).
|
|
349
|
+
*/
|
|
350
|
+
_instanceStatus(synapseId, timeoutMs = 3_000) {
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
let call = null;
|
|
353
|
+
let settled = false;
|
|
354
|
+
const done = (value) => {
|
|
355
|
+
if (settled) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
settled = true;
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
try {
|
|
361
|
+
call?.write({ stop: true });
|
|
362
|
+
call?.end();
|
|
363
|
+
} catch {
|
|
364
|
+
// stream already closed
|
|
365
|
+
}
|
|
366
|
+
resolve(value);
|
|
367
|
+
};
|
|
368
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
call = this.client.WatchDriveInstanceStatus({
|
|
372
|
+
start: { synapse_id: synapseId }
|
|
373
|
+
});
|
|
374
|
+
} catch {
|
|
375
|
+
done(null);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
call.on("data", (payload) => {
|
|
380
|
+
const update = payload?.update;
|
|
381
|
+
if (!update) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
done({
|
|
385
|
+
state: String(update.state ?? "DRIVE_INSTANCE_STATE_ABSENT")
|
|
386
|
+
.replace("DRIVE_INSTANCE_STATE_", "")
|
|
387
|
+
.toLowerCase(),
|
|
388
|
+
totalCount: Number(update.total_count ?? 0),
|
|
389
|
+
completedCount: Number(update.completed_count ?? 0)
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
call.on("error", () => done(null));
|
|
393
|
+
call.on("end", () => done(null));
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------- reading
|
|
398
|
+
|
|
399
|
+
/** List a folder of a drive. */
|
|
400
|
+
async listDirectory(driveRef, rawPath, { recursive = false, maxEntries = 500 } = {}) {
|
|
401
|
+
const path = normalizeDrivePath(rawPath, { allowRoot: true });
|
|
402
|
+
const session = await this._session(driveRef);
|
|
403
|
+
const { entries, folders } = session;
|
|
404
|
+
|
|
405
|
+
const kind = pathKind(entries, folders, path);
|
|
406
|
+
if (kind === null) {
|
|
407
|
+
throw notFound(`no such folder: '${path}'`);
|
|
408
|
+
}
|
|
409
|
+
if (kind === "file") {
|
|
410
|
+
throw invalid(`'${path}' is a file; use read_file`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const items = listDirectory(entries, folders, path, { recursive });
|
|
414
|
+
const truncated = items.length > maxEntries;
|
|
415
|
+
return {
|
|
416
|
+
path,
|
|
417
|
+
total: items.length,
|
|
418
|
+
truncated,
|
|
419
|
+
items: items.slice(0, maxEntries).map((item) => ({
|
|
420
|
+
...item,
|
|
421
|
+
modified: isoTime(item.modified)
|
|
422
|
+
}))
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_requireFile(session, path) {
|
|
427
|
+
const entry = findEntry(session.entries, path);
|
|
428
|
+
if (entry) {
|
|
429
|
+
return entry;
|
|
430
|
+
}
|
|
431
|
+
const kind = pathKind(session.entries, session.folders, path);
|
|
432
|
+
if (kind === "directory") {
|
|
433
|
+
throw invalid(`'${path}' is a folder; use list_directory`);
|
|
434
|
+
}
|
|
435
|
+
throw notFound(`no such file: '${path}'`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Read a file. Text is returned as a string; binary files only when
|
|
440
|
+
* `encoding` is "base64".
|
|
441
|
+
*
|
|
442
|
+
* `offset` and `limit` count characters for text, and bytes for base64.
|
|
443
|
+
*/
|
|
444
|
+
async readFile(driveRef, rawPath, { offset = 0, limit, encoding = "utf-8" } = {}) {
|
|
445
|
+
const path = normalizeDrivePath(rawPath);
|
|
446
|
+
const session = await this._session(driveRef);
|
|
447
|
+
const entry = this._requireFile(session, path);
|
|
448
|
+
const { maxFileBytes, maxResponseChars, maxBinaryReturnBytes } = this.options;
|
|
449
|
+
|
|
450
|
+
if (entry.size > maxFileBytes) {
|
|
451
|
+
throw tooLarge(
|
|
452
|
+
`'${path}' is ${entry.size} bytes, which exceeds the ${maxFileBytes} byte limit`
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
456
|
+
throw invalid("offset must be a non-negative integer");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const base = {
|
|
460
|
+
path,
|
|
461
|
+
size: entry.size,
|
|
462
|
+
binary: entry.binary,
|
|
463
|
+
modified: isoTime(entry.modified)
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
if (entry.binary && encoding !== "base64") {
|
|
467
|
+
return {
|
|
468
|
+
...base,
|
|
469
|
+
content: null,
|
|
470
|
+
note: 'binary file: call read_file again with encoding "base64" to fetch its bytes'
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await waitForDocumentReady(this.client, session.id, entry.documentId, {
|
|
475
|
+
timeoutMs: this.options.syncTimeoutMs
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
if (encoding === "base64") {
|
|
479
|
+
const bytes = await this._readBytes(session, entry);
|
|
480
|
+
const cap = Math.min(limit ?? maxBinaryReturnBytes, maxBinaryReturnBytes);
|
|
481
|
+
const slice = bytes.subarray(offset, offset + cap);
|
|
482
|
+
return {
|
|
483
|
+
...base,
|
|
484
|
+
encoding: "base64",
|
|
485
|
+
offset,
|
|
486
|
+
returned: slice.length,
|
|
487
|
+
total: bytes.length,
|
|
488
|
+
truncated: offset + slice.length < bytes.length,
|
|
489
|
+
content: Buffer.from(slice).toString("base64")
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const text = await readTextContent(this.ctx, session.id, entry.documentId, {
|
|
494
|
+
timeoutMs: this.options.syncTimeoutMs
|
|
495
|
+
});
|
|
496
|
+
const cap = Math.min(limit ?? maxResponseChars, maxResponseChars);
|
|
497
|
+
const slice = text.slice(offset, offset + cap);
|
|
498
|
+
return {
|
|
499
|
+
...base,
|
|
500
|
+
encoding: "utf-8",
|
|
501
|
+
offset,
|
|
502
|
+
returned: slice.length,
|
|
503
|
+
total: text.length,
|
|
504
|
+
truncated: offset + slice.length < text.length,
|
|
505
|
+
content: slice
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async _readBytes(session, entry) {
|
|
510
|
+
if (entry.schemaType === "blob") {
|
|
511
|
+
return getBlob(this.client, session.id, entry.contentHash, entry.documentId, {
|
|
512
|
+
maxBytes: this.options.maxFileBytes
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
if (entry.schemaType === "array") {
|
|
516
|
+
return readLegacyArrayContent(this.ctx, session.id, entry.documentId, {
|
|
517
|
+
timeoutMs: this.options.syncTimeoutMs
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
const text = await readTextContent(this.ctx, session.id, entry.documentId, {
|
|
521
|
+
timeoutMs: this.options.syncTimeoutMs
|
|
522
|
+
});
|
|
523
|
+
return new TextEncoder().encode(text);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Search a drive by file name (`namePattern`: substring or glob) and/or by
|
|
528
|
+
* text content (`contentQuery`: case-insensitive substring).
|
|
529
|
+
*/
|
|
530
|
+
async searchFiles(
|
|
531
|
+
driveRef,
|
|
532
|
+
{ namePattern = "", contentQuery = "", path: rawPath = "", maxResults = 50 } = {}
|
|
533
|
+
) {
|
|
534
|
+
const folder = normalizeDrivePath(rawPath, { allowRoot: true });
|
|
535
|
+
const session = await this._session(driveRef);
|
|
536
|
+
|
|
537
|
+
const candidates = searchEntriesByPath(session.entries, namePattern, folder);
|
|
538
|
+
|
|
539
|
+
if (!contentQuery) {
|
|
540
|
+
const files = candidates.slice(0, maxResults).map((entry) => ({
|
|
541
|
+
path: entry.path,
|
|
542
|
+
size: entry.size,
|
|
543
|
+
modified: isoTime(entry.modified)
|
|
544
|
+
}));
|
|
545
|
+
return { files, total: candidates.length, truncated: candidates.length > maxResults };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return this._searchContent(session, candidates, contentQuery, maxResults);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async _searchContent(session, candidates, query, maxResults) {
|
|
552
|
+
const needle = query.toLowerCase();
|
|
553
|
+
const { contentSearchMaxFileBytes, contentSearchMaxFiles, contentSearchTimeoutMs } =
|
|
554
|
+
this.options;
|
|
555
|
+
|
|
556
|
+
const eligible = candidates.filter(
|
|
557
|
+
(entry) => !entry.binary && entry.size <= contentSearchMaxFileBytes
|
|
558
|
+
);
|
|
559
|
+
const scanList = eligible.slice(0, contentSearchMaxFiles);
|
|
560
|
+
const deadline = Date.now() + contentSearchTimeoutMs;
|
|
561
|
+
|
|
562
|
+
const matches = [];
|
|
563
|
+
let scanned = 0;
|
|
564
|
+
let unreadable = 0;
|
|
565
|
+
let stop = false;
|
|
566
|
+
|
|
567
|
+
await forEachLimited(scanList, 4, async (entry) => {
|
|
568
|
+
if (stop || Date.now() > deadline) {
|
|
569
|
+
stop = true;
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
await waitForDocumentReady(this.client, session.id, entry.documentId, {
|
|
574
|
+
timeoutMs: 5_000
|
|
575
|
+
});
|
|
576
|
+
const text = await readTextContent(this.ctx, session.id, entry.documentId, {
|
|
577
|
+
timeoutMs: 10_000
|
|
578
|
+
});
|
|
579
|
+
scanned++;
|
|
580
|
+
const lines = text.split(/\r?\n/);
|
|
581
|
+
for (let i = 0; i < lines.length; i++) {
|
|
582
|
+
if (lines[i].toLowerCase().includes(needle)) {
|
|
583
|
+
matches.push({
|
|
584
|
+
path: entry.path,
|
|
585
|
+
line: i + 1,
|
|
586
|
+
text: lines[i].length > 300 ? `${lines[i].slice(0, 300)}…` : lines[i]
|
|
587
|
+
});
|
|
588
|
+
if (matches.length >= maxResults * 4) {
|
|
589
|
+
// Enough to fill the result after sorting; stop reading more.
|
|
590
|
+
stop = true;
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
unreadable++;
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
matches.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
|
|
601
|
+
return {
|
|
602
|
+
matches: matches.slice(0, maxResults),
|
|
603
|
+
totalMatches: matches.length,
|
|
604
|
+
truncated: matches.length > maxResults || stop,
|
|
605
|
+
filesScanned: scanned,
|
|
606
|
+
filesSkipped: {
|
|
607
|
+
binary: candidates.filter((entry) => entry.binary).length,
|
|
608
|
+
tooLarge: candidates.filter(
|
|
609
|
+
(entry) => !entry.binary && entry.size > contentSearchMaxFileBytes
|
|
610
|
+
).length,
|
|
611
|
+
overScanLimit: Math.max(0, eligible.length - scanList.length),
|
|
612
|
+
unreadable
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------- writing
|
|
618
|
+
|
|
619
|
+
async _driveLimits(driveId) {
|
|
620
|
+
const resource = await this._driveWithAttributes(driveId);
|
|
621
|
+
const driveMax = positiveIntegerAttribute(resource, ATTR_MAX_FILE_SIZE);
|
|
622
|
+
return {
|
|
623
|
+
maxFileBytes: driveMax
|
|
624
|
+
? Math.min(driveMax, this.options.maxFileBytes)
|
|
625
|
+
: this.options.maxFileBytes,
|
|
626
|
+
ignoreBinaryFiles: booleanAttribute(resource, ATTR_IGNORE_BINARY, true)
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async _setMetadata(session, request) {
|
|
631
|
+
await this.client.SetSynapseDocumentMetadata({
|
|
632
|
+
synapse_id: session.id,
|
|
633
|
+
...request
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Create a file, or replace the content of an existing one when `overwrite`
|
|
639
|
+
* is set. `encoding` says how `content` is encoded: "utf-8" for text,
|
|
640
|
+
* "base64" for bytes.
|
|
641
|
+
*/
|
|
642
|
+
async writeFile(driveRef, rawPath, { content, encoding = "utf-8", overwrite = false }) {
|
|
643
|
+
this._assertWritable();
|
|
644
|
+
const path = normalizeDrivePath(rawPath);
|
|
645
|
+
|
|
646
|
+
if (typeof content !== "string") {
|
|
647
|
+
throw invalid("content must be a string");
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let bytes;
|
|
651
|
+
let text = null;
|
|
652
|
+
if (encoding === "base64") {
|
|
653
|
+
const compact = content.replace(/\s+/g, "");
|
|
654
|
+
if (!BASE64_RE.test(compact) || compact.length % 4 === 1) {
|
|
655
|
+
throw invalid("content is not valid base64");
|
|
656
|
+
}
|
|
657
|
+
bytes = Buffer.from(compact, "base64");
|
|
658
|
+
} else if (encoding === "utf-8") {
|
|
659
|
+
text = content;
|
|
660
|
+
bytes = Buffer.from(content, "utf8");
|
|
661
|
+
} else {
|
|
662
|
+
throw invalid(`unsupported encoding '${encoding}'`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const session = await this._session(driveRef);
|
|
666
|
+
const limits = await this._driveLimits(session.id);
|
|
667
|
+
if (bytes.length > limits.maxFileBytes) {
|
|
668
|
+
throw tooLarge(
|
|
669
|
+
`content is ${bytes.length} bytes, which exceeds the ${limits.maxFileBytes} byte limit for this drive`
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return session.exclusive(async () => {
|
|
674
|
+
const { entries, folders } = session;
|
|
675
|
+
const existing = findEntry(entries, path);
|
|
676
|
+
|
|
677
|
+
if (!existing && pathKind(entries, folders, path) === "directory") {
|
|
678
|
+
throw conflict(`'${path}' is a folder`);
|
|
679
|
+
}
|
|
680
|
+
const blocker = findFileAncestor(entries, path);
|
|
681
|
+
if (blocker) {
|
|
682
|
+
throw conflict(`cannot create '${path}': '${blocker}' is a file`);
|
|
683
|
+
}
|
|
684
|
+
if (existing && !overwrite) {
|
|
685
|
+
throw conflict(`'${path}' already exists; pass overwrite: true to replace it`);
|
|
686
|
+
}
|
|
687
|
+
if (existing?.schemaType === "array") {
|
|
688
|
+
throw conflict(
|
|
689
|
+
`'${path}' uses a legacy binary format that cannot be modified; delete it and create it again`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// An existing file keeps its kind; a new one is classified by content.
|
|
694
|
+
const binary = existing ? existing.binary : looksBinary(bytes);
|
|
695
|
+
if (!binary && looksBinary(bytes)) {
|
|
696
|
+
throw invalid(
|
|
697
|
+
`'${path}' is a text file but the content is binary; delete it and create it again`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
if (binary && limits.ignoreBinaryFiles) {
|
|
701
|
+
throw forbidden(
|
|
702
|
+
"this drive ignores binary files; enable 'include binary files' in the drive settings to store them"
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const documentId = existing?.documentId ?? randomUUID();
|
|
707
|
+
const json = JSON.stringify({ name: path, s: bytes.length });
|
|
708
|
+
|
|
709
|
+
if (binary) {
|
|
710
|
+
const hash = blobHash(bytes);
|
|
711
|
+
await ensureBlob(this.client, session.id, hash, bytes, documentId);
|
|
712
|
+
await this._setMetadata(session, {
|
|
713
|
+
document_id: documentId,
|
|
714
|
+
metadata: [{ name: "content", type: "blob" }],
|
|
715
|
+
json,
|
|
716
|
+
blob_hash: hash
|
|
717
|
+
});
|
|
718
|
+
} else {
|
|
719
|
+
const body = text ?? new TextDecoder().decode(bytes);
|
|
720
|
+
if (!existing) {
|
|
721
|
+
// Content first, so the listing never shows a file without its bytes.
|
|
722
|
+
if (body.length > 0) {
|
|
723
|
+
await writeTextContent(this.ctx, session.id, documentId, body, {
|
|
724
|
+
timeoutMs: this.options.syncTimeoutMs
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
await this._setMetadata(session, {
|
|
728
|
+
document_id: documentId,
|
|
729
|
+
metadata: [{ name: "content", type: "text" }],
|
|
730
|
+
json,
|
|
731
|
+
publish_hash: true
|
|
732
|
+
});
|
|
733
|
+
} else {
|
|
734
|
+
const changed = await writeTextContent(
|
|
735
|
+
this.ctx,
|
|
736
|
+
session.id,
|
|
737
|
+
documentId,
|
|
738
|
+
body,
|
|
739
|
+
{ timeoutMs: this.options.syncTimeoutMs }
|
|
740
|
+
);
|
|
741
|
+
if (changed || existing.size !== bytes.length) {
|
|
742
|
+
await this._setMetadata(session, {
|
|
743
|
+
document_id: documentId,
|
|
744
|
+
metadata: schemaToMetadata(existing),
|
|
745
|
+
json
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
await session.addFolders(ancestorsOf(path));
|
|
752
|
+
|
|
753
|
+
await session.waitFor(() => {
|
|
754
|
+
const current = findEntry(session.entries, path);
|
|
755
|
+
return Boolean(current && current.size === bytes.length);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
path,
|
|
760
|
+
documentId,
|
|
761
|
+
size: bytes.length,
|
|
762
|
+
created: !existing,
|
|
763
|
+
binary
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Create a folder (and any missing parents). Idempotent. */
|
|
769
|
+
async createFolder(driveRef, rawPath) {
|
|
770
|
+
this._assertWritable();
|
|
771
|
+
const path = normalizeDrivePath(rawPath);
|
|
772
|
+
const session = await this._session(driveRef);
|
|
773
|
+
|
|
774
|
+
return session.exclusive(async () => {
|
|
775
|
+
const { entries, folders } = session;
|
|
776
|
+
const kind = pathKind(entries, folders, path);
|
|
777
|
+
if (kind === "file") {
|
|
778
|
+
throw conflict(`'${path}' is a file`);
|
|
779
|
+
}
|
|
780
|
+
const blocker = findFileAncestor(entries, path);
|
|
781
|
+
if (blocker) {
|
|
782
|
+
throw conflict(`cannot create '${path}': '${blocker}' is a file`);
|
|
783
|
+
}
|
|
784
|
+
if (kind === "directory") {
|
|
785
|
+
return { path, created: false };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (!(await session.addFolders([...ancestorsOf(path), path]))) {
|
|
789
|
+
throw forbidden("the folders list of this drive is read-only for the current identity");
|
|
790
|
+
}
|
|
791
|
+
await session.waitFor(() => session.folders.includes(path));
|
|
792
|
+
return { path, created: true };
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Rename or move a file or folder to the new full path `to`. The destination
|
|
798
|
+
* must not exist; missing parent folders are created.
|
|
799
|
+
*/
|
|
800
|
+
async movePath(driveRef, rawFrom, rawTo) {
|
|
801
|
+
this._assertWritable();
|
|
802
|
+
const from = normalizeDrivePath(rawFrom);
|
|
803
|
+
const to = normalizeDrivePath(rawTo);
|
|
804
|
+
const session = await this._session(driveRef);
|
|
805
|
+
|
|
806
|
+
return session.exclusive(async () => {
|
|
807
|
+
const { entries, folders } = session;
|
|
808
|
+
const sourceKind = pathKind(entries, folders, from);
|
|
809
|
+
if (sourceKind === null) {
|
|
810
|
+
throw notFound(`no such file or folder: '${from}'`);
|
|
811
|
+
}
|
|
812
|
+
if (from === to) {
|
|
813
|
+
throw invalid("source and destination are the same");
|
|
814
|
+
}
|
|
815
|
+
if (sourceKind === "directory" && isWithin(to, from)) {
|
|
816
|
+
throw invalid("cannot move a folder into itself");
|
|
817
|
+
}
|
|
818
|
+
if (pathKind(entries, folders, to) !== null) {
|
|
819
|
+
throw conflict(`'${to}' already exists`);
|
|
820
|
+
}
|
|
821
|
+
const blocker = findFileAncestor(entries, to);
|
|
822
|
+
if (blocker) {
|
|
823
|
+
throw conflict(`cannot move to '${to}': '${blocker}' is a file`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const { rewrites, folderRenames } = planMove(entries, folders, from, to);
|
|
827
|
+
if (folderRenames.length > 0 && !session.foldersWritable) {
|
|
828
|
+
throw forbidden(
|
|
829
|
+
"the folders list of this drive is read-only for the current identity, so folders cannot be moved"
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
await forEachLimited(rewrites, 8, ({ entry, newPath }) =>
|
|
834
|
+
this._setMetadata(session, {
|
|
835
|
+
document_id: entry.documentId,
|
|
836
|
+
metadata: schemaToMetadata(entry),
|
|
837
|
+
json: JSON.stringify({ name: newPath, s: entry.size })
|
|
838
|
+
})
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
if (sourceKind === "directory" && session.foldersWritable) {
|
|
842
|
+
await session.mutateFolders((map) => {
|
|
843
|
+
for (const { from: oldPath, to: newPath } of folderRenames) {
|
|
844
|
+
map.delete(oldPath);
|
|
845
|
+
map.set(newPath, true);
|
|
846
|
+
}
|
|
847
|
+
for (const ancestor of [...ancestorsOf(to), to]) {
|
|
848
|
+
if (!map.has(ancestor)) {
|
|
849
|
+
map.set(ancestor, true);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
} else {
|
|
854
|
+
await session.addFolders(ancestorsOf(to));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
await session.waitFor(() =>
|
|
858
|
+
rewrites.every(({ entry, newPath }) => {
|
|
859
|
+
const moved = findEntry(session.entries, newPath);
|
|
860
|
+
return Boolean(moved && moved.documentId === entry.documentId);
|
|
861
|
+
})
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
return { from, to, movedFiles: rewrites.length };
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Delete a file, or a folder and everything in it when `recursive`. Deleting
|
|
870
|
+
* a file leaves its containing folders in place.
|
|
871
|
+
*/
|
|
872
|
+
async deletePath(driveRef, rawPath, { recursive = false } = {}) {
|
|
873
|
+
this._assertWritable();
|
|
874
|
+
const path = normalizeDrivePath(rawPath);
|
|
875
|
+
const session = await this._session(driveRef);
|
|
876
|
+
|
|
877
|
+
return session.exclusive(async () => {
|
|
878
|
+
const { entries, folders } = session;
|
|
879
|
+
const kind = pathKind(entries, folders, path);
|
|
880
|
+
if (kind === null) {
|
|
881
|
+
throw notFound(`no such file or folder: '${path}'`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (kind === "file") {
|
|
885
|
+
const entry = findEntry(entries, path);
|
|
886
|
+
await this.client.DeleteSynapseDocumentMetadata({
|
|
887
|
+
synapse_id: session.id,
|
|
888
|
+
document_id: entry.documentId
|
|
889
|
+
});
|
|
890
|
+
await session.waitFor(() => !findEntry(session.entries, path));
|
|
891
|
+
return { path, deletedFiles: 1 };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const doomed = entries.filter((entry) => isWithin(entry.path, path));
|
|
895
|
+
if (hasChildren(entries, folders, path) && !recursive) {
|
|
896
|
+
throw conflict(
|
|
897
|
+
`folder '${path}' is not empty (${doomed.length} file${doomed.length === 1 ? "" : "s"}); pass recursive: true to delete it and its contents`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
await forEachLimited(doomed, 4, (entry) =>
|
|
902
|
+
this.client.DeleteSynapseDocumentMetadata({
|
|
903
|
+
synapse_id: session.id,
|
|
904
|
+
document_id: entry.documentId
|
|
905
|
+
})
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
const folderPaths = folders.filter((folder) => isWithin(folder, path));
|
|
909
|
+
let folderListUpdated = true;
|
|
910
|
+
if (folderPaths.length > 0) {
|
|
911
|
+
folderListUpdated = await session.removeFolders(folderPaths);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
await session.waitFor(
|
|
915
|
+
() => !session.entries.some((entry) => isWithin(entry.path, path))
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
path,
|
|
920
|
+
deletedFiles: doomed.length,
|
|
921
|
+
...(folderListUpdated
|
|
922
|
+
? {}
|
|
923
|
+
: {
|
|
924
|
+
warning:
|
|
925
|
+
"files were deleted but the folders list is read-only for the current identity, so empty folders may remain"
|
|
926
|
+
})
|
|
927
|
+
};
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------- versions
|
|
932
|
+
|
|
933
|
+
/** Saved versions of a file, newest first. */
|
|
934
|
+
async listFileVersions(driveRef, rawPath, { limit = 50, offset = 0 } = {}) {
|
|
935
|
+
const path = normalizeDrivePath(rawPath);
|
|
936
|
+
const session = await this._session(driveRef);
|
|
937
|
+
const entry = this._requireFile(session, path);
|
|
938
|
+
|
|
939
|
+
let response;
|
|
940
|
+
try {
|
|
941
|
+
response = await this.client.ListSyncDocumentVersions({
|
|
942
|
+
database_id: session.id,
|
|
943
|
+
document_id: entry.documentId,
|
|
944
|
+
limit,
|
|
945
|
+
offset
|
|
946
|
+
});
|
|
947
|
+
} catch (err) {
|
|
948
|
+
throw formatVersionError(err);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const versions = (response?.versions ?? []).map((version) => ({
|
|
952
|
+
versionId: String(version.version_id),
|
|
953
|
+
timestamp: isoTime(Number(version.timestamp)),
|
|
954
|
+
identity: String(version.identity ?? ""),
|
|
955
|
+
label: String(version.label ?? ""),
|
|
956
|
+
contentHash: String(version.document_hash ?? "")
|
|
957
|
+
}));
|
|
958
|
+
return { path, versions };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** The text of `entry` as it was at a saved version. */
|
|
962
|
+
async _versionText(session, entry, versionId) {
|
|
963
|
+
let response;
|
|
964
|
+
try {
|
|
965
|
+
response = await this.client.GetSyncDocumentVersion({
|
|
966
|
+
database_id: session.id,
|
|
967
|
+
document_id: entry.documentId,
|
|
968
|
+
version_id: versionId
|
|
969
|
+
});
|
|
970
|
+
} catch (err) {
|
|
971
|
+
throw formatVersionError(err);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const state = toBytes(response?.state_update);
|
|
975
|
+
if (state.length === 0) {
|
|
976
|
+
return "";
|
|
977
|
+
}
|
|
978
|
+
const doc = new this.ctx.Y.Doc();
|
|
979
|
+
try {
|
|
980
|
+
doc.guid = entry.documentId;
|
|
981
|
+
this.ctx.Y.applyUpdateV2(doc, state);
|
|
982
|
+
return doc.getText("content").toString();
|
|
983
|
+
} finally {
|
|
984
|
+
doc.destroy();
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** Read the text content of a file as it was at a saved version. */
|
|
989
|
+
async readFileVersion(driveRef, rawPath, versionId, { offset = 0, limit } = {}) {
|
|
990
|
+
const path = normalizeDrivePath(rawPath);
|
|
991
|
+
const session = await this._session(driveRef);
|
|
992
|
+
const entry = this._requireFile(session, path);
|
|
993
|
+
if (entry.binary) {
|
|
994
|
+
throw invalid("reading earlier versions of binary files is not supported");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const text = await this._versionText(session, entry, versionId);
|
|
998
|
+
|
|
999
|
+
const cap = Math.min(limit ?? this.options.maxResponseChars, this.options.maxResponseChars);
|
|
1000
|
+
const slice = text.slice(offset, offset + cap);
|
|
1001
|
+
return {
|
|
1002
|
+
path,
|
|
1003
|
+
versionId,
|
|
1004
|
+
encoding: "utf-8",
|
|
1005
|
+
offset,
|
|
1006
|
+
returned: slice.length,
|
|
1007
|
+
total: text.length,
|
|
1008
|
+
truncated: offset + slice.length < text.length,
|
|
1009
|
+
content: slice
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** Save a version checkpoint of a file. */
|
|
1014
|
+
async createFileVersion(driveRef, rawPath, label = "") {
|
|
1015
|
+
this._assertWritable();
|
|
1016
|
+
const path = normalizeDrivePath(rawPath);
|
|
1017
|
+
const session = await this._session(driveRef);
|
|
1018
|
+
const entry = this._requireFile(session, path);
|
|
1019
|
+
|
|
1020
|
+
try {
|
|
1021
|
+
const response = await this.client.CreateSyncDocumentVersion({
|
|
1022
|
+
database_id: session.id,
|
|
1023
|
+
document_id: entry.documentId,
|
|
1024
|
+
label: String(label ?? "").trim()
|
|
1025
|
+
});
|
|
1026
|
+
return { path, versionId: String(response?.version_id ?? "") };
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
throw formatVersionError(err);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Replace a file's content with that of a saved version. The Volt records an
|
|
1034
|
+
* audit version of the revert, whose id is returned.
|
|
1035
|
+
*/
|
|
1036
|
+
async revertFileVersion(driveRef, rawPath, versionId) {
|
|
1037
|
+
this._assertWritable();
|
|
1038
|
+
const path = normalizeDrivePath(rawPath);
|
|
1039
|
+
const session = await this._session(driveRef);
|
|
1040
|
+
|
|
1041
|
+
return session.exclusive(async () => {
|
|
1042
|
+
const entry = this._requireFile(session, path);
|
|
1043
|
+
if (entry.binary) {
|
|
1044
|
+
throw invalid("reverting binary files is not supported");
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
let response;
|
|
1048
|
+
try {
|
|
1049
|
+
response = await this.client.RevertSyncDocumentVersion({
|
|
1050
|
+
database_id: session.id,
|
|
1051
|
+
document_id: entry.documentId,
|
|
1052
|
+
version_id: versionId,
|
|
1053
|
+
label: ""
|
|
1054
|
+
});
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
throw formatVersionError(err);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// The file now holds the version's text. The index records its size,
|
|
1060
|
+
// which clients write, so bring it in line.
|
|
1061
|
+
const text = await this._versionText(session, entry, versionId);
|
|
1062
|
+
const size = Buffer.byteLength(text, "utf8");
|
|
1063
|
+
if (size !== entry.size) {
|
|
1064
|
+
await this._setMetadata(session, {
|
|
1065
|
+
document_id: entry.documentId,
|
|
1066
|
+
metadata: schemaToMetadata(entry),
|
|
1067
|
+
json: JSON.stringify({ name: entry.path, s: size })
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return { path, size, auditVersionId: String(response?.version_id ?? "") };
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ---------------------------------------------------------------- lifecycle
|
|
1076
|
+
|
|
1077
|
+
/** Close every open sync stream. */
|
|
1078
|
+
async close() {
|
|
1079
|
+
const sessions = [...this.sessions.values()];
|
|
1080
|
+
this.sessions.clear();
|
|
1081
|
+
await Promise.all(sessions.map((session) => session.close().catch(() => {})));
|
|
1082
|
+
}
|
|
1083
|
+
}
|