Line data Source code
1 : import { type StorageBackend, type SessionData, type ScriptData, type SessionEntry, type AppSettings, StorageBackendType, STORAGE_SESSION_FILE, STORAGE_SCRIPTS_FOLDER } from './storage_backend.js';
2 : import { type DataFile, type DataFileContent, ExternalScriptChangeError, STORAGE_ATTACHMENTS_FOLDER, isAttachmentFileName, validateDataFileName } from './storage_backend.js';
3 :
4 : import { exists, fileStamp, join, listFiles, mkdir, readDir, readFile, readTextFile, remove, rename, writeFile, writeTextFile, type FileStamp } from '../platform/electron_fs.js';
5 :
6 : /// The name of the session-level .gitignore that keeps generated files out of version control.
7 1 : const GITIGNORE_FILE = '.gitignore';
8 : /// The entries of the session-level .gitignore: the former query result cache of the session and Hyper database files.
9 1 : const GITIGNORE_ENTRIES = ['cache/', '*.hyper'];
10 :
11 : /// A file of a session folder with its stamp
12 : export type NamedFileStamp = FileStamp & { name: string };
13 :
14 : /// The scripts and attachments of a session folder with their stamps
15 : export interface SessionFolderStamps {
16 : scripts: NamedFileStamp[];
17 : attachments: NamedFileStamp[];
18 : }
19 :
20 1 : function sameStamp(a: FileStamp | null, b: FileStamp | null): boolean {
21 1 : return a == null || b == null ? a == b : a.size === b.size && a.mtimeMs === b.mtimeMs;
22 1 : }
23 :
24 : /// Native filesystem storage backend for a single Electron session.
25 : ///
26 : /// One directory holds exactly one session. Unlike OPFS, there is no `sessions/<uuid>` nesting and
27 : /// no manifest file in the directory: the session's files (`dashql-session.json`,
28 : /// `dashql-catalog-relations.sql`, `dashql-catalog-functions.sql`, `scripts/…`) are written *directly* under the
29 : /// configured directory. The session UUID passed to each method identifies the session for the
30 : /// caller's routing, but does not affect the on-disk layout (the directory already is the session).
31 : ///
32 : /// The session *registry* (which sessions exist, and where each lives) is owned by the OPFS root
33 : /// manifest, not here. The registry-level methods on this backend are therefore inert; the
34 : /// composite backend always routes those to OPFS.
35 1 : export class NativeStorageBackend implements StorageBackend {
36 : /// The absolute directory on disk that holds this session's files
37 1 : private readonly dir: string;
38 : /// Whether the .gitignore was already checked by this backend
39 1 : private gitignoreChecked = false;
40 : /// The stamps of the scripts as this backend last wrote or acknowledged them, null for a deleted script
41 1 : private readonly scriptStamps = new Map<string, FileStamp | null>();
42 : /// Were the scripts acknowledged? From then on a script without a stamp is known to be absent, and
43 : /// script writes fail with an `ExternalScriptChangeError` when the stamp on disk differs.
44 1 : private scriptsAcknowledged = false;
45 :
46 1 : constructor(dir: string) {
47 1 : this.dir = dir;
48 1 : }
49 :
50 0 : getBackendType(): StorageBackendType {
51 0 : return StorageBackendType.Native;
52 0 : }
53 :
54 : /// The absolute directory backing this session
55 1 : getDir(): string {
56 1 : return this.dir;
57 1 : }
58 :
59 1 : async initialize(): Promise<void> {
60 1 : if (!(await exists(this.dir))) {
61 1 : await mkdir(this.dir, { recursive: true });
62 : }
63 1 : }
64 :
65 : /// Resolve a relative storage path against the absolute directory using OS-correct separators
66 1 : private async abs(relative: string): Promise<string> {
67 1 : const parts = relative.split('/').filter(p => p.length > 0);
68 1 : return await join(this.dir, ...parts);
69 1 : }
70 :
71 : /// Ensure a directory (given as a relative path) exists
72 1 : private async ensureDir(relative: string): Promise<void> {
73 1 : const dir = relative.length > 0 ? await this.abs(relative) : this.dir;
74 1 : if (!(await exists(dir))) {
75 1 : await mkdir(dir, { recursive: true });
76 : }
77 1 : }
78 :
79 : /// Natural sort for strings with numeric components (e.g., "page-1" < "page-2" < "page-10")
80 1 : private naturalSort(a: string, b: string): number {
81 1 : return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
82 1 : }
83 :
84 : // ---- Registry-level operations (owned by OPFS; inert here) -------------------------------
85 :
86 0 : async listSessions(): Promise<SessionEntry[]> {
87 : // The registry lives in the OPFS session tree, never in a native session directory.
88 0 : return [];
89 0 : }
90 :
91 0 : async loadAppSettings(): Promise<AppSettings | null> {
92 0 : return null;
93 0 : }
94 :
95 0 : async saveAppSettings(_settings: AppSettings): Promise<void> {
96 : // No-op: app settings live on the OPFS root manifest.
97 0 : }
98 :
99 : // ---- Per-session operations ---------------------------------------------------------------
100 :
101 : /// Read a file of the session folder, null if it does not exist
102 1 : private async readSessionFile(fileName: string): Promise<string | null> {
103 1 : const path = await this.abs(fileName);
104 1 : return await exists(path) ? await readTextFile(path) : null;
105 1 : }
106 :
107 1 : async loadSession(sessionId: string): Promise<SessionData> {
108 1 : const text = await this.readSessionFile(STORAGE_SESSION_FILE);
109 1 : if (text == null) {
110 0 : throw new Error(`Missing ${STORAGE_SESSION_FILE} in ${this.dir}`);
111 : }
112 1 : return JSON.parse(text) as SessionData;
113 1 : }
114 :
115 : /// Ensure the session-level .gitignore holds our entries. Missing entries are appended, so a
116 : /// user-authored .gitignore keeps its own lines and folders from older versions get new entries.
117 1 : private async ensureGitignore(): Promise<void> {
118 1 : if (this.gitignoreChecked) return;
119 1 : const file = await this.abs(GITIGNORE_FILE);
120 1 : const existing = (await exists(file)) ? await readTextFile(file) : '';
121 1 : const lines = new Set(existing.split(/\r?\n/).map(line => line.trim()));
122 1 : const missing = GITIGNORE_ENTRIES.filter(entry => !lines.has(entry));
123 1 : if (missing.length > 0) {
124 1 : const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
125 1 : await writeTextFile(file, `${existing}${separator}${missing.join('\n')}\n`);
126 1 : }
127 1 : this.gitignoreChecked = true;
128 1 : }
129 :
130 1 : async saveSessionManifest(_sessionId: string, data: SessionData): Promise<void> {
131 1 : await this.ensureDir('');
132 : // Keep generated files out of version control as soon as the session folder is written
133 1 : await this.ensureGitignore();
134 1 : const metaFile = await this.abs(STORAGE_SESSION_FILE);
135 1 : await writeTextFile(metaFile, JSON.stringify(data, null, 2));
136 1 : }
137 :
138 :
139 :
140 1 : async deleteSession(_sessionId: string): Promise<void> {
141 : // No-op: a native session lives in a user-owned folder on disk. Deleting the session in
142 : // dashql only unregisters it — the registry entry lives in the OPFS root manifest and is
143 : // dropped by the composite backend. The files on disk are deliberately left intact so the
144 : // folder can be re-loaded later (and so we never destroy data the user put there).
145 1 : }
146 :
147 1 : async loadScripts(_sessionId: string): Promise<ScriptData[]> {
148 1 : const scriptsDir = await this.abs(STORAGE_SCRIPTS_FOLDER);
149 1 : if (!(await exists(scriptsDir))) {
150 0 : return [];
151 : }
152 :
153 1 : const entries = await readDir(scriptsDir);
154 1 : const scripts: ScriptData[] = [];
155 1 : for (const entry of entries) {
156 1 : if (entry.isDirectory) {
157 1 : throw new Error(`Invalid V2 session layout: nested scripts directory ${entry.name}`);
158 : }
159 1 : if (entry.isFile && entry.name.toLowerCase() === 'dashql-draft.sql') {
160 1 : throw new Error('Invalid V2 session layout: dashql-draft.sql is not supported');
161 : }
162 1 : if (entry.isFile && entry.name.toLowerCase().endsWith('.sql')) {
163 1 : const sql = await readTextFile(await this.abs(`${STORAGE_SCRIPTS_FOLDER}/${entry.name}`));
164 1 : scripts.push({ name: entry.name, sql });
165 1 : }
166 1 : }
167 1 : scripts.sort((a, b) => this.naturalSort(a.name, b.name));
168 1 : return scripts;
169 1 : }
170 :
171 1 : async loadScript(sessionId: string, scriptName: string): Promise<ScriptData> {
172 1 : const scriptFile = await this.abs(`${STORAGE_SCRIPTS_FOLDER}/${scriptName}`);
173 1 : if (!(await exists(scriptFile))) {
174 0 : throw new Error(`Script not found: session ${sessionId}, script ${scriptName}`);
175 : }
176 1 : const sql = await readTextFile(scriptFile);
177 1 : return { name: scriptName, sql };
178 1 : }
179 :
180 1 : async saveScript(
181 1 : sessionId: string,
182 1 : scriptName: string,
183 1 : sql: string
184 1 : ): Promise<void> {
185 1 : await this.ensureDir(STORAGE_SCRIPTS_FOLDER);
186 1 : const scriptFile = await this.abs(`${STORAGE_SCRIPTS_FOLDER}/${scriptName}`);
187 1 : await this.checkScriptUnchanged(sessionId, scriptName, scriptFile);
188 1 : this.scriptStamps.set(scriptName, await writeTextFile(scriptFile, sql));
189 1 : }
190 :
191 1 : async deleteScript(sessionId: string, scriptName: string): Promise<void> {
192 1 : const scriptFile = await this.abs(`${STORAGE_SCRIPTS_FOLDER}/${scriptName}`);
193 1 : await this.checkScriptUnchanged(sessionId, scriptName, scriptFile);
194 1 : if (await exists(scriptFile)) {
195 1 : await remove(scriptFile);
196 : }
197 1 : this.scriptStamps.set(scriptName, null);
198 1 : }
199 :
200 1 : async renameScript(sessionId: string, oldScriptName: string, newScriptName: string): Promise<void> {
201 1 : if (oldScriptName === newScriptName) {
202 0 : return;
203 : }
204 1 : const oldFile = await this.abs(`${STORAGE_SCRIPTS_FOLDER}/${oldScriptName}`);
205 1 : const newFile = await this.abs(`${STORAGE_SCRIPTS_FOLDER}/${newScriptName}`);
206 : // The rename must neither move an external version nor replace an external file
207 1 : await this.checkScriptUnchanged(sessionId, oldScriptName, oldFile);
208 1 : await this.checkScriptUnchanged(sessionId, newScriptName, newFile);
209 : // A missing source means the script hasn't been flushed yet; the pending write under the new
210 : // name will create it, so there is nothing to move here.
211 1 : if (!(await exists(oldFile))) {
212 0 : return;
213 : }
214 : // Atomic file rename. The new clean base is disambiguated unique within the folder, so the
215 : // destination is free. The file keeps its stamp.
216 1 : await rename(oldFile, newFile);
217 1 : this.scriptStamps.set(newScriptName, this.scriptStamps.get(oldScriptName) ?? null);
218 1 : this.scriptStamps.set(oldScriptName, null);
219 1 : }
220 :
221 : /// The stamps of the scripts and attachments, read from two directory listings without reading a file
222 1 : async stampFolder(): Promise<SessionFolderStamps> {
223 1 : const [scripts, attachments] = await Promise.all([
224 1 : listFiles(await this.abs(STORAGE_SCRIPTS_FOLDER)),
225 1 : listFiles(await this.abs(STORAGE_ATTACHMENTS_FOLDER)),
226 1 : ]);
227 1 : return { scripts: scripts.filter(file => file.name.toLowerCase().endsWith('.sql')), attachments: attachments.filter(file => isAttachmentFileName(file.name)) };
228 1 : }
229 :
230 : /// Acknowledge the scripts as they are on disk, script writes check the disk from now on
231 1 : acknowledgeScripts(scripts: NamedFileStamp[]): void {
232 1 : this.scriptStamps.clear();
233 1 : for (const script of scripts) {
234 1 : this.scriptStamps.set(script.name, { size: script.size, mtimeMs: script.mtimeMs });
235 : }
236 1 : this.scriptsAcknowledged = true;
237 1 : }
238 :
239 : /// The scripts whose stamps differ from what this backend wrote or acknowledged, sorted by name.
240 : /// Every script differs before the scripts were acknowledged.
241 1 : findExternalScriptChanges(scripts: NamedFileStamp[]): string[] {
242 1 : const disk = new Map(scripts.map(script => [script.name, script]));
243 1 : const names = new Set(disk.keys());
244 1 : for (const [name, stamp] of this.scriptStamps) {
245 1 : if (stamp != null) names.add(name);
246 : }
247 1 : if (!this.scriptsAcknowledged) {
248 1 : return [...names].sort();
249 : }
250 1 : return [...names].filter(name => !sameStamp(disk.get(name) ?? null, this.scriptStamps.get(name) ?? null)).sort();
251 1 : }
252 :
253 : /// Fail with an `ExternalScriptChangeError` when an acknowledged script changed on disk
254 1 : private async checkScriptUnchanged(sessionId: string, scriptName: string, scriptFile: string): Promise<void> {
255 1 : if (this.scriptsAcknowledged && !sameStamp(await fileStamp(scriptFile), this.scriptStamps.get(scriptName) ?? null)) {
256 1 : throw new ExternalScriptChangeError(sessionId, scriptName);
257 : }
258 1 : }
259 :
260 1 : async listDataFiles(_sessionId: string): Promise<DataFile[]> {
261 1 : const dir = await this.abs(STORAGE_ATTACHMENTS_FOLDER);
262 1 : if (!(await exists(dir))) return [];
263 1 : const files: DataFile[] = [];
264 1 : for (const entry of await readDir(dir)) {
265 1 : if (entry.isDirectory) throw new Error(`Invalid session layout: nested data directory ${entry.name}`);
266 : // Other files in the folder are no attachments
267 1 : if (entry.isFile && isAttachmentFileName(entry.name)) {
268 1 : validateDataFileName(entry.name);
269 1 : const stamp = await fileStamp(await this.abs(`${STORAGE_ATTACHMENTS_FOLDER}/${entry.name}`));
270 1 : if (stamp != null) files.push({ name: entry.name, sizeBytes: stamp.size, mtimeMs: stamp.mtimeMs });
271 1 : }
272 1 : }
273 1 : return files.sort((a, b) => this.naturalSort(a.name, b.name));
274 1 : }
275 :
276 1 : async loadDataFile(_sessionId: string, name: string): Promise<Uint8Array> {
277 1 : validateDataFileName(name);
278 1 : return await readFile(await this.abs(`${STORAGE_ATTACHMENTS_FOLDER}/${name}`));
279 1 : }
280 :
281 1 : async saveDataFile(sessionId: string, name: string, content: DataFileContent): Promise<void> {
282 1 : validateDataFileName(name);
283 1 : await this.ensureDir(STORAGE_ATTACHMENTS_FOLDER);
284 1 : const bytes = content instanceof Blob ? new Uint8Array(await content.arrayBuffer()) : content;
285 1 : await writeFile(await this.abs(`${STORAGE_ATTACHMENTS_FOLDER}/${name}`), bytes);
286 1 : }
287 :
288 1 : async deleteDataFile(sessionId: string, name: string): Promise<void> {
289 1 : validateDataFileName(name);
290 1 : const path = await this.abs(`${STORAGE_ATTACHMENTS_FOLDER}/${name}`);
291 1 : if (!(await exists(path))) return;
292 1 : await remove(path);
293 1 : }
294 :
295 1 : async renameDataFile(sessionId: string, oldName: string, newName: string): Promise<void> {
296 1 : validateDataFileName(oldName);
297 1 : validateDataFileName(newName);
298 1 : if (oldName === newName) return;
299 1 : const oldPath = await this.abs(`${STORAGE_ATTACHMENTS_FOLDER}/${oldName}`);
300 1 : if (!(await exists(oldPath))) return;
301 1 : await rename(oldPath, await this.abs(`${STORAGE_ATTACHMENTS_FOLDER}/${newName}`));
302 1 : }
303 :
304 0 : async clearAllStorage(): Promise<void> {
305 : // No-op: like deleteSession, this never touches the user-owned folder on disk. "Clear all
306 : // storage" only resets the OPFS root (registry + OPFS-backed sessions); native sessions are
307 : // simply unregistered when the manifest is wiped, and their files stay put on disk.
308 0 : }
309 1 : }
|