Line data Source code
1 : import { type SessionRegistryBackend, type ConnectionEntry, STORAGE_CONNECTIONS_FOLDER, type SessionData, type ScriptData, type SessionEntry, type StorageManifest, type AppSettings, StorageBackendType, STORAGE_MANIFEST_FILE, STORAGE_SESSIONS_FOLDER, STORAGE_SESSION_FILE, STORAGE_SCRIPTS_FOLDER, STORAGE_SHELL_FOLDER } from './storage_backend.js';
2 : import { STORAGE_VERSION } from './migration/versions.js';
3 : import { type DataFile, type DataFileContent, STORAGE_ATTACHMENTS_FOLDER, isAttachmentFileName, validateDataFileName } from './storage_backend.js';
4 : import { sleep } from '../utils/sleep.js';
5 : import { getOPFSRoot } from '../platform/opfs_root.js';
6 : import {
7 : INITIAL_SESSION_FOLDER,
8 : STORAGE_FOLDER_FILE,
9 : STORAGE_SESSION_TREE_FOLDER,
10 : type SessionTreeFolder,
11 : dropDuplicateSessions,
12 : emptySessionTree,
13 : flattenSessionTree,
14 : folderAt,
15 : folderNameError,
16 : folderPathKey,
17 : isFolderPathWithin,
18 : listFolders,
19 : moveSessionInTree,
20 : readSessionTreeDirectory,
21 : removeSessionFromTree,
22 : sameFolderPath,
23 : upsertSessionInTree,
24 : } from './session_tree.js';
25 :
26 : /// The name of the web lock that guards the manifest
27 1 : export const OPFS_MANIFEST_LOCK = 'dashql-manifest';
28 : /// The name of the web lock that guards the session tree
29 1 : export const OPFS_SESSION_TREE_LOCK = 'dashql-session-tree';
30 :
31 : /// The attempts to delete the files of a connection while one of them is in use, and the delay between them
32 1 : const DELETE_IN_USE_ATTEMPTS = 20;
33 1 : const DELETE_IN_USE_RETRY_MS = 100;
34 :
35 : /// Origin Private File System storage backend.
36 : ///
37 : /// This backend owns the session registry (the session tree `sessiontree/`), which lists *every*
38 : /// session regardless of where its files physically live. Sessions stored in OPFS keep their files under
39 : /// `sessions/<uuid>/…`; sessions relocated to a native directory keep only a registry entry here
40 : /// (their files live on disk, managed by `NativeStorageBackend`).
41 : ///
42 : /// Every per-session method is keyed by the bare session UUID.
43 1 : export class OPFSStorageBackend implements SessionRegistryBackend {
44 1 : private rootHandle: FileSystemDirectoryHandle | null = null;
45 : /// The latest manifest mutation, every read-modify-write of the manifest chains onto it
46 1 : private manifestChain: Promise<void> = Promise.resolve();
47 : /// The latest change of the session tree, every change of the tree chains onto it
48 1 : private treeChain: Promise<void> = Promise.resolve();
49 :
50 1 : constructor(private readonly getRoot: () => Promise<FileSystemDirectoryHandle> = getOPFSRoot) {}
51 :
52 1 : getBackendType(): StorageBackendType {
53 1 : return StorageBackendType.OPFS;
54 1 : }
55 :
56 1 : async initialize(): Promise<void> {
57 1 : this.rootHandle = await this.getRoot();
58 1 : await this.createSessionTree();
59 1 : }
60 :
61 : /// A storage without a session tree starts with the folder My Sessions.
62 : /// The tree is only created once, a deleted folder stays deleted.
63 1 : private async createSessionTree(): Promise<void> {
64 1 : const root = this.ensureInitialized();
65 1 : await this.withTreeLock(async () => {
66 1 : try {
67 1 : await root.getDirectoryHandle(STORAGE_SESSION_TREE_FOLDER, { create: false });
68 1 : } catch (error) {
69 1 : if ((error as DOMException).name !== 'NotFoundError') throw error;
70 1 : await this.writeFolderFile([], []);
71 1 : await this.writeFolderFile(INITIAL_SESSION_FOLDER, []);
72 1 : }
73 1 : });
74 1 : }
75 :
76 : /// Run a manifest operation exclusively.
77 : ///
78 : /// Every write of the manifest is a read-modify-write, so concurrent writers (session registry
79 : /// updates, app settings, connections) would otherwise lose each other's updates. The chain
80 : /// serializes the writers of this backend, the web lock serializes them across tabs that share
81 : /// the same OPFS root.
82 1 : private withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
83 1 : const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
84 1 : const run = locks != null ? () => locks.request(OPFS_MANIFEST_LOCK, fn) : fn;
85 1 : const result = this.manifestChain.then(run, run);
86 1 : this.manifestChain = result.then(() => { }, () => { });
87 1 : return result;
88 1 : }
89 :
90 : /// Run an operation on the session tree exclusively, within this backend and across tabs.
91 : /// Changes of the session tree read and write several folder files, the lock keeps them consistent.
92 1 : private withTreeLock<T>(fn: () => Promise<T>): Promise<T> {
93 1 : const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
94 1 : const run = locks != null ? () => locks.request(OPFS_SESSION_TREE_LOCK, fn) : fn;
95 1 : const result = this.treeChain.then(run, run);
96 1 : this.treeChain = result.then(() => { }, () => { });
97 1 : return result;
98 1 : }
99 :
100 : /// Mutate the manifest exclusively. The mutation returns false when nothing changed.
101 1 : private mutateManifest(fn: (manifest: StorageManifest) => boolean | void): Promise<void> {
102 1 : return this.withManifestLock(async () => {
103 1 : const root = this.ensureInitialized();
104 1 : const manifest = await this.readManifest(root);
105 1 : if (fn(manifest) === false) {
106 1 : return;
107 : }
108 1 : await this.writeManifest(root, manifest);
109 1 : });
110 1 : }
111 :
112 1 : private ensureInitialized(): FileSystemDirectoryHandle {
113 1 : if (!this.rootHandle) {
114 1 : throw new Error('OPFSStorageBackend not initialized. Call initialize() first.');
115 : }
116 1 : return this.rootHandle;
117 1 : }
118 :
119 : /// The relative folder that holds a session's files, e.g. "sessions/<uuid>"
120 1 : private sessionRelPath(sessionId: string): string {
121 1 : return `${STORAGE_SESSIONS_FOLDER}/${sessionId}`;
122 1 : }
123 :
124 : /// Natural sort for strings with numeric components (e.g., "page-1" < "page-2" < "page-10")
125 1 : private naturalSort(a: string, b: string): number {
126 1 : return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
127 1 : }
128 :
129 : /// The sessions of the session tree, in the order of the sidebar
130 1 : async listSessions(): Promise<SessionEntry[]> {
131 1 : return flattenSessionTree(await this.loadSessionTree());
132 1 : }
133 :
134 1 : async loadSession(sessionId: string): Promise<SessionData> {
135 1 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), false);
136 1 : const metaFile = await sessionDir.getFileHandle(STORAGE_SESSION_FILE);
137 1 : const file = await metaFile.getFile();
138 1 : const text = await file.text();
139 1 : const data: SessionData = JSON.parse(text);
140 :
141 : // sessionId is required - will throw if missing
142 1 : if (!data.sessionId) {
143 1 : throw new Error(`Session ${sessionId} is missing required sessionId field. Please migrate the session or regenerate it.`);
144 : }
145 :
146 1 : return data;
147 1 : }
148 :
149 1 : async saveSessionManifest(sessionId: string, data: SessionData): Promise<void> {
150 1 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), true);
151 1 : const metaFile = await sessionDir.getFileHandle(STORAGE_SESSION_FILE, { create: true });
152 1 : const writable = await metaFile.createWritable();
153 1 : await writable.write(JSON.stringify(data, null, 2));
154 1 : await writable.close();
155 :
156 1 : await this.upsertSessionEntry({ path: sessionId, storageType: StorageBackendType.OPFS });
157 1 : }
158 :
159 :
160 :
161 1 : async deleteSession(sessionId: string): Promise<void> {
162 1 : await this.deleteSessionFiles(sessionId);
163 : // Always try to update the manifest even if the session files weren't found
164 : // (in case the manifest still has a stale reference).
165 1 : await this.removeSessionEntry(sessionId);
166 1 : }
167 :
168 : /// Delete a session's files only, leaving the registry entry intact.
169 1 : async deleteSessionFiles(sessionId: string): Promise<void> {
170 1 : const relativePath = this.sessionRelPath(sessionId);
171 1 : const root = this.ensureInitialized();
172 :
173 1 : try {
174 : // Navigate to the parent directory, then remove the session entry
175 1 : const parts = relativePath.split('/').filter(p => p);
176 1 : if (parts.length > 0) {
177 1 : let parentDir: FileSystemDirectoryHandle = root;
178 1 : for (let i = 0; i < parts.length - 1; i++) {
179 1 : parentDir = await parentDir.getDirectoryHandle(parts[i], { create: false });
180 : }
181 1 : await parentDir.removeEntry(parts[parts.length - 1], { recursive: true });
182 1 : }
183 1 : } catch (error) {
184 : // If sessions folder or session doesn't exist, that's fine - it's already deleted
185 0 : if ((error as any).name !== 'NotFoundError') {
186 0 : throw error;
187 : }
188 0 : }
189 1 : }
190 :
191 1 : async loadScripts(sessionId: string): Promise<ScriptData[]> {
192 1 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), false).catch((error) => {
193 0 : if ((error as any).name === 'NotFoundError') return null;
194 0 : throw error;
195 1 : });
196 1 : if (!sessionDir) {
197 1 : return [];
198 : }
199 1 : let scriptsDir: FileSystemDirectoryHandle;
200 1 : try {
201 1 : scriptsDir = await sessionDir.getDirectoryHandle(STORAGE_SCRIPTS_FOLDER, { create: false });
202 1 : } catch (error) {
203 1 : if ((error as any).name === 'NotFoundError') {
204 1 : return [];
205 : }
206 1 : throw error;
207 1 : }
208 1 : const scripts: ScriptData[] = [];
209 :
210 1 : for await (const [name, handle] of scriptsDir.entries()) {
211 1 : if (handle.kind === 'directory') {
212 1 : throw new Error(`Invalid V2 session layout: nested scripts directory ${name}`);
213 : }
214 1 : if (name.toLowerCase() === 'dashql-draft.sql') {
215 1 : throw new Error('Invalid V2 session layout: dashql-draft.sql is not supported');
216 : }
217 1 : if (handle.kind === 'file' && name.toLowerCase().endsWith('.sql')) {
218 1 : const file = await (handle as FileSystemFileHandle).getFile();
219 1 : scripts.push({ name, sql: await file.text() });
220 1 : }
221 1 : }
222 1 : scripts.sort((a, b) => this.naturalSort(a.name, b.name));
223 1 : return scripts;
224 1 : }
225 :
226 : /// Move a single file between (or within) OPFS directories, preserving its contents byte-for-byte.
227 : ///
228 : /// Prefers the non-standard `FileSystemFileHandle.move()` (Chromium) for a true in-place move, and
229 : /// falls back to copy-then-delete where it is unavailable (Safari/Firefox). The same-directory case
230 : /// (`srcDir === destDir`) is a pure rename.
231 1 : private async moveFile(
232 1 : srcDir: FileSystemDirectoryHandle,
233 1 : srcName: string,
234 1 : destDir: FileSystemDirectoryHandle,
235 1 : destName: string,
236 1 : ): Promise<void> {
237 1 : if (srcDir === destDir && srcName === destName) {
238 0 : return;
239 : }
240 1 : const srcHandle = await srcDir.getFileHandle(srcName, { create: false });
241 1 : const move = (srcHandle as any).move as ((...args: any[]) => Promise<void>) | undefined;
242 1 : if (typeof move === 'function') {
243 : // move(name) renames in place; move(destDir, name) relocates across directories.
244 0 : await (srcDir === destDir ? move.call(srcHandle, destName) : move.call(srcHandle, destDir, destName));
245 0 : return;
246 0 : }
247 : // Fallback: stream the source File (a Blob, so this is binary-safe) into the new handle, then
248 : // drop the original.
249 1 : const file = await srcHandle.getFile();
250 1 : const destHandle = await destDir.getFileHandle(destName, { create: true });
251 1 : const writable = await destHandle.createWritable();
252 1 : await writable.write(file);
253 1 : await writable.close();
254 1 : await srcDir.removeEntry(srcName);
255 1 : }
256 :
257 :
258 1 : async loadScript(sessionId: string, scriptName: string): Promise<ScriptData> {
259 1 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), false);
260 1 : const scriptsDir = await sessionDir.getDirectoryHandle(STORAGE_SCRIPTS_FOLDER, { create: false });
261 :
262 1 : try {
263 1 : const fileHandle = await scriptsDir.getFileHandle(scriptName);
264 1 : const file = await fileHandle.getFile();
265 1 : const sql = await file.text();
266 1 : return { name: scriptName, sql };
267 1 : } catch {
268 0 : throw new Error(`Script not found: session ${sessionId}, script ${scriptName}`);
269 0 : }
270 1 : }
271 :
272 1 : async saveScript(
273 1 : sessionId: string,
274 1 : scriptName: string,
275 1 : sql: string
276 1 : ): Promise<void> {
277 1 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), true);
278 1 : const scriptsDir = await sessionDir.getDirectoryHandle(STORAGE_SCRIPTS_FOLDER, { create: true });
279 1 : const fileHandle = await scriptsDir.getFileHandle(scriptName, { create: true });
280 1 : const writable = await fileHandle.createWritable();
281 1 : await writable.write(sql);
282 1 : await writable.close();
283 1 : }
284 :
285 0 : async deleteScript(sessionId: string, scriptName: string): Promise<void> {
286 0 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), false);
287 0 : const scriptsDir = await sessionDir.getDirectoryHandle(STORAGE_SCRIPTS_FOLDER, { create: false });
288 0 : await scriptsDir.removeEntry(scriptName);
289 0 : }
290 :
291 1 : async renameScript(sessionId: string, oldScriptName: string, newScriptName: string): Promise<void> {
292 1 : if (oldScriptName === newScriptName) {
293 1 : return;
294 : }
295 : // Navigate without creating anything: if the session, page, or source file isn't flushed yet,
296 : // the pending write under the new name creates them, so there is nothing to move. The
297 : // scripts/folder/file handles surface a raw OPFS NotFoundError, but getSessionDir re-wraps that
298 : // into a generic "Directory not found" Error, so the no-op guard has to recognise both forms.
299 1 : let scriptsDir: FileSystemDirectoryHandle;
300 1 : try {
301 1 : const sessionDir = await this.getSessionDir(this.sessionRelPath(sessionId), false);
302 1 : scriptsDir = await sessionDir.getDirectoryHandle(STORAGE_SCRIPTS_FOLDER, { create: false });
303 1 : await scriptsDir.getFileHandle(oldScriptName, { create: false });
304 1 : } catch (error) {
305 1 : if ((error as any).name === 'NotFoundError' || ((error as any).message ?? '').startsWith('Directory not found')) {
306 1 : return;
307 : }
308 0 : throw error;
309 0 : }
310 1 : await this.moveFile(scriptsDir, oldScriptName, scriptsDir, newScriptName);
311 1 : }
312 :
313 1 : private async dataDir(sessionId: string, create: boolean): Promise<FileSystemDirectoryHandle | null> {
314 1 : const root = this.ensureInitialized();
315 1 : try {
316 1 : const sessions = await root.getDirectoryHandle(STORAGE_SESSIONS_FOLDER, { create });
317 1 : const session = await sessions.getDirectoryHandle(sessionId, { create });
318 1 : return await session.getDirectoryHandle(STORAGE_ATTACHMENTS_FOLDER, { create });
319 1 : } catch (error) {
320 1 : if (!create && (error as Error).name === 'NotFoundError') return null;
321 1 : throw error;
322 1 : }
323 1 : }
324 :
325 1 : async listDataFiles(sessionId: string): Promise<DataFile[]> {
326 1 : const dir = await this.dataDir(sessionId, false);
327 1 : if (!dir) return [];
328 1 : const files: DataFile[] = [];
329 1 : for await (const [name, handle] of dir.entries()) {
330 1 : if (handle.kind === 'directory') throw new Error(`Invalid session layout: nested data directory ${name}`);
331 : // Other files in the folder are no attachments
332 1 : if (!isAttachmentFileName(name)) continue;
333 1 : validateDataFileName(name);
334 1 : const file = await (handle as FileSystemFileHandle).getFile();
335 1 : files.push({ name, sizeBytes: file.size, mtimeMs: file.lastModified });
336 1 : }
337 1 : return files.sort((a, b) => this.naturalSort(a.name, b.name));
338 1 : }
339 :
340 1 : async loadDataFile(sessionId: string, name: string): Promise<Uint8Array> {
341 1 : validateDataFileName(name);
342 1 : const dir = await this.dataDir(sessionId, false);
343 1 : if (!dir) throw new Error(`Data file not found: ${name}`);
344 1 : const handle = await dir.getFileHandle(name, { create: false });
345 1 : return new Uint8Array(await (await handle.getFile()).arrayBuffer());
346 1 : }
347 :
348 1 : async saveDataFile(sessionId: string, name: string, content: DataFileContent): Promise<void> {
349 1 : validateDataFileName(name);
350 1 : const dir = (await this.dataDir(sessionId, true))!;
351 1 : const handle = await dir.getFileHandle(name, { create: true });
352 1 : const writable = await handle.createWritable();
353 : // FileSystemWritableFileStream accepts Blob directly and streams it into OPFS without
354 : // materializing a second full attachment buffer in the renderer.
355 1 : if (content instanceof Blob) {
356 0 : await writable.write(content);
357 1 : } else {
358 1 : const buffer = new ArrayBuffer(content.byteLength);
359 1 : new Uint8Array(buffer).set(content);
360 1 : await writable.write(buffer);
361 1 : }
362 1 : await writable.close();
363 1 : }
364 :
365 1 : async deleteDataFile(sessionId: string, name: string): Promise<void> {
366 1 : validateDataFileName(name);
367 1 : const dir = await this.dataDir(sessionId, false);
368 1 : if (!dir) return;
369 1 : try {
370 1 : await dir.removeEntry(name);
371 1 : } catch (error) {
372 0 : if ((error as Error).name === 'NotFoundError') return;
373 0 : throw error;
374 0 : }
375 1 : }
376 :
377 1 : async renameDataFile(sessionId: string, oldName: string, newName: string): Promise<void> {
378 1 : validateDataFileName(oldName);
379 1 : validateDataFileName(newName);
380 1 : if (oldName === newName) return;
381 1 : const dir = await this.dataDir(sessionId, false);
382 1 : if (!dir) return;
383 1 : await this.moveFile(dir, oldName, dir, newName);
384 1 : }
385 :
386 : /// Write an empty file (creating or truncating it), which advances its mtime.
387 0 : private async writeEmptyFile(dir: FileSystemDirectoryHandle, name: string): Promise<void> {
388 0 : const handle = await dir.getFileHandle(name, { create: true });
389 0 : const writable = await handle.createWritable();
390 0 : await writable.write(new ArrayBuffer(0));
391 0 : await writable.close();
392 0 : }
393 :
394 1 : async clearAllStorage(): Promise<void> {
395 1 : const root = this.ensureInitialized();
396 :
397 : // Step 1: Remove the session tree FIRST.
398 : // This ensures the app won't try to restore sessions even if the other cleanup fails.
399 1 : try {
400 1 : await this.withTreeLock(() => root.removeEntry(STORAGE_SESSION_TREE_FOLDER, { recursive: true }));
401 1 : } catch (error) {
402 0 : if ((error as DOMException).name !== 'NotFoundError') {
403 0 : console.warn('Failed to clear the session tree:', error);
404 : }
405 0 : }
406 1 : try {
407 1 : await this.withManifestLock(() => this.writeManifest(root, {}));
408 1 : } catch (error) {
409 0 : console.warn('Failed to clear manifest:', error);
410 0 : }
411 :
412 : // Step 2: Delete app-owned data folders while leaving unrelated origin storage untouched.
413 1 : for (const folder of [STORAGE_SESSIONS_FOLDER, STORAGE_CONNECTIONS_FOLDER, STORAGE_SHELL_FOLDER]) {
414 1 : try {
415 1 : await root.removeEntry(folder, { recursive: true });
416 1 : } catch (error) {
417 1 : if ((error as DOMException).name !== 'NotFoundError') {
418 0 : console.warn(`Failed to delete ${folder} folder:`, error);
419 : }
420 1 : }
421 : }
422 1 : }
423 :
424 1 : async loadAppSettings(): Promise<AppSettings | null> {
425 1 : const root = this.ensureInitialized();
426 1 : try {
427 1 : const indexFile = await root.getFileHandle(STORAGE_MANIFEST_FILE, { create: false });
428 1 : const file = await indexFile.getFile();
429 1 : const text = await file.text();
430 1 : const manifest: StorageManifest = JSON.parse(text);
431 1 : return manifest.appSettings ?? null;
432 1 : } catch (error) {
433 1 : if ((error as any).name === 'NotFoundError') {
434 1 : return null;
435 : }
436 1 : throw error;
437 1 : }
438 1 : }
439 :
440 1 : async saveAppSettings(settings: AppSettings): Promise<void> {
441 1 : await this.mutateManifest(manifest => {
442 1 : manifest.appSettings = settings;
443 1 : });
444 1 : }
445 :
446 1 : async loadManifest(): Promise<StorageManifest> {
447 1 : const root = this.ensureInitialized();
448 1 : return await this.withManifestLock(() => this.readManifest(root));
449 1 : }
450 :
451 1 : async updateConnections(update: (connections: ConnectionEntry[] | undefined) => ConnectionEntry[] | null): Promise<void> {
452 1 : await this.mutateManifest(manifest => {
453 1 : const next = update(manifest.connections);
454 1 : if (next == null) {
455 0 : return false;
456 : }
457 1 : manifest.connections = next;
458 1 : return true;
459 1 : });
460 1 : }
461 :
462 : /// The folder of a connection's files, e.g. "connections/<uuid>"
463 1 : private connectionRelPath(connectionId: string): string {
464 1 : return `${STORAGE_CONNECTIONS_FOLDER}/${connectionId}`;
465 1 : }
466 :
467 1 : async loadConnectionFile(connectionId: string, fileName: string): Promise<string | null> {
468 1 : try {
469 1 : const dir = await this.getSessionDir(this.connectionRelPath(connectionId), false);
470 1 : const handle = await dir.getFileHandle(fileName, { create: false });
471 1 : return await (await handle.getFile()).text();
472 1 : } catch {
473 1 : return null;
474 1 : }
475 1 : }
476 :
477 1 : async saveConnectionFile(connectionId: string, fileName: string, text: string): Promise<void> {
478 1 : const dir = await this.getSessionDir(this.connectionRelPath(connectionId), true);
479 1 : const handle = await dir.getFileHandle(fileName, { create: true });
480 1 : const writable = await handle.createWritable();
481 1 : await writable.write(text);
482 1 : await writable.close();
483 1 : }
484 :
485 1 : async createConnectionFolder(connectionId: string): Promise<void> {
486 1 : await this.getSessionDir(this.connectionRelPath(connectionId), true);
487 1 : }
488 :
489 1 : async deleteConnectionFiles(connectionId: string): Promise<void> {
490 1 : const root = this.ensureInitialized();
491 : // The embedded Hyper closes the monitoring database of a deleted connection right after the deletion,
492 : // until then its file is in use
493 1 : for (let attempt = 1; ; ++attempt) {
494 1 : try {
495 1 : const connections = await root.getDirectoryHandle(STORAGE_CONNECTIONS_FOLDER, { create: false });
496 1 : await connections.removeEntry(connectionId, { recursive: true });
497 1 : return;
498 1 : } catch (error) {
499 1 : const name = (error as any).name;
500 1 : if (name === 'NotFoundError') return;
501 1 : const inUse = name === 'NoModificationAllowedError' || name === 'InvalidModificationError';
502 1 : if (!inUse || attempt >= DELETE_IN_USE_ATTEMPTS) throw error;
503 1 : await sleep(DELETE_IN_USE_RETRY_MS);
504 1 : }
505 : }
506 1 : }
507 :
508 1 : private async readManifest(root: FileSystemDirectoryHandle): Promise<StorageManifest> {
509 1 : try {
510 1 : const indexFile = await root.getFileHandle(STORAGE_MANIFEST_FILE, { create: false });
511 1 : const file = await indexFile.getFile();
512 1 : const text = await file.text();
513 1 : const manifest: unknown = JSON.parse(text);
514 1 : if (manifest == null || typeof manifest !== 'object' || Array.isArray(manifest)) {
515 1 : throw new Error('Invalid manifest format: not a JSON object');
516 : }
517 1 : return manifest as StorageManifest;
518 1 : } catch (error) {
519 1 : if ((error as any).name === 'NotFoundError') {
520 1 : return {};
521 : }
522 1 : throw error;
523 1 : }
524 1 : }
525 :
526 : /// Write the manifest. The storage is migrated at app start, the manifest is at the current version.
527 1 : private async writeManifest(root: FileSystemDirectoryHandle, manifest: StorageManifest): Promise<void> {
528 1 : const indexFile = await root.getFileHandle(STORAGE_MANIFEST_FILE, { create: true });
529 1 : const writable = await indexFile.createWritable();
530 1 : await writable.write(JSON.stringify({ ...manifest, storageVersion: STORAGE_VERSION }, null, 2));
531 1 : await writable.close();
532 1 : }
533 :
534 : /// Load the session tree. A session that the tree registers twice is listed where it comes first.
535 1 : async loadSessionTree(): Promise<SessionTreeFolder> {
536 1 : return await this.withTreeLock(async () => dropDuplicateSessions(await this.readTree()));
537 1 : }
538 :
539 : /// Insert or replace a session's registry entry (matched by UUID), without touching files.
540 : ///
541 : /// A registered session keeps its place in the tree, a new one is appended to the folder at
542 : /// `folderPath` (the root by default). We never re-sort.
543 1 : async upsertSessionEntry(entry: SessionEntry, folderPath: readonly string[] = []): Promise<void> {
544 1 : await this.mutateTree(tree => upsertSessionInTree(tree, entry, folderPath));
545 1 : }
546 :
547 : /// Remove a session's registry entry (matched by UUID), without touching files.
548 1 : async removeSessionEntry(sessionId: string): Promise<void> {
549 1 : await this.mutateTree(tree => removeSessionFromTree(tree, sessionId));
550 1 : }
551 :
552 : /// Move a session to a position in a folder, without touching files. The index counts the sessions of
553 : /// the folder without the moved session. A session that isn't registered yet is placed as an OPFS
554 : /// session, its first write keeps the place.
555 1 : async moveSession(sessionId: string, folderPath: string[], index: number): Promise<void> {
556 1 : await this.mutateTree(tree => {
557 1 : if (folderAt(tree, folderPath) == null) {
558 1 : throw new Error(`There is no folder ${folderPath.join('/')}`);
559 : }
560 1 : return moveSessionInTree(tree, sessionId, folderPath, index);
561 1 : });
562 1 : }
563 :
564 : /// Create a folder, its parent must exist
565 1 : async createFolder(path: string[]): Promise<void> {
566 1 : await this.withTreeLock(async () => {
567 1 : const tree = await this.readTree();
568 1 : const name = path[path.length - 1];
569 1 : const parent = folderAt(tree, path.slice(0, -1));
570 1 : if (name == null || parent == null) {
571 1 : throw new Error(`There is no folder ${path.slice(0, -1).join('/')}`);
572 : }
573 1 : const error = folderNameError(name, parent.folders.map(folder => folder.name));
574 1 : if (error != null) throw new Error(error);
575 1 : await this.writeFolderFile(path, []);
576 1 : });
577 1 : }
578 :
579 : /// Move a folder into another folder under a name, its sessions and subfolders move with it.
580 : /// Renaming a folder moves it within its parent.
581 1 : async moveFolder(path: string[], parentPath: string[], name: string = path[path.length - 1] ?? ''): Promise<void> {
582 1 : await this.withTreeLock(async () => {
583 1 : const tree = await this.readTree();
584 1 : const parent = folderAt(tree, parentPath);
585 1 : if (path.length === 0 || folderAt(tree, path) == null) {
586 0 : throw new Error(`There is no folder ${path.join('/')}`);
587 : }
588 1 : if (parent == null) {
589 0 : throw new Error(`There is no folder ${parentPath.join('/')}`);
590 : }
591 1 : if (isFolderPathWithin(parentPath, path)) {
592 1 : throw new Error('A folder cannot move into itself');
593 : }
594 1 : const sameParent = sameFolderPath(parentPath, path.slice(0, -1));
595 1 : const oldName = path[path.length - 1];
596 1 : if (sameParent && name === oldName) return;
597 1 : const siblings = parent.folders.map(folder => folder.name)
598 1 : .filter(sibling => !(sameParent && sibling === oldName));
599 1 : const error = folderNameError(name, siblings);
600 1 : if (error != null) throw new Error(error);
601 1 : if (sameParent && name.toLowerCase() === oldName.toLowerCase()) {
602 : // A file system that ignores the case would copy the folder onto itself, the move takes a detour
603 1 : const detour = `${name}.${crypto.randomUUID()}`;
604 1 : await this.copyFolderUnlocked(path, parentPath, detour);
605 1 : await this.copyFolderUnlocked([...parentPath, detour], parentPath, name);
606 1 : return;
607 1 : }
608 1 : await this.copyFolderUnlocked(path, parentPath, name);
609 1 : });
610 1 : }
611 :
612 : /// Delete a folder. Its sessions and the sessions of its subfolders move to the parent folder, no
613 : /// session is deleted.
614 1 : async deleteFolder(path: string[]): Promise<void> {
615 1 : await this.withTreeLock(async () => {
616 1 : const tree = dropDuplicateSessions(await this.readTree());
617 1 : const folder = folderAt(tree, path);
618 1 : if (path.length === 0 || folder == null) {
619 0 : throw new Error(`There is no folder ${path.join('/')}`);
620 : }
621 1 : const parentPath = path.slice(0, -1);
622 1 : const parent = folderAt(tree, parentPath)!;
623 : // The parent registers the sessions before the folder goes away, an interruption leaves duplicates
624 1 : const moved = flattenSessionTree(folder);
625 1 : if (moved.length > 0) {
626 1 : await this.writeFolderFile(parentPath, [...parent.sessions, ...moved]);
627 : }
628 1 : const parentDir = await this.folderDir(parentPath, false);
629 1 : await parentDir.removeEntry(path[path.length - 1], { recursive: true });
630 1 : });
631 1 : }
632 :
633 : /// Read the session tree as it is stored, duplicates included
634 1 : private async readTree(): Promise<SessionTreeFolder> {
635 1 : const root = this.ensureInitialized();
636 1 : try {
637 1 : const dir = await root.getDirectoryHandle(STORAGE_SESSION_TREE_FOLDER, { create: false });
638 1 : return await readSessionTreeDirectory(dir);
639 1 : } catch (error) {
640 1 : if ((error as DOMException).name === 'NotFoundError') return emptySessionTree();
641 1 : throw error;
642 1 : }
643 1 : }
644 :
645 : /// Change the sessions of the tree exclusively, only the folder files that change are written.
646 : /// The folders that gain sessions are written first, an interruption duplicates a session instead of
647 : /// losing it, and the next change drops the duplicates.
648 1 : private async mutateTree(fn: (tree: SessionTreeFolder) => SessionTreeFolder): Promise<void> {
649 1 : await this.withTreeLock(async () => {
650 1 : const stored = await this.readTree();
651 1 : const next = fn(dropDuplicateSessions(stored));
652 1 : const before = new Map(listFolders(stored).map(folder => [folderPathKey(folder.path), folder.sessions]));
653 1 : const changed = listFolders(next).filter(folder => {
654 1 : const sessions = before.get(folderPathKey(folder.path));
655 1 : return sessions == null || JSON.stringify(sessions) !== JSON.stringify(folder.sessions);
656 1 : });
657 1 : changed.sort((left, right) => (right.sessions.length - (before.get(folderPathKey(right.path))?.length ?? 0))
658 1 : - (left.sessions.length - (before.get(folderPathKey(left.path))?.length ?? 0)));
659 1 : for (const folder of changed) {
660 1 : await this.writeFolderFile(folder.path, folder.sessions);
661 : }
662 1 : });
663 1 : }
664 :
665 : /// The directory of a folder of the session tree
666 1 : private async folderDir(path: readonly string[], create: boolean): Promise<FileSystemDirectoryHandle> {
667 1 : let dir = await this.ensureInitialized().getDirectoryHandle(STORAGE_SESSION_TREE_FOLDER, { create });
668 1 : for (const name of path) {
669 1 : dir = await dir.getDirectoryHandle(name, { create });
670 : }
671 1 : return dir;
672 1 : }
673 :
674 : /// Write the folder file of a folder, the folder is created when missing
675 1 : private async writeFolderFile(path: readonly string[], sessions: SessionEntry[]): Promise<void> {
676 1 : const dir = await this.folderDir(path, true);
677 1 : const writable = await (await dir.getFileHandle(STORAGE_FOLDER_FILE, { create: true })).createWritable();
678 1 : await writable.write(JSON.stringify({ sessions }, null, 2));
679 1 : await writable.close();
680 1 : }
681 :
682 : /// Copy a folder into a parent under a name, then remove the original. The copy comes first, an
683 : /// interruption leaves both folders and the sessions are listed where they come first.
684 : /// The tree holds small folder files only, so a copy is cheap and works in every browser.
685 1 : private async copyFolderUnlocked(path: readonly string[], parentPath: readonly string[], name: string): Promise<void> {
686 1 : const source = await this.folderDir(path, false);
687 1 : const target = await (await this.folderDir(parentPath, true)).getDirectoryHandle(name, { create: true });
688 1 : await copyDirectory(source, target);
689 1 : const sourceParent = await this.folderDir(path.slice(0, -1), false);
690 1 : await sourceParent.removeEntry(path[path.length - 1], { recursive: true });
691 1 : }
692 :
693 1 : private async getSessionDir(
694 1 : relativePath: string,
695 1 : create: boolean
696 1 : ): Promise<FileSystemDirectoryHandle> {
697 1 : const root = this.ensureInitialized();
698 1 : const parts = relativePath.split('/');
699 1 : let currentDir = root;
700 1 : let accumulated = '';
701 1 : for (const part of parts) {
702 1 : if (part) {
703 1 : accumulated = accumulated ? `${accumulated}/${part}` : part;
704 1 : try {
705 1 : currentDir = await currentDir.getDirectoryHandle(part, { create });
706 1 : } catch (error) {
707 1 : if ((error as any).name === 'NotFoundError') {
708 1 : throw new Error(`Directory not found: opfs://${accumulated}`);
709 : }
710 1 : throw error;
711 1 : }
712 1 : }
713 : }
714 1 : return currentDir;
715 1 : }
716 1 : }
717 :
718 : /// Copy the files and directories of a directory into another one
719 1 : async function copyDirectory(source: FileSystemDirectoryHandle, target: FileSystemDirectoryHandle): Promise<void> {
720 1 : for await (const [name, handle] of source.entries()) {
721 1 : if (handle.kind === 'directory') {
722 1 : await copyDirectory(handle as FileSystemDirectoryHandle, await target.getDirectoryHandle(name, { create: true }));
723 1 : continue;
724 1 : }
725 1 : const file = await (handle as FileSystemFileHandle).getFile();
726 1 : const writable = await (await target.getFileHandle(name, { create: true })).createWritable();
727 1 : await writable.write(file);
728 1 : await writable.close();
729 1 : }
730 1 : }
|