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

            Line data    Source code
       1              : import type { MigrationFolder } from './migration_folder.js';
       2              : import { SESSION_FORMAT_VERSION } from './versions.js';
       3              : import { catalogFilePrefix } from './session/v3_catalog_file_prefix.js';
       4              : import { notebooksToSessions } from './session/v4_notebooks_to_sessions.js';
       5              : import { scriptsInSessionFile } from './session/v5_scripts_in_session_file.js';
       6              : import { sessionConnections } from './session/v6_session_connections.js';
       7              : import { catalogsOfConnections } from './session/v7_catalogs_of_connections.js';
       8              : import { connectionsOfScripts } from './session/v8_connections_of_scripts.js';
       9              : import { dropSessionSettings } from './session/v9_drop_session_settings.js';
      10              : 
      11              : /// A step that brings a session folder from the previous format version to `version`.
      12              : ///
      13              : /// A step only touches the top-level files of the folder and spells out every file name and field
      14              : /// it touches, it never uses the storage constants of the app. Those describe the current layout
      15              : /// while a step describes the layout of its own version. Steps are idempotent: the version is
      16              : /// written after a step, an interrupted migration repeats the step.
      17              : export interface SessionMigrationStep {
      18              :     /// The format version the step produces
      19              :     version: number;
      20              :     /// What the step changes
      21              :     description: string;
      22              :     /// Migrate the folder
      23              :     migrate(folder: MigrationFolder): Promise<void>;
      24              : }
      25              : 
      26              : /// The steps in order. Steps are never removed, a session may skip any number of app versions.
      27            1 : export const SESSION_MIGRATION_STEPS: readonly SessionMigrationStep[] = [
      28            1 :     catalogFilePrefix,
      29            1 :     notebooksToSessions,
      30            1 :     scriptsInSessionFile,
      31            1 :     sessionConnections,
      32            1 :     catalogsOfConnections,
      33            1 :     connectionsOfScripts,
      34            1 :     dropSessionSettings,
      35            1 : ];
      36              : 
      37              : /// The oldest format version the steps migrate, older sessions are not supported
      38            1 : const OLDEST_SESSION_FORMAT_VERSION = 2;
      39              : 
      40              : /// Every name the session metadata had, newest first
      41            1 : const SESSION_METADATA_FILES = ['dashql-session.json', 'dashql-notebook.json'];
      42              : 
      43              : /// The outcome of a session migration
      44            1 : export enum SessionMigrationResult {
      45              :     /// The session was at the target version already
      46            1 :     Current = 'current',
      47              :     /// Steps were applied
      48            1 :     Migrated = 'migrated',
      49              :     /// The folder has no session metadata
      50            1 :     Missing = 'missing',
      51              :     /// The metadata is unreadable or older than the oldest supported version, the folder stays untouched
      52            1 :     Unsupported = 'unsupported',
      53              :     /// The session was written by a newer version of the app, the folder stays untouched
      54            1 :     Newer = 'newer',
      55            1 : }
      56              : 
      57              : /// The session metadata of a folder
      58              : interface SessionMetadataFile {
      59              :     /// The file name
      60              :     name: string;
      61              :     /// The parsed metadata, null if it is not a JSON object
      62              :     data: Record<string, unknown> | null;
      63              : }
      64              : 
      65              : /// Read the session metadata under its newest name
      66            1 : async function readSessionMetadata(folder: MigrationFolder): Promise<SessionMetadataFile | null> {
      67            1 :     for (const name of SESSION_METADATA_FILES) {
      68            1 :         const text = await folder.readText(name);
      69            1 :         if (text == null) {
      70            1 :             continue;
      71              :         }
      72            1 :         try {
      73            1 :             const data: unknown = JSON.parse(text);
      74            1 :             const isObject = data != null && typeof data === 'object' && !Array.isArray(data);
      75            1 :             return { name, data: isObject ? data as Record<string, unknown> : null };
      76            1 :         } catch {
      77            1 :             return { name, data: null };
      78            1 :         }
      79            1 :     }
      80            1 :     return null;
      81            1 : }
      82              : 
      83              : /// Bring a session folder to a format version, by default the current one
      84            1 : export async function migrateSessionFolder(
      85            1 :     folder: MigrationFolder,
      86            1 :     targetVersion: number = SESSION_FORMAT_VERSION,
      87            1 :     steps: readonly SessionMigrationStep[] = SESSION_MIGRATION_STEPS,
      88            1 : ): Promise<SessionMigrationResult> {
      89            1 :     const metadata = await readSessionMetadata(folder);
      90            1 :     if (metadata == null) {
      91            1 :         return SessionMigrationResult.Missing;
      92              :     }
      93            1 :     const version = metadata.data?.formatVersion;
      94            1 :     if (typeof version !== 'number' || !Number.isInteger(version) || version < OLDEST_SESSION_FORMAT_VERSION) {
      95            1 :         return SessionMigrationResult.Unsupported;
      96              :     }
      97            1 :     const latestVersion = steps.reduce((latest, step) => Math.max(latest, step.version), OLDEST_SESSION_FORMAT_VERSION);
      98            1 :     if (version > latestVersion) {
      99            1 :         return SessionMigrationResult.Newer;
     100              :     }
     101              : 
     102            1 :     let current = version;
     103            1 :     for (const step of steps) {
     104            1 :         if (step.version <= current || step.version > targetVersion) {
     105            1 :             continue;
     106              :         }
     107            1 :         await step.migrate(folder);
     108            1 :         const migrated = await readSessionMetadata(folder);
     109            1 :         if (migrated?.data == null) {
     110            1 :             throw new Error(`Session migration to format version ${step.version} lost the session metadata`);
     111              :         }
     112            1 :         await folder.writeText(migrated.name, JSON.stringify({ ...migrated.data, formatVersion: step.version }, null, 2));
     113            1 :         current = step.version;
     114            1 :     }
     115            1 :     return current === version ? SessionMigrationResult.Current : SessionMigrationResult.Migrated;
     116            1 : }
        

Generated by: LCOV version 2.0-1