@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.
@@ -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
+ }