LCOV - code coverage report
Current view: top level - dashql-app/src/platform/hyperdb - hyperdb_opfs_instance.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 100 100
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import { getOPFSRoot, hyperOPFSRoot } from '../opfs_root.js';
       2              : 
       3              : /// The OPFS folder with one folder per embedded database instance, e.g. `instances/<uuid>/compute.hyper`
       4            1 : export const HYPER_INSTANCES_DIRECTORY = 'instances';
       5              : /// The folder of the Hyper log files in an instance folder
       6            1 : export const HYPER_INSTANCE_LOGS_DIRECTORY = 'logs';
       7              : /// The prefix of the Web Lock that marks an instance as alive, followed by the instance id
       8            1 : export const HYPER_INSTANCE_LOCK_PREFIX = 'dashql-instance-';
       9              : /// The instance folders of app versions before the instances folder sit in the OPFS root, with a lock of the same name
      10            1 : export const HYPER_LEGACY_INSTANCE_PREFIX = 'dashql-session-';
      11              : /// The default log folder of Hyper in the OPFS root, which app versions before the instance log folders filled
      12            1 : export const HYPER_LEGACY_LOGS_DIRECTORY = 'logs';
      13              : 
      14              : /// The database files of an embedded database instance, which lives as long as its app window.
      15              : /// A Web Lock marks the instance as alive, the folders of instances without a lock are stale.
      16              : export interface HyperDBOPFSInstance {
      17              :     /// The Hyper path of the instance folder
      18              :     readonly directoryPath: string;
      19              :     /// The Hyper path of the compute database with the data frames
      20              :     readonly computeDatabasePath: string;
      21              :     /// The Hyper path of the user database of the embedded Hyper connector
      22              :     readonly userDatabasePath: string;
      23              :     /// The Hyper path of the database of the snippet search
      24              :     readonly snippetsDatabasePath: string;
      25              :     /// The Hyper path of the folder of the Hyper log files, the setting `log_file_path` of the instance
      26              :     readonly logDirectoryPath: string;
      27              :     /// Remove the files of the instance and release its lock
      28              :     dispose(): Promise<void>;
      29              : }
      30              : 
      31            1 : export async function createHyperDBOPFSInstance(
      32            1 :     getRoot: () => Promise<FileSystemDirectoryHandle> = getOPFSRoot,
      33            1 : ): Promise<HyperDBOPFSInstance> {
      34            1 :     const id = crypto.randomUUID();
      35            1 :     const release = await holdLock(`${HYPER_INSTANCE_LOCK_PREFIX}${id}`);
      36            1 :     try {
      37            1 :         const instances = await (await getRoot()).getDirectoryHandle(HYPER_INSTANCES_DIRECTORY, { create: true });
      38            1 :         const instance = await instances.getDirectoryHandle(id, { create: true });
      39            1 :         await instance.getDirectoryHandle(HYPER_INSTANCE_LOGS_DIRECTORY, { create: true });
      40            1 :         const folder = `${hyperOPFSRoot()}/${HYPER_INSTANCES_DIRECTORY}/${id}`;
      41            1 :         let disposed = false;
      42            1 :         return {
      43            1 :             directoryPath: folder,
      44            1 :             computeDatabasePath: `${folder}/compute.hyper`,
      45            1 :             userDatabasePath: `${folder}/user.hyper`,
      46            1 :             snippetsDatabasePath: `${folder}/snippets.hyper`,
      47            1 :             logDirectoryPath: `${folder}/${HYPER_INSTANCE_LOGS_DIRECTORY}`,
      48            1 :             async dispose(): Promise<void> {
      49            1 :                 if (disposed) return;
      50            1 :                 disposed = true;
      51            1 :                 try {
      52            1 :                     await instances.removeEntry(id, { recursive: true });
      53            1 :                 } finally {
      54            1 :                     release();
      55            1 :                 }
      56            1 :             },
      57            1 :         };
      58            1 :     } catch (error) {
      59            1 :         release();
      60            1 :         throw error;
      61            1 :     }
      62            1 : }
      63              : 
      64              : /// Remove the folders of the instances whose app window is gone, including the instance folders and the log
      65              : /// files of earlier app versions.
      66            1 : export async function cleanupStaleHyperDBOPFSInstances(
      67            1 :     getRoot: () => Promise<FileSystemDirectoryHandle> = getOPFSRoot,
      68            1 : ): Promise<void> {
      69            1 :     if (navigator.locks == null) return;
      70            1 :     const root = await getRoot();
      71            1 :     let instances: FileSystemDirectoryHandle | null = null;
      72            1 :     try {
      73            1 :         instances = await root.getDirectoryHandle(HYPER_INSTANCES_DIRECTORY, { create: false });
      74            1 :     } catch (error) {
      75            1 :         if ((error as DOMException).name !== 'NotFoundError') throw error;
      76            1 :     }
      77            1 :     if (instances != null) {
      78            1 :         for await (const [name, handle] of instances.entries()) {
      79            1 :             if (handle.kind === 'directory' && isUuid(name)) await removeUnlocked(instances, name, `${HYPER_INSTANCE_LOCK_PREFIX}${name}`);
      80              :         }
      81              :     }
      82            1 :     for await (const [name, handle] of root.entries()) {
      83            1 :         if (handle.kind === 'directory' && name.startsWith(HYPER_LEGACY_INSTANCE_PREFIX) && isUuid(name.slice(HYPER_LEGACY_INSTANCE_PREFIX.length))) {
      84            1 :             await removeUnlocked(root, name, name);
      85              :         }
      86              :     }
      87              :     // The log folders of earlier app versions have no lock, the processes of open windows keep their files open.
      88            1 :     let logs: FileSystemDirectoryHandle | null = null;
      89            1 :     try {
      90            1 :         logs = await root.getDirectoryHandle(HYPER_LEGACY_LOGS_DIRECTORY, { create: false });
      91            1 :     } catch (error) {
      92            1 :         if ((error as DOMException).name !== 'NotFoundError') throw error;
      93            1 :     }
      94            1 :     if (logs != null) {
      95            1 :         for await (const [name, handle] of logs.entries()) {
      96            1 :             if (handle.kind === 'directory' && isUuid(name)) await removeOpenTolerant(logs, name);
      97              :         }
      98              :     }
      99            1 : }
     100              : 
     101              : /// Remove a folder unless its lock is held.
     102            1 : async function removeUnlocked(parent: FileSystemDirectoryHandle, name: string, lockName: string): Promise<void> {
     103            1 :     const failure = await navigator.locks.request(lockName, { ifAvailable: true }, async lock => {
     104            1 :         if (lock == null) return;
     105            1 :         try {
     106            1 :             await removeOpenTolerant(parent, name);
     107            1 :         } catch (error) {
     108            1 :             return { error };
     109            1 :         }
     110            1 :     });
     111            1 :     if (failure != null) throw failure.error;
     112            1 : }
     113              : 
     114              : /// Remove a folder, a folder with files that are still open is left for a later cleanup.
     115            1 : async function removeOpenTolerant(parent: FileSystemDirectoryHandle, name: string): Promise<void> {
     116            1 :     try {
     117            1 :         await parent.removeEntry(name, { recursive: true });
     118            1 :     } catch (error) {
     119              :         // Browsers report locked OPFS handles with either modification error while workers terminate.
     120            1 :         const errorName = (error as DOMException).name;
     121            1 :         if (errorName !== 'InvalidModificationError' && errorName !== 'NoModificationAllowedError'
     122            1 :             && errorName !== 'NotFoundError') throw error;
     123            1 :     }
     124            1 : }
     125              : 
     126            1 : async function holdLock(name: string): Promise<() => void> {
     127            1 :     if (navigator.locks == null) return () => {};
     128            1 :     let acquired!: () => void;
     129            1 :     const acquiredLock = new Promise<void>(resolve => { acquired = resolve; });
     130            1 :     let release!: () => void;
     131            1 :     const held = new Promise<void>(resolve => { release = resolve; });
     132            1 :     void navigator.locks.request(name, async () => {
     133            1 :         acquired();
     134            1 :         await held;
     135            1 :     });
     136            1 :     await acquiredLock;
     137            1 :     return release;
     138            1 : }
     139              : 
     140              : /// Is the value a UUID, e.g. the id of an instance?
     141            1 : export function isUuid(value: string): boolean {
     142            1 :     return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
     143            1 : }
        

Generated by: LCOV version 2.0-1