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