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

            Line data    Source code
       1              : import * as React from 'react';
       2              : 
       3              : import {
       4              :     ConnectionState,
       5              :     ConnectionAction,
       6              :     ConnectionStateWithoutId,
       7              :     CATALOG_UPDATE_PARTIALLY_SUCCEEDED,
       8              :     CATALOG_UPDATE_SUCCEEDED,
       9              :     DELETE_CONNECTION,
      10              :     SWITCH_CONNECTOR_TYPE,
      11              :     reduceConnectionState,
      12              :     SET_CONNECTION_DRAFT,
      13              :     DISCARD_CONNECTION_DRAFT,
      14              :     COMMIT_CONNECTION_PARAMS,
      15              : } from './connection_state.js';
      16              : import { QUERY_PROGRESS_UPDATED, QUERY_RECEIVED_BATCH } from '../query/query_execution_state.js';
      17              : import { Dispatch } from '../utils/variant.js';
      18              : import { UpdateQueue, useStore, useStoreState, useUpdateQueue } from '../utils/store.js';
      19              : import { CONNECTOR_TYPES } from './connector_info.js';
      20              : import { isHyperWasmConnection } from './connection_label.js';
      21              : import { useStorageWriter } from '../storage/storage_provider.js';
      22              : import { useLogger } from '../platform/logger/logger_provider.js';
      23              : import type { Logger } from '../platform/logger/logger.js';
      24              : 
      25            1 : const LOG_CTX = 'connection_registry';
      26              : import {
      27              :     DEBOUNCE_DURATION_SESSION_WRITE,
      28              :     groupConnectionEntryWrites,
      29              :     groupConnectionFileWrites,
      30              :     groupSessionConnectionCopyWrites,
      31              :     WRITE_CONNECTION_ENTRY,
      32              :     WRITE_CONNECTION_FILE,
      33              :     PATCH_SESSION_CONNECTION,
      34              :     DELETE_CONNECTION_ENTRY,
      35              :     type StorageWriter,
      36              : } from '../storage/storage_writer.js';
      37              : import { STORAGE_CATALOG_CHANGES, STORAGE_CATALOG_FUNCTIONS, STORAGE_CATALOG_RELATIONS, type ConnectionEntry, type SessionConnection } from '../storage/storage_backend.js';
      38              : import { connectionCatalogIsDurable, connectionParamsEqual, type ConnectionParams } from './connection_params.js';
      39              : 
      40              : /// Drafts are written to the app manifest only, edits arrive with every keystroke
      41              : export const DEBOUNCE_DURATION_CONNECTION_DRAFT_WRITE = 250;
      42              : 
      43              : /// The connection registry
      44              : ///
      45              : /// Connections are entities of their own, keyed by the global connection
      46              : /// id. The scripts of sessions run on them, several sessions
      47              : /// may reference the same connection and then share its channel, catalog and queries.
      48              : ///
      49              : /// Note that we're deliberately not using immutable maps for the connections here.
      50              : /// Following the same reasoning as with the session registry, we don't have code that
      51              : /// explicitly observes modifications of the registry map.
      52              : /// Instead, shallow-compare the entire registry object again.
      53              : export interface ConnectionRegistry {
      54              :     connections: Map<string, ConnectionState>;  // connectionId -> ConnectionState
      55              :     connectionsByType: string[][];  // arrays of databaseIds by connector type
      56              : }
      57              : 
      58              : /// The sessions that use the connections.
      59              : ///
      60              : /// The scripts of a session own the references to their connections, the session scripts registry
      61              : /// provides them to the connection layer. A host without sessions (e.g. the shell) has no references.
      62              : export interface ConnectionReferences {
      63              :     /// The sessions with scripts on a connection, in registration order
      64              :     sessionsOf(connectionId: string): readonly string[];
      65              :     /// The connections that the scripts of a session use in feed order, then the pending connection
      66              :     connectionsOf(sessionId: string): readonly string[];
      67              :     /// Subscribe to changes of the references
      68              :     subscribe(listener: () => void): () => void;
      69              : }
      70              : 
      71              : /// The references of a host without sessions
      72            1 : export const NO_CONNECTION_REFERENCES: ConnectionReferences = {
      73            1 :     sessionsOf: () => [],
      74            1 :     connectionsOf: () => [],
      75            1 :     subscribe: () => () => { },
      76            1 : };
      77              : 
      78            1 : const CONNECTION_REFERENCES_CTX = React.createContext<ConnectionReferences>(NO_CONNECTION_REFERENCES);
      79              : /// Provide the sessions that use the connections
      80            1 : export const ConnectionReferencesProvider = CONNECTION_REFERENCES_CTX.Provider;
      81              : 
      82              : /// Get the references without subscribing to their changes
      83            1 : export function useConnectionReferences(): ConnectionReferences {
      84            1 :     return React.useContext(CONNECTION_REFERENCES_CTX);
      85            1 : }
      86              : 
      87              : /// Subscribe to a slice of the references.
      88              : /// The selector must return values that keep their identity while unchanged.
      89            1 : export function useConnectionReferencesSelector<T>(select: (references: ConnectionReferences) => T): T {
      90            1 :     const references = useConnectionReferences();
      91            1 :     return React.useSyncExternalStore(references.subscribe, () => select(references));
      92            1 : }
      93              : 
      94              : /// The connections of a session, null while a connection is not registered
      95            1 : export function resolveSessionConnections(
      96            1 :     registry: Pick<ConnectionRegistry, 'connections'>,
      97            1 :     references: ConnectionReferences,
      98            1 :     sessionId: string | null,
      99            1 : ): ConnectionState[] | null {
     100            1 :     if (sessionId == null) return null;
     101            1 :     const connections = references.connectionsOf(sessionId)
     102            1 :         .map(connectionId => registry.connections.get(connectionId));
     103            1 :     if (connections.length === 0 || connections.some(database => database == null)) return null;
     104            1 :     return connections as ConnectionState[];
     105            1 : }
     106              : 
     107              : /// Is a connection listed next to the sessions?
     108              : /// Connections that sessions reference are listed, as are active connections that no session references.
     109            1 : export function isListedConnection(
     110            1 :     registry: Pick<ConnectionRegistry, 'connections'>,
     111            1 :     references: ConnectionReferences,
     112            1 :     connectionId: string,
     113            1 : ): boolean {
     114            1 :     const database = registry.connections.get(connectionId);
     115            1 :     if (database == null) return false;
     116            1 :     return references.sessionsOf(connectionId).length > 0 || database.active;
     117            1 : }
     118              : 
     119              : /// Register a connection in the registry indices (mutates the registry)
     120            1 : export function registerConnection(registry: ConnectionRegistry, database: ConnectionState): void {
     121            1 :     registry.connections.set(database.connectionId, database);
     122            1 :     const byType = registry.connectionsByType[database.connectorInfo.connectorType];
     123            1 :     if (!byType.includes(database.connectionId)) byType.push(database.connectionId);
     124            1 : }
     125              : 
     126              : /// The queries a session issued on a connection, active ones first
     127            1 : export function findSessionQueries(database: ConnectionState, sessionId: string, includeFinished: boolean = true): number[] {
     128            1 :     const active = database.queriesActiveOrdered.filter(queryId => database.queriesActive.get(queryId)?.queryMetadata.sessionId === sessionId);
     129            1 :     if (!includeFinished) return active;
     130            0 :     const finished = database.queriesFinishedOrdered.filter(queryId => database.queriesFinished.get(queryId)?.queryMetadata.sessionId === sessionId);
     131            0 :     return [...active, ...finished];
     132            1 : }
     133              : 
     134              : /// Build the manifest entry of a connection
     135            1 : export function connectionEntryFromState(database: ConnectionState): ConnectionEntry {
     136            1 :     return {
     137            1 :         id: database.connectionId,
     138            1 :         params: database.params,
     139            1 :         ...(database.paramsDraft == null ? {} : { paramsDraft: database.paramsDraft }),
     140            1 :         ...(database.name == null ? {} : { name: database.name }),
     141            1 :         ...(database.cacheQueryResults == null ? {} : { cacheQueryResults: database.cacheQueryResults }),
     142            1 :     };
     143            1 : }
     144              : 
     145              : /// Build the copy of a connection that a session file stores
     146            1 : export function sessionConnectionFromState(database: ConnectionState): SessionConnection {
     147            1 :     return {
     148            1 :         connectionId: database.connectionId,
     149            1 :         params: database.params,
     150            1 :     };
     151            1 : }
     152              : 
     153              : /// Did the saved params change?
     154            1 : export function didConnectionParamsChange(prev: ConnectionState, next: ConnectionState): boolean {
     155            1 :     return !connectionParamsEqual(prev.params, next.params);
     156            1 : }
     157              : 
     158              : 
     159              : /// Did the persisted connection entry change?
     160            1 : export function didPersistedConnectionChange(prev: ConnectionState, next: ConnectionState): boolean {
     161            1 :     if (!prev.active && next.active) return true;
     162            1 :     return didConnectionParamsChange(prev, next) || !connectionParamsEqual(prev.paramsDraft, next.paramsDraft) || prev.name !== next.name || prev.cacheQueryResults !== next.cacheQueryResults;
     163            1 : }
     164              : 
     165              : /// Write the saved params of a connection into the app manifest and into every session that
     166              : /// references it. The writes are debounced and not awaited, Save awaits them explicitly.
     167            1 : export function scheduleConnectionParamsWrites(references: ConnectionReferences, database: ConnectionState, storageWriter: StorageWriter): void {
     168            1 :     void storageWriter.write(
     169            1 :         groupConnectionEntryWrites(database.connectionId),
     170            1 :         { type: WRITE_CONNECTION_ENTRY, value: connectionEntryFromState(database) },
     171              :         DEBOUNCE_DURATION_SESSION_WRITE,
     172            1 :     );
     173            1 :     for (const sessionId of references.sessionsOf(database.connectionId)) {
     174            0 :         void storageWriter.write(
     175            0 :             groupSessionConnectionCopyWrites(sessionId, database.connectionId),
     176            0 :             { type: PATCH_SESSION_CONNECTION, value: [sessionId, sessionConnectionFromState(database)] },
     177              :             DEBOUNCE_DURATION_SESSION_WRITE,
     178            0 :         );
     179              :     }
     180            1 : }
     181              : 
     182              : export type SetConnectionRegistryAction = React.SetStateAction<ConnectionRegistry>;
     183              : export type ConnectionDispatch = (action: ConnectionAction) => void;
     184              : export type DynamicConnectionDispatch = (connectionId: string | null, action: ConnectionAction) => void;
     185              : 
     186              : /// A stable handle to the latest registry.
     187              : ///
     188              : /// The registry changes with every query lifecycle event (including the internal SQLFrame queries of
     189              : /// computations), which re-renders every context consumer.
     190              : /// Components that only care about a small slice of the registry subscribe to the store instead.
     191              : export interface ConnectionRegistryStore {
     192              :     /// The latest registry
     193              :     current: ConnectionRegistry;
     194              :     /// Subscribe to registry changes
     195              :     subscribe: (listener: () => void) => () => void;
     196              : }
     197              : 
     198            1 : const CONNECTION_REGISTRY_CTX = React.createContext<ConnectionRegistry | null>(null);
     199            1 : const CONNECTION_REGISTRY_SETTER_CTX = React.createContext<Dispatch<SetConnectionRegistryAction> | null>(null);
     200            1 : const CONNECTION_REGISTRY_STORE_CTX = React.createContext<ConnectionRegistryStore | null>(null);
     201            1 : const CONNECTION_REGISTRY_QUEUE_CTX = React.createContext<UpdateQueue<ConnectionRegistry> | null>(null);
     202              : 
     203              : type Props = {
     204              :     children: React.ReactElement | React.ReactElement[];
     205              : };
     206              : 
     207            1 : export const ConnectionRegistry: React.FC<Props> = (props: Props) => {
     208              :     // Registry updates free Wasm objects and schedule storage writes, the store runs every update once
     209            1 :     const store = useStore<ConnectionRegistry>(() => ({
     210            1 :         connections: new Map(),
     211            1 :         connectionsByType: CONNECTOR_TYPES.map(() => ([])),
     212            1 :     }));
     213            1 :     const queue = useUpdateQueue(store);
     214            1 :     const reg = useStoreState(store);
     215            1 :     return (
     216            1 :         <CONNECTION_REGISTRY_STORE_CTX.Provider value={store}>
     217            1 :             <CONNECTION_REGISTRY_QUEUE_CTX.Provider value={queue}>
     218            1 :                 <CONNECTION_REGISTRY_SETTER_CTX.Provider value={store.update}>
     219            1 :                     <CONNECTION_REGISTRY_CTX.Provider value={reg}>
     220            1 :                         {props.children}
     221            1 :                     </CONNECTION_REGISTRY_CTX.Provider>
     222            1 :                 </CONNECTION_REGISTRY_SETTER_CTX.Provider>
     223            1 :             </CONNECTION_REGISTRY_QUEUE_CTX.Provider>
     224            1 :         </CONNECTION_REGISTRY_STORE_CTX.Provider>
     225              :     );
     226            1 : };
     227              : 
     228              : /// Get the registry store without subscribing to registry changes
     229            1 : export function useConnectionRegistryStore(): ConnectionRegistryStore {
     230            1 :     return React.useContext(CONNECTION_REGISTRY_STORE_CTX)!;
     231            1 : }
     232              : 
     233              : /// Subscribe to a slice of the registry.
     234              : /// The component only re-renders when the selected value changes.
     235              : /// The selector must return values that keep their identity while unchanged.
     236              : export function useConnectionRegistrySelector<T>(select: (registry: ConnectionRegistry) => T): T {
     237              :     const store = useConnectionRegistryStore();
     238              :     return React.useSyncExternalStore(store.subscribe, () => select(store.current));
     239              : }
     240              : 
     241              : /// Modify the registry without subscribing to registry changes
     242            1 : export function useConnectionRegistrySetter(): Dispatch<SetConnectionRegistryAction> {
     243            1 :     return React.useContext(CONNECTION_REGISTRY_SETTER_CTX)!;
     244            1 : }
     245              : 
     246              : export type ConnectionAllocator = (state: ConnectionStateWithoutId, connectionId?: string) => ConnectionState;
     247              : 
     248              : /// Allocate a new connection that no session references yet.
     249              : /// A session references the connection once its scripts use it.
     250              : /// Active connections are written into the app manifest right away.
     251            1 : export function useConnectionAllocator(): ConnectionAllocator {
     252            1 :     const setReg = React.useContext(CONNECTION_REGISTRY_SETTER_CTX)!;
     253            1 :     const storageWriter = useStorageWriter();
     254            1 :     return React.useCallback((state: ConnectionStateWithoutId, fixedId?: string) => {
     255            1 :         const connectionId = fixedId ?? crypto.randomUUID();
     256            1 :         const database: ConnectionState = { ...state, connectionId };
     257            1 :         setReg((reg) => {
     258            1 :             registerConnection(reg, database);
     259            1 :             return { ...reg };
     260            1 :         });
     261            1 :         if (database.active) {
     262            1 :             void storageWriter.write(
     263            1 :                 groupConnectionEntryWrites(connectionId),
     264            1 :                 { type: WRITE_CONNECTION_ENTRY, value: { ...connectionEntryFromState(database), createdAt: new Date().toISOString() } },
     265            1 :             );
     266              :         }
     267            1 :         return database;
     268            1 :     }, [setReg, storageWriter]);
     269            1 : }
     270              : 
     271            1 : export function useConnectionRegistry(): [ConnectionRegistry, Dispatch<SetConnectionRegistryAction>] {
     272            1 :     return [React.useContext(CONNECTION_REGISTRY_CTX)!, React.useContext(CONNECTION_REGISTRY_SETTER_CTX)!];
     273            1 : }
     274              : 
     275            1 : export function useDynamicConnectionDispatch(): [ConnectionRegistry, DynamicConnectionDispatch] {
     276            1 :     const registry = React.useContext(CONNECTION_REGISTRY_CTX)!;
     277            1 :     const dispatch = useConnectionDispatch();
     278            1 :     return [registry, dispatch];
     279            1 : }
     280              : 
     281              : /// Apply an action to a connection of the registry (mutates the registry)
     282            1 : function applyConnectionAction(reg: ConnectionRegistry, references: ConnectionReferences, connectionId: string, action: ConnectionAction, storageWriter: StorageWriter, logger: Logger): ConnectionRegistry {
     283            1 :     const prev = reg.connections.get(connectionId);
     284            1 :     if (!prev) {
     285            1 :         console.warn(`no connection registered with id ${connectionId}`);
     286            1 :         return reg;
     287            1 :     }
     288            1 :     const connectorType = prev.connectorInfo.connectorType;
     289            1 :     const sessionIds = references.sessionsOf(connectionId);
     290              : 
     291              :     // Connections outlive the sessions that reference them, but a referenced connection
     292              :     // must neither be deleted nor change its type underneath other sessions.
     293            1 :     if (action.type == DELETE_CONNECTION && sessionIds.length > 0) {
     294            1 :         logger.warn('refusing to delete a connection that sessions reference', {
     295            1 :             connectionId,
     296            1 :             sessions: sessionIds.join(','),
     297            1 :         }, LOG_CTX);
     298            1 :         return reg;
     299            1 :     }
     300              :     // Sessions fall back to the embedded Hyper and the user can't attach one again, it is never deleted
     301            1 :     if (action.type == DELETE_CONNECTION && isHyperWasmConnection(prev)) {
     302            1 :         logger.warn('refusing to delete the connection to the embedded Hyper', { connectionId }, LOG_CTX);
     303            1 :         return reg;
     304            1 :     }
     305            1 :     if (action.type == SWITCH_CONNECTOR_TYPE && sessionIds.length > 1) {
     306            1 :         logger.warn('refusing to switch the type of a shared connection', { connectionId }, LOG_CTX);
     307            1 :         return reg;
     308            1 :     }
     309            1 :     const next = reduceConnectionState(prev, action, logger);
     310              : 
     311            1 :     if (action.type == DELETE_CONNECTION) {
     312            1 :         reg.connectionsByType[connectorType] = reg.connectionsByType[connectorType].filter(id => id != connectionId);
     313            1 :         reg.connections.delete(connectionId);
     314            1 :         if (prev.active) {
     315            1 :             void storageWriter.write(groupConnectionEntryWrites(connectionId), { type: DELETE_CONNECTION_ENTRY, value: connectionId });
     316              :         }
     317            1 :         return { ...reg };
     318            1 :     }
     319            1 :     reg.connections.set(connectionId, next);
     320              :     // Update type index when connector type changes
     321            1 :     if (action.type == SWITCH_CONNECTOR_TYPE && next.connectorInfo.connectorType !== connectorType) {
     322            1 :         reg.connectionsByType[connectorType] = reg.connectionsByType[connectorType].filter(id => id != connectionId);
     323            1 :         reg.connectionsByType[next.connectorInfo.connectorType].push(connectionId);
     324            1 :     }
     325              :     // Only connections that sessions reference or that were activated are persisted.
     326              :     // Saving writes the committed params by itself and awaits the writes.
     327            1 :     if (sessionIds.length === 0 && !next.active) {
     328            1 :         return { ...reg };
     329              :     }
     330            1 :     if (action.type !== COMMIT_CONNECTION_PARAMS) {
     331            1 :         if (didConnectionParamsChange(prev, next) || (!prev.active && next.active)) {
     332              :             // Saved params are replicated into every session that references the connection
     333            1 :             scheduleConnectionParamsWrites(references, next, storageWriter);
     334            1 :         } else if (didPersistedConnectionChange(prev, next)) {
     335              :             // Drafts, names and the caching choice only live in the app manifest
     336            1 :             void storageWriter.write(
     337            1 :                 groupConnectionEntryWrites(connectionId),
     338            1 :                 { type: WRITE_CONNECTION_ENTRY, value: connectionEntryFromState(next) },
     339              :                 DEBOUNCE_DURATION_CONNECTION_DRAFT_WRITE,
     340            1 :             );
     341              :         }
     342            1 :     }
     343            1 :     if (!next.active) {
     344            1 :         return { ...reg };
     345              :     }
     346              :     // The catalog of the connection is written into the app storage, sessions don't keep a copy.
     347              :     // The relations of a connection whose database doesn't outlive the app are never written.
     348            1 :     const durable = connectionCatalogIsDurable(next.params);
     349            1 :     if (action.type === CATALOG_UPDATE_SUCCEEDED || action.type === CATALOG_UPDATE_PARTIALLY_SUCCEEDED) {
     350            1 :         const relationsSql = next.catalogRelationScript.toString();
     351            1 :         const functionsSql = next.catalogFunctionScript.toString();
     352            1 :         if (durable) {
     353            1 :             void storageWriter.write(
     354            1 :                 groupConnectionFileWrites(connectionId, STORAGE_CATALOG_RELATIONS),
     355            1 :                 { type: WRITE_CONNECTION_FILE, value: [connectionId, STORAGE_CATALOG_RELATIONS, relationsSql] },
     356              :                 DEBOUNCE_DURATION_SESSION_WRITE,
     357            1 :             );
     358              :         }
     359            1 :         void storageWriter.write(
     360            1 :             groupConnectionFileWrites(connectionId, STORAGE_CATALOG_FUNCTIONS),
     361            1 :             { type: WRITE_CONNECTION_FILE, value: [connectionId, STORAGE_CATALOG_FUNCTIONS, functionsSql] },
     362              :             DEBOUNCE_DURATION_SESSION_WRITE,
     363            1 :         );
     364            1 :     }
     365              :     // Executed statements record catalog changes, a refresh clears them
     366            1 :     if (durable && next.catalogChangesSql !== prev.catalogChangesSql) {
     367            1 :         void storageWriter.write(
     368            1 :             groupConnectionFileWrites(connectionId, STORAGE_CATALOG_CHANGES),
     369            1 :             { type: WRITE_CONNECTION_FILE, value: [connectionId, STORAGE_CATALOG_CHANGES, next.catalogChangesSql] },
     370              :             DEBOUNCE_DURATION_SESSION_WRITE,
     371            1 :         );
     372              :     }
     373            1 :     return { ...reg };
     374            1 : }
     375              : 
     376              : /// Dispatch actions to connections without subscribing to registry changes.
     377              : /// The dispatches of all components are applied in order through the queue of the registry.
     378            1 : export function useConnectionDispatch(): DynamicConnectionDispatch {
     379            1 :     const queue = React.useContext(CONNECTION_REGISTRY_QUEUE_CTX)!;
     380            1 :     const references = useConnectionReferences();
     381            1 :     const storageWriter = useStorageWriter();
     382            1 :     const logger = useLogger();
     383            1 :     return React.useCallback((connectionId: string | null, action: ConnectionAction) => {
     384              :         // No id provided? Then do nothing.
     385            1 :         if (connectionId == null) {
     386            1 :             return;
     387              :         }
     388              :         // Query execution may happen in a worker or remotely, but Arrow batch decoding and these
     389              :         // state updates happen on the renderer thread. A fast result stream can therefore enqueue
     390              :         // a microtask cascade that repeatedly rerenders the session before the browser gets a
     391              :         // frame to paint. Publish only the latest streamed progress once per frame; lifecycle
     392              :         // actions stay immediate and flush queued stream updates first to preserve action order.
     393            1 :         const waitForFrame = action.type === QUERY_PROGRESS_UPDATED || action.type === QUERY_RECEIVED_BATCH;
     394            1 :         queue.push(reg => applyConnectionAction(reg, references, connectionId, action, storageWriter, logger), waitForFrame);
     395            1 :     }, [queue, references, storageWriter, logger]);
     396            1 : }
     397              : 
     398              : /// The connections that the scripts of a session use
     399            1 : export function useSessionConnections(sessionId: string | null): ConnectionState[] | null {
     400            1 :     const [registry] = useConnectionRegistry();
     401            1 :     const references = useConnectionReferences();
     402              :     // Re-render when the session uses other connections
     403            1 :     useConnectionReferencesSelector(references => sessionId == null ? '' : references.connectionsOf(sessionId).join('\n'));
     404              :     // The list keeps its identity while the connections are unchanged, consumers depend on it in effects
     405            1 :     const resolved = resolveSessionConnections(registry, references, sessionId);
     406            1 :     const latest = React.useRef(resolved);
     407            1 :     const same = latest.current === resolved || (latest.current != null && resolved != null
     408            1 :         && latest.current.length === resolved.length && latest.current.every((connection, i) => connection === resolved[i]));
     409            1 :     if (!same) latest.current = resolved;
     410            1 :     return latest.current;
     411            1 : }
     412              : 
     413            1 : export function useConnectionById(connectionId: string | null): [ConnectionState | null, ConnectionDispatch] {
     414            1 :     const [registry, dispatch] = useDynamicConnectionDispatch();
     415            1 :     const capturingDispatch = React.useCallback((action: ConnectionAction) => dispatch(connectionId, action), [connectionId, dispatch]);
     416            1 :     return [connectionId == null ? null : registry.connections.get(connectionId) ?? null, capturingDispatch];
     417            1 : }
     418              : 
     419              : /// Editing dispatches immediately; the registry debounces only the durable manifest write.
     420            1 : export function useConnectionDraft(connectionId: string | null) {
     421            1 :     const [database, dispatch] = useConnectionById(connectionId);
     422            1 :     const setDraft = React.useCallback((params: ConnectionParams) => {
     423            1 :         dispatch({ type: SET_CONNECTION_DRAFT, value: params });
     424            1 :     }, [dispatch]);
     425            1 :     const discard = React.useCallback(() => {
     426            0 :         dispatch({ type: DISCARD_CONNECTION_DRAFT, value: null });
     427            1 :     }, [dispatch]);
     428            1 :     return { params: database?.params ?? null, paramsDraft: database?.paramsDraft, setDraft, discard };
     429            1 : }
     430              : 
     431              : /// Forms write from user actions, never from hydration effects (which could resurrect a draft).
     432            1 : export function useConnectionDraftForm<T>(
     433            1 :     connectionId: string | null,
     434            1 :     decode: (params: ConnectionParams | null) => T,
     435            1 :     encode: (form: T) => ConnectionParams,
     436            1 : ): [T, React.Dispatch<React.SetStateAction<T>>] {
     437            1 :     const { params, paramsDraft, setDraft } = useConnectionDraft(connectionId);
     438            1 :     const source = paramsDraft ?? params;
     439            1 :     const [form, setForm] = React.useState(() => decode(source));
     440            1 :     const sourceRef = React.useRef(source);
     441            1 :     const formRef = React.useRef(form);
     442            1 :     const databaseIdRef = React.useRef(connectionId);
     443            1 :     const editedParamsRef = React.useRef<ConnectionParams | undefined>(undefined);
     444            1 :     if (databaseIdRef.current !== connectionId || sourceRef.current !== source) {
     445            1 :         const ownEdit = databaseIdRef.current === connectionId && paramsDraft != null
     446            1 :             && connectionParamsEqual(editedParamsRef.current, paramsDraft);
     447            1 :         databaseIdRef.current = connectionId;
     448            1 :         sourceRef.current = source;
     449            1 :         if (!ownEdit) {
     450            1 :             editedParamsRef.current = undefined;
     451            1 :             formRef.current = decode(source);
     452            1 :             setForm(formRef.current);
     453            1 :         }
     454            1 :     }
     455            1 :     const update = (value: React.SetStateAction<T>) => {
     456            1 :         const next = typeof value === 'function' ? (value as (prev: T) => T)(formRef.current) : value;
     457            1 :         formRef.current = next;
     458            1 :         setForm(next);
     459            1 :         const encoded = encode(next);
     460            1 :         editedParamsRef.current = encoded;
     461            1 :         setDraft(encoded);
     462            1 :     };
     463            1 :     return [formRef.current, update];
     464            1 : }
        

Generated by: LCOV version 2.0-1