Line data Source code
1 : import * as React from 'react';
2 : import { StorageWriter } from './storage_writer.js';
3 : import { type StorageBackend, StorageBackendType } from './storage_backend.js';
4 : import { OPFSStorageBackend } from './opfs_storage_backend.js';
5 : import { CompositeStorageBackend } from './composite_storage_backend.js';
6 : import { type SessionLocation } from './session_locator.js';
7 : import { type SessionTreeFolder, emptySessionTree } from './session_tree.js';
8 : import { useLogger } from '../platform/logger/logger_provider.js';
9 : import { stringifyError } from '../platform/logger/logger.js';
10 : import { migrateStorage } from './migration/storage_migration.js';
11 : import { useAppHandOver } from '../platform/crosstab/app_hand_over.js';
12 : import { IndicatorStatus } from '../ui/foundations/status_indicator.js';
13 : import { APP_SETUP_NOT_STARTED, AppSetupPage } from '../app/app_setup_page.js';
14 :
15 : // Storage context for the writer
16 1 : const StorageWriterContext = React.createContext<StorageWriter | null>(null);
17 :
18 : // Storage context for reading
19 : export interface StorageReader {
20 : backend: StorageBackend;
21 : /// The backend that routes sessions between the app storage and native folders.
22 : /// Null for a bare backend without per-session routing, e.g. an injected test backend.
23 : sessionFolders: CompositeStorageBackend | null;
24 : /// The physical location of a session's files (used by the UI for a display path).
25 : getSessionLocation(sessionId: string): SessionLocation;
26 : /// The user-facing session order (the order of the session tree), as session UUIDs. Empty for a
27 : /// bare backend without per-session routing.
28 : getSessionOrder(): string[];
29 : /// The session tree, the folders and sessions of the sidebar. Empty for a bare backend without
30 : /// per-session routing.
31 : getSessionTree(): SessionTreeFolder;
32 : }
33 1 : const StorageReaderContext = React.createContext<StorageReader | null>(null);
34 : /// The status of the migration of the storage at startup, skipped for a provided backend
35 1 : const StorageMigrationStatusContext = React.createContext<IndicatorStatus>(IndicatorStatus.Skip);
36 :
37 : interface StorageProviderProps {
38 : backend?: StorageBackend;
39 : children?: React.ReactNode;
40 : }
41 :
42 1 : export const StorageProvider: React.FC<StorageProviderProps> = ({ backend: providedBackend, children }) => {
43 1 : const logger = useLogger();
44 1 : const [backend, setBackend] = React.useState<StorageBackend | null>(providedBackend || null);
45 1 : const [migrationStatus, setMigrationStatus] = React.useState<IndicatorStatus>(providedBackend ? IndicatorStatus.Skip : IndicatorStatus.None);
46 1 : const [migrationError, setMigrationError] = React.useState<string | null>(null);
47 1 : const initialization = React.useRef<Promise<void> | null>(null);
48 :
49 : // Initialize the configured backend if no backend was provided
50 1 : React.useEffect(() => {
51 1 : if (providedBackend) {
52 1 : logger.info("Using provided storage backend", {}, "storage_provider");
53 1 : setBackend(providedBackend);
54 1 : return;
55 1 : }
56 :
57 1 : const initBackend = async () => {
58 1 : const initStartTime = performance.now();
59 :
60 : // The storage is brought to the current version before anything reads it
61 1 : setMigrationStatus(IndicatorStatus.Running);
62 1 : try {
63 1 : const migrated = await migrateStorage(logger);
64 1 : setMigrationStatus(migrated.steps + migrated.sessions > 0 ? IndicatorStatus.Succeeded : IndicatorStatus.Skip);
65 1 : } catch (error) {
66 1 : logger.error("Failed to migrate the storage", { error: stringifyError(error) }, "storage_provider");
67 1 : setMigrationStatus(IndicatorStatus.Failed);
68 1 : setMigrationError(`The storage could not be migrated: ${error instanceof Error ? error.message : String(error)}`);
69 1 : return;
70 1 : }
71 :
72 : // OPFS is the only copy of browser sessions, ask the browser not to evict it under storage pressure
73 1 : void navigator.storage?.persist?.().then(persisted => {
74 1 : logger.info("Requested persistent storage", { persisted: persisted.toString() }, "storage_provider");
75 1 : }, error => {
76 0 : logger.warn("Failed to request persistent storage", { error: stringifyError(error) }, "storage_provider");
77 1 : });
78 :
79 1 : logger.info("Initializing storage backend", {}, "storage_provider");
80 1 : const opfsBackend = new OPFSStorageBackend();
81 1 : const composite = new CompositeStorageBackend(opfsBackend, logger);
82 1 : await composite.initialize();
83 :
84 1 : const initDuration = performance.now() - initStartTime;
85 1 : logger.info("Storage backend initialized", {
86 1 : backend: composite.getBackendType(),
87 1 : durationMs: initDuration.toFixed(2)
88 1 : }, "storage_provider");
89 :
90 1 : setBackend(composite);
91 1 : };
92 :
93 1 : initialization.current = initBackend();
94 1 : }, [providedBackend]);
95 :
96 : // Create storage writer instance
97 1 : const writer = React.useMemo(() => {
98 1 : if (!backend) return null;
99 1 : return new StorageWriter(logger, backend);
100 1 : }, [logger, backend]);
101 :
102 : // Write the pending changes when the page goes away, the writer debounces them.
103 : // Browsers don't wait for the writes when the page unloads, a hidden page usually still finishes them.
104 : // Electron asks for the writes before it closes a window and waits for them.
105 1 : React.useEffect(() => {
106 1 : if (!writer) return;
107 1 : const flush = async () => {
108 1 : try {
109 1 : await writer.flush();
110 1 : } catch (error) {
111 1 : logger.error("Failed to flush the storage writer", { error: stringifyError(error) }, "storage_provider");
112 1 : }
113 1 : };
114 1 : const flushIfHidden = () => {
115 1 : if (document.visibilityState === 'hidden') void flush();
116 1 : };
117 1 : const flushOnPageHide = () => void flush();
118 1 : window.addEventListener('pagehide', flushOnPageHide);
119 1 : document.addEventListener('visibilitychange', flushIfHidden);
120 1 : const removeElectronFlush = globalThis.dashqlElectron?.onFlush(flush);
121 1 : return () => {
122 1 : window.removeEventListener('pagehide', flushOnPageHide);
123 1 : document.removeEventListener('visibilitychange', flushIfHidden);
124 1 : removeElectronFlush?.();
125 1 : };
126 1 : }, [writer, logger]);
127 :
128 : // Another tab takes over the app, the migration and the pending writes finish first
129 1 : useAppHandOver(async () => {
130 0 : await initialization.current;
131 0 : writer?.pause();
132 0 : await writer?.flush();
133 1 : });
134 :
135 :
136 : // Create storage reader
137 1 : const reader = React.useMemo<StorageReader | null>(() => {
138 1 : if (!backend) return null;
139 :
140 1 : const sessionFolders = backend instanceof CompositeStorageBackend ? backend : null;
141 1 : return {
142 1 : backend,
143 1 : sessionFolders,
144 1 : getSessionLocation(sessionId: string): SessionLocation {
145 1 : if (sessionFolders != null) {
146 1 : return sessionFolders.getSessionLocation(sessionId);
147 : }
148 : // A bare backend (e.g. an injected test backend) has no per-session routing.
149 1 : return { type: backend.getBackendType() === StorageBackendType.Native ? StorageBackendType.Native : StorageBackendType.OPFS };
150 1 : },
151 0 : getSessionOrder(): string[] {
152 : // A bare backend has no session tree; the caller falls back to its own order.
153 0 : return sessionFolders?.getSessionOrder() ?? [];
154 0 : },
155 1 : getSessionTree(): SessionTreeFolder {
156 1 : return sessionFolders?.getSessionTree() ?? emptySessionTree();
157 1 : },
158 1 : };
159 1 : }, [backend, logger]);
160 :
161 1 : if (!backend || !writer || !reader) {
162 : // Still migrating or initializing, the app providers below are not set up yet
163 1 : return <AppSetupPage status={{ ...APP_SETUP_NOT_STARTED, applyMigrations: migrationStatus }} error={migrationError} showInternals={false} />;
164 : }
165 :
166 1 : return (
167 1 : <StorageMigrationStatusContext.Provider value={migrationStatus}>
168 1 : <StorageReaderContext.Provider value={reader}>
169 1 : <StorageWriterContext.Provider value={writer}>
170 1 : {children}
171 1 : </StorageWriterContext.Provider>
172 1 : </StorageReaderContext.Provider>
173 1 : </StorageMigrationStatusContext.Provider>
174 : );
175 1 : };
176 :
177 : /// The status of the migration of the storage at startup
178 1 : export function useStorageMigrationStatus(): IndicatorStatus {
179 1 : return React.useContext(StorageMigrationStatusContext);
180 1 : }
181 :
182 1 : export function useStorageWriter(): StorageWriter {
183 1 : const writer = React.useContext(StorageWriterContext);
184 1 : if (!writer) {
185 1 : throw new Error('useStorageWriter must be used within StorageProvider');
186 : }
187 1 : return writer;
188 1 : }
189 :
190 1 : export function useStorage(): [StorageReader, StorageWriter] {
191 1 : const reader = React.useContext(StorageReaderContext);
192 1 : const writer = React.useContext(StorageWriterContext);
193 1 : if (!reader || !writer) {
194 1 : throw new Error('useStorage must be used within StorageProvider');
195 : }
196 1 : return [reader, writer];
197 1 : }
198 :
199 1 : export function useStorageReader(): StorageReader {
200 1 : const reader = React.useContext(StorageReaderContext);
201 1 : if (!reader) {
202 1 : throw new Error('useStorageReader must be used within StorageProvider');
203 : }
204 1 : return reader;
205 1 : }
|