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

            Line data    Source code
       1              : import type { Logger } from '../../platform/logger/logger.js';
       2              : import { stringifyError } from '../../platform/logger/logger.js';
       3              : import { OPFS_MANIFEST_LOCK } from '../opfs_storage_backend.js';
       4              : import { listSessionFolders, readManifestJson, writeManifestJson } from './storage_root.js';
       5              : import { migrateSessionFolder, SessionMigrationResult } from './session_migration.js';
       6              : import { notebooksToSessions } from './storage/v2_notebooks_to_sessions.js';
       7              : import { globalConnections } from './storage/v3_global_connections.js';
       8              : import { sessionCatalogsToConnections } from './storage/v4_session_catalogs_to_connections.js';
       9              : import { shellFolder } from './storage/v5_shell_folder.js';
      10              : import { dropConnectionAliases } from './storage/v6_drop_connection_aliases.js';
      11              : import { sessionTree } from './storage/v7_session_tree.js';
      12              : import { flattenSessionTree, readSessionTree } from '../session_tree.js';
      13              : import { getOPFSRoot } from '../../platform/opfs_root.js';
      14              : 
      15            1 : const LOG_CTX = 'storage_migration';
      16              : 
      17              : /// What a storage migration step works on
      18              : export interface StorageMigrationContext {
      19              :     /// The storage root (the OPFS root)
      20              :     root: FileSystemDirectoryHandle;
      21              :     /// The logger
      22              :     logger: Logger;
      23              : }
      24              : 
      25              : /// A step that brings the storage from the previous version to `version`.
      26              : ///
      27              : /// A step spells out every file, folder and field it touches, it never uses the storage constants
      28              : /// of the app. Those describe the current layout while a step describes the layout of its own
      29              : /// version. Steps are idempotent: the version is written after a step, an interrupted migration
      30              : /// repeats the step.
      31              : export interface StorageMigrationStep {
      32              :     /// The storage version the step produces
      33              :     version: number;
      34              :     /// What the step changes
      35              :     description: string;
      36              :     /// Migrate the storage
      37              :     migrate(context: StorageMigrationContext): Promise<void>;
      38              : }
      39              : 
      40              : /// The steps in order. Steps are never removed, a storage may skip any number of app versions.
      41            1 : export const STORAGE_MIGRATION_STEPS: readonly StorageMigrationStep[] = [
      42            1 :     notebooksToSessions,
      43            1 :     globalConnections,
      44            1 :     sessionCatalogsToConnections,
      45            1 :     shellFolder,
      46            1 :     dropConnectionAliases,
      47            1 :     sessionTree,
      48            1 : ];
      49              : 
      50              : /// What a storage migration changed
      51              : export interface StorageMigrationSummary {
      52              :     /// The storage steps that ran
      53              :     steps: number;
      54              :     /// The session folders that were brought to the current session format
      55              :     sessions: number;
      56              : }
      57              : 
      58              : /// Run an operation while holding the manifest lock, which the tabs of the app share
      59            1 : async function withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
      60            1 :     const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
      61            1 :     return locks != null ? await locks.request(OPFS_MANIFEST_LOCK, fn) : await fn();
      62            1 : }
      63              : 
      64              : /// Bring the storage of the app to the current version.
      65              : ///
      66              : /// The app runs this at startup before anything reads the storage. The storage steps bring the
      67              : /// storage root to the current version, then every reachable session folder is brought to the
      68              : /// current session format. Session folders that become reachable later are migrated when they are
      69              : /// registered. A storage of a newer app version is refused. Returns what the migration changed.
      70            1 : export async function migrateStorage(
      71            1 :     logger: Logger,
      72            1 :     root?: FileSystemDirectoryHandle,
      73            1 :     steps: readonly StorageMigrationStep[] = STORAGE_MIGRATION_STEPS,
      74            1 : ): Promise<StorageMigrationSummary> {
      75            1 :     const storageRoot = root ?? await getOPFSRoot();
      76            1 :     const summary: StorageMigrationSummary = { steps: 0, sessions: 0 };
      77            1 :     await withManifestLock(async () => {
      78            1 :         let manifest = await readManifestJson(storageRoot);
      79            1 :         if (manifest == null) {
      80              :             // A fresh storage, the first write of the manifest stamps the current version
      81            1 :             return;
      82              :         }
      83            1 :         const version = manifest.storageVersion ?? 1;
      84            1 :         if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) {
      85            1 :             throw new Error(`Invalid storage version: ${JSON.stringify(version)}`);
      86              :         }
      87            1 :         const latestVersion = steps.reduce((latest, step) => Math.max(latest, step.version), 1);
      88            1 :         if (version > latestVersion) {
      89            1 :             throw new Error(`The storage was written by a newer version of DashQL (storage version ${version}, this version supports ${latestVersion})`);
      90              :         }
      91              : 
      92            1 :         for (const step of steps) {
      93            1 :             if (step.version <= version) {
      94            1 :                 continue;
      95              :             }
      96            1 :             logger.info('Migrating the storage', { version: step.version.toString(), step: step.description }, LOG_CTX);
      97            1 :             await step.migrate({ root: storageRoot, logger });
      98            1 :             manifest = await readManifestJson(storageRoot) ?? {};
      99            1 :             await writeManifestJson(storageRoot, { ...manifest, storageVersion: step.version });
     100            1 :             ++summary.steps;
     101            1 :         }
     102              : 
     103              :         // The storage is current, the session tree registers the sessions
     104            1 :         const sessionEntries = flattenSessionTree(await readSessionTree(storageRoot));
     105            1 :         for (const { sessionId, folder } of await listSessionFolders(storageRoot, sessionEntries)) {
     106            1 :             try {
     107            1 :                 const result = await migrateSessionFolder(folder);
     108            1 :                 if (result === SessionMigrationResult.Migrated) {
     109            1 :                     logger.info('Migrated a session', { sessionId }, LOG_CTX);
     110            1 :                     ++summary.sessions;
     111            1 :                 } else if (result !== SessionMigrationResult.Current) {
     112            1 :                     logger.warn('Session cannot be migrated', { sessionId, result }, LOG_CTX);
     113              :                 }
     114            1 :             } catch (error) {
     115              :                 // The session is surfaced as invalid when it is loaded
     116            1 :                 logger.warn('Failed to migrate a session', { sessionId, error: stringifyError(error) }, LOG_CTX);
     117            1 :             }
     118              :         }
     119            1 :     });
     120            1 :     return summary;
     121            1 : }
        

Generated by: LCOV version 2.0-1