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