LCOV - code coverage report
Current view: top level - dashql-app/src/storage - storage_backend.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 100.0 % 42 42
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import type * as app_manifest from '@ankoh/dashql-jsonschema/app_manifest.js';
       2              : import type * as app_session from '@ankoh/dashql-jsonschema/app_session.js';
       3              : import type { SessionEntry as SessionTreeEntry, SessionTreeFolder } from './session_tree.js';
       4              : 
       5              : import { hyperOPFSRoot } from '../platform/opfs_root.js';
       6              : 
       7              : // Storage file and folder naming conventions
       8            1 : export const STORAGE_MANIFEST_FILE = 'dashql-manifest.json';
       9            1 : export const STORAGE_SESSIONS_FOLDER = 'sessions';
      10            1 : export const STORAGE_SESSION_FILE = 'dashql-session.json';
      11            1 : export const STORAGE_SCRIPTS_FOLDER = 'scripts';
      12            1 : export const STORAGE_ATTACHMENTS_FOLDER = 'attachments';
      13            1 : export const STORAGE_SHELL_FOLDER = 'shell';
      14              : /// The folder that holds the files of the connections, e.g. `connections/<uuid>/…`
      15            1 : export const STORAGE_CONNECTIONS_FOLDER = 'connections';
      16              : /// The monitoring database of a connection with the extract of its Hyper log, e.g. `connections/<uuid>/monitoring.hyper`.
      17              : /// It is deleted with the files of the connection, and it is not part of session bundles.
      18            1 : export const STORAGE_CONNECTION_MONITORING_FILE = 'monitoring.hyper';
      19              : 
      20              : /// The Hyper path of the monitoring database of a connection, its folder must exist, see createConnectionFolder()
      21            1 : export function connectionMonitoringDatabasePath(connectionId: string): string {
      22            1 :     return `${hyperOPFSRoot()}/${STORAGE_CONNECTIONS_FOLDER}/${connectionId}/${STORAGE_CONNECTION_MONITORING_FILE}`;
      23            1 : }
      24              : 
      25              : /// The relations of the catalog, as the last catalog refresh described them
      26            1 : export const STORAGE_CATALOG_RELATIONS = 'dashql-catalog-relations.sql';
      27              : /// The functions of the catalog
      28            1 : export const STORAGE_CATALOG_FUNCTIONS = 'dashql-catalog-functions.sql';
      29              : /// What the relations of a connection don't say: the relations to ask the connection about and those that are gone
      30            1 : export const STORAGE_CATALOG_STATE = 'dashql-catalog-state.json';
      31              : /// Older versions of the app kept the changes of executed statements here, the file is cleared when it is found
      32            1 : export const STORAGE_CATALOG_CHANGES = 'dashql-catalog-changes.sql';
      33              : export const STORAGE_SCRIPT_EXTENSION = '.sql';
      34              : 
      35              : export interface DataFile {
      36              :     name: string;
      37              :     sizeBytes: number;
      38              :     /// The time of the last modification in milliseconds since the epoch, 0 if the backend doesn't know it
      39              :     mtimeMs?: number;
      40              : }
      41              : 
      42              : export interface DataFileData {
      43              :     name: string;
      44              :     bytes: Uint8Array;
      45              : }
      46              : 
      47              : /// Browser-originated attachments stay as Blobs until OPFS consumes them, avoiding a renderer-memory
      48              : /// copy for large picker/drop files. Other persistence paths use bytes.
      49              : export type DataFileContent = Uint8Array | Blob;
      50              : 
      51              : /// The extension of attachments: a session attaches Parquet files only, scripts read them as table parameters
      52            1 : export const ATTACHMENT_EXTENSION = '.parquet';
      53              : 
      54              : /// Is a file name the name of an attachment, a Parquet file? Listings leave other files out.
      55            1 : export function isAttachmentFileName(name: string): boolean {
      56            1 :     return name.length > ATTACHMENT_EXTENSION.length && name.toLowerCase().endsWith(ATTACHMENT_EXTENSION);
      57            1 : }
      58              : 
      59            1 : export function validateDataFileName(name: string): void {
      60            1 :     if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) {
      61            1 :         throw new Error(`Invalid data file basename: ${name}`);
      62              :     }
      63            1 :     if (!isAttachmentFileName(name)) {
      64            1 :         throw new Error(`Only Parquet files can be attached: ${name}`);
      65              :     }
      66            1 : }
      67              : 
      68              : // Re-export JSON Schema types
      69              : export type SessionEntry = SessionTreeEntry;
      70              : export type StorageManifest = app_manifest.StorageManifest;
      71              : export type AppSettings = app_manifest.AppSettings;
      72              : export type ConnectionEntry = app_manifest.ConnectionEntry;
      73              : export type SessionData = app_session.SessionData;
      74              : export type SessionMetadata = app_session.SessionMetadata;
      75              : export type SessionScript = app_session.SessionScript;
      76              : export type SessionAttachment = app_session.SessionAttachment;
      77              : export type SessionConnection = app_session.SessionConnection;
      78              : export type ConnectionParams = SessionConnection['params'];
      79              : 
      80              : /// The kind of filesystem backend
      81            1 : export enum StorageBackendType {
      82              :     /// Origin Private File System (browser-only)
      83            1 :     OPFS = 'opfs',
      84              :     /// Native filesystem (Electron only)
      85            1 :     Native = 'native',
      86            1 : }
      87              : 
      88              : /// Storage interface for DashQL.
      89              : ///
      90              : /// Identity model: the session UUID is authoritative. Every per-session method is keyed by the bare
      91              : /// `sessionId` (the UUID). Backends translate that UUID into their own physical layout:
      92              : ///   - OPFS writes `sessions/<uuid>/…` under the OPFS root.
      93              : ///   - The native backend is constructed for a single directory and writes that session's files
      94              : ///     directly into it.
      95              : /// There is no storage-prefix concept on this interface anymore; any prefix is purely for display
      96              : /// and lives in `session_locator.ts`.
      97              : export interface StorageBackend {
      98              :     /// Get the backend type
      99              :     getBackendType(): StorageBackendType;
     100              : 
     101              :     /// Initialize the storage backend (optional)
     102              :     initialize?(): Promise<void>;
     103              : 
     104              :     /// List all sessions in the order of the sidebar (registry-level)
     105              :     listSessions(): Promise<SessionEntry[]>;
     106              : 
     107              :     /// Load persisted app settings from the manifest (registry-level)
     108              :     loadAppSettings(): Promise<AppSettings | null>;
     109              :     /// Persist app settings to the manifest (registry-level)
     110              :     saveAppSettings(settings: AppSettings): Promise<void>;
     111              :     /// Load a session by UUID
     112              :     loadSession(sessionId: string): Promise<SessionData>;
     113              :     /// Save a session
     114              :     saveSessionManifest(sessionId: string, data: SessionData): Promise<void>;
     115              :     /// Delete a session
     116              :     deleteSession(sessionId: string): Promise<void>;
     117              : 
     118              :     /// Load the session's flat, naturally ordered script files. The order of the feed is the order
     119              :     /// of the scripts in the session file.
     120              :     loadScripts(sessionId: string): Promise<ScriptData[]>;
     121              :     /// Load a session script
     122              :     loadScript(sessionId: string, scriptName: string): Promise<ScriptData>;
     123              :     /// Save a session script
     124              :     saveScript(sessionId: string, scriptName: string, sql: string): Promise<void>;
     125              :     /// Delete a session script
     126              :     deleteScript(sessionId: string, scriptName: string): Promise<void>;
     127              :     /// Rename a script file, preserving its contents (no rewrite from memory). Callers must
     128              :     /// guarantee `oldScriptName` exists and `newScriptName` does not.
     129              :     renameScript(sessionId: string, oldScriptName: string, newScriptName: string): Promise<void>;
     130              : 
     131              :     listDataFiles(sessionId: string): Promise<DataFile[]>;
     132              :     loadDataFile(sessionId: string, name: string): Promise<Uint8Array>;
     133              :     saveDataFile(sessionId: string, name: string, content: DataFileContent): Promise<void>;
     134              :     deleteDataFile(sessionId: string, name: string): Promise<void>;
     135              :     renameDataFile(sessionId: string, oldName: string, newName: string): Promise<void>;
     136              : 
     137              :     /// Clear all storage (delete all sessions and reset manifest)
     138              :     clearAllStorage?(): Promise<void>;
     139              : }
     140              : 
     141              : /// A backend that also owns the session registry (the session tree) and the root manifest.
     142              : ///
     143              : /// Only the OPFS backend implements this: the OPFS session tree is the single registry of every
     144              : /// session, regardless of where each session's files physically live. The composite backend uses
     145              : /// these methods to keep the tree in sync when a session is relocated to a native directory
     146              : /// (the tree entry stays in OPFS; only the files move).
     147              : ///
     148              : /// The folders of the tree are paths of folder names from the root, the root is the empty path.
     149              : export interface SessionRegistryBackend extends StorageBackend {
     150              :     /// Load the session tree, the folders and sessions of the sidebar
     151              :     loadSessionTree(): Promise<SessionTreeFolder>;
     152              :     /// Insert or replace a session's registry entry (matched by UUID), without touching files.
     153              :     /// A registered session keeps its place, a new one is appended to the folder (the root by default).
     154              :     upsertSessionEntry(entry: SessionEntry, folderPath?: readonly string[]): Promise<void>;
     155              :     /// Remove a session's registry entry (matched by UUID), without touching files.
     156              :     removeSessionEntry(sessionId: string): Promise<void>;
     157              :     /// Move a session to a position in a folder, without touching files. The index counts the
     158              :     /// sessions of the folder without the moved one. An unregistered session is placed as an OPFS
     159              :     /// session.
     160              :     moveSession(sessionId: string, folderPath: string[], index: number): Promise<void>;
     161              :     /// Create a folder in an existing folder
     162              :     createFolder(path: string[]): Promise<void>;
     163              :     /// Move a folder into another folder under a name (its current name by default), renaming
     164              :     /// moves it within its parent
     165              :     moveFolder(path: string[], parentPath: string[], name?: string): Promise<void>;
     166              :     /// Delete a folder, its sessions move to the parent folder
     167              :     deleteFolder(path: string[]): Promise<void>;
     168              :     /// Delete a session's files only, leaving the registry entry intact.
     169              :     deleteSessionFiles(sessionId: string): Promise<void>;
     170              : 
     171              :     /// Load the whole manifest. A manifest without connections was written before connections were
     172              :     /// global, the `connections` field is then left absent.
     173              :     loadManifest(): Promise<StorageManifest>;
     174              :     /// Update the connections of the manifest exclusively.
     175              :     /// The update receives the current connections (undefined if the manifest has none yet) and
     176              :     /// returns the new connections, or null when nothing changed.
     177              :     updateConnections(update: (connections: ConnectionEntry[] | undefined) => ConnectionEntry[] | null): Promise<void>;
     178              :     /// Load a file of a connection (e.g. its catalog relations), null if it doesn't exist
     179              :     loadConnectionFile(connectionId: string, fileName: string): Promise<string | null>;
     180              :     /// Save a file of a connection
     181              :     saveConnectionFile(connectionId: string, fileName: string, text: string): Promise<void>;
     182              :     /// Create the folder of a connection when missing, e.g. for Hyper to create a database in it
     183              :     createConnectionFolder(connectionId: string): Promise<void>;
     184              :     /// Delete all files of a connection
     185              :     deleteConnectionFiles(connectionId: string): Promise<void>;
     186              : }
     187              : 
     188              : /// Does a backend own the session registry?
     189            1 : export function isSessionRegistryBackend(backend: StorageBackend): backend is SessionRegistryBackend {
     190            1 :     return typeof (backend as Partial<SessionRegistryBackend>).updateConnections === 'function';
     191            1 : }
     192              : 
     193              : /// A script file changed outside the app since the storage last wrote or acknowledged it.
     194              : /// Thrown instead of overwriting the external version.
     195            1 : export class ExternalScriptChangeError extends Error {
     196              :     /// The session id
     197            1 :     readonly sessionId: string;
     198              :     /// The script file name
     199            1 :     readonly fileName: string;
     200              : 
     201            1 :     constructor(sessionId: string, fileName: string) {
     202            1 :         super(`script ${fileName} of session ${sessionId} changed outside the app`);
     203            1 :         this.name = 'ExternalScriptChangeError';
     204            1 :         this.sessionId = sessionId;
     205            1 :         this.fileName = fileName;
     206            1 :     }
     207            1 : }
     208              : 
     209              : // Script data represents a single SQL script
     210              : export interface ScriptData {
     211              :     /// The script name, the file name in scripts/ (e.g. "extract.sql")
     212              :     name: string;
     213              :     /// The sql text
     214              :     sql: string;
     215              : }
        

Generated by: LCOV version 2.0-1