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

            Line data    Source code
       1              : import { StorageBackendType, type SessionEntry } from './storage_backend.js';
       2              : import {
       3              :     assembleSessionBundle,
       4              :     seedConnectionCatalogs,
       5              :     type SessionBundle,
       6              :     sessionBundlesEqual,
       7              : } from './session_bundle.js';
       8              : import {
       9              :     type CompositeStorageBackend,
      10              :     type SessionImportConflict,
      11              :     type PreparedNativeSession,
      12              : } from './composite_storage_backend.js';
      13              : 
      14              : export type { SessionImportConflict, PreparedNativeSession } from './composite_storage_backend.js';
      15              : 
      16              : export interface SessionImportTransactionOptions {
      17              :     randomUUID?: () => string;
      18              : }
      19              : 
      20            1 : export async function findSessionImportConflict(
      21            1 :     backend: CompositeStorageBackend,
      22            1 :     sessionId: string,
      23            1 : ): Promise<SessionImportConflict | null> {
      24            1 :     return await backend.findSessionImportConflict(sessionId);
      25            1 : }
      26              : 
      27            1 : export async function prepareNativeSessionImport(
      28            1 :     backend: CompositeStorageBackend,
      29            1 :     dir: string,
      30            1 : ): Promise<PreparedNativeSession> {
      31            1 :     return await backend.prepareNativeSession(dir);
      32            1 : }
      33              : 
      34              : /// Write a new portable session under an explicit UUID and verify every durable field.
      35            1 : export async function writePortableSessionFresh(
      36            1 :     backend: CompositeStorageBackend,
      37            1 :     bundle: SessionBundle,
      38            1 :     targetSessionId: string = bundle.session.sessionId,
      39            1 : ): Promise<string> {
      40            1 :     const conflict = await backend.findSessionImportConflict(targetSessionId);
      41            1 :     if (conflict) {
      42            1 :         throw new Error(`Session ${conflict.sessionId} is already registered`);
      43              :     }
      44              : 
      45            1 :     try {
      46            1 :         await backend.writePortableSessionBundle(bundle, targetSessionId, true);
      47            1 :         await verifyPortableBundle(backend, bundle, targetSessionId);
      48            1 :         await seedConnectionCatalogs(backend, bundle, targetSessionId);
      49            1 :     } catch (error) {
      50            1 :         try {
      51            1 :             await backend.deletePortableSession(targetSessionId);
      52            1 :         } catch {
      53              :             // Preserve the import failure when best-effort cleanup also fails.
      54            1 :         }
      55            1 :         throw error;
      56            1 :     }
      57            1 :     return targetSessionId;
      58            1 : }
      59              : 
      60              : /// Replace a registered session with a portable OPFS copy using a verified staging UUID.
      61            1 : export async function replaceSessionWithPortableBundle(
      62            1 :     backend: CompositeStorageBackend,
      63            1 :     bundle: SessionBundle,
      64            1 :     conflict: SessionImportConflict,
      65            1 :     options: SessionImportTransactionOptions = {},
      66            1 : ): Promise<string> {
      67            1 :     const targetSessionId = conflict.sessionId;
      68            1 :     requireMatchingSource(bundle, targetSessionId);
      69            1 :     const oldEntry = await findManifestEntry(backend, targetSessionId);
      70              :     // A replacement is also the recovery path for registered sessions that the current format can
      71              :     // no longer read (for example V1 sessions containing dashql-draft.sql). Keep rollback data when
      72              :     // possible, but do not prevent the explicitly confirmed replacement when it is unavailable.
      73            1 :     const oldBundle = await tryReadBundle(backend, targetSessionId);
      74            1 :     const stagingId = (options.randomUUID ?? (() => crypto.randomUUID()))();
      75            1 :     if (await backend.findSessionImportConflict(stagingId)) {
      76            1 :         throw new Error(`Staging session ${stagingId} is already registered`);
      77              :     }
      78              : 
      79            1 :     let operationError: unknown = null;
      80            1 :     let targetMutationStarted = false;
      81            1 :     try {
      82            1 :         await backend.writePortableSessionBundle(bundle, stagingId, true);
      83            1 :         await verifyPortableBundle(backend, bundle, stagingId);
      84              : 
      85              :         // Upsert first to retain the existing manifest position and route subsequent live accesses
      86              :         // to OPFS. Deleting the OPFS directory then removes every stale durable/cache file at once.
      87            1 :         await backend.upsertSessionEntry({ path: targetSessionId, storageType: StorageBackendType.OPFS });
      88            1 :         targetMutationStarted = true;
      89            1 :         await backend.deletePortableSessionFiles(targetSessionId);
      90            1 :         await backend.writePortableSessionBundle(bundle, targetSessionId, false);
      91            1 :         await verifyPortableBundle(backend, bundle, targetSessionId);
      92            1 :         await seedConnectionCatalogs(backend, bundle, targetSessionId);
      93            1 :         return targetSessionId;
      94            1 :     } catch (error) {
      95            1 :         operationError = error;
      96            1 :         if (targetMutationStarted) {
      97            1 :             await rollbackPortableReplacement(backend, targetSessionId, oldEntry, oldBundle);
      98              :         }
      99            1 :         throw error;
     100            1 :     } finally {
     101            1 :         try {
     102            1 :             await backend.deletePortableSession(stagingId);
     103            1 :         } catch (cleanupError) {
     104            1 :             if (operationError == null) {
     105            1 :                 throw cleanupError;
     106              :             }
     107            1 :         }
     108            1 :     }
     109            1 : }
     110              : 
     111              : /// Register a validated selected folder directly, preserving its UUID and all folder/cache contents.
     112            1 : export async function registerNativeSession(
     113            1 :     backend: CompositeStorageBackend,
     114            1 :     prepared: PreparedNativeSession,
     115            1 : ): Promise<string> {
     116            1 :     const sessionId = prepared.bundle.session.sessionId;
     117            1 :     const conflict = await backend.findSessionImportConflict(sessionId);
     118            1 :     if (conflict) {
     119            1 :         throw new Error(`Session ${conflict.sessionId} is already registered`);
     120              :     }
     121            1 :     await backend.registerPreparedNativeSession(prepared, sessionId);
     122            1 :     return sessionId;
     123            1 : }
     124              : 
     125              : /// Replace a registration with a validated selected native folder. User-owned native folders are
     126              : /// never changed; an old OPFS directory is removed only after the manifest flip succeeds.
     127            1 : export async function replaceSessionWithNativeFolder(
     128            1 :     backend: CompositeStorageBackend,
     129            1 :     prepared: PreparedNativeSession,
     130            1 :     conflict: SessionImportConflict,
     131            1 : ): Promise<string> {
     132            1 :     const targetSessionId = conflict.sessionId;
     133            1 :     requireMatchingSource(prepared.bundle, targetSessionId);
     134            1 :     if (conflict.location.type === StorageBackendType.Native
     135            1 :         && conflict.location.nativePath === prepared.dir) {
     136            1 :         return targetSessionId;
     137              :     }
     138              : 
     139            1 :     const oldEntry = await findManifestEntry(backend, targetSessionId);
     140            1 :     let manifestFlipped = false;
     141            1 :     try {
     142            1 :         await backend.registerPreparedNativeSession(prepared, targetSessionId);
     143            1 :         manifestFlipped = true;
     144            1 :         if (conflict.location.type === StorageBackendType.OPFS) {
     145            1 :             await backend.deletePortableSessionFiles(targetSessionId);
     146              :         }
     147            1 :         return targetSessionId;
     148            1 :     } catch (error) {
     149            1 :         if (manifestFlipped) {
     150            1 :             try {
     151            1 :                 await backend.upsertSessionEntry(oldEntry);
     152            1 :             } catch {
     153              :                 // Preserve the replacement error if manifest rollback also fails.
     154            0 :             }
     155              :         }
     156            1 :         throw error;
     157            1 :     }
     158            1 : }
     159              : 
     160            1 : async function rollbackPortableReplacement(
     161            1 :     backend: CompositeStorageBackend,
     162            1 :     targetSessionId: string,
     163            1 :     oldEntry: SessionEntry,
     164            1 :     oldBundle: SessionBundle | null,
     165            1 : ): Promise<void> {
     166            1 :     await backend.deletePortableSessionFiles(targetSessionId);
     167            1 :     if (oldEntry.storageType !== StorageBackendType.Native && oldBundle) {
     168            1 :         await backend.writePortableSessionBundle(oldBundle, targetSessionId, false);
     169              :     }
     170            1 :     await backend.upsertSessionEntry(oldEntry);
     171            1 : }
     172              : 
     173            1 : async function findManifestEntry(
     174            1 :     backend: CompositeStorageBackend,
     175            1 :     sessionId: string,
     176            1 : ): Promise<SessionEntry> {
     177            1 :     const entries = await backend.listSessions();
     178            1 :     const entry = entries.find(candidate => candidate.path.toLowerCase() === sessionId.toLowerCase());
     179            1 :     if (!entry) {
     180            1 :         throw new Error(`Session ${sessionId} is no longer registered`);
     181              :     }
     182            1 :     return { ...entry };
     183            1 : }
     184              : 
     185            1 : async function tryReadBundle(
     186            1 :     backend: CompositeStorageBackend,
     187            1 :     sessionId: string,
     188            1 : ): Promise<SessionBundle | null> {
     189            1 :     try {
     190            1 :         return await backend.readSessionBundle(sessionId);
     191            1 :     } catch {
     192            1 :         return null;
     193            1 :     }
     194            1 : }
     195              : 
     196            1 : async function verifyPortableBundle(
     197            1 :     backend: CompositeStorageBackend,
     198            1 :     source: SessionBundle,
     199            1 :     targetSessionId: string,
     200            1 : ): Promise<void> {
     201            1 :     const expected = normalizePortableBundle(source, targetSessionId);
     202            1 :     const actual = await backend.readPortableSessionBundle(targetSessionId);
     203            1 :     if (!sessionBundlesEqual(expected, actual)) {
     204            1 :         throw new Error(`Session import verification failed for ${targetSessionId}`);
     205              :     }
     206            1 : }
     207              : 
     208            1 : function normalizePortableBundle(bundle: SessionBundle, sessionId: string): SessionBundle {
     209            1 :     const session = { ...bundle.session, sessionId };
     210            1 :     delete session.sessionPath;
     211            1 :     delete session.storageType;
     212            1 :     delete session.nativePath;
     213              :     // The written session lists exactly the written scripts and attachments, the catalog belongs to
     214              :     // the connection
     215            1 :     return assembleSessionBundle({ session, catalogs: [] }, bundle.scripts, bundle.data ?? []);
     216            1 : }
     217              : 
     218            1 : function requireMatchingSource(bundle: SessionBundle, targetSessionId: string): void {
     219            1 :     if (bundle.session.sessionId.toLowerCase() !== targetSessionId.toLowerCase()) {
     220            1 :         throw new Error(
     221            1 :             `Source session ${bundle.session.sessionId} does not match replacement ${targetSessionId}`,
     222            1 :         );
     223              :     }
     224            1 : }
        

Generated by: LCOV version 2.0-1