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

            Line data    Source code
       1              : import type { Logger } from '../platform/logger/logger.js';
       2              : import type { StorageBackend } from './storage_backend.js';
       3              : 
       4            1 : const LOG_CTX = 'session_transfer';
       5              : 
       6              : /// The result of copying one or more sessions between backends.
       7              : export interface SessionTransferResult {
       8              :     /// The number of sessions copied
       9              :     sessionCount: number;
      10              :     /// The number of files written (session file, scripts and data)
      11              :     fileCount: number;
      12              : }
      13              : 
      14              : /// Copy a single session's files from `source` to `target`, keyed by the same UUID.
      15              : ///
      16              : /// The UUID is the authoritative identity and is preserved verbatim - nothing is re-prefixed. The
      17              : /// session file, the flat scripts and the binary data are copied as
      18              : /// they are. `source` is left untouched; the caller deletes the source copy only after the copy is
      19              : /// verified.
      20            1 : export async function copySession(
      21            1 :     sessionId: string,
      22            1 :     source: StorageBackend,
      23            1 :     target: StorageBackend,
      24            1 :     logger: Logger,
      25            1 : ): Promise<SessionTransferResult> {
      26            1 :     let fileCount = 0;
      27              : 
      28              :     // Session manifest (copied verbatim; sessionId is preserved)
      29            1 :     const sessionData = await source.loadSession(sessionId);
      30            1 :     await target.saveSessionManifest(sessionId, sessionData);
      31            1 :     fileCount++;
      32              : 
      33            1 :     for (const script of await source.loadScripts(sessionId)) {
      34            1 :         await target.saveScript(sessionId, script.name, script.sql);
      35            1 :         fileCount++;
      36            1 :     }
      37            1 :     for (const file of await source.listDataFiles(sessionId)) {
      38            1 :         await target.saveDataFile(sessionId, file.name, await source.loadDataFile(sessionId, file.name));
      39            1 :         fileCount++;
      40            1 :     }
      41              : 
      42            1 :     logger.info('copied session', { sessionId, files: String(fileCount) }, LOG_CTX);
      43            1 :     return { sessionCount: 1, fileCount };
      44            1 : }
      45              : 
      46              : /// Clone a session into `target` under a new UUID (always used for OPFS duplicates).
      47              : ///
      48              : /// Rewrites `sessionId`, drops the display-only `sessionPath`, and suffixes a named session
      49              : /// with " (copy)". The clone references the same connections. Query result cache is not copied. On any write failure the new session is
      50              : /// deleted so a half-written clone cannot linger in the registry.
      51            1 : export async function cloneSession(
      52            1 :     sourceSessionId: string,
      53            1 :     source: StorageBackend,
      54            1 :     target: StorageBackend,
      55            1 :     newSessionId: string,
      56            1 :     logger: Logger,
      57            1 : ): Promise<SessionTransferResult> {
      58            1 :     let fileCount = 0;
      59            1 :     try {
      60              :         // The copy shares the connections of its source
      61            1 :         const sessionData = {
      62            1 :             ...(await source.loadSession(sourceSessionId)),
      63            1 :             sessionId: newSessionId,
      64            1 :         };
      65            1 :         delete sessionData.sessionPath;
      66            1 :         const trimmedName = sessionData.name?.trim();
      67            1 :         if (trimmedName) {
      68            1 :             sessionData.name = `${trimmedName} (copy)`;
      69              :         }
      70              : 
      71            1 :         await target.saveSessionManifest(newSessionId, sessionData);
      72            1 :         fileCount++;
      73              : 
      74            1 :         for (const script of await source.loadScripts(sourceSessionId)) {
      75            1 :             await target.saveScript(newSessionId, script.name, script.sql);
      76            1 :             fileCount++;
      77            1 :         }
      78            1 :         for (const file of await source.listDataFiles(sourceSessionId)) {
      79            1 :             await target.saveDataFile(newSessionId, file.name, await source.loadDataFile(sourceSessionId, file.name));
      80            1 :             fileCount++;
      81            1 :         }
      82              : 
      83            1 :         logger.info('cloned session', {
      84            1 :             sourceSessionId,
      85            1 :             sessionId: newSessionId,
      86            1 :             files: String(fileCount),
      87            1 :         }, LOG_CTX);
      88            1 :         return { sessionCount: 1, fileCount };
      89            1 :     } catch (error) {
      90            1 :         try {
      91            1 :             await target.deleteSession(newSessionId);
      92            1 :         } catch {
      93              :             // Preserve the clone error if rollback fails.
      94            0 :         }
      95            1 :         throw error;
      96            1 :     }
      97            1 : }
      98              : 
      99              : /// Verify a single session was copied completely by re-reading `target`.
     100              : ///
     101              : /// Checks that the target session parses with a sessionId and that its per-session script count
     102              : /// matches the source. Returns false on any mismatch so the caller can abort and keep the source
     103              : /// untouched.
     104            1 : export async function verifySession(sessionId: string, source: StorageBackend, target: StorageBackend): Promise<boolean> {
     105            1 :     let targetData;
     106            1 :     try {
     107            1 :         targetData = await target.loadSession(sessionId);
     108            1 :     } catch {
     109            0 :         return false;
     110            0 :     }
     111            1 :     if (!targetData.sessionId) {
     112            0 :         return false;
     113              :     }
     114              : 
     115            1 :     if ((await source.loadScripts(sessionId)).length !== (await target.loadScripts(sessionId)).length) return false;
     116            1 :     try {
     117            1 :         const sourceFiles = await source.listDataFiles(sessionId);
     118            1 :         const targetFiles = await target.listDataFiles(sessionId);
     119            1 :         if (sourceFiles.length !== targetFiles.length) return false;
     120            1 :         for (const file of sourceFiles) {
     121            1 :             const sourceBytes = await source.loadDataFile(sessionId, file.name);
     122            1 :             const targetBytes = await target.loadDataFile(sessionId, file.name);
     123            1 :             if (sourceBytes.length !== targetBytes.length || !sourceBytes.every((byte, i) => byte === targetBytes[i])) return false;
     124            1 :         }
     125            1 :         return true;
     126            1 :     } catch {
     127            0 :         return false;
     128            0 :     }
     129            1 : }
        

Generated by: LCOV version 2.0-1