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