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

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

Generated by: LCOV version 2.0-1