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

            Line data    Source code
       1              : import type {
       2              :     AppSettings,
       3              :     ConnectionEntry,
       4              :     DataFile,
       5              :     SessionData,
       6              :     SessionEntry,
       7              :     SessionRegistryBackend,
       8              :     ScriptData,
       9              :     StorageBackend,
      10              :     StorageManifest,
      11              : } from './storage_backend.js';
      12              : import { StorageBackendType, validateDataFileName } from './storage_backend.js';
      13              : import { SESSION_FORMAT_VERSION } from './migration/versions.js';
      14              : import {
      15              :     type SessionTreeFolder,
      16              :     emptySessionTree,
      17              :     flattenSessionTree,
      18              :     folderAt,
      19              :     folderNameError,
      20              :     isFolderPathWithin,
      21              :     moveSessionInTree,
      22              :     removeSessionFromTree,
      23              :     upsertSessionInTree,
      24              : } from './session_tree.js';
      25              : 
      26            1 : export class SessionTestBackend implements StorageBackend {
      27            1 :     readonly sessions = new Map<string, SessionData>();
      28            1 :     readonly scripts = new Map<string, Map<string, string>>();
      29            1 :     readonly data = new Map<string, Map<string, Uint8Array>>();
      30              :     /// The modification times of the data files, a counter that every save advances
      31            1 :     private readonly dataModified = new Map<string, number>();
      32            1 :     private dataClock = 0;
      33            1 :     readonly calls: string[] = [];
      34            1 :     appSettings: AppSettings | null = null;
      35              :     /// The connections of the manifest, undefined before the connections were migrated
      36            1 :     connections: ConnectionEntry[] | undefined = undefined;
      37              :     /// The files of the connections, keyed by `<connectionId>/<fileName>`
      38            1 :     readonly connectionFiles = new Map<string, string>();
      39              : 
      40            1 :     constructor(private readonly type = StorageBackendType.OPFS) {}
      41              : 
      42            1 :     getBackendType(): StorageBackendType { return this.type; }
      43            1 :     async listSessions(): Promise<SessionEntry[]> { return [...this.sessions.keys()].map(path => ({ path })); }
      44            1 :     async loadAppSettings(): Promise<AppSettings | null> { return this.appSettings; }
      45            1 :     async saveAppSettings(settings: AppSettings): Promise<void> { this.appSettings = settings; }
      46            1 :     async loadManifest(): Promise<StorageManifest> {
      47            1 :         return {
      48            1 :             ...(this.appSettings ? { appSettings: this.appSettings } : {}),
      49            1 :             ...(this.connections ? { connections: structuredClone(this.connections) } : {}),
      50            1 :         };
      51            1 :     }
      52            1 :     async updateConnections(update: (connections: ConnectionEntry[] | undefined) => ConnectionEntry[] | null): Promise<void> {
      53            1 :         const next = update(this.connections ? structuredClone(this.connections) : undefined);
      54            1 :         if (next != null) {
      55            1 :             this.calls.push('connections');
      56            1 :             this.connections = next;
      57            1 :         }
      58            1 :     }
      59            1 :     async loadConnectionFile(connectionId: string, fileName: string): Promise<string | null> {
      60            1 :         return this.connectionFiles.get(`${connectionId}/${fileName}`) ?? null;
      61            1 :     }
      62            1 :     async saveConnectionFile(connectionId: string, fileName: string, text: string): Promise<void> {
      63            1 :         this.calls.push(`connection-file:${connectionId}/${fileName}`);
      64            1 :         this.connectionFiles.set(`${connectionId}/${fileName}`, text);
      65            1 :     }
      66            0 :     async createConnectionFolder(connectionId: string): Promise<void> {
      67            0 :         this.calls.push(`connection-folder:${connectionId}`);
      68            0 :     }
      69            1 :     async deleteConnectionFiles(connectionId: string): Promise<void> {
      70            1 :         for (const key of [...this.connectionFiles.keys()]) {
      71            0 :             if (key.startsWith(`${connectionId}/`)) this.connectionFiles.delete(key);
      72              :         }
      73            1 :     }
      74            1 :     async loadSession(sessionId: string): Promise<SessionData> {
      75            1 :         const session = this.sessions.get(sessionId);
      76            1 :         if (!session) throw new Error(`No session ${sessionId}`);
      77            1 :         return session;
      78            1 :     }
      79            1 :     async saveSessionManifest(sessionId: string, data: SessionData): Promise<void> {
      80            1 :         this.calls.push(`manifest:${sessionId}`);
      81            1 :         this.sessions.set(sessionId, data);
      82            1 :     }
      83            1 :     async deleteSession(sessionId: string): Promise<void> {
      84            1 :         this.calls.push(`delete-session:${sessionId}`);
      85            1 :         this.sessions.delete(sessionId);
      86            1 :         this.scripts.delete(sessionId);
      87            1 :         this.data.delete(sessionId);
      88            1 :     }
      89            1 :     async loadScripts(sessionId: string): Promise<ScriptData[]> {
      90            1 :         return [...(this.scripts.get(sessionId) ?? new Map())]
      91            1 :             .map(([name, sql]) => ({ name, sql }))
      92            1 :             .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
      93            1 :     }
      94            1 :     async loadScript(sessionId: string, scriptName: string): Promise<ScriptData> {
      95            1 :         const sql = this.scripts.get(sessionId)?.get(scriptName);
      96            1 :         if (sql == null) throw new Error(`Script not found: ${scriptName}`);
      97            1 :         return { name: scriptName, sql };
      98            1 :     }
      99            1 :     async saveScript(sessionId: string, scriptName: string, sql: string): Promise<void> {
     100            1 :         this.calls.push(`write:${sessionId}/${scriptName}=${sql}`);
     101            1 :         const scripts = this.scripts.get(sessionId) ?? new Map<string, string>();
     102            1 :         scripts.set(scriptName, sql);
     103            1 :         this.scripts.set(sessionId, scripts);
     104            1 :     }
     105            1 :     async deleteScript(sessionId: string, scriptName: string): Promise<void> {
     106            1 :         this.calls.push(`delete:${sessionId}/${scriptName}`);
     107            1 :         this.scripts.get(sessionId)?.delete(scriptName);
     108            1 :     }
     109            1 :     async renameScript(sessionId: string, oldScriptName: string, newScriptName: string): Promise<void> {
     110            1 :         this.calls.push(`rename:${sessionId}/${oldScriptName}->${newScriptName}`);
     111            1 :         const scripts = this.scripts.get(sessionId);
     112            1 :         if (!scripts?.has(oldScriptName)) return;
     113            1 :         const sql = scripts.get(oldScriptName)!;
     114            1 :         scripts.delete(oldScriptName);
     115            1 :         scripts.set(newScriptName, sql);
     116            1 :     }
     117            1 :     async listDataFiles(sessionId: string): Promise<DataFile[]> {
     118            1 :         return [...(this.data.get(sessionId) ?? new Map<string, Uint8Array>())]
     119            1 :             .map(([name, bytes]) => ({ name, sizeBytes: bytes.byteLength, mtimeMs: this.dataModified.get(`${sessionId}/${name}`) ?? 0 }))
     120            1 :             .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
     121            1 :     }
     122            1 :     async loadDataFile(sessionId: string, name: string): Promise<Uint8Array> {
     123            1 :         validateDataFileName(name);
     124            1 :         const bytes = this.data.get(sessionId)?.get(name);
     125            1 :         if (!bytes) throw new Error(`Data file not found: ${name}`);
     126            1 :         return bytes.slice();
     127            1 :     }
     128            1 :     async saveDataFile(sessionId: string, name: string, content: Uint8Array | Blob): Promise<void> {
     129            1 :         validateDataFileName(name);
     130            1 :         const data = this.data.get(sessionId) ?? new Map<string, Uint8Array>();
     131            1 :         const bytes = content instanceof Blob ? new Uint8Array(await content.arrayBuffer()) : content;
     132            1 :         data.set(name, bytes.slice());
     133            1 :         this.data.set(sessionId, data);
     134            1 :         this.dataModified.set(`${sessionId}/${name}`, ++this.dataClock);
     135            1 :     }
     136            1 :     async deleteDataFile(sessionId: string, name: string): Promise<void> {
     137            1 :         validateDataFileName(name);
     138            1 :         this.data.get(sessionId)?.delete(name);
     139            1 :     }
     140            0 :     async renameDataFile(sessionId: string, oldName: string, newName: string): Promise<void> {
     141            0 :         validateDataFileName(oldName);
     142            0 :         validateDataFileName(newName);
     143            0 :         const data = this.data.get(sessionId);
     144            0 :         if (!data?.has(oldName) || oldName === newName) return;
     145            0 :         data.set(newName, data.get(oldName)!);
     146            0 :         data.delete(oldName);
     147            0 :     }
     148            1 : }
     149              : 
     150            1 : export const TEST_SESSION_ID = '11111111-2222-4333-8444-555555555555';
     151            1 : export const TEST_CONNECTION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
     152              : 
     153              : /// The connection fields of a test session
     154            1 : export function testConnections(...connections: SessionData['connections']): Pick<SessionData, 'connections'> {
     155            1 :     return { connections };
     156            1 : }
     157              : 
     158            1 : export function testSession(overrides: Partial<SessionData> = {}): SessionData {
     159            1 :     return {
     160            1 :         formatVersion: SESSION_FORMAT_VERSION,
     161            1 :         sessionId: TEST_SESSION_ID,
     162            1 :         name: 'V2 Session',
     163            1 :         connections: [{ connectionId: TEST_CONNECTION_ID, params: { hyper: {} } as any }],
     164            1 :         scripts: [],
     165            1 :         attachments: [],
     166            1 :         metadata: {},
     167            1 :         ...overrides,
     168            1 :     };
     169            1 : }
     170              : 
     171              : /// A test backend that owns a session registry in memory, the backend of the composite storage
     172            1 : export class SessionTestRegistry extends SessionTestBackend implements SessionRegistryBackend {
     173            1 :     tree: SessionTreeFolder = emptySessionTree();
     174              : 
     175              :     /// The registered sessions in tree order
     176            1 :     get manifest(): SessionEntry[] { return flattenSessionTree(this.tree); }
     177              : 
     178            1 :     override async listSessions(): Promise<SessionEntry[]> { return this.manifest; }
     179            1 :     override async saveSessionManifest(id: string, data: SessionData): Promise<void> {
     180            1 :         await super.saveSessionManifest(id, data);
     181            1 :         await this.upsertSessionEntry({ path: id, storageType: StorageBackendType.OPFS });
     182            1 :     }
     183            1 :     async loadSessionTree(): Promise<SessionTreeFolder> { return structuredClone(this.tree); }
     184            1 :     async upsertSessionEntry(entry: SessionEntry, folderPath?: readonly string[]): Promise<void> {
     185            1 :         this.tree = upsertSessionInTree(this.tree, entry, folderPath);
     186            1 :     }
     187            1 :     async removeSessionEntry(id: string): Promise<void> { this.tree = removeSessionFromTree(this.tree, id); }
     188            1 :     async moveSession(id: string, folderPath: string[], index: number): Promise<void> {
     189            1 :         this.tree = moveSessionInTree(this.tree, id, folderPath, index);
     190            1 :     }
     191            1 :     async createFolder(path: string[]): Promise<void> {
     192            1 :         const parent = folderAt(this.tree, path.slice(0, -1));
     193            1 :         const name = path[path.length - 1];
     194            1 :         if (parent == null || name == null) throw new Error('There is no parent folder');
     195            1 :         const error = folderNameError(name, parent.folders.map(folder => folder.name));
     196            1 :         if (error != null) throw new Error(error);
     197            1 :         this.tree = this.mapFolder(path.slice(0, -1), folder => ({
     198            1 :             ...folder,
     199            1 :             folders: [...folder.folders, { name, path, folders: [], sessions: [] }]
     200            1 :                 .sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true })),
     201            1 :         }));
     202            1 :     }
     203            1 :     async moveFolder(path: string[], parentPath: string[], name: string = path[path.length - 1] ?? ''): Promise<void> {
     204            1 :         const folder = folderAt(this.tree, path);
     205            1 :         if (folder == null || isFolderPathWithin(parentPath, path)) throw new Error('Cannot move the folder');
     206            1 :         const rebase = (current: SessionTreeFolder, base: string[]): SessionTreeFolder => ({
     207            1 :             ...current,
     208            1 :             name: base[base.length - 1],
     209            1 :             path: base,
     210            1 :             folders: current.folders.map(child => rebase(child, [...base, child.name])),
     211            1 :         });
     212            1 :         this.tree = this.mapFolder(path.slice(0, -1), parent => ({ ...parent, folders: parent.folders.filter(child => child.name !== folder.name) }));
     213            1 :         this.tree = this.mapFolder(parentPath, parent => ({ ...parent, folders: [...parent.folders, rebase(folder, [...parentPath, name])] }));
     214            1 :     }
     215            1 :     async deleteFolder(path: string[]): Promise<void> {
     216            1 :         const folder = folderAt(this.tree, path);
     217            1 :         if (folder == null || path.length === 0) throw new Error('There is no folder');
     218            1 :         const moved = flattenSessionTree(folder);
     219            1 :         this.tree = this.mapFolder(path.slice(0, -1), parent => ({
     220            1 :             ...parent,
     221            1 :             folders: parent.folders.filter(child => child.name !== folder.name),
     222            1 :             sessions: [...parent.sessions, ...moved],
     223            1 :         }));
     224            1 :     }
     225            1 :     async deleteSessionFiles(id: string): Promise<void> { await super.deleteSession(id); }
     226              :     /// Delete the files and the registry entry, like the OPFS backend
     227            1 :     override async deleteSession(id: string): Promise<void> {
     228            1 :         await this.deleteSessionFiles(id);
     229            1 :         await this.removeSessionEntry(id);
     230            1 :     }
     231              : 
     232            1 :     private mapFolder(path: string[], map: (folder: SessionTreeFolder) => SessionTreeFolder): SessionTreeFolder {
     233            1 :         const visit = (folder: SessionTreeFolder, rest: string[]): SessionTreeFolder => rest.length === 0
     234            1 :             ? map(folder)
     235            1 :             : { ...folder, folders: folder.folders.map(child => child.name === rest[0] ? visit(child, rest.slice(1)) : child) };
     236            1 :         return visit(this.tree, path);
     237            1 :     }
     238            1 : }
        

Generated by: LCOV version 2.0-1