LCOV - code coverage report
Current view: top level - dashql-app/src/storage/migration - session_migration.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 100.0 % 61 61
Test Date: 2026-11-09 20:22:13

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

Generated by: LCOV version 2.0-1