LCOV - code coverage report
Current view: top level - dashql-app/src/storage - storage_provider.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 94.5 % 128 121
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import { StorageWriter } from './storage_writer.js';
       3              : import { type StorageBackend, StorageBackendType } from './storage_backend.js';
       4              : import { OPFSStorageBackend } from './opfs_storage_backend.js';
       5              : import { CompositeStorageBackend } from './composite_storage_backend.js';
       6              : import { type SessionLocation } from './session_locator.js';
       7              : import { type SessionTreeFolder, emptySessionTree } from './session_tree.js';
       8              : import { useLogger } from '../platform/logger/logger_provider.js';
       9              : import { stringifyError } from '../platform/logger/logger.js';
      10              : import { migrateStorage } from './migration/storage_migration.js';
      11              : import { useAppHandOver } from '../platform/crosstab/app_hand_over.js';
      12              : import { IndicatorStatus } from '../ui/foundations/status_indicator.js';
      13              : import { APP_SETUP_NOT_STARTED, AppSetupPage } from '../app/app_setup_page.js';
      14              : 
      15              : // Storage context for the writer
      16            1 : const StorageWriterContext = React.createContext<StorageWriter | null>(null);
      17              : 
      18              : // Storage context for reading
      19              : export interface StorageReader {
      20              :     backend: StorageBackend;
      21              :     /// The backend that routes sessions between the app storage and native folders.
      22              :     /// Null for a bare backend without per-session routing, e.g. an injected test backend.
      23              :     sessionFolders: CompositeStorageBackend | null;
      24              :     /// The physical location of a session's files (used by the UI for a display path).
      25              :     getSessionLocation(sessionId: string): SessionLocation;
      26              :     /// The user-facing session order (the order of the session tree), as session UUIDs. Empty for a
      27              :     /// bare backend without per-session routing.
      28              :     getSessionOrder(): string[];
      29              :     /// The session tree, the folders and sessions of the sidebar. Empty for a bare backend without
      30              :     /// per-session routing.
      31              :     getSessionTree(): SessionTreeFolder;
      32              : }
      33            1 : const StorageReaderContext = React.createContext<StorageReader | null>(null);
      34              : /// The status of the migration of the storage at startup, skipped for a provided backend
      35            1 : const StorageMigrationStatusContext = React.createContext<IndicatorStatus>(IndicatorStatus.Skip);
      36              : 
      37              : interface StorageProviderProps {
      38              :     backend?: StorageBackend;
      39              :     children?: React.ReactNode;
      40              : }
      41              : 
      42            1 : export const StorageProvider: React.FC<StorageProviderProps> = ({ backend: providedBackend, children }) => {
      43            1 :     const logger = useLogger();
      44            1 :     const [backend, setBackend] = React.useState<StorageBackend | null>(providedBackend || null);
      45            1 :     const [migrationStatus, setMigrationStatus] = React.useState<IndicatorStatus>(providedBackend ? IndicatorStatus.Skip : IndicatorStatus.None);
      46            1 :     const [migrationError, setMigrationError] = React.useState<string | null>(null);
      47            1 :     const initialization = React.useRef<Promise<void> | null>(null);
      48              : 
      49              :     // Initialize the configured backend if no backend was provided
      50            1 :     React.useEffect(() => {
      51            1 :         if (providedBackend) {
      52            1 :             logger.info("Using provided storage backend", {}, "storage_provider");
      53            1 :             setBackend(providedBackend);
      54            1 :             return;
      55            1 :         }
      56              : 
      57            1 :         const initBackend = async () => {
      58            1 :             const initStartTime = performance.now();
      59              : 
      60              :             // The storage is brought to the current version before anything reads it
      61            1 :             setMigrationStatus(IndicatorStatus.Running);
      62            1 :             try {
      63            1 :                 const migrated = await migrateStorage(logger);
      64            1 :                 setMigrationStatus(migrated.steps + migrated.sessions > 0 ? IndicatorStatus.Succeeded : IndicatorStatus.Skip);
      65            1 :             } catch (error) {
      66            1 :                 logger.error("Failed to migrate the storage", { error: stringifyError(error) }, "storage_provider");
      67            1 :                 setMigrationStatus(IndicatorStatus.Failed);
      68            1 :                 setMigrationError(`The storage could not be migrated: ${error instanceof Error ? error.message : String(error)}`);
      69            1 :                 return;
      70            1 :             }
      71              : 
      72              :             // OPFS is the only copy of browser sessions, ask the browser not to evict it under storage pressure
      73            1 :             void navigator.storage?.persist?.().then(persisted => {
      74            1 :                 logger.info("Requested persistent storage", { persisted: persisted.toString() }, "storage_provider");
      75            1 :             }, error => {
      76            0 :                 logger.warn("Failed to request persistent storage", { error: stringifyError(error) }, "storage_provider");
      77            1 :             });
      78              : 
      79            1 :             logger.info("Initializing storage backend", {}, "storage_provider");
      80            1 :             const opfsBackend = new OPFSStorageBackend();
      81            1 :             const composite = new CompositeStorageBackend(opfsBackend, logger);
      82            1 :             await composite.initialize();
      83              : 
      84            1 :             const initDuration = performance.now() - initStartTime;
      85            1 :             logger.info("Storage backend initialized", {
      86            1 :                 backend: composite.getBackendType(),
      87            1 :                 durationMs: initDuration.toFixed(2)
      88            1 :             }, "storage_provider");
      89              : 
      90            1 :             setBackend(composite);
      91            1 :         };
      92              : 
      93            1 :         initialization.current = initBackend();
      94            1 :     }, [providedBackend]);
      95              : 
      96              :     // Create storage writer instance
      97            1 :     const writer = React.useMemo(() => {
      98            1 :         if (!backend) return null;
      99            1 :         return new StorageWriter(logger, backend);
     100            1 :     }, [logger, backend]);
     101              : 
     102              :     // Write the pending changes when the page goes away, the writer debounces them.
     103              :     // Browsers don't wait for the writes when the page unloads, a hidden page usually still finishes them.
     104              :     // Electron asks for the writes before it closes a window and waits for them.
     105            1 :     React.useEffect(() => {
     106            1 :         if (!writer) return;
     107            1 :         const flush = async () => {
     108            1 :             try {
     109            1 :                 await writer.flush();
     110            1 :             } catch (error) {
     111            1 :                 logger.error("Failed to flush the storage writer", { error: stringifyError(error) }, "storage_provider");
     112            1 :             }
     113            1 :         };
     114            1 :         const flushIfHidden = () => {
     115            1 :             if (document.visibilityState === 'hidden') void flush();
     116            1 :         };
     117            1 :         const flushOnPageHide = () => void flush();
     118            1 :         window.addEventListener('pagehide', flushOnPageHide);
     119            1 :         document.addEventListener('visibilitychange', flushIfHidden);
     120            1 :         const removeElectronFlush = globalThis.dashqlElectron?.onFlush(flush);
     121            1 :         return () => {
     122            1 :             window.removeEventListener('pagehide', flushOnPageHide);
     123            1 :             document.removeEventListener('visibilitychange', flushIfHidden);
     124            1 :             removeElectronFlush?.();
     125            1 :         };
     126            1 :     }, [writer, logger]);
     127              : 
     128              :     // Another tab takes over the app, the migration and the pending writes finish first
     129            1 :     useAppHandOver(async () => {
     130            0 :         await initialization.current;
     131            0 :         writer?.pause();
     132            0 :         await writer?.flush();
     133            1 :     });
     134              : 
     135              : 
     136              :     // Create storage reader
     137            1 :     const reader = React.useMemo<StorageReader | null>(() => {
     138            1 :         if (!backend) return null;
     139              : 
     140            1 :         const sessionFolders = backend instanceof CompositeStorageBackend ? backend : null;
     141            1 :         return {
     142            1 :             backend,
     143            1 :             sessionFolders,
     144            1 :             getSessionLocation(sessionId: string): SessionLocation {
     145            1 :                 if (sessionFolders != null) {
     146            1 :                     return sessionFolders.getSessionLocation(sessionId);
     147              :                 }
     148              :                 // A bare backend (e.g. an injected test backend) has no per-session routing.
     149            1 :                 return { type: backend.getBackendType() === StorageBackendType.Native ? StorageBackendType.Native : StorageBackendType.OPFS };
     150            1 :             },
     151            0 :             getSessionOrder(): string[] {
     152              :                 // A bare backend has no session tree; the caller falls back to its own order.
     153            0 :                 return sessionFolders?.getSessionOrder() ?? [];
     154            0 :             },
     155            1 :             getSessionTree(): SessionTreeFolder {
     156            1 :                 return sessionFolders?.getSessionTree() ?? emptySessionTree();
     157            1 :             },
     158            1 :         };
     159            1 :     }, [backend, logger]);
     160              : 
     161            1 :     if (!backend || !writer || !reader) {
     162              :         // Still migrating or initializing, the app providers below are not set up yet
     163            1 :         return <AppSetupPage status={{ ...APP_SETUP_NOT_STARTED, applyMigrations: migrationStatus }} error={migrationError} showInternals={false} />;
     164              :     }
     165              : 
     166            1 :     return (
     167            1 :         <StorageMigrationStatusContext.Provider value={migrationStatus}>
     168            1 :             <StorageReaderContext.Provider value={reader}>
     169            1 :                 <StorageWriterContext.Provider value={writer}>
     170            1 :                     {children}
     171            1 :                 </StorageWriterContext.Provider>
     172            1 :             </StorageReaderContext.Provider>
     173            1 :         </StorageMigrationStatusContext.Provider>
     174              :     );
     175            1 : };
     176              : 
     177              : /// The status of the migration of the storage at startup
     178            1 : export function useStorageMigrationStatus(): IndicatorStatus {
     179            1 :     return React.useContext(StorageMigrationStatusContext);
     180            1 : }
     181              : 
     182            1 : export function useStorageWriter(): StorageWriter {
     183            1 :     const writer = React.useContext(StorageWriterContext);
     184            1 :     if (!writer) {
     185            1 :         throw new Error('useStorageWriter must be used within StorageProvider');
     186              :     }
     187            1 :     return writer;
     188            1 : }
     189              : 
     190            1 : export function useStorage(): [StorageReader, StorageWriter] {
     191            1 :     const reader = React.useContext(StorageReaderContext);
     192            1 :     const writer = React.useContext(StorageWriterContext);
     193            1 :     if (!reader || !writer) {
     194            1 :         throw new Error('useStorage must be used within StorageProvider');
     195              :     }
     196            1 :     return [reader, writer];
     197            1 : }
     198              : 
     199            1 : export function useStorageReader(): StorageReader {
     200            1 :     const reader = React.useContext(StorageReaderContext);
     201            1 :     if (!reader) {
     202            1 :         throw new Error('useStorageReader must be used within StorageProvider');
     203              :     }
     204            1 :     return reader;
     205            1 : }
        

Generated by: LCOV version 2.0-1