LCOV - code coverage report
Current view: top level - dashql-app/src/storage - opfs_storage_backend.ts Coverage Total Hit
Test: DashQL 915828b Lines: 92.8 % 525 487
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1