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 : }
|