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

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

Generated by: LCOV version 2.0-1