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