LCOV - code coverage report
Current view: top level - dashql-app/src/connections - connection_persistence.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 74.8 % 107 80
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import { useStorageWriter } from '../storage/storage_provider.js';
       4              : import {
       5              :     groupConnectionEntryWrites,
       6              :     groupSessionConnectionCopyWrites,
       7              :     PATCH_SESSION_CONNECTION,
       8              :     WRITE_CONNECTION_ENTRY,
       9              :     type StorageWriteResult,
      10              : } from '../storage/storage_writer.js';
      11              : import { COMMIT_CONNECTION_PARAMS, type ConnectionState } from './connection_state.js';
      12              : import {
      13              :     connectionEntryFromState,
      14              :     useConnectionReferences,
      15              :     sessionConnectionFromState,
      16              :     useConnectionDispatch,
      17              :     useConnectionRegistryStore,
      18              : } from './connection_registry.js';
      19              : import { connectionParamsWithoutDraftRows, type ConnectionParams } from './connection_params.js';
      20              : import type { SessionConnection } from '../storage/storage_backend.js';
      21              : 
      22              : /// The copy of a connection with saved params that the sessions store
      23            1 : function sessionCopy(connection: ConnectionState, params: ConnectionParams): SessionConnection {
      24            1 :     return { ...sessionConnectionFromState(connection), params };
      25            1 : }
      26              : 
      27              : /// The outcome of writing a session's copy of the connection params
      28              : export interface SessionCopySaveResult {
      29              :     /// The session
      30              :     sessionId: string;
      31              :     /// The outcome of the write
      32              :     result: StorageWriteResult;
      33              : }
      34              : 
      35              : /// The outcome of saving a connection
      36              : export interface ConnectionSaveResult {
      37              :     /// The saved params
      38              :     params: ConnectionParams;
      39              :     /// The outcome of writing the app manifest, the connection is saved if it was written
      40              :     manifest: StorageWriteResult;
      41              :     /// The outcomes of writing the copies in the sessions that reference the connection
      42              :     sessions: SessionCopySaveResult[];
      43              : }
      44              : 
      45              : /// Did saving persist the connection everywhere?
      46            1 : export function connectionSaveSucceeded(result: ConnectionSaveResult): boolean {
      47            1 :     return result.manifest.status === 'written' && result.sessions.every(n => n.result.status === 'written');
      48            1 : }
      49              : 
      50              : /// The sessions whose copies were not written
      51            1 : export function failedSessionCopies(result: ConnectionSaveResult): SessionCopySaveResult[] {
      52            1 :     return result.sessions.filter(n => n.result.status !== 'written');
      53            1 : }
      54              : 
      55              : /// The state of saving a connection
      56              : export interface ConnectionSaveState {
      57              :     /// Is the connection being saved?
      58              :     saving: boolean;
      59              :     /// The outcome of the last save, if any
      60              :     result: ConnectionSaveResult | null;
      61              : }
      62              : 
      63              : /// The API to save connections
      64              : interface ConnectionPersistence {
      65              :     /// The save states of connections (connectionId -> state)
      66              :     states: Map<string, ConnectionSaveState>;
      67              :     /// Save the draft of a connection, or the given params
      68              :     save: (connectionId: string, params?: ConnectionParams) => Promise<ConnectionSaveResult>;
      69              :     /// Write the copies of sessions again
      70              :     retry: (connectionId: string, sessionIds: string[]) => Promise<ConnectionSaveResult | null>;
      71              : }
      72              : 
      73            1 : const CONNECTION_PERSISTENCE_CTX = React.createContext<ConnectionPersistence | null>(null);
      74              : 
      75            1 : const IDLE_SAVE_STATE: ConnectionSaveState = { saving: false, result: null };
      76              : 
      77              : /// Saves connections.
      78              : ///
      79              : /// The app manifest is the source of truth for connections, every session that references a
      80              : /// connection keeps a copy of its params. Saving writes the manifest first and the copies after, so a
      81              : /// partial failure heals forward: the next load rewrites stale copies from the manifest.
      82            1 : export const ConnectionPersistenceProvider: React.FC<{ children: React.ReactElement }> = (props) => {
      83            1 :     const storageWriter = useStorageWriter();
      84            1 :     const registryStore = useConnectionRegistryStore();
      85            1 :     const references = useConnectionReferences();
      86            1 :     const dispatch = useConnectionDispatch();
      87            1 :     const [states, setStates] = React.useState(() => new Map<string, ConnectionSaveState>());
      88              : 
      89            1 :     const setState = React.useCallback((connectionId: string, state: ConnectionSaveState) => {
      90            1 :         setStates(prev => new Map(prev).set(connectionId, state));
      91            1 :     }, []);
      92              : 
      93            1 :     const writeSessionCopies = React.useCallback(async (copy: SessionConnection, sessionIds: string[]) => {
      94            1 :         return await Promise.all(sessionIds.map(async sessionId => ({
      95            1 :             sessionId,
      96            1 :             result: await storageWriter.writeDurable(
      97            1 :                 groupSessionConnectionCopyWrites(sessionId, copy.connectionId),
      98            1 :                 { type: PATCH_SESSION_CONNECTION, value: [sessionId, copy] },
      99            1 :             ),
     100            1 :         })));
     101            1 :     }, [storageWriter]);
     102              : 
     103            1 :     const save = React.useCallback(async (connectionId: string, saved?: ConnectionParams): Promise<ConnectionSaveResult> => {
     104            1 :         const connection = registryStore.current.connections.get(connectionId);
     105            1 :         if (connection == null) {
     106            0 :             throw new Error(`no connection registered with id ${connectionId}`);
     107              :         }
     108            1 :         const params = connectionParamsWithoutDraftRows(saved ?? connection.paramsDraft ?? connection.params);
     109            1 :         setState(connectionId, { saving: true, result: null });
     110            1 :         try {
     111              :             // Write the source of truth first, the same key replaces pending draft writes
     112            1 :             const manifest = await storageWriter.writeDurable(
     113            1 :                 groupConnectionEntryWrites(connectionId),
     114            1 :                 { type: WRITE_CONNECTION_ENTRY, value: { ...connectionEntryFromState({ ...connection, paramsDraft: undefined }), params } },
     115            1 :             );
     116            1 :             if (manifest.status !== 'written') {
     117            0 :                 const result: ConnectionSaveResult = { params, manifest, sessions: [] };
     118            0 :                 setState(connectionId, { saving: false, result });
     119            0 :                 return result;
     120            0 :             }
     121            1 :             dispatch(connectionId, { type: COMMIT_CONNECTION_PARAMS, value: params });
     122            1 :             const sessionIds = [...references.sessionsOf(connectionId)];
     123            1 :             const result: ConnectionSaveResult = { params, manifest, sessions: await writeSessionCopies(sessionCopy(connection, params), sessionIds) };
     124            1 :             setState(connectionId, { saving: false, result });
     125            1 :             return result;
     126            1 :         } catch (error) {
     127            0 :             const result: ConnectionSaveResult = { params, manifest: { status: 'failed', error }, sessions: [] };
     128            0 :             setState(connectionId, { saving: false, result });
     129            0 :             return result;
     130            0 :         }
     131            1 :     }, [dispatch, references, registryStore, setState, storageWriter, writeSessionCopies]);
     132              : 
     133            1 :     const retry = React.useCallback(async (connectionId: string, sessionIds: string[]): Promise<ConnectionSaveResult | null> => {
     134            0 :         const last = states.get(connectionId)?.result;
     135            0 :         if (last == null || last.manifest.status !== 'written') return null;
     136            0 :         setState(connectionId, { saving: true, result: last });
     137            0 :         const connection = registryStore.current.connections.get(connectionId);
     138            0 :         if (connection == null) return null;
     139            0 :         const retried = await writeSessionCopies(sessionCopy(connection, last.params), sessionIds);
     140            0 :         const bySession = new Map(retried.map(r => [r.sessionId, r]));
     141            0 :         const result: ConnectionSaveResult = {
     142            0 :             ...last,
     143            0 :             sessions: last.sessions.map(n => bySession.get(n.sessionId) ?? n),
     144            0 :         };
     145            0 :         setState(connectionId, { saving: false, result });
     146            0 :         return result;
     147            1 :     }, [registryStore, setState, states, writeSessionCopies]);
     148              : 
     149            1 :     const value = React.useMemo(() => ({ states, save, retry }), [states, save, retry]);
     150            1 :     return (
     151            1 :         <CONNECTION_PERSISTENCE_CTX.Provider value={value}>
     152            1 :             {props.children}
     153            1 :         </CONNECTION_PERSISTENCE_CTX.Provider>
     154              :     );
     155            1 : };
     156              : 
     157              : /// Save a connection and observe the save state
     158            1 : export function useConnectionSave(connectionId: string | null) {
     159            1 :     const persistence = React.useContext(CONNECTION_PERSISTENCE_CTX);
     160            1 :     const state = (connectionId == null ? null : persistence?.states.get(connectionId)) ?? IDLE_SAVE_STATE;
     161            1 :     const save = React.useCallback(async (): Promise<ConnectionSaveResult | null> => {
     162            1 :         if (connectionId == null || persistence == null) return null;
     163            1 :         return await persistence.save(connectionId);
     164            1 :     }, [connectionId, persistence]);
     165            1 :     const retry = React.useCallback(async (sessionIds: string[]): Promise<ConnectionSaveResult | null> => {
     166            0 :         if (connectionId == null || persistence == null) return null;
     167            0 :         return await persistence.retry(connectionId, sessionIds);
     168            1 :     }, [connectionId, persistence]);
     169            1 :     return { ...state, save, retry };
     170            1 : }
     171              : 
     172              : /// Save the params of any connection, e.g. of a connection that is not shown
     173            1 : export function useConnectionSaver(): (connectionId: string, params: ConnectionParams) => Promise<ConnectionSaveResult | null> {
     174            1 :     const persistence = React.useContext(CONNECTION_PERSISTENCE_CTX);
     175            1 :     return React.useCallback(async (connectionId: string, params: ConnectionParams) => {
     176            1 :         return persistence == null ? null : await persistence.save(connectionId, params);
     177            1 :     }, [persistence]);
     178            1 : }
     179              : 
     180              : /// Save the draft of a connection before connecting with it.
     181              : /// Resolves false if the connection must not be set up because the draft could not be saved.
     182            1 : export function useSaveBeforeConnect(connectionId: string | null): () => Promise<boolean> {
     183            1 :     const persistence = React.useContext(CONNECTION_PERSISTENCE_CTX);
     184            1 :     const registryStore = useConnectionRegistryStore();
     185            1 :     return React.useCallback(async () => {
     186            1 :         if (connectionId == null || persistence == null) return true;
     187            0 :         const connection = registryStore.current.connections.get(connectionId);
     188            1 :         if (connection?.paramsDraft == null) return true;
     189            0 :         const result = await persistence.save(connectionId);
     190            0 :         return result.manifest.status === 'written';
     191            1 :     }, [connectionId, persistence, registryStore]);
     192            1 : }
        

Generated by: LCOV version 2.0-1