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