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