Line data Source code
1 : import * as React from 'react';
2 :
3 : import { connectionEntryFromState, registerConnection, useConnectionRegistry, type ConnectionRegistry } from '../../connections/connection_registry.js';
4 : import { replaceSessionScriptsInRegistry, useSessionScriptsDispatch, useSessionScriptsRegistry } from '../scripts/session_scripts_registry.js';
5 : import { sessionScriptsMatchStorageSnapshot, replaceSessionScriptsFromStorage, scriptConnectionOf, SET_SESSION_ATTACHMENTS } from '../scripts/session_scripts.js';
6 : import { markSessionAttachmentsChanged } from '../attachments/attachment_mounts.js';
7 : import { isDesktopHost } from '../../platform/native_globals.js';
8 : import { useLogger } from '../../platform/logger/logger_provider.js';
9 : import { stringifyError } from '../../platform/logger/logger.js';
10 : import { useStorage } from '../../storage/storage_provider.js';
11 : import { useAppFlavor } from '../../config/flavor_provider.js';
12 : import type { SessionScriptsStorageSnapshot, SessionScripts } from '../scripts/session_scripts.js';
13 : import type { NamedFileStamp } from '../../storage/native_storage_backend.js';
14 : import { connectionStateFromStorage, destroyUnregisteredConnection } from '../../connections/connection_restore.js';
15 : import { resolveSessionScripts, sessionScriptsResolutionChanged } from './session_script_connections.js';
16 : import type { ConnectionState } from '../../connections/connection_state.js';
17 : import { findEmbeddedConnection } from '../../connections/embedded_connection.js';
18 : import { isLocalHyperWasmParams } from '../../storage/session_validation.js';
19 : import {
20 : DEBOUNCE_DURATION_SCRIPT_WRITE,
21 : DEBOUNCE_DURATION_SESSION_WRITE,
22 : groupConnectionEntryWrites,
23 : groupScriptWrites,
24 : groupSessionAttachmentListWrites,
25 : groupSessionScriptListWrites,
26 : REFRESH_SESSION_ATTACHMENTS,
27 : WRITE_CONNECTION_ENTRY,
28 : WRITE_SCRIPT,
29 : WRITE_SESSION_SCRIPTS,
30 : type StorageWriter,
31 : } from '../../storage/storage_writer.js';
32 : import {
33 : STORAGE_SCRIPTS_FOLDER,
34 : type ScriptData,
35 : type SessionData,
36 : } from '../../storage/storage_backend.js';
37 :
38 1 : const LOG_CTX = 'native_session_sync';
39 :
40 : /// The scripts of a session as its folder holds them, resolved like the restoration of the app does.
41 : /// A connection that the app doesn't know is created from the copy of the session file, e.g. after
42 : /// pulling a script on a new connection. It is registered when the reload is applied and must be
43 : /// destroyed otherwise.
44 : export interface StoredSessionScripts {
45 : /// The scripts as the session scripts take them
46 : snapshot: SessionScriptsStorageSnapshot;
47 : /// The session file that describes the resolved scripts
48 : session: SessionData;
49 : /// The connections that were created from copies of the session file
50 : created: ConnectionState[];
51 : }
52 :
53 1 : export function readStoredSessionScripts(
54 1 : registry: ConnectionRegistry,
55 1 : session: SessionData,
56 1 : scripts: ScriptData[],
57 1 : ): StoredSessionScripts | null {
58 : // Scripts on unknown connections run on the embedded database
59 1 : const embedded = findEmbeddedConnection(registry.connections.values());
60 1 : if (embedded == null) return null;
61 1 : const created: ConnectionState[] = [];
62 1 : const resolved = resolveSessionScripts(session, scripts.map(script => script.name), () => embedded, (connectionId, copy) => {
63 1 : const known = registry.connections.get(connectionId)
64 1 : ?? (copy != null && isLocalHyperWasmParams(copy.params) ? embedded : null);
65 1 : if (known != null || copy == null) return known ?? null;
66 1 : const connection = connectionStateFromStorage(embedded.instance, connectionId, copy);
67 1 : created.push(connection);
68 1 : return connection;
69 1 : });
70 1 : const sqlByName = new Map(scripts.map(script => [script.name, script.sql]));
71 1 : return {
72 1 : snapshot: {
73 1 : scripts: resolved.scripts.map(entry => ({
74 1 : name: entry.name,
75 1 : sql: sqlByName.get(entry.name) ?? '',
76 1 : connection: scriptConnectionOf(entry.connection),
77 1 : ...(entry.unresolvedConnectionId == null ? {} : { unresolvedConnectionId: entry.unresolvedConnectionId }),
78 1 : })),
79 1 : },
80 1 : session: resolved.session,
81 1 : created,
82 1 : };
83 1 : }
84 :
85 : interface SessionReloadDecision {
86 : reload: boolean;
87 : discardPendingWrites: boolean;
88 : }
89 :
90 1 : async function confirmExternalReload(appName: string, hasPendingWrites: boolean): Promise<SessionReloadDecision> {
91 1 : const message = hasPendingWrites
92 1 : ? `This session changed on disk while ${appName} still has local changes waiting to be saved. Reload from disk and discard those pending local changes?`
93 1 : : `This session changed outside ${appName}. Reload it from disk?`;
94 1 : const reload = await globalThis.dashqlElectron!.confirm({message, title: 'Session changed on disk'});
95 1 : return { reload, discardPendingWrites: reload && hasPendingWrites };
96 1 : }
97 :
98 1 : function isReloadedStorageKey(sessionId: string, key: string): boolean {
99 1 : return key === groupSessionScriptListWrites(sessionId)
100 1 : || key === `${sessionId}/${STORAGE_SCRIPTS_FOLDER}`
101 1 : || key.startsWith(`${sessionId}/${STORAGE_SCRIPTS_FOLDER}/`);
102 1 : }
103 :
104 : /// The attachments of a session as a comparable value
105 1 : function attachmentListing(files: NamedFileStamp[]): string {
106 1 : return JSON.stringify(files.map(file => [file.name, file.size, file.mtimeMs]).sort());
107 1 : }
108 :
109 : /// Keep the scripts of the app over the disk: the scripts that differ on disk are written again.
110 : /// Files that only exist on disk are left alone.
111 1 : function writeKeptSessionScripts(writer: StorageWriter, sessionScripts: SessionScripts, disk: ScriptData[]): void {
112 1 : const sessionId = sessionScripts.sessionId;
113 1 : const sqlByName = new Map(disk.map(script => [script.name, script.sql]));
114 1 : for (const ref of Object.values(sessionScripts.scriptRefs)) {
115 1 : const sql = sessionScripts.scripts[ref.scriptId]?.scriptSession.getText();
116 1 : if (sql == null || sqlByName.get(ref.fileName) === sql) continue;
117 1 : void writer.write(
118 1 : groupScriptWrites(sessionId, ref.fileName),
119 1 : { type: WRITE_SCRIPT, value: [sessionId, ref.fileName, sql] },
120 : DEBOUNCE_DURATION_SCRIPT_WRITE,
121 1 : );
122 1 : }
123 1 : }
124 :
125 : /// Native-only synchronization boundary. Session folders are not watched, they are checked when the
126 : /// window gets the focus and when the storage writer holds a write because a script changed outside
127 : /// the app. A check compares the stamps of the files (size and modification time) with what the
128 : /// native backend wrote or acknowledged, reads the files only when a stamp differs, prompts when the
129 : /// content differs from the app and delegates the reload to the session domain.
130 1 : export const NativeSessionSync: React.FC = () => {
131 1 : const logger = useLogger();
132 1 : const appName = useAppFlavor().displayName;
133 1 : const [reader, writer] = useStorage();
134 1 : const [connectionRegistry, setConnectionRegistry] = useConnectionRegistry();
135 1 : const [sessionScriptsRegistry, setSessionScriptsRegistry] = useSessionScriptsRegistry();
136 1 : const dispatchSessionScripts = useSessionScriptsDispatch();
137 1 : const connectionRegistryRef = React.useRef(connectionRegistry);
138 1 : const sessionScriptsRegistryRef = React.useRef(sessionScriptsRegistry);
139 1 : connectionRegistryRef.current = connectionRegistry;
140 1 : sessionScriptsRegistryRef.current = sessionScriptsRegistry;
141 1 : const queuedRef = React.useRef(new Set<string>());
142 1 : const processingRef = React.useRef(false);
143 : /// The sessions that were checked once, the first check acknowledges the disk
144 1 : const checkedRef = React.useRef(new Set<string>());
145 : /// The sessions whose first check was requested, a failed one is retried on the next focus
146 1 : const firstCheckRequestedRef = React.useRef(new Set<string>());
147 : /// The attachment listing of a session at its last check
148 1 : const attachmentsRef = React.useRef(new Map<string, string>());
149 :
150 1 : const checkSession = React.useEffectEvent(async (sessionId: string) => {
151 1 : const folder = reader.sessionFolders?.nativeSessionFolder(sessionId) ?? null;
152 1 : if (folder == null) {
153 1 : return;
154 : }
155 1 : writer.pauseSession(sessionId);
156 1 : let stored: StoredSessionScripts | null = null;
157 1 : let registered = false;
158 1 : try {
159 : // A write that started before the check must finish before the stable disk read
160 1 : await writer.settle();
161 1 : const stamps = await folder.stampFolder();
162 : // Loading the session file mirrors the attachments into OPFS for embedded HyperDB-WASM.
163 : // The restoration loaded it already, later checks reload it when the attachments changed.
164 1 : let sessionFile: SessionData | null = null;
165 1 : const attachments = attachmentListing(stamps.attachments);
166 1 : const previousAttachments = attachmentsRef.current.get(sessionId);
167 1 : if (previousAttachments !== undefined && previousAttachments !== attachments) {
168 1 : sessionFile = await reader.backend.loadSession(sessionId);
169 : // The scripts read the mirrored attachments, the mount looks them up again
170 1 : markSessionAttachmentsChanged(sessionId);
171 1 : void reader.backend.listDataFiles(sessionId)
172 1 : .then(files => dispatchSessionScripts(sessionId, { type: SET_SESSION_ATTACHMENTS, value: files }))
173 1 : .catch(error => logger.warn('Listing the attachments failed', { sessionId, error: stringifyError(error) }, LOG_CTX));
174 : // The session file lists the attachments, the write runs once the session resumes
175 1 : void writer.write(groupSessionAttachmentListWrites(sessionId), { type: REFRESH_SESSION_ATTACHMENTS, value: sessionId });
176 1 : }
177 : // A change is remembered once it was handled, a failed read is retried by the next check
178 1 : attachmentsRef.current.set(sessionId, attachments);
179 1 : if (!checkedRef.current.has(sessionId)) {
180 : // The restoration read the scripts moments ago, they are what the app shows
181 1 : checkedRef.current.add(sessionId);
182 1 : folder.acknowledgeScripts(stamps.scripts);
183 1 : return;
184 1 : }
185 1 : if (folder.findExternalScriptChanges(stamps.scripts).length === 0) {
186 1 : return;
187 : }
188 : // A stamp differs, read the scripts. A file that changes after its stamp was taken has a
189 : // newer stamp than the acknowledged one and is checked again.
190 1 : const scripts = await reader.backend.loadScripts(sessionId);
191 1 : sessionFile ??= await reader.backend.loadSession(sessionId);
192 1 : stored = readStoredSessionScripts(connectionRegistryRef.current, sessionFile, scripts);
193 1 : if (stored == null) {
194 0 : return;
195 : }
196 1 : const snapshot = stored.snapshot;
197 1 : const currentSessionScripts = sessionScriptsRegistryRef.current.sessionScriptsMap.get(sessionId);
198 1 : if (currentSessionScripts == null) {
199 0 : return;
200 : }
201 1 : if (sessionScriptsMatchStorageSnapshot(currentSessionScripts, snapshot)) {
202 : // Only the stamps changed, or the disk changed to what the app shows
203 1 : folder.acknowledgeScripts(stamps.scripts);
204 1 : return;
205 1 : }
206 :
207 1 : const isReloadedKey = (key: string) => isReloadedStorageKey(sessionId, key);
208 1 : const promptGeneration = writer.getSessionWriteGeneration(sessionId, isReloadedKey);
209 1 : const pendingKeys = writer.getPendingKeysForSession(sessionId, isReloadedKey);
210 1 : let decision = await confirmExternalReload(appName, pendingKeys.length > 0);
211 : // The editor remains interactive while the native dialog is open. If a local edit was
212 : // queued during a clean-file prompt, require the explicit destructive confirmation too.
213 1 : const pendingAfterPrompt = writer.getPendingKeysForSession(sessionId, isReloadedKey);
214 1 : if (decision.reload && pendingKeys.length === 0 && pendingAfterPrompt.length > 0) {
215 1 : decision = await confirmExternalReload(appName, true);
216 : }
217 1 : if (!decision.reload) {
218 : // The app version wins, it replaces the external version on disk
219 1 : folder.acknowledgeScripts(stamps.scripts);
220 1 : const latest = sessionScriptsRegistryRef.current.sessionScriptsMap.get(sessionId);
221 1 : if (latest) writeKeptSessionScripts(writer, latest, scripts);
222 1 : return;
223 1 : }
224 1 : if (writer.getSessionWriteGeneration(sessionId, isReloadedKey) !== promptGeneration) {
225 : // The generation changed while the dialog was open. Requeue and take a fresh disk
226 : // snapshot instead of applying a stale destructive decision.
227 1 : queuedRef.current.add(sessionId);
228 1 : return;
229 1 : }
230 1 : if (decision.discardPendingWrites) {
231 1 : writer.cancelPendingWritesForSession(sessionId, isReloadedKey);
232 : }
233 1 : folder.acknowledgeScripts(stamps.scripts);
234 : // Connections that the session file brought along join the app before the scripts use them
235 1 : const created = stored.created;
236 1 : if (created.length > 0) {
237 1 : setConnectionRegistry(registry => {
238 1 : for (const connection of created) registerConnection(registry, connection);
239 1 : return { ...registry };
240 1 : });
241 1 : for (const connection of created) {
242 1 : void writer.write(
243 1 : groupConnectionEntryWrites(connection.connectionId),
244 1 : { type: WRITE_CONNECTION_ENTRY, value: { ...connectionEntryFromState(connection), createdAt: new Date().toISOString() } },
245 1 : );
246 : }
247 1 : }
248 1 : registered = true;
249 : // Files that were added or removed outside the app change the script list
250 1 : if (sessionScriptsResolutionChanged(sessionFile, stored.session)) {
251 1 : const { scripts, connections } = stored.session;
252 1 : void writer.write(
253 1 : groupSessionScriptListWrites(sessionId),
254 1 : { type: WRITE_SESSION_SCRIPTS, value: [sessionId, { scripts, connections }] },
255 : DEBOUNCE_DURATION_SESSION_WRITE,
256 1 : );
257 1 : }
258 1 : const latestSessionScripts = sessionScriptsRegistryRef.current.sessionScriptsMap.get(sessionId);
259 1 : if (latestSessionScripts) {
260 1 : const reloadedSessionScripts = replaceSessionScriptsFromStorage(latestSessionScripts, snapshot, logger);
261 1 : setSessionScriptsRegistry(registry => {
262 : // Do not overwrite a session that was replaced by another lifecycle operation
263 : // while the native confirmation dialog was open.
264 1 : if (registry.sessionScriptsMap.get(sessionId) !== latestSessionScripts) {
265 0 : return registry;
266 : }
267 1 : return replaceSessionScriptsInRegistry(registry, latestSessionScripts, reloadedSessionScripts);
268 1 : });
269 1 : }
270 1 : logger.info('reloaded externally changed session', { sessionId }, LOG_CTX);
271 1 : } catch (error) {
272 1 : logger.warn('failed to check the session folder', {
273 1 : sessionId,
274 1 : error: stringifyError(error),
275 1 : }, LOG_CTX);
276 1 : } finally {
277 1 : if (!registered) {
278 1 : for (const connection of stored?.created ?? []) destroyUnregisteredConnection(connection);
279 : }
280 1 : writer.resumeSession(sessionId);
281 1 : }
282 1 : });
283 :
284 1 : const enqueueCheck = React.useEffectEvent((sessionId: string) => {
285 1 : queuedRef.current.add(sessionId);
286 1 : if (processingRef.current) {
287 0 : return;
288 : }
289 1 : processingRef.current = true;
290 1 : void (async () => {
291 1 : while (queuedRef.current.size > 0) {
292 1 : const next = queuedRef.current.values().next().value as string;
293 1 : queuedRef.current.delete(next);
294 1 : await checkSession(next);
295 1 : }
296 1 : processingRef.current = false;
297 1 : })();
298 1 : });
299 :
300 1 : const enqueueAllChecks = React.useEffectEvent(() => {
301 1 : for (const sessionId of sessionScriptsRegistryRef.current.sessionScriptsMap.keys()) {
302 1 : enqueueCheck(sessionId);
303 : }
304 1 : });
305 :
306 : // Effect events must not be dependencies, they change with every render
307 1 : React.useEffect(() => {
308 1 : if (!isDesktopHost()) {
309 1 : return;
310 : }
311 1 : const onFocus = () => enqueueAllChecks();
312 1 : window.addEventListener('focus', onFocus);
313 1 : const unsubscribe = writer.subscribeExternalChanges(sessionId => enqueueCheck(sessionId));
314 1 : return () => {
315 1 : window.removeEventListener('focus', onFocus);
316 1 : unsubscribe();
317 1 : };
318 1 : }, [writer]);
319 :
320 : // The first check of a session acknowledges its scripts, the writer checks the disk from then on
321 1 : React.useEffect(() => {
322 1 : if (!isDesktopHost()) {
323 1 : return;
324 : }
325 1 : for (const sessionId of sessionScriptsRegistry.sessionScriptsMap.keys()) {
326 1 : if (!firstCheckRequestedRef.current.has(sessionId)) {
327 1 : firstCheckRequestedRef.current.add(sessionId);
328 1 : enqueueCheck(sessionId);
329 1 : }
330 : }
331 1 : }, [sessionScriptsRegistry]);
332 :
333 1 : return null;
334 1 : };
|