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