LCOV - code coverage report
Current view: top level - dashql-app/src/connections - connection_registry.tsx Coverage Total Hit
Test: DashQL d805a4b Lines: 97.6 % 286 279
Test Date: 2026-11-09 20:22:13

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

Generated by: LCOV version 2.0-1