LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - native_session_sync_react.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.2 % 217 213
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1