Line data Source code
1 : import * as React from 'react';
2 :
3 : import { forgetSessionScriptReferences } from './script_reference_reports.js';
4 : import { forgetDataflowGraph } from '../dataflow/dataflow_graph.js';
5 : import { ANALYZE_OUTDATED_SCRIPT, SessionScripts, SessionScriptsAction, ScriptData, destroySessionScripts, isAnalysisOutdated, newScriptConnection, promoteEphemeralSession, reduceSessionScripts, SessionScriptsEffects, sessionConnectionIds, sessionScriptListWrite } from './session_scripts.js';
6 : import { Dispatch } from '../../utils/variant.js';
7 : import { UpdateQueue, useStore, useStoreState, useUpdateQueue } from '../../utils/store.js';
8 : import { useStorageWriter } from '../../storage/storage_provider.js';
9 : import { ConnectionReferencesProvider, type ConnectionReferences } from '../../connections/connection_registry.js';
10 : import { useLogger } from '../../platform/logger/logger_provider.js';
11 : import { stringifyError } from '../../platform/logger/logger.js';
12 : import type { InvalidSession } from '../../storage/session_validation.js';
13 : import {
14 : REPLACE_SESSION_SCRIPTS,
15 : DEBOUNCE_DURATION_SESSION_WRITE,
16 : type SessionReplacement,
17 : groupSessionScriptListWrites,
18 : groupSessionWrites,
19 : WRITE_SESSION_SCRIPTS,
20 : } from "../../storage/storage_writer.js";
21 :
22 1 : const LOG_CTX = 'session_scripts_registry';
23 :
24 : /// The scripts registry.
25 : ///
26 : /// Note that we're deliberately not using immutable maps for session scripts and the connection index.
27 : /// We're never "observing" these maps directly and thus can live with the simple variants.
28 : /// Shallow-compare the entire registry object instead when reacting to session list changes.
29 : export interface SessionScriptsRegistry {
30 : /// The scripts map (sessionId -> SessionScripts)
31 : sessionScriptsMap: Map<string, SessionScripts>;
32 : /// The index to find the sessions with scripts that analyze with a connection (connectionId -> sessionIds)
33 : sessionScriptsByConnection: Map<string, string[]>;
34 : /// The sessions that were refused a load (sessionId -> reason). They never enter the scripts map,
35 : /// the workbench lists them as invalid and they can only be deleted.
36 : invalidSessions: Map<string, InvalidSession>;
37 : }
38 :
39 : export type SetSessionScriptsRegistryAction = React.SetStateAction<SessionScriptsRegistry>;
40 :
41 : /// Copy the indices of a registry before modifying them. Snapshots of the registry are never modified, a
42 : /// component that renders an older snapshot keeps seeing the maps of that snapshot.
43 1 : export function copySessionScriptsRegistry(reg: SessionScriptsRegistry): SessionScriptsRegistry {
44 1 : return {
45 1 : ...reg,
46 1 : sessionScriptsMap: new Map(reg.sessionScriptsMap),
47 1 : sessionScriptsByConnection: new Map(reg.sessionScriptsByConnection),
48 1 : };
49 1 : }
50 :
51 : /// The registry with the next scripts of a session, the given registry stays unchanged
52 1 : export function replaceSessionScriptsInRegistry(reg: SessionScriptsRegistry, prev: SessionScripts, next: SessionScripts): SessionScriptsRegistry {
53 1 : const copy = copySessionScriptsRegistry(reg);
54 1 : copy.sessionScriptsMap.set(next.sessionId, next);
55 1 : reindexSessionScriptConnections(copy, prev, next);
56 1 : return copy;
57 1 : }
58 : export type SessionScriptsAllocator = (scripts: SessionScripts) => [string, SessionScripts];
59 : export type ModifySessionScripts = (action: SessionScriptsAction) => Promise<SessionScripts | null> | void;
60 : /// Resolves once the action was applied to the sessions
61 : export type ModifyConnectionSessionScripts = (conn: string, action: SessionScriptsAction) => Promise<void>;
62 :
63 : // The registry changes with every editor update while the setter is stable.
64 : // Hooks that only modify the registry subscribe to the setter context and don't re-render on every update.
65 1 : const SESSION_SCRIPTS_REGISTRY_CTX = React.createContext<SessionScriptsRegistry | null>(null);
66 1 : const SESSION_SCRIPTS_REGISTRY_SETTER_CTX = React.createContext<Dispatch<SetSessionScriptsRegistryAction> | null>(null);
67 : // Reads the current registry without subscribing to its changes
68 1 : const SESSION_SCRIPTS_REGISTRY_READER_CTX = React.createContext<(() => SessionScriptsRegistry) | null>(null);
69 : // The session script actions of all components are applied in order through one queue
70 1 : const SESSION_SCRIPTS_REGISTRY_QUEUE_CTX = React.createContext<UpdateQueue<SessionScriptsRegistry> | null>(null);
71 :
72 : type Props = {};
73 :
74 1 : export const SessionScriptsRegistryProvider: React.FC<React.PropsWithChildren<Props>> = (props: React.PropsWithChildren<Props>) => {
75 : // Reducing session scripts mutates Wasm objects and schedules storage writes, the store runs every update once
76 1 : const store = useStore<SessionScriptsRegistry>(() => ({
77 1 : sessionScriptsMap: new Map(),
78 1 : sessionScriptsByConnection: new Map(),
79 1 : invalidSessions: new Map(),
80 1 : }));
81 1 : const queue = useUpdateQueue(store);
82 1 : const reg = useStoreState(store);
83 : // The scripts own the references to their connections, the connection layer reads them from here
84 1 : const references = React.useMemo<ConnectionReferences>(() => sessionConnectionReferences(store), [store]);
85 1 : return (
86 1 : <ConnectionReferencesProvider value={references}>
87 1 : <SESSION_SCRIPTS_REGISTRY_QUEUE_CTX.Provider value={queue}>
88 1 : <SESSION_SCRIPTS_REGISTRY_READER_CTX.Provider value={store.get}>
89 1 : <SESSION_SCRIPTS_REGISTRY_SETTER_CTX.Provider value={store.update}>
90 1 : <SESSION_SCRIPTS_REGISTRY_CTX.Provider value={reg}>
91 1 : {props.children}
92 1 : </SESSION_SCRIPTS_REGISTRY_CTX.Provider>
93 1 : </SESSION_SCRIPTS_REGISTRY_SETTER_CTX.Provider>
94 1 : </SESSION_SCRIPTS_REGISTRY_READER_CTX.Provider>
95 1 : </SESSION_SCRIPTS_REGISTRY_QUEUE_CTX.Provider>
96 1 : </ConnectionReferencesProvider>
97 : );
98 1 : };
99 :
100 : /// The connections that the sessions of a scripts registry use
101 1 : export function sessionConnectionReferences(registry: { current: SessionScriptsRegistry; subscribe: (listener: () => void) => () => void }): ConnectionReferences {
102 1 : return {
103 1 : sessionsOf: connectionId => registry.current.sessionScriptsByConnection.get(connectionId) ?? [],
104 1 : connectionsOf: sessionId => {
105 1 : const scripts = registry.current.sessionScriptsMap.get(sessionId);
106 1 : return scripts == null ? [] : sessionConnectionIds(scripts);
107 1 : },
108 1 : subscribe: registry.subscribe,
109 1 : };
110 1 : }
111 :
112 1 : export function useSessionScriptsRegistry(): [SessionScriptsRegistry, Dispatch<SetSessionScriptsRegistryAction>] {
113 1 : return [React.useContext(SESSION_SCRIPTS_REGISTRY_CTX)!, React.useContext(SESSION_SCRIPTS_REGISTRY_SETTER_CTX)!];
114 1 : }
115 :
116 : /// Modify the registry without subscribing to its changes
117 1 : export function useSessionScriptsRegistrySetter(): Dispatch<SetSessionScriptsRegistryAction> {
118 1 : return React.useContext(SESSION_SCRIPTS_REGISTRY_SETTER_CTX)!;
119 1 : }
120 :
121 : /// Read the current registry without subscribing to its changes
122 1 : export function useSessionScriptsRegistryReader(): () => SessionScriptsRegistry {
123 1 : return React.useContext(SESSION_SCRIPTS_REGISTRY_READER_CTX)!;
124 1 : }
125 :
126 : /// The sessions that were refused a load
127 : export interface InvalidSessions {
128 : /// The invalid sessions (sessionId -> reason)
129 : invalidSessions: ReadonlyMap<string, InvalidSession>;
130 : /// Delete the files of an invalid session and drop it from the list
131 : deleteInvalidSession: (sessionId: string) => Promise<void>;
132 : }
133 :
134 1 : export function useInvalidSessions(): InvalidSessions {
135 1 : const reg = React.useContext(SESSION_SCRIPTS_REGISTRY_CTX)!;
136 1 : const setReg = React.useContext(SESSION_SCRIPTS_REGISTRY_SETTER_CTX)!;
137 1 : const storage = useStorageWriter();
138 1 : const logger = useLogger();
139 1 : const deleteInvalidSession = React.useCallback(async (sessionId: string) => {
140 0 : try {
141 0 : await storage.deleteSession(sessionId);
142 0 : } catch (error) {
143 0 : logger.error('Failed to delete invalid session', { sessionId, error: stringifyError(error) }, LOG_CTX);
144 0 : }
145 0 : setReg(reg => {
146 0 : if (!reg.invalidSessions.has(sessionId)) return reg;
147 0 : const invalidSessions = new Map(reg.invalidSessions);
148 0 : invalidSessions.delete(sessionId);
149 0 : return { ...reg, invalidSessions };
150 0 : });
151 1 : }, [logger, setReg, storage]);
152 1 : return React.useMemo(() => ({ invalidSessions: reg.invalidSessions, deleteInvalidSession }), [reg.invalidSessions, deleteInvalidSession]);
153 1 : }
154 :
155 1 : export function useSessionScriptsAllocator(): SessionScriptsAllocator {
156 1 : const storage = useStorageWriter();
157 1 : const setReg = React.useContext(SESSION_SCRIPTS_REGISTRY_SETTER_CTX)!;
158 1 : return React.useCallback((state: SessionScripts) => {
159 1 : const sessionId = state.sessionId;
160 1 : const scripts: SessionScripts = { ...state };
161 :
162 : // Modify the registry
163 1 : setReg((prevReg) => {
164 1 : const connectorInfo = newScriptConnection(scripts, null)?.connectorInfo;
165 1 : if (scripts.sessionMetadata.originalFileName == "" && connectorInfo != null) {
166 1 : scripts.sessionMetadata.originalFileName = `${connectorInfo.names.fileShort}`;
167 : }
168 :
169 1 : const reg = copySessionScriptsRegistry(prevReg);
170 1 : for (const connectionId of sessionConnectionIds(scripts)) {
171 1 : addSessionScriptsForConnection(reg, connectionId, sessionId);
172 : }
173 1 : reg.sessionScriptsMap.set(sessionId, scripts);
174 1 : return reg;
175 1 : });
176 :
177 1 : storage.write(groupSessionWrites(scripts.sessionId), {
178 1 : type: REPLACE_SESSION_SCRIPTS,
179 1 : value: sessionReplacement(scripts),
180 1 : }, DEBOUNCE_DURATION_SESSION_WRITE);
181 : // Scheduled after the session file is written, the script list has a single writer
182 1 : storage.write(groupSessionScriptListWrites(scripts.sessionId), {
183 1 : type: WRITE_SESSION_SCRIPTS,
184 1 : value: [scripts.sessionId, sessionScriptListWrite(scripts)],
185 1 : }, DEBOUNCE_DURATION_SESSION_WRITE);
186 1 : return [sessionId, scripts];
187 1 : }, [setReg, storage]);
188 1 : }
189 :
190 : /// The write of a whole session, its script texts are read when the write runs
191 1 : export function sessionReplacement(scripts: SessionScripts): SessionReplacement {
192 1 : return {
193 1 : sessionId: scripts.sessionId,
194 1 : name: scripts.name,
195 1 : scripts: scripts.scriptOrder.flatMap(fileName => {
196 0 : const script = scripts.scripts[scripts.scriptRefs[fileName]?.scriptId];
197 0 : return script == null ? [] : [{
198 0 : fileName,
199 0 : readText: () => script.scriptSession.getText(),
200 0 : }];
201 1 : }),
202 1 : connection: sessionScriptListWrite(scripts).connections[0] ?? null,
203 1 : originalFileName: scripts.sessionMetadata.originalFileName,
204 1 : };
205 1 : }
206 :
207 : /// Record that a session analyzes its scripts with a connection (mutates the registry)
208 1 : export function addSessionScriptsForConnection(reg: SessionScriptsRegistry, connectionId: string, sessionId: string): void {
209 1 : const sessions = reg.sessionScriptsByConnection.get(connectionId) ?? [];
210 1 : if (!sessions.includes(sessionId)) {
211 1 : reg.sessionScriptsByConnection.set(connectionId, [...sessions, sessionId]);
212 : }
213 1 : }
214 :
215 : /// Follow the connections of a session's scripts in the connection index (mutates the registry)
216 1 : export function reindexSessionScriptConnections(reg: SessionScriptsRegistry, prev: SessionScripts, next: SessionScripts): void {
217 1 : if (prev.scripts === next.scripts && prev.pendingConnection === next.pendingConnection) return;
218 1 : const prevIds = new Set(sessionConnectionIds(prev));
219 1 : const nextIds = new Set(sessionConnectionIds(next));
220 1 : for (const connectionId of prevIds) {
221 1 : if (!nextIds.has(connectionId)) removeSessionScriptsForConnection(reg, connectionId, next.sessionId);
222 : }
223 1 : for (const connectionId of nextIds) {
224 1 : if (!prevIds.has(connectionId)) addSessionScriptsForConnection(reg, connectionId, next.sessionId);
225 : }
226 1 : }
227 :
228 : /// Drop a session from the scripts of a connection (mutates the registry)
229 1 : export function removeSessionScriptsForConnection(reg: SessionScriptsRegistry, connectionId: string, sessionId: string): void {
230 1 : const sessions = (reg.sessionScriptsByConnection.get(connectionId) ?? []).filter(id => id !== sessionId);
231 1 : if (sessions.length === 0) {
232 1 : reg.sessionScriptsByConnection.delete(connectionId);
233 : } else {
234 1 : reg.sessionScriptsByConnection.set(connectionId, sessions);
235 : }
236 1 : }
237 :
238 : /// Remove a session from all three registry indices.
239 : ///
240 : /// Pure and idempotent (a missing entry is a no-op, the given registry stays unchanged), so it is safe to run
241 : /// inside a React state updater that may be invoked more than once. It touches no Wasm — freeing the session's Wasm is
242 : /// the caller's responsibility and must happen separately (see useSessionScriptsDeletion), because that
243 : /// teardown is order-sensitive against the shared connection catalog.
244 1 : export function removeSessionScriptsFromRegistry(reg: SessionScriptsRegistry, sessionId: string): SessionScriptsRegistry {
245 1 : const entry = reg.sessionScriptsMap.get(sessionId);
246 1 : if (!entry) return reg;
247 1 : const next = copySessionScriptsRegistry(reg);
248 1 : next.sessionScriptsMap.delete(sessionId);
249 1 : for (const connectionId of sessionConnectionIds(entry)) {
250 1 : removeSessionScriptsForConnection(next, connectionId, sessionId);
251 : }
252 1 : return next;
253 1 : }
254 :
255 : /// Delete a session's scripts and free their Wasm.
256 : ///
257 : /// Session scripts share the connection's catalog by reference (see session_creation) and own every
258 : /// editor session they create. destroySessionScripts() drops those editor sessions from the shared catalog and then frees
259 : /// them, so it MUST run while that catalog is still alive, i.e.
260 : /// *before* the connection is deleted (DELETE_CONNECTION destroys the catalog). We therefore tear
261 : /// the Wasm down synchronously here, in the event handler, and keep the registry-map removal a
262 : /// pure updater (safe to run more than once). Callers must invoke this before dispatching
263 : /// DELETE_CONNECTION for the same session.
264 1 : export function useSessionScriptsDeletion(): (sessionId: string) => void {
265 1 : const readReg = React.useContext(SESSION_SCRIPTS_REGISTRY_READER_CTX)!;
266 1 : const setReg = React.useContext(SESSION_SCRIPTS_REGISTRY_SETTER_CTX)!;
267 1 : return React.useCallback((sessionId: string) => {
268 : // Free the session-owned Wasm now, before the shared catalog can be destroyed. The store holds the
269 : // latest registry, a rendered snapshot may miss the latest scripts of the session.
270 0 : const scripts = readReg().sessionScriptsMap.get(sessionId);
271 0 : if (scripts) {
272 0 : destroySessionScripts(scripts);
273 : }
274 : // The caches of the session in the app go with it
275 0 : forgetSessionScriptReferences(sessionId);
276 0 : forgetDataflowGraph(sessionId);
277 : // Drop the entry from all three indices.
278 0 : setReg((prev) => removeSessionScriptsFromRegistry(prev, sessionId));
279 1 : }, [readReg, setReg]);
280 1 : }
281 :
282 : /// Apply an action to the scripts of a session through the shared queue
283 1 : function dispatchSessionScriptsAction(
284 1 : queue: UpdateQueue<SessionScriptsRegistry>,
285 1 : storageWriter: ReturnType<typeof useStorageWriter>,
286 1 : logger: ReturnType<typeof useLogger>,
287 1 : id: string,
288 1 : action: SessionScriptsAction,
289 1 : ): Promise<SessionScripts | null> {
290 1 : return new Promise<SessionScripts | null>((resolve) => {
291 1 : logger.debug('Queued session script action', {
292 1 : sessionId: id,
293 1 : actionType: action.type.description ?? action.type.toString(),
294 1 : }, LOG_CTX);
295 1 : queue.push(reg => {
296 1 : const prev = reg.sessionScriptsMap.get(id);
297 1 : if (!prev) {
298 1 : logger.warn('No session scripts registered for session', { sessionId: id }, LOG_CTX);
299 1 : resolve(null);
300 1 : return reg;
301 1 : }
302 1 : let next: SessionScripts;
303 1 : const effects = new SessionScriptsEffects(storageWriter);
304 1 : try {
305 1 : next = promoteEphemeralSession(reduceSessionScripts(prev, action, effects, logger), reg.sessionScriptsMap.values(), effects);
306 1 : } catch (e: unknown) {
307 : // Drop the action, a throw would also drop the other updates of the queue flush
308 1 : logger.error('Session script action failed', {
309 1 : sessionId: id,
310 1 : actionType: action.type.description ?? action.type.toString(),
311 1 : error: stringifyError(e),
312 1 : }, LOG_CTX);
313 1 : resolve(prev);
314 1 : return reg;
315 1 : }
316 1 : effects.commit();
317 1 : resolve(next);
318 : // Keep the registry identity for no-ops (e.g. re-selecting the hovered script)
319 1 : if (next === prev) return reg;
320 1 : return replaceSessionScriptsInRegistry(reg, prev, next);
321 1 : });
322 1 : });
323 1 : }
324 :
325 1 : export function useSessionScripts(id: string | null): [SessionScripts | null, ModifySessionScripts] {
326 1 : const registry = React.useContext(SESSION_SCRIPTS_REGISTRY_CTX)!;
327 1 : const queue = React.useContext(SESSION_SCRIPTS_REGISTRY_QUEUE_CTX)!;
328 1 : const storageWriter = useStorageWriter();
329 1 : const logger = useLogger();
330 :
331 : /// Wrapper to modify an individual session scripts collection
332 1 : const dispatch = React.useCallback((action: SessionScriptsAction) => {
333 1 : if (id == null) return Promise.resolve(null);
334 1 : return dispatchSessionScriptsAction(queue, storageWriter, logger, id, action);
335 1 : }, [id, queue, storageWriter, logger]);
336 :
337 1 : return [id == null ? null : registry.sessionScriptsMap.get(id) ?? null, dispatch];
338 1 : };
339 :
340 : /// Modify the scripts of any session without subscribing to the registry
341 1 : export function useSessionScriptsDispatch(): (id: string, action: SessionScriptsAction) => Promise<SessionScripts | null> {
342 1 : const queue = React.useContext(SESSION_SCRIPTS_REGISTRY_QUEUE_CTX)!;
343 1 : const storageWriter = useStorageWriter();
344 1 : const logger = useLogger();
345 1 : return React.useCallback(
346 1 : (id: string, action: SessionScriptsAction) => dispatchSessionScriptsAction(queue, storageWriter, logger, id, action),
347 1 : [queue, storageWriter, logger],
348 1 : );
349 1 : }
350 :
351 : /// Return a script with current analysis, synchronizing session state first when necessary.
352 1 : export async function ensureSessionScriptAnalyzed(
353 1 : sessionScripts: SessionScripts,
354 1 : scriptKey: number,
355 1 : modifySessionScripts: ModifySessionScripts,
356 1 : ): Promise<ScriptData | null> {
357 1 : const scriptData = sessionScripts.scripts[scriptKey];
358 1 : if (!scriptData) return null;
359 1 : if (!isAnalysisOutdated(scriptData)) return scriptData;
360 1 : const result = modifySessionScripts({ type: ANALYZE_OUTDATED_SCRIPT, value: scriptKey });
361 1 : if (!result) return null;
362 1 : const next = await result;
363 1 : return next ? next.scripts[scriptKey] ?? null : null;
364 1 : }
365 :
366 1 : export function useConnectionScriptsDispatch(): ModifyConnectionSessionScripts {
367 1 : const queue = React.useContext(SESSION_SCRIPTS_REGISTRY_QUEUE_CTX)!;
368 1 : const storage = useStorageWriter();
369 1 : const logger = useLogger();
370 :
371 1 : return React.useCallback<ModifyConnectionSessionScripts>((conn: string, action: SessionScriptsAction) => new Promise<void>(resolve => {
372 1 : queue.push(prevReg => {
373 1 : let reg = prevReg;
374 : // Every session with scripts that analyze with the connection receives the action
375 1 : for (const sessionId of [...(reg.sessionScriptsByConnection.get(conn) ?? [])]) {
376 1 : const prev = reg.sessionScriptsMap.get(sessionId);
377 1 : if (prev) {
378 1 : let next: SessionScripts;
379 1 : const effects = new SessionScriptsEffects(storage);
380 1 : try {
381 1 : next = reduceSessionScripts(prev, action, effects, logger);
382 1 : } catch (e: unknown) {
383 : // Skip the session, the other sessions of the connection still receive the action
384 1 : logger.error('Connection script action failed', {
385 1 : connectionId: conn,
386 1 : sessionId,
387 1 : actionType: action.type.description ?? action.type.toString(),
388 1 : error: stringifyError(e),
389 1 : }, LOG_CTX);
390 1 : continue;
391 1 : }
392 1 : effects.commit();
393 1 : if (next !== prev) reg = replaceSessionScriptsInRegistry(reg, prev, next);
394 1 : }
395 1 : }
396 1 : resolve();
397 1 : return reg;
398 1 : });
399 1 : }), [queue, storage, logger]);
400 1 : }
|