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