Line data Source code
1 : import * as core from '@ankoh/dashql-core';
2 : import * as Immutable from 'immutable';
3 :
4 : import { DashQLCompletionState, DashQLPendingDiff, DashQLProcessorUpdateOut } from '../editor/dashql_processor.js';
5 : import { deriveFocusFromCompletionCandidates, deriveFocusFromEditorUpdate, SemanticUserFocus } from './focus.js';
6 : import { ConnectorInfo, ConnectorType } from '../../connections/connector_info.js';
7 : import { VariantKind } from '../../utils/variant.js';
8 : import {
9 : DEBOUNCE_DURATION_SCRIPT_WRITE,
10 : DEBOUNCE_DURATION_SESSION_WRITE,
11 : DELETE_SCRIPT as STORAGE_DELETE_SCRIPT,
12 : groupSessionConnectionCopyWrites,
13 : groupSessionManifestWrites,
14 : groupSessionScriptListWrites,
15 : groupScriptDeletes,
16 : groupScriptRenames,
17 : groupScriptWrites,
18 : RENAME_SCRIPT as STORAGE_RENAME_SCRIPT,
19 : PATCH_SESSION_CONNECTION,
20 : StorageWriter,
21 : WRITE_SESSION_PROPERTIES,
22 : WRITE_SESSION_SCRIPTS,
23 : WRITE_SCRIPT,
24 : type SessionScriptList,
25 : } from '../../storage/storage_writer.js';
26 : import type { ConnectionParams, SessionConnection, SessionScript } from '../../storage/storage_backend.js';
27 : import type { ConnectionState } from '../../connections/connection_state.js';
28 : import { isHyperWasmConnection } from '../../connections/connection_label.js';
29 : import { Logger, LoggerLike, stringifyError } from '../../platform/logger/logger.js';
30 : import { ScriptAnnotations, ScriptRef, SessionMetadata as SessionMetadataType, ResolvedVisualizeQuery, createEmptyAnnotations, createScriptRef, generateScriptFileName, scriptDisplayName, uniqueScriptBase } from './script_types.js';
31 :
32 : import { chartStatementId, compileVisualizeQuery } from './script_compilation.js';
33 : import { dropSessionTableParameters, publishTableParameters } from './table_parameters_publisher.js';
34 : import { attachmentNames, attachmentParameterName, attachmentStamp, readSessionAttachments, sameSessionAttachments, type SessionAttachment } from '../attachments/session_attachments.js';
35 : import { unmountSessionAttachments } from '../attachments/attachment_mounts.js';
36 : import type { DataFile } from '../../storage/storage_backend.js';
37 : import { applyScriptTextChanges, computeScriptReferenceReports, renameReferenceChanges, type ScriptTextChange, type TableParameterTarget } from './script_reference_check.js';
38 :
39 1 : const LOG_CTX = 'session_scripts';
40 :
41 : /// A script key
42 : export type ScriptKey = number;
43 :
44 : /// The result owner of the queries of a script, the connection keeps only the latest result of a script
45 1 : export function scriptResultOwner(sessionId: string, scriptKey: ScriptKey): string {
46 1 : return `${sessionId}/${scriptKey}`;
47 1 : }
48 : /// A script data map
49 : export type ScriptDataMap = { [scriptKey: number]: ScriptData };
50 : /// A session user focus
51 : export interface ScriptFocus {
52 : /// The file name of the selected cell (empty if none)
53 : fileName: string;
54 : }
55 :
56 : /// The runtime state of a session's scripts
57 : export interface SessionScripts {
58 : /// Session scripts contain many references into the Wasm heap.
59 : /// Consumers therefore resolve the "right" module through here.
60 : instance: core.DashQL;
61 : /// The session identifier.
62 : sessionId: string;
63 : /// The user-supplied session name, independent from the identity of its connections.
64 : name: string | null;
65 : /// The connection that the next appended script runs on when no script determines it: the
66 : /// connection of a session without scripts, or the connection picked for the next script.
67 : /// A session has no default connection, its scripts name their connections.
68 : pendingConnection: ScriptConnection | null;
69 : /// The session metadata
70 : sessionMetadata: SessionMetadataType;
71 : /// The scripts
72 : scripts: ScriptDataMap;
73 : /// Committed cells keyed by their file name.
74 : scriptRefs: { [fileName: string]: ScriptRef };
75 : /// The file names of the committed cells in feed order, the keys of `scriptRefs`
76 : scriptOrder: string[];
77 : /// The session focus (selected cell by file name)
78 : scriptFocus: ScriptFocus;
79 : /// The semantic user focus info (if any)
80 : semanticUserFocus: SemanticUserFocus | null;
81 : /// The attachments of the session, a Parquet attachment is a table parameter of the scripts. Absent until the
82 : /// files of the session were listed.
83 : attachments?: SessionAttachment[];
84 : }
85 :
86 : /// The connection that a script is analyzed and executed with
87 : export interface ScriptConnection {
88 : /// The connection id
89 : connectionId: string;
90 : /// The connector info of the connection
91 : connectorInfo: ConnectorInfo;
92 : /// The catalog of the connection, the script session lives in it
93 : catalog: core.DashQLCatalog;
94 : /// The params of the connection when the script took it. The session file needs them to hold a
95 : /// copy of a connection that it doesn't hold yet, a copy that it holds wins.
96 : params?: ConnectionParams;
97 : /// Is the connection the embedded Hyper, which scans the results of scripts of other connections?
98 : local?: boolean;
99 : }
100 :
101 : /// The connection of a script that runs on a connection of the app
102 1 : export function scriptConnectionOf(connection: ConnectionState): ScriptConnection {
103 1 : return {
104 1 : connectionId: connection.connectionId, connectorInfo: connection.connectorInfo, catalog: connection.catalog, params: connection.params,
105 1 : local: isHyperWasmConnection(connection),
106 1 : };
107 1 : }
108 :
109 : /// The name of the ephemeral session, the sidebar shows it until a script promotes the session
110 1 : export const EPHEMERAL_SESSION_NAME = 'New Session';
111 :
112 : /// Is this the ephemeral session of the greeter? It has no scripts and the first one promotes it to a full session.
113 1 : export function isEphemeralSession(state: SessionScripts): boolean {
114 1 : return state.sessionMetadata?.ephemeral === true;
115 1 : }
116 :
117 : /// The first of "Untitled", "Untitled 2", ... that no other session has, compared case-insensitively
118 1 : export function freeSessionName(names: Iterable<string | null>): string {
119 1 : const taken = new Set<string>();
120 1 : for (const name of names) {
121 1 : if (name != null) taken.add(name.trim().toLowerCase());
122 : }
123 1 : if (!taken.has('untitled')) return 'Untitled';
124 1 : let n = 2;
125 1 : while (taken.has(`untitled ${n}`)) ++n;
126 1 : return `Untitled ${n}`;
127 1 : }
128 :
129 : /// The names that a new session cannot take, the ephemeral session gives up its name
130 1 : export function takenSessionNames(sessions: Iterable<SessionScripts>): (string | null)[] {
131 1 : return [...sessions].filter(session => !isEphemeralSession(session)).map(session => session.name);
132 1 : }
133 :
134 : /// The scripts of a session in session order, the order of the feed
135 1 : export function orderedSessionScripts(state: SessionScripts): ScriptData[] {
136 1 : return (state.scriptOrder ?? []).flatMap(name => state.scripts[state.scriptRefs[name]?.scriptId] ?? []);
137 1 : }
138 :
139 : /// What the views that the core derives from a session read: the names, connections and analyses of its scripts and
140 : /// its attachments. The dataflow, the inputs and the checks of script references derive again when it changes.
141 1 : export function sessionAnalysisSignature(state: SessionScripts): string {
142 1 : return orderedSessionScripts(state).map(script => [
143 1 : script.scriptKey,
144 1 : script.fileName,
145 1 : script.connection.connectionId,
146 1 : script.connection.local ? 'L' : 'R',
147 1 : script.scriptSession.getTextVersion(),
148 : // The analyzed text, not the cursor: the views read the text and its analysis only
149 1 : script.editorUpdate?.textVersion ?? '',
150 1 : script.editorUpdate?.catalogRevision ?? '',
151 1 : script.editorUpdate?.analysisAvailable ? 'A' : '',
152 1 : ].join('\u0001')).concat((state.attachments ?? []).map(attachment => [
153 0 : attachment.fileName,
154 0 : attachmentStamp(attachment),
155 0 : attachment.columns?.join(',') ?? '',
156 1 : ].join('\u0001'))).join('\u0002');
157 1 : }
158 :
159 : /// The ids of the connections that the scripts of a session run on, in the order the scripts first
160 : /// use them. A session run takes exactly these connections.
161 1 : export function scriptConnectionIds(state: SessionScripts): string[] {
162 1 : return [...new Set(orderedSessionScripts(state).map(script => script.connection.connectionId))];
163 1 : }
164 :
165 : /// The ids of the connections that a session uses: the connections of its scripts and the pending
166 : /// connection. Everything else that knows the connections of a session derives them from here.
167 1 : export function sessionConnectionIds(state: SessionScripts): string[] {
168 1 : const ids = scriptConnectionIds(state);
169 1 : if (state.pendingConnection != null && !ids.includes(state.pendingConnection.connectionId)) {
170 1 : ids.push(state.pendingConnection.connectionId);
171 : }
172 1 : return ids;
173 1 : }
174 :
175 : /// The connection that a new script runs on, null when the session has neither scripts nor a pending
176 : /// connection. A script inserted at an index takes the connection of the script before it, the first
177 : /// script that of the script after it. An appended script takes the pending connection, otherwise the
178 : /// connection of the last script.
179 1 : export function newScriptConnection(state: SessionScripts, index: number | null): ScriptConnection | null {
180 1 : const scripts = orderedSessionScripts(state);
181 1 : if (index == null || scripts.length === 0) {
182 1 : return state.pendingConnection ?? scripts[scripts.length - 1]?.connection ?? null;
183 : }
184 1 : const neighbor = scripts[Math.max(0, Math.min(Math.trunc(index), scripts.length) - 1)];
185 1 : return neighbor.connection;
186 1 : }
187 :
188 : /// The number of scripts of a session that run on a connection.
189 : /// The scripts keep the connection in the session, the pending connection alone does not.
190 1 : export function scriptsOnConnection(state: SessionScripts, connectionId: string): number {
191 1 : let count = 0;
192 1 : for (const script of Object.values(state.scripts)) {
193 1 : if (script.connection.connectionId === connectionId) count += 1;
194 : }
195 1 : return count;
196 1 : }
197 :
198 : /// A script as storage holds it
199 : export interface StoredSessionScript {
200 : /// The file name
201 : name: string;
202 : /// The text
203 : sql: string;
204 : /// The connection
205 : connection: ScriptConnection;
206 : /// The connection that the session file names for the script while the app doesn't know it
207 : unresolvedConnectionId?: string;
208 : }
209 :
210 : /// The id of the connection that the session file names for a script
211 1 : export function persistedConnectionId(script: { connection: ScriptConnection; unresolvedConnectionId?: string }): string {
212 1 : return script.unresolvedConnectionId ?? script.connection.connectionId;
213 1 : }
214 :
215 : /// Storage-shaped session content used when an external filesystem change replaces the persisted
216 : /// session. Keeping this type here prevents the native watcher from knowing about WASM lifetime
217 : /// and catalog invariants.
218 : export interface SessionScriptsStorageSnapshot {
219 : /// The scripts in feed order
220 : scripts: StoredSessionScript[];
221 : }
222 :
223 1 : export function sessionScriptsMatchStorageSnapshot(state: SessionScripts, snapshot: SessionScriptsStorageSnapshot): boolean {
224 1 : if (state.scriptOrder.length !== snapshot.scripts.length) {
225 1 : return false;
226 : }
227 1 : return snapshot.scripts.every((stored, i) => {
228 1 : const ref = state.scriptRefs[stored.name];
229 1 : const script = ref == null ? undefined : state.scripts[ref.scriptId];
230 1 : return state.scriptOrder[i] === stored.name
231 1 : && script?.connection.connectionId === stored.connection.connectionId
232 1 : && persistedConnectionId(script) === persistedConnectionId(stored)
233 1 : && script.scriptSession.getText() === stored.sql;
234 1 : });
235 1 : }
236 :
237 : /// A script data
238 : export interface ScriptData {
239 : /// The script key
240 : scriptKey: number;
241 : /// The durable editor session that owns the native script
242 : scriptSession: core.DashQLScriptSession;
243 : /// The latest plain editor-session snapshot.
244 : editorUpdate?: core.buffers.editor.EditorUpdateT | null;
245 : /// Whether the editor session must be reanalyzed against its text or catalog.
246 : analysisOutdated: boolean;
247 : /// The derived annotations for the ui
248 : annotations: ScriptAnnotations;
249 : /// The statistics
250 : statistics: Immutable.List<core.buffers.editor.EditorProcessingStatisticsT>;
251 : /// The completion state.
252 : completion: DashQLCompletionState | null;
253 : /// A pending, staged formatting rewrite shown as an in-place diff.
254 : /// Set by SET_SCRIPT_TEXT; cleared once the user accepts/rejects it in the
255 : /// editor (which round-trips back through UPDATE_FROM_PROCESSOR).
256 : pendingDiff: DashQLPendingDiff | null;
257 : /// The last text change that the app made to the script, e.g. the rename of a script it references. An editor
258 : /// that shows `fromText` applies the changes instead of replacing its text, it keeps its cursor and history.
259 : externalChanges?: { fromText: string; changes: ScriptTextChange[] } | null;
260 : /// The latest query id
261 : latestQueryId: number | null;
262 : /// The retained result/computation id, independent of later commands or failures.
263 : latestResultQueryId?: number | null;
264 : /// The file name of this committed script
265 : fileName: string;
266 : /// The connection the script is analyzed and executed with
267 : connection: ScriptConnection;
268 : /// The connection that the session file names for the script while the app doesn't know it, e.g.
269 : /// after a hand edit. The script runs on the embedded database meanwhile and the session file
270 : /// keeps the reference until another connection is picked.
271 : unresolvedConnectionId?: string;
272 : }
273 :
274 1 : export const SELECT_NEXT_SCRIPT = Symbol('SELECT_NEXT_SCRIPT');
275 1 : export const SELECT_PREV_SCRIPT = Symbol('SELECT_PREV_SCRIPT');
276 1 : export const SELECT_SCRIPT = Symbol('SELECT_SCRIPT');
277 1 : export const ANALYZE_OUTDATED_SCRIPT = Symbol('ANALYZE_OUTDATED_SCRIPT');
278 1 : export const APPLY_PLANNER_ANALYSIS = Symbol('APPLY_PLANNER_ANALYSIS');
279 1 : export const UPDATE_FROM_PROCESSOR = Symbol('UPDATE_FROM_PROCESSOR');
280 1 : export const CATALOG_DID_UPDATE = Symbol('CATALOG_DID_UPDATE');
281 1 : export const REGISTER_QUERY = Symbol('REGISTER_QUERY');
282 1 : export const REGISTER_SCRIPT_RESULT = Symbol('REGISTER_SCRIPT_RESULT');
283 1 : export const SET_SCRIPT_EXECUTION_VISUALIZATION = Symbol('SET_SCRIPT_EXECUTION_VISUALIZATION');
284 1 : export const CREATE_SCRIPT = Symbol('CREATE_SCRIPT');
285 1 : export const DELETE_SCRIPT = Symbol('DELETE_SCRIPT');
286 1 : export const RENAME_SCRIPT = Symbol('RENAME_SCRIPT');
287 1 : export const REORDER_SCRIPTS = Symbol('REORDER_SCRIPTS');
288 1 : export const SET_SCRIPT_TEXT = Symbol('SET_SCRIPT_TEXT');
289 1 : export const SET_SCRIPT_VIS_SPEC = Symbol('SET_SCRIPT_VIS_SPEC');
290 1 : export const SET_SESSION_INPUT = Symbol('SET_SESSION_INPUT');
291 1 : export const ACCEPT_PENDING_DIFF = Symbol('ACCEPT_PENDING_DIFF');
292 1 : export const REJECT_PENDING_DIFF = Symbol('REJECT_PENDING_DIFF');
293 1 : export const RENAME_SESSION = Symbol('RENAME_SESSION');
294 1 : export const SET_SCRIPT_CONNECTION = Symbol('SET_SCRIPT_CONNECTION');
295 1 : export const SET_PENDING_CONNECTION = Symbol('SET_PENDING_CONNECTION');
296 1 : export const REPLACE_CONNECTION = Symbol('REPLACE_CONNECTION');
297 1 : export const DETACH_CONNECTION = Symbol('DETACH_CONNECTION');
298 1 : export const SET_SESSION_ATTACHMENTS = Symbol('SET_SESSION_ATTACHMENTS');
299 1 : export const SET_ATTACHMENT_COLUMNS = Symbol('SET_ATTACHMENT_COLUMNS');
300 1 : export const RENAME_ATTACHMENT = Symbol('RENAME_ATTACHMENT');
301 :
302 : export type SessionScriptsAction =
303 : | VariantKind<typeof SELECT_NEXT_SCRIPT, null>
304 : | VariantKind<typeof SELECT_PREV_SCRIPT, null>
305 : | VariantKind<typeof SELECT_SCRIPT, string>
306 : | VariantKind<typeof ANALYZE_OUTDATED_SCRIPT, ScriptKey>
307 : | VariantKind<typeof APPLY_PLANNER_ANALYSIS, Array<{ scriptKey: number; editorUpdate: core.buffers.editor.EditorUpdateT }>>
308 : | VariantKind<typeof UPDATE_FROM_PROCESSOR, DashQLProcessorUpdateOut>
309 : | VariantKind<typeof CATALOG_DID_UPDATE, string>
310 : | VariantKind<typeof REGISTER_QUERY, [ScriptKey, number]>
311 : | VariantKind<typeof REGISTER_SCRIPT_RESULT, [ScriptKey, number]>
312 : | VariantKind<typeof SET_SCRIPT_EXECUTION_VISUALIZATION, [ScriptKey, ResolvedVisualizeQuery | null]>
313 : | VariantKind<typeof CREATE_SCRIPT, { index: number | null, text?: string, connection?: ScriptConnection }>
314 : | VariantKind<typeof DELETE_SCRIPT, string>
315 : | VariantKind<typeof RENAME_SCRIPT, { fileName: string, newFileName: string }>
316 : | VariantKind<typeof REORDER_SCRIPTS, string[]>
317 : /// With a diff, the rewrite is staged to accept or reject, together with a diff that is already staged
318 : | VariantKind<typeof SET_SCRIPT_TEXT, { scriptKey: ScriptKey, text: string, withDiff?: boolean }>
319 : | VariantKind<typeof SET_SCRIPT_VIS_SPEC, { scriptKey: ScriptKey, spec: string }>
320 : | VariantKind<typeof SET_SESSION_INPUT, { name: string, literal: string, scriptKeys: ScriptKey[] }>
321 : | VariantKind<typeof ACCEPT_PENDING_DIFF, ScriptKey>
322 : | VariantKind<typeof REJECT_PENDING_DIFF, ScriptKey>
323 : | VariantKind<typeof RENAME_SESSION, string | null>
324 : | VariantKind<typeof SET_SCRIPT_CONNECTION, { fileName: string, connection: ScriptConnection }>
325 : | VariantKind<typeof SET_PENDING_CONNECTION, ScriptConnection>
326 : /// The scripts and the pending connection of the session on one connection move to another
327 : | VariantKind<typeof REPLACE_CONNECTION, { from: string, into: ConnectionState }>
328 : | VariantKind<typeof DETACH_CONNECTION, { from: string, into: ScriptConnection }>
329 : /// The data files of the session as the storage lists them
330 : | VariantKind<typeof SET_SESSION_ATTACHMENTS, DataFile[]>
331 : /// The columns of an attachment, read from the file with the stamp
332 : | VariantKind<typeof SET_ATTACHMENT_COLUMNS, { fileName: string, stamp: string, columns: string[] }>
333 : /// An attachment whose file was renamed, the scripts that read it read the new name
334 : | VariantKind<typeof RENAME_ATTACHMENT, { fileName: string, newFileName: string }>
335 : ;
336 :
337 1 : const STATS_HISTORY_LIMIT = 20;
338 :
339 : /// Create the data of an empty script on a connection.
340 : /// A script that moves to another connection keeps its script key, the key is unused in the catalog of the connection.
341 1 : export function createEmptyScriptData(instance: core.DashQL, connection: ScriptConnection, fileName: string = '', movedScriptKey?: ScriptKey): [number, ScriptData] {
342 1 : const scriptSession = movedScriptKey == null
343 1 : ? instance.createScriptSession(connection.catalog)
344 1 : : instance.createScriptSessionWithEntryId(connection.catalog, movedScriptKey);
345 1 : const scriptKey = scriptSession.getCatalogEntryId();
346 : const scriptData: ScriptData = {
347 1 : scriptKey,
348 1 : scriptSession,
349 1 : editorUpdate: null,
350 1 : analysisOutdated: true,
351 1 : statistics: Immutable.List(),
352 1 : annotations: createEmptyAnnotations(),
353 1 : completion: null,
354 1 : pendingDiff: null,
355 1 : latestQueryId: null,
356 1 : latestResultQueryId: null,
357 1 : fileName,
358 1 : connection,
359 : };
360 1 : return [scriptKey, scriptData];
361 1 : }
362 :
363 1 : enum FocusUpdate {
364 1 : Clear,
365 1 : UpdateFromCursor,
366 1 : UpdateFromCompletion,
367 1 : };
368 :
369 : /// Returns the committed cells in feed order.
370 1 : export function getScriptRefs(state: SessionScripts): ScriptRef[] {
371 1 : return state.scriptOrder.map(name => state.scriptRefs[name]);
372 1 : }
373 :
374 : /// Returns the currently selected cell, or the first cell when focus is unset.
375 1 : export function getSelectedScriptRef(state: SessionScripts): ScriptRef | undefined {
376 1 : const file = state.scriptFocus.fileName;
377 1 : if (file && state.scriptRefs[file]) return state.scriptRefs[file];
378 1 : const files = state.scriptOrder;
379 1 : return files.length > 0 ? state.scriptRefs[files[0]] : undefined;
380 1 : }
381 :
382 : /// The script list of the session file, the cells in feed order with their connections
383 1 : export function sessionScriptList(state: SessionScripts): SessionScript[] {
384 1 : return state.scriptOrder.flatMap(name => {
385 1 : const script = state.scripts[state.scriptRefs[name]?.scriptId];
386 1 : return script == null ? [] : [{ name, connectionId: persistedConnectionId(script) }];
387 1 : });
388 1 : }
389 :
390 : /// The copies of the connections that the scripts of a session use, as far as the scripts know them
391 1 : export function sessionConnectionCopies(state: SessionScripts): SessionConnection[] {
392 1 : const copies = new Map<string, SessionConnection>();
393 1 : for (const script of Object.values(state.scripts)) {
394 1 : const { connectionId, params } = script.connection;
395 1 : if (params != null && !copies.has(connectionId)) copies.set(connectionId, { connectionId, params });
396 1 : }
397 1 : return [...copies.values()];
398 1 : }
399 :
400 : /// The script list of a session as the session file stores it.
401 : /// A session without scripts holds the pending connection, the given copies list it first.
402 1 : export function sessionScriptListWrite(state: SessionScripts): SessionScriptList {
403 1 : const pending = state.pendingConnection;
404 1 : const pendingCopy = state.scriptOrder.length === 0 && pending?.params != null
405 1 : ? [{ connectionId: pending.connectionId, params: pending.params }]
406 1 : : [];
407 1 : return { scripts: sessionScriptList(state), connections: [...pendingCopy, ...sessionConnectionCopies(state)] };
408 1 : }
409 :
410 : /// Persist the order and the connections of the cells, the session file follows with its connections
411 : /// The storage of the session reducer. The registry defers the effects of a reduction until it returned: a
412 : /// reducer that throws leaves the storage and the native objects of the previous state untouched.
413 : export interface SessionScriptsStorage {
414 : /// Schedule a write
415 : write: StorageWriter['write'];
416 : /// Free native objects that the previous state still references, right away without a registry
417 : release?(free: () => void): void;
418 : }
419 :
420 : /// The effects of a session reduction, applied once it returned
421 1 : export class SessionScriptsEffects implements SessionScriptsStorage {
422 1 : private readonly writes: Array<() => void> = [];
423 1 : private readonly frees: Array<() => void> = [];
424 :
425 1 : constructor(private readonly storage: StorageWriter) { }
426 :
427 1 : write(...args: Parameters<StorageWriter['write']>): Promise<boolean> {
428 1 : return new Promise<boolean>((resolve, reject) => {
429 1 : this.writes.push(() => this.storage.write(...args).then(resolve, reject));
430 1 : });
431 1 : }
432 1 : release(free: () => void): void {
433 1 : this.frees.push(free);
434 1 : }
435 : /// Apply the effects, in the order of the reduction
436 1 : commit(): void {
437 1 : for (const write of this.writes) write();
438 1 : for (const free of this.frees) free();
439 1 : this.writes.length = 0;
440 1 : this.frees.length = 0;
441 1 : }
442 1 : }
443 :
444 : /// Free native objects of the previous state once the reduction returned
445 1 : function release(storage: SessionScriptsStorage, free: () => void) {
446 1 : if (storage.release) storage.release(free);
447 1 : else free();
448 1 : }
449 :
450 1 : function writeSessionScriptList(state: SessionScripts, storage: SessionScriptsStorage) {
451 1 : void storage.write(
452 1 : groupSessionScriptListWrites(state.sessionId),
453 1 : { type: WRITE_SESSION_SCRIPTS, value: [state.sessionId, sessionScriptListWrite(state)] },
454 : DEBOUNCE_DURATION_SESSION_WRITE,
455 1 : );
456 1 : }
457 :
458 1 : function writeSessionProperties(state: SessionScripts, storage: SessionScriptsStorage) {
459 1 : void storage.write(
460 1 : groupSessionManifestWrites(state.sessionId),
461 1 : { type: WRITE_SESSION_PROPERTIES, value: [state.sessionId, { name: state.name, ...(isEphemeralSession(state) ? { ephemeral: true } : {}) }] },
462 : DEBOUNCE_DURATION_SESSION_WRITE,
463 1 : );
464 1 : }
465 :
466 : /// Promote an ephemeral session with scripts to a full session: it drops the marker and takes a free name.
467 : /// Every path that adds a script ends here, also the commands of the agent.
468 1 : export function promoteEphemeralSession(state: SessionScripts, sessions: Iterable<SessionScripts>, storage: SessionScriptsStorage): SessionScripts {
469 1 : if (!isEphemeralSession(state) || state.scriptOrder.length === 0) return state;
470 1 : const { ephemeral: _promoted, ...sessionMetadata } = state.sessionMetadata;
471 1 : const next: SessionScripts = { ...state, sessionMetadata, name: freeSessionName(takenSessionNames(sessions)) };
472 1 : writeSessionProperties(next, storage);
473 1 : return next;
474 1 : }
475 :
476 1 : export function reduceSessionScripts(state: SessionScripts, action: SessionScriptsAction, storage: SessionScriptsStorage, logger: Logger): SessionScripts {
477 : // The table parameters of the scripts read the results of the scripts, which follow every change
478 1 : return publishTableParameters(state, reduceScripts(state, action, storage, logger));
479 1 : }
480 :
481 : /// Rewrite the table parameters that read a renamed script or attachment to read `name`, in `scripts`. The references
482 : /// are those of a plan with the names before the rename, the scripts are analyzed first if their analysis lags behind
483 : /// their text. The script `keep` keeps its text. Returns the rewritten scripts.
484 1 : function rewriteRenamedReferences(
485 1 : state: SessionScripts,
486 1 : scripts: ScriptDataMap,
487 1 : target: TableParameterTarget,
488 1 : name: string,
489 1 : keep: ScriptKey | null,
490 1 : logger: Logger,
491 1 : ): ScriptData[] {
492 1 : for (const [key, script] of Object.entries(scripts)) {
493 1 : if (script.editorUpdate?.textVersion !== script.scriptSession.getTextVersion()) {
494 1 : scripts[Number(key)] = analyzeScriptData(script, logger);
495 : }
496 : }
497 1 : let changes = new Map<number, ScriptTextChange[]>();
498 1 : try {
499 1 : const reports = computeScriptReferenceReports({ ...state, scripts });
500 1 : changes = renameReferenceChanges(reports, target, `:${state.instance.quoteIdentifier(name)}`);
501 1 : } catch (e: any) {
502 0 : logger.error("Failed to find the references of a renamed table parameter", { target: JSON.stringify(target), error: stringifyError(e) }, LOG_CTX);
503 0 : }
504 1 : const rewritten: ScriptData[] = [];
505 1 : for (const [key, scriptChanges] of changes) {
506 1 : const script = scripts[key];
507 1 : if (script == null || key === keep) continue;
508 1 : const fromText = script.scriptSession.getText();
509 1 : replaceScriptSessionText(script.scriptSession, applyScriptTextChanges(fromText, scriptChanges));
510 1 : const next = analyzeScriptData({ ...script, pendingDiff: null }, logger);
511 1 : next.externalChanges = { fromText, changes: scriptChanges };
512 1 : scripts[key] = next;
513 1 : rewritten.push(next);
514 1 : }
515 1 : return rewritten;
516 1 : }
517 :
518 : /// Write the scripts whose references were renamed
519 1 : function writeRewrittenScripts(state: SessionScripts, rewritten: readonly ScriptData[], storage: SessionScriptsStorage): void {
520 1 : for (const script of rewritten) {
521 1 : storage.write(
522 1 : groupScriptWrites(state.sessionId, script.fileName),
523 1 : { type: WRITE_SCRIPT, value: [state.sessionId, script.fileName, script.scriptSession.getText()] },
524 : DEBOUNCE_DURATION_SCRIPT_WRITE
525 1 : );
526 : }
527 1 : }
528 :
529 1 : function reduceScripts(state: SessionScripts, action: SessionScriptsAction, storage: SessionScriptsStorage, logger: Logger): SessionScripts {
530 1 : switch (action.type) {
531 1 : case SET_SESSION_ATTACHMENTS: {
532 1 : const attachments = readSessionAttachments(action.value, state.attachments);
533 1 : if (state.attachments != null && sameSessionAttachments(state.attachments, attachments)) return state;
534 1 : return { ...state, attachments };
535 1 : }
536 1 : case RENAME_ATTACHMENT: {
537 : // The file was renamed already, the scripts that read the attachment read the new name
538 1 : const { fileName, newFileName } = action.value;
539 1 : const index = state.attachments?.findIndex(a => a.fileName === fileName) ?? -1;
540 1 : const name = attachmentParameterName(newFileName);
541 1 : if (index < 0 || name == null || fileName === newFileName) return state;
542 1 : const newScripts = { ...state.scripts };
543 1 : const rewritten = rewriteRenamedReferences(state, newScripts, { attachment: fileName }, name, null, logger);
544 1 : for (const script of rewritten) {
545 1 : markOtherScriptsOutdatedInPlace(newScripts, script);
546 : }
547 1 : const attachments = [...state.attachments!];
548 1 : attachments[index] = { ...attachments[index], fileName: newFileName, name };
549 1 : const next = { ...state, scripts: newScripts, attachments };
550 1 : writeRewrittenScripts(next, rewritten, storage);
551 1 : return next;
552 1 : }
553 1 : case SET_ATTACHMENT_COLUMNS: {
554 1 : const { fileName, stamp, columns } = action.value;
555 1 : const index = state.attachments?.findIndex(a => a.fileName === fileName && attachmentStamp(a) === stamp) ?? -1;
556 1 : if (index < 0) return state;
557 1 : const attachments = [...state.attachments!];
558 1 : attachments[index] = { ...attachments[index], columns };
559 1 : return { ...state, attachments };
560 1 : }
561 1 : case RENAME_SESSION: {
562 1 : const trimmed = action.value?.trim() ?? '';
563 1 : const name = trimmed.length > 0 ? trimmed : null;
564 1 : if (name === state.name) return state;
565 1 : const next = { ...state, name };
566 1 : writeSessionProperties(next, storage);
567 1 : return next;
568 1 : }
569 1 : case SELECT_NEXT_SCRIPT: {
570 1 : const files = state.scriptOrder;
571 1 : const cur = files.indexOf(state.scriptFocus.fileName);
572 1 : const nextIdx = Math.min(Math.max(cur, 0) + 1, files.length - 1);
573 1 : const fileName = files[nextIdx] ?? state.scriptFocus.fileName;
574 1 : return {
575 1 : ...clearSemanticUserFocus(state),
576 1 : scriptFocus: { ...state.scriptFocus, fileName },
577 1 : };
578 1 : }
579 1 : case SELECT_PREV_SCRIPT: {
580 1 : const files = state.scriptOrder;
581 1 : const cur = files.indexOf(state.scriptFocus.fileName);
582 1 : const prevIdx = Math.max((cur < 0 ? 0 : cur) - 1, 0);
583 1 : const fileName = files[prevIdx] ?? state.scriptFocus.fileName;
584 1 : return {
585 1 : ...clearSemanticUserFocus(state),
586 1 : scriptFocus: { ...state.scriptFocus, fileName },
587 1 : };
588 1 : }
589 1 : case SELECT_SCRIPT: {
590 1 : const fileName = action.value;
591 1 : if (!state.scriptRefs[fileName]) return state;
592 : // Hovering a card re-selects it constantly, keep the state (and the semantic focus) untouched.
593 1 : if (state.scriptFocus.fileName === fileName) return state;
594 1 : return {
595 1 : ...clearSemanticUserFocus(state),
596 1 : scriptFocus: { ...state.scriptFocus, fileName },
597 1 : };
598 1 : }
599 1 : case CATALOG_DID_UPDATE: {
600 1 : const scripts = { ...state.scripts };
601 1 : for (const scriptKey in scripts) {
602 1 : const prev = scripts[scriptKey];
603 1 : if (prev.connection.connectionId !== action.value) continue;
604 1 : scripts[scriptKey] = {
605 1 : ...prev,
606 1 : analysisOutdated: true,
607 1 : };
608 1 : }
609 1 : return {
610 1 : ...state,
611 1 : scripts
612 1 : };
613 1 : }
614 :
615 1 : case ANALYZE_OUTDATED_SCRIPT:
616 1 : return analyzeOutdatedScript(state, action.value, logger);
617 :
618 1 : case APPLY_PLANNER_ANALYSIS: {
619 1 : let scripts = state.scripts;
620 1 : const focusedScriptKey = getSelectedScriptRef(state)?.scriptId;
621 1 : let semanticUserFocus: SemanticUserFocus | null = null;
622 1 : for (const { scriptKey, editorUpdate } of action.value) {
623 1 : const prevScript = scripts[scriptKey];
624 1 : if (!prevScript ||
625 1 : prevScript.scriptSession.getTextVersion() !== editorUpdate.textVersion ||
626 1 : prevScript.scriptSession.getCatalogRevision() !== editorUpdate.catalogRevision ||
627 1 : editorUpdate.status !== core.buffers.editor.EditorUpdateStatus.OK ||
628 1 : !editorUpdate.analysisAvailable) {
629 1 : continue;
630 : }
631 : // Cursor movement can supersede a planner snapshot without changing text or catalog.
632 1 : const nextUpdate = prevScript.scriptSession.getStateRevision() === editorUpdate.stateRevision
633 1 : ? editorUpdate
634 1 : : prevScript.scriptSession.analyze();
635 1 : const projectionChanged = prevScript.editorUpdate?.stateRevision !== nextUpdate.stateRevision;
636 1 : if (scripts === state.scripts) scripts = { ...scripts };
637 1 : scripts[scriptKey] = {
638 1 : ...prevScript,
639 1 : editorUpdate: nextUpdate,
640 1 : analysisOutdated: false,
641 1 : annotations: deriveScriptAnnotations(nextUpdate, prevScript.scriptSession,
642 1 : prevScript.connection.connectorInfo.connectorType, logger),
643 1 : statistics: editorUpdate.analysisUpdated
644 1 : ? rotateScriptStatistics(prevScript.statistics, editorUpdate.processingStatistics)
645 1 : : prevScript.statistics,
646 1 : completion: projectionChanged ? null : prevScript.completion,
647 1 : };
648 1 : if (scriptKey === focusedScriptKey) {
649 1 : semanticUserFocus = scripts[scriptKey].completion != null
650 1 : ? deriveFocusFromCompletionCandidates(scriptKey, scripts[scriptKey])
651 1 : : deriveFocusFromEditorUpdate(scriptKey, nextUpdate);
652 : }
653 1 : }
654 1 : if (scripts === state.scripts) return state;
655 : // Analysis is already published by the planner. Publishing again invalidates its revision.
656 1 : return {
657 1 : ...state,
658 1 : scripts,
659 1 : semanticUserFocus,
660 1 : };
661 1 : }
662 :
663 1 : case UPDATE_FROM_PROCESSOR: {
664 1 : const update = action.value;
665 1 : const prevScript = state.scripts[update.scriptKey];
666 1 : const prevFocus = state.semanticUserFocus;
667 1 : const updateSession = update.scriptSession;
668 : // If the script key does not refer to a value we know, we cannot keep the new script alive.
669 : // Drop the update.
670 1 : if (!prevScript) {
671 1 : logger.warn("Dropping editor update for unknown script", {
672 1 : sessionId: state.sessionId,
673 1 : scriptKey: update.scriptKey.toString(),
674 1 : textVersion: update.editorUpdate?.textVersion.toString(),
675 1 : stateRevision: update.editorUpdate?.stateRevision.toString(),
676 1 : }, LOG_CTX);
677 1 : return clearSemanticUserFocus(state);
678 1 : }
679 1 : if (updateSession !== prevScript.scriptSession) {
680 1 : logger.warn("Dropping editor update from stale editor session", {
681 1 : sessionId: state.sessionId,
682 1 : scriptKey: update.scriptKey.toString(),
683 1 : textVersion: update.editorUpdate?.textVersion.toString(),
684 1 : stateRevision: update.editorUpdate?.stateRevision.toString(),
685 1 : }, LOG_CTX);
686 1 : return clearSemanticUserFocus(state);
687 1 : }
688 1 : const nextCursorContext = update.editorUpdate?.primaryCursorContext;
689 1 : const documentChanged = prevScript.editorUpdate?.textVersion !== update.editorUpdate?.textVersion;
690 1 : const analysisRefreshed = update.editorUpdate?.analysisUpdated === true;
691 1 : const projectionChanged = prevScript.editorUpdate?.stateRevision !== update.editorUpdate?.stateRevision;
692 1 : logger.debug("Applying editor processor update", {
693 1 : sessionId: state.sessionId,
694 1 : scriptKey: update.scriptKey.toString(),
695 1 : documentChanged: documentChanged.toString(),
696 1 : analysisRefreshed: analysisRefreshed.toString(),
697 1 : projectionChanged: projectionChanged.toString(),
698 1 : previousTextVersion: prevScript.editorUpdate?.textVersion.toString(),
699 1 : textVersion: update.editorUpdate?.textVersion.toString(),
700 1 : previousStateRevision: prevScript.editorUpdate?.stateRevision.toString(),
701 1 : stateRevision: update.editorUpdate?.stateRevision.toString(),
702 1 : cursorOffset: update.editorUpdate?.primaryCursorState?.textOffset?.toString(),
703 1 : }, LOG_CTX);
704 1 : let focusUpdate: FocusUpdate | null = null;
705 1 : if (projectionChanged) {
706 1 : focusUpdate = FocusUpdate.Clear;
707 : }
708 : // Did the completion change?
709 1 : if (update.scriptCompletion) {
710 1 : if (update.scriptCompletion.buffer !== prevScript.completion?.buffer) {
711 1 : focusUpdate = FocusUpdate.UpdateFromCompletion;
712 : } else {
713 : // Did the completion index change?
714 1 : if (update.scriptCompletion.candidateId !== prevScript.completion?.candidateId) {
715 1 : focusUpdate = FocusUpdate.UpdateFromCompletion;
716 : }
717 : }
718 1 : }
719 1 : if (projectionChanged) {
720 1 : focusUpdate = update.scriptCompletion != null
721 1 : ? FocusUpdate.UpdateFromCompletion
722 1 : : nextCursorContext != null
723 1 : ? FocusUpdate.UpdateFromCursor
724 1 : : FocusUpdate.Clear;
725 : }
726 1 : const nextScript: ScriptData = {
727 1 : ...prevScript,
728 1 : analysisOutdated: false,
729 1 : completion: update.scriptCompletion,
730 1 : pendingDiff: update.scriptPendingDiff,
731 1 : editorUpdate: update.editorUpdate ?? prevScript.editorUpdate,
732 1 : statistics: update.editorUpdate?.analysisUpdated
733 1 : ? rotateScriptStatistics(prevScript.statistics, update.editorUpdate.processingStatistics)
734 1 : : prevScript.statistics,
735 1 : };
736 : // Publishing declarations equal to the published ones keeps the catalog revision
737 1 : let catalogChanged = false;
738 1 : if (documentChanged || analysisRefreshed) {
739 1 : nextScript.annotations = deriveScriptAnnotations(update.editorUpdate, updateSession,
740 1 : prevScript.connection.connectorInfo.connectorType, logger);
741 1 : const catalogRevision = updateSession.getCatalogRevision();
742 1 : updateSession.loadIntoCatalog();
743 1 : catalogChanged = updateSession.getCatalogRevision() !== catalogRevision;
744 1 : }
745 : // Update semantic user focus
746 1 : let semanticUserFocus: SemanticUserFocus | null = prevFocus;
747 1 : switch (focusUpdate) {
748 1 : case FocusUpdate.Clear:
749 1 : semanticUserFocus = null;
750 1 : break;
751 1 : case FocusUpdate.UpdateFromCursor:
752 1 : semanticUserFocus = deriveFocusFromEditorUpdate(update.scriptKey, nextScript.editorUpdate);
753 1 : break;
754 1 : case FocusUpdate.UpdateFromCompletion:
755 1 : semanticUserFocus = deriveFocusFromCompletionCandidates(update.scriptKey, nextScript);
756 : break;
757 1 : }
758 1 : let nextState: SessionScripts = {
759 1 : ...state,
760 1 : scripts: {
761 1 : ...state.scripts,
762 1 : [update.scriptKey]: nextScript
763 1 : },
764 1 : semanticUserFocus,
765 1 : };
766 :
767 : // Mark the other scripts of the catalog as outdated when the published declarations changed.
768 : // Eventually, we could restrict to those that are depending?
769 1 : if (catalogChanged) {
770 1 : markOtherScriptsOutdatedInPlace(nextState.scripts, nextScript);
771 : }
772 : // Persist only when the document text changed. Cursor/completion updates
773 : // still flow through this action but must not rewrite identical SQL.
774 1 : if (documentChanged) {
775 1 : const scriptKey = update.scriptKey;
776 1 : const scriptData = nextState.scripts[scriptKey];
777 1 : if (scriptData?.fileName) {
778 1 : const sql = scriptData.scriptSession.getText();
779 1 : storage.write(
780 1 : groupScriptWrites(nextState.sessionId, scriptData.fileName),
781 1 : { type: WRITE_SCRIPT, value: [nextState.sessionId, scriptData.fileName, sql] },
782 : DEBOUNCE_DURATION_SCRIPT_WRITE
783 1 : );
784 1 : }
785 1 : }
786 1 : return nextState;
787 1 : }
788 :
789 1 : case REGISTER_QUERY: {
790 1 : const [scriptKey, queryId] = action.value;
791 1 : const scriptData = state.scripts[scriptKey];
792 1 : if (!scriptData) {
793 1 : logger.warn("Orphan query references invalid script", {
794 1 : scriptKey: scriptKey.toString(),
795 1 : queryId: queryId.toString(),
796 1 : }, LOG_CTX);
797 1 : return state;
798 1 : } else {
799 1 : const next = { ...state, scripts: { ...state.scripts } };
800 1 : next.scripts[scriptKey] = {
801 1 : ...scriptData,
802 1 : latestQueryId: queryId,
803 1 : latestResultQueryId: scriptData.latestResultQueryId === undefined
804 1 : ? scriptData.latestQueryId : scriptData.latestResultQueryId,
805 1 : };
806 1 : return next;
807 1 : }
808 1 : }
809 :
810 1 : case SET_SCRIPT_EXECUTION_VISUALIZATION: {
811 1 : const [scriptKey, visualizeQuery] = action.value;
812 1 : const scriptData = state.scripts[scriptKey];
813 1 : if (!scriptData) return state;
814 1 : return {
815 1 : ...state,
816 1 : scripts: {
817 1 : ...state.scripts,
818 1 : [scriptKey]: {
819 1 : ...scriptData,
820 1 : annotations: { ...scriptData.annotations, visualizeQuery },
821 1 : },
822 1 : },
823 1 : };
824 1 : }
825 :
826 1 : case REGISTER_SCRIPT_RESULT: {
827 1 : const [scriptKey, queryId] = action.value;
828 1 : const scriptData = state.scripts[scriptKey];
829 : // Query ids are monotonic; an older overlapping execution must not replace a newer result.
830 1 : if (!scriptData || (scriptData.latestResultQueryId != null && scriptData.latestResultQueryId >= queryId)) {
831 1 : return state;
832 : }
833 1 : return {
834 1 : ...state,
835 1 : scripts: {
836 1 : ...state.scripts,
837 1 : [scriptKey]: { ...scriptData, latestResultQueryId: queryId },
838 1 : },
839 1 : };
840 1 : }
841 :
842 1 : case DELETE_SCRIPT: {
843 1 : const deletedFileName = action.value;
844 1 : const deletedEntry = state.scriptRefs[deletedFileName];
845 1 : if (!deletedEntry) return state;
846 1 : const remainingFiles = state.scriptOrder.filter(name => name !== deletedFileName);
847 1 : const scriptRefs = { ...state.scriptRefs };
848 1 : delete scriptRefs[deletedFileName];
849 :
850 : // Adjust focus if needed
851 1 : let newFile = state.scriptFocus.fileName;
852 1 : if (newFile === deletedFileName) {
853 1 : const oldIdx = state.scriptOrder.indexOf(deletedFileName);
854 1 : newFile = remainingFiles[Math.max(0, oldIdx - 1)] ?? remainingFiles[0] ?? '';
855 1 : }
856 :
857 : // A session without scripts keeps the connection of its last script for the next one
858 1 : const deletedScript = state.scripts[deletedEntry.scriptId];
859 1 : const pendingConnection = remainingFiles.length === 0
860 1 : ? state.pendingConnection ?? deletedScript?.connection ?? null
861 1 : : state.pendingConnection;
862 1 : const next = destroyDeadScripts(storage, {
863 1 : ...clearSemanticUserFocus(state),
864 1 : pendingConnection,
865 1 : scriptRefs,
866 1 : scriptOrder: remainingFiles,
867 1 : scriptFocus: { ...state.scriptFocus, fileName: newFile },
868 1 : });
869 1 : storage.write(
870 1 : groupScriptDeletes(next.sessionId, deletedEntry.fileName),
871 1 : { type: STORAGE_DELETE_SCRIPT, value: [next.sessionId, deletedEntry.fileName] },
872 : DEBOUNCE_DURATION_SCRIPT_WRITE
873 1 : );
874 1 : writeSessionScriptList(next, storage);
875 1 : return next;
876 1 : }
877 :
878 1 : case CREATE_SCRIPT: {
879 : // Null appends; an index inserts at that feed boundary. Text seeds the new script, e.g. from the composer.
880 1 : const { index, text, connection } = action.value;
881 : // A script takes no name of an attachment, the table parameter would be ambiguous
882 1 : const fileName = generateScriptFileName(state.scriptRefs, undefined, attachmentNames(state.attachments));
883 :
884 : // Create a new script on the given connection, otherwise on the connection of its neighbors
885 1 : const scriptConnection = connection ?? newScriptConnection(state, index);
886 1 : if (scriptConnection == null) {
887 1 : logger.warn("CREATE_SCRIPT misses a connection", { sessionId: state.sessionId }, LOG_CTX);
888 1 : return state;
889 1 : }
890 1 : let [scriptKey, scriptData] = createEmptyScriptData(state.instance, scriptConnection, fileName);
891 1 : const scripts: ScriptDataMap = { ...state.scripts };
892 1 : if (text) {
893 : // Analyze right away so the script can be executed without waiting for an editor,
894 : // and mark the others outdated since the new script was loaded into the catalog.
895 1 : replaceScriptSessionText(scriptData.scriptSession, text);
896 1 : scriptData = analyzeScriptData(scriptData, logger);
897 1 : markOtherScriptsOutdatedInPlace(scripts, scriptData);
898 1 : }
899 1 : scripts[scriptKey] = scriptData;
900 :
901 1 : const scriptOrder = [...state.scriptOrder];
902 1 : const insertAt = index == null ? scriptOrder.length : Math.max(0, Math.min(Math.trunc(index), scriptOrder.length));
903 1 : scriptOrder.splice(insertAt, 0, fileName);
904 :
905 1 : const next: SessionScripts = {
906 1 : ...clearSemanticUserFocus(state),
907 : // The pending connection is taken by the script that runs on it
908 1 : pendingConnection: state.pendingConnection?.connectionId === scriptConnection.connectionId ? null : state.pendingConnection,
909 1 : scripts,
910 1 : scriptRefs: { ...state.scriptRefs, [fileName]: createScriptRef(scriptKey, fileName) },
911 1 : scriptOrder,
912 1 : scriptFocus: { ...state.scriptFocus, fileName },
913 1 : };
914 1 : storage.write(
915 1 : groupScriptWrites(next.sessionId, fileName),
916 1 : { type: WRITE_SCRIPT, value: [next.sessionId, fileName, text ?? ''] },
917 : DEBOUNCE_DURATION_SCRIPT_WRITE
918 1 : );
919 1 : writeSessionScriptList(next, storage);
920 1 : return next;
921 1 : }
922 :
923 1 : case RENAME_SCRIPT: {
924 1 : const { fileName: oldFileName, newFileName: requestedName } = action.value;
925 1 : const entry = state.scriptRefs[oldFileName];
926 1 : if (!entry) {
927 1 : logger.warn("RENAME_SCRIPT references invalid script", {
928 1 : fileName: oldFileName,
929 1 : }, LOG_CTX);
930 1 : return state;
931 1 : }
932 : // The rename input edits the display name. Drop a ".sql" the user included, disambiguate
933 : // the name against the other scripts and attach the ".sql" extension. An empty name and a
934 : // name that is not a plain file name are ignored.
935 1 : const requestedBase = scriptDisplayName(requestedName.trim());
936 1 : if (!requestedBase || /[/\\\0]/.test(requestedBase)) {
937 1 : return state;
938 : }
939 1 : const cleanBase = uniqueScriptBase(requestedBase, state.scriptRefs, oldFileName, attachmentNames(state.attachments));
940 1 : const newFileName = `${cleanBase}.sql`;
941 1 : const renamed = oldFileName !== newFileName;
942 1 : const renamedEntry: ScriptRef = { ...entry, fileName: newFileName };
943 1 : const scriptRefs = { ...state.scriptRefs };
944 1 : if (renamed) delete scriptRefs[oldFileName];
945 1 : scriptRefs[newFileName] = renamedEntry;
946 1 : const scriptOrder = state.scriptOrder.map(name => name === oldFileName ? newFileName : name);
947 :
948 : // Update the script data fileName and re-analyze immediately with the new path.
949 1 : const scriptId = entry.scriptId;
950 1 : const updatedScriptData = state.scripts[scriptId];
951 1 : const newScripts = { ...state.scripts };
952 : // The scripts that reference the script read the new name. A script that references itself is a cycle, it
953 : // keeps the name.
954 1 : const rewritten = renamed
955 1 : ? rewriteRenamedReferences(state, newScripts, { script: oldFileName }, cleanBase, scriptId, logger)
956 1 : : [];
957 1 : if (newScripts[scriptId]) {
958 1 : const renamedScriptData: ScriptData = { ...newScripts[scriptId], fileName: newFileName };
959 1 : if (renamed) {
960 1 : newScripts[scriptId] = analyzeScriptData(renamedScriptData, logger);
961 1 : markOtherScriptsOutdatedInPlace(newScripts, renamedScriptData);
962 1 : } else {
963 1 : newScripts[scriptId] = renamedScriptData;
964 : }
965 1 : }
966 1 : for (const script of rewritten) {
967 1 : markOtherScriptsOutdatedInPlace(newScripts, script);
968 : }
969 :
970 : // Keep focus on this script across the rename
971 1 : const focusFile = state.scriptFocus.fileName === oldFileName
972 1 : ? newFileName
973 1 : : state.scriptFocus.fileName;
974 :
975 1 : const next = {
976 1 : ...(renamed ? clearSemanticUserFocus(state) : state),
977 1 : scriptRefs,
978 1 : scriptOrder,
979 1 : scripts: newScripts,
980 1 : scriptFocus: { ...state.scriptFocus, fileName: focusFile },
981 1 : };
982 1 : writeRewrittenScripts(next, rewritten, storage);
983 1 : if (renamed) {
984 : // Rename the file in place: its SQL is unchanged, so move it rather than delete-old +
985 : // rewrite-new. The new clean base is disambiguated unique within the page, so the
986 : // rename target never collides with another script's existing name.
987 1 : storage.write(
988 1 : groupScriptRenames(next.sessionId, oldFileName),
989 1 : { type: STORAGE_RENAME_SCRIPT, value: [next.sessionId, oldFileName, newFileName] },
990 : DEBOUNCE_DURATION_SCRIPT_WRITE
991 1 : );
992 1 : writeSessionScriptList(next, storage);
993 1 : } else {
994 : // No rename: just persist the current contents under the unchanged name.
995 1 : const sql = updatedScriptData ? updatedScriptData.scriptSession.getText() : '';
996 1 : storage.write(
997 1 : groupScriptWrites(next.sessionId, newFileName),
998 1 : { type: WRITE_SCRIPT, value: [next.sessionId, newFileName, sql] },
999 : DEBOUNCE_DURATION_SCRIPT_WRITE
1000 1 : );
1001 1 : }
1002 1 : return next;
1003 1 : }
1004 :
1005 1 : case REORDER_SCRIPTS: {
1006 1 : const requestedOrder = action.value;
1007 :
1008 : // Build the target order: the requested files (that still exist, de-duplicated), then any
1009 : // files the caller omitted, appended in their current feed order so none are dropped.
1010 1 : const seen = new Set<string>();
1011 1 : const order: string[] = [];
1012 1 : for (const file of requestedOrder) {
1013 1 : if (state.scriptRefs[file] && !seen.has(file)) {
1014 1 : seen.add(file);
1015 1 : order.push(file);
1016 1 : }
1017 : }
1018 1 : const currentOrder = state.scriptOrder;
1019 1 : for (const file of currentOrder) {
1020 1 : if (!seen.has(file)) {
1021 1 : seen.add(file);
1022 1 : order.push(file);
1023 1 : }
1024 : }
1025 :
1026 : // Same order as the current feed → change nothing (don't churn the disk).
1027 1 : if (order.length === currentOrder.length && order.every((f, i) => f === currentOrder[i])) {
1028 1 : return state;
1029 : }
1030 : // The order lives in the session file, the script files stay untouched
1031 1 : const next: SessionScripts = { ...state, scriptOrder: order };
1032 1 : writeSessionScriptList(next, storage);
1033 1 : return next;
1034 1 : }
1035 :
1036 1 : case SET_SCRIPT_CONNECTION: {
1037 1 : const { fileName, connection } = action.value;
1038 1 : const entry = state.scriptRefs[fileName];
1039 1 : const prev = entry == null ? undefined : state.scripts[entry.scriptId];
1040 1 : if (!prev) {
1041 1 : logger.warn("SET_SCRIPT_CONNECTION references invalid script", { fileName }, LOG_CTX);
1042 1 : return state;
1043 1 : }
1044 1 : if (prev.connection.connectionId === connection.connectionId) {
1045 1 : if (prev.unresolvedConnectionId == null) return state;
1046 : // Picking the connection the script runs on replaces the unresolved reference
1047 1 : const next: SessionScripts = {
1048 1 : ...state,
1049 1 : scripts: { ...state.scripts, [prev.scriptKey]: { ...prev, unresolvedConnectionId: undefined } },
1050 1 : };
1051 1 : writeSessionScriptList(next, storage);
1052 1 : return next;
1053 1 : }
1054 : // A script session belongs to one catalog, the script moves into a new script session under
1055 : // its key. The new session is created first, a failure leaves the script untouched.
1056 1 : let moved: [number, ScriptData];
1057 1 : try {
1058 1 : moved = moveScriptData(storage, state.instance, prev, connection);
1059 1 : } catch (e: any) {
1060 1 : logger.warn("Failed to move script to another connection", { fileName, error: stringifyError(e) }, LOG_CTX);
1061 1 : return state;
1062 1 : }
1063 1 : let [scriptKey, scriptData] = moved;
1064 1 : scriptData = analyzeScriptData(scriptData, logger);
1065 1 : const scripts: ScriptDataMap = { ...state.scripts };
1066 : // The scripts of both catalogs may have referenced the moved script
1067 1 : markOtherScriptsOutdatedInPlace(scripts, prev);
1068 1 : markOtherScriptsOutdatedInPlace(scripts, scriptData);
1069 1 : scripts[scriptKey] = scriptData;
1070 1 : const next: SessionScripts = {
1071 1 : ...clearSemanticUserFocus(state),
1072 1 : scripts,
1073 1 : };
1074 1 : writeSessionScriptList(next, storage);
1075 1 : return next;
1076 1 : }
1077 :
1078 1 : case SET_PENDING_CONNECTION: {
1079 : // The next appended script runs on the connection, the scripts keep theirs
1080 1 : const connection = action.value;
1081 1 : if (connection.connectionId === state.pendingConnection?.connectionId) return state;
1082 1 : if (connection.params == null) {
1083 1 : logger.warn("SET_PENDING_CONNECTION misses the params of the connection", { connectionId: connection.connectionId }, LOG_CTX);
1084 1 : return state;
1085 1 : }
1086 1 : const next: SessionScripts = { ...state, pendingConnection: connection };
1087 : // A session without scripts holds the connection in its session file
1088 1 : if (state.scriptOrder.length === 0) writeSessionScriptList(next, storage);
1089 1 : return next;
1090 1 : }
1091 :
1092 1 : case DETACH_CONNECTION: {
1093 : // A session lets go of a connection that only its next script would use. A session without
1094 : // scripts then holds the other connection.
1095 1 : const { from, into } = action.value;
1096 1 : if (state.pendingConnection?.connectionId !== from || scriptsOnConnection(state, from) > 0) return state;
1097 1 : if (state.scriptOrder.length > 0) return { ...state, pendingConnection: null };
1098 1 : return reduceSessionScripts({ ...state, pendingConnection: null }, { type: SET_PENDING_CONNECTION, value: into }, storage, logger);
1099 1 : }
1100 :
1101 1 : case REPLACE_CONNECTION: {
1102 1 : const { from, into } = action.value;
1103 1 : const target = scriptConnectionOf(into);
1104 1 : const scripts: ScriptDataMap = { ...state.scripts };
1105 1 : let moved = 0;
1106 1 : for (const prev of Object.values(state.scripts)) {
1107 1 : if (prev.connection.connectionId !== from) continue;
1108 1 : try {
1109 1 : const [scriptKey, scriptData] = moveScriptData(storage, state.instance, prev, target);
1110 1 : scripts[scriptKey] = scriptData;
1111 1 : moved += 1;
1112 1 : } catch (e: any) {
1113 : // The script stays on the replaced connection, it is not removed then
1114 1 : logger.warn("Failed to move script to the replacing connection", { fileName: prev.fileName, error: stringifyError(e) }, LOG_CTX);
1115 1 : }
1116 1 : }
1117 1 : const movesPending = state.pendingConnection?.connectionId === from;
1118 1 : if (moved === 0 && !movesPending) return state;
1119 : // The moved scripts analyze lazily, the scripts of the target catalog may reference them
1120 1 : for (const key in scripts) {
1121 1 : const script = scripts[key];
1122 1 : if (script.analysisOutdated || script.connection.catalog !== target.catalog) continue;
1123 1 : scripts[key] = { ...script, analysisOutdated: true };
1124 1 : }
1125 1 : const next: SessionScripts = {
1126 1 : ...clearSemanticUserFocus(state),
1127 1 : ...(movesPending ? { pendingConnection: target } : {}),
1128 1 : scripts,
1129 1 : };
1130 : // The references of the session file follow. The script list is written as well, it replaces
1131 : // a pending list that still names the replaced connection.
1132 1 : void storage.write(
1133 1 : groupSessionConnectionCopyWrites(state.sessionId, from),
1134 1 : { type: PATCH_SESSION_CONNECTION, value: [state.sessionId, { connectionId: into.connectionId, params: into.params }, [from]] },
1135 : DEBOUNCE_DURATION_SESSION_WRITE,
1136 1 : );
1137 1 : writeSessionScriptList(next, storage);
1138 1 : return next;
1139 1 : }
1140 :
1141 1 : case SET_SCRIPT_TEXT: {
1142 1 : const { scriptKey, text, withDiff } = action.value;
1143 1 : const scriptData = state.scripts[scriptKey];
1144 1 : if (!scriptData) {
1145 1 : logger.warn("SET_SCRIPT_TEXT references invalid script", { scriptKey: scriptKey.toString() }, LOG_CTX);
1146 1 : return state;
1147 1 : }
1148 : // Compute a staged diff against the prior text *before* we overwrite it (a genuine text
1149 : // change is required — an unchanged rewrite produces no overlay). The prior script still
1150 : // holds the old text here; diff it against a throwaway target seeded with the new text.
1151 : // A rewrite of a script with a staged diff restages it against the text before the staged
1152 : // diff, so that rejecting it reverts both. A rewrite back to that text drops the diff.
1153 1 : const currentText = scriptData.scriptSession.getText();
1154 1 : const priorText = withDiff ? scriptData.pendingDiff?.priorText ?? currentText : currentText;
1155 1 : let pendingDiff: DashQLPendingDiff | null = null;
1156 1 : if (withDiff && text !== priorText) {
1157 1 : if (priorText !== currentText) {
1158 1 : replaceScriptSessionText(scriptData.scriptSession, priorText);
1159 : }
1160 1 : pendingDiff = computePendingDiff(state.instance, scriptData.scriptSession, text, priorText, logger);
1161 1 : }
1162 : // Rewrite the script text in-place
1163 1 : replaceScriptSessionText(scriptData.scriptSession, text);
1164 : // Re-analyze through the path-aware helper (destroys the stale buffers, refreshes
1165 : // buffers + annotations incl. visualizeQuery, reloads the script into the catalog)
1166 1 : const nextScriptData = analyzeScriptData(scriptData, logger);
1167 1 : nextScriptData.pendingDiff = pendingDiff;
1168 :
1169 1 : const nextState: SessionScripts = {
1170 1 : ...clearSemanticUserFocus(state),
1171 1 : scripts: {
1172 1 : ...state.scripts,
1173 1 : [scriptKey]: nextScriptData,
1174 1 : },
1175 1 : };
1176 : // The text changed and was reloaded into the catalog; mark the other scripts of the
1177 : // catalog outdated so cross-script references re-resolve (mirrors UPDATE_FROM_PROCESSOR).
1178 1 : markOtherScriptsOutdatedInPlace(nextState.scripts, nextScriptData);
1179 :
1180 : // Persist only the updated script (same tail as UPDATE_FROM_PROCESSOR)
1181 1 : const sql = nextScriptData.scriptSession.getText();
1182 1 : if (nextScriptData.fileName) {
1183 1 : storage.write(
1184 1 : groupScriptWrites(nextState.sessionId, nextScriptData.fileName),
1185 1 : { type: WRITE_SCRIPT, value: [nextState.sessionId, nextScriptData.fileName, sql] },
1186 : DEBOUNCE_DURATION_SCRIPT_WRITE
1187 1 : );
1188 : }
1189 1 : return nextState;
1190 1 : }
1191 :
1192 1 : case SET_SESSION_INPUT: {
1193 : // The value of an input is the `@param :name = <literal>` tag of every script that uses it. The tags are
1194 : // rewritten as changes, a mounted editor keeps its cursor and undoes them as one step.
1195 1 : const { name, literal, scriptKeys } = action.value;
1196 1 : const scripts = { ...state.scripts };
1197 1 : const rewritten: ScriptData[] = [];
1198 1 : for (const scriptKey of scriptKeys) {
1199 1 : const script = scripts[scriptKey];
1200 1 : if (script == null) continue;
1201 1 : const fromText = script.scriptSession.getText();
1202 1 : let text: string;
1203 1 : try {
1204 1 : text = script.scriptSession.rewriteParamTag(name, literal);
1205 1 : } catch (e: any) {
1206 1 : logger.warn("Cannot write the value of an input", { input: name, fileName: script.fileName, error: stringifyError(e) }, LOG_CTX);
1207 1 : continue;
1208 1 : }
1209 1 : if (text === fromText) continue;
1210 1 : replaceScriptSessionText(script.scriptSession, text);
1211 1 : const next = analyzeScriptData({ ...script, pendingDiff: null }, logger);
1212 1 : next.externalChanges = { fromText, changes: [textChange(fromText, text)] };
1213 1 : scripts[scriptKey] = next;
1214 1 : rewritten.push(next);
1215 1 : }
1216 1 : if (rewritten.length === 0) return state;
1217 1 : for (const script of rewritten) {
1218 1 : markOtherScriptsOutdatedInPlace(scripts, script);
1219 : }
1220 1 : const next = { ...state, scripts };
1221 1 : for (const script of rewritten) {
1222 1 : if (!script.fileName) continue;
1223 1 : storage.write(
1224 1 : groupScriptWrites(next.sessionId, script.fileName),
1225 1 : { type: WRITE_SCRIPT, value: [next.sessionId, script.fileName, script.scriptSession.getText()] },
1226 : DEBOUNCE_DURATION_SCRIPT_WRITE
1227 1 : );
1228 1 : }
1229 1 : return next;
1230 1 : }
1231 :
1232 1 : case SET_SCRIPT_VIS_SPEC: {
1233 : // Rewrite the `@vis vegalite` tag of the charted statement and stage the rewrite as a diff to accept or
1234 : // reject, like a formatting. The rewrite keeps the other tags, e.g. `@board`.
1235 : // The chart builder sends every edit, each one restages the diff against the text before the first edit,
1236 : // so the script shows the whole chart edit as one diff, including the starter chart of a new board card.
1237 1 : const { scriptKey, spec } = action.value;
1238 1 : const scriptData = state.scripts[scriptKey];
1239 1 : if (!scriptData) {
1240 1 : logger.warn("SET_SCRIPT_VIS_SPEC references invalid script", { scriptKey: scriptKey.toString() }, LOG_CTX);
1241 1 : return state;
1242 1 : }
1243 1 : const scriptSession = scriptData.scriptSession;
1244 1 : const currentText = scriptSession.getText();
1245 1 : const priorText = scriptData.pendingDiff?.priorText ?? currentText;
1246 1 : let text: string | null = null;
1247 1 : try {
1248 : // The diff is computed against the text of the session, rewrite the text before the first edit
1249 1 : if (priorText !== currentText) {
1250 1 : replaceScriptSessionText(scriptSession, priorText);
1251 : }
1252 1 : const statementId = chartStatementId(scriptSession);
1253 1 : if (statementId == null) {
1254 1 : logger.warn("SET_SCRIPT_VIS_SPEC found no statement to chart", { scriptKey: scriptKey.toString() }, LOG_CTX);
1255 : } else {
1256 1 : text = scriptSession.rewriteVisRenderer(statementId, 'vegalite', spec);
1257 : }
1258 1 : } catch (e: unknown) {
1259 1 : logger.warn("SET_SCRIPT_VIS_SPEC failed to rewrite the chart tag", { scriptKey: scriptKey.toString(), error: String(e) }, LOG_CTX);
1260 1 : }
1261 1 : if (text == null || text === currentText) {
1262 : // Leave the script as it is
1263 1 : if (scriptSession.getText() !== currentText) {
1264 1 : replaceScriptSessionText(scriptSession, currentText);
1265 : }
1266 1 : return state;
1267 1 : }
1268 : // A spec that restores the chart before the first edit drops the diff
1269 1 : return reduceSessionScripts(state, { type: SET_SCRIPT_TEXT, value: { scriptKey, text, withDiff: true } }, storage, logger);
1270 1 : }
1271 :
1272 1 : case ACCEPT_PENDING_DIFF: {
1273 : // Keep the current (new) text — SET_SCRIPT_TEXT already applied and persisted it — and
1274 : // just drop the staged diff. Mirrors the editor's DashQLDiffAcceptEffect path, which
1275 : // clears scriptPendingDiff without touching the rope.
1276 1 : const scriptKey = action.value;
1277 1 : const scriptData = state.scripts[scriptKey];
1278 1 : if (!scriptData || scriptData.pendingDiff == null) {
1279 1 : return state;
1280 : }
1281 1 : return {
1282 1 : ...state,
1283 1 : scripts: {
1284 1 : ...state.scripts,
1285 1 : [scriptKey]: { ...scriptData, pendingDiff: null },
1286 1 : },
1287 1 : };
1288 1 : }
1289 :
1290 1 : case REJECT_PENDING_DIFF: {
1291 : // Restore the verbatim prior text and drop the staged diff. This reproduces the
1292 : // editor's DashQLDiffRejectEffect path (restore priorText + clear) via the same tail as
1293 : // SET_SCRIPT_TEXT, so a diff rejected from the feed and one rejected from the Details
1294 : // editor leave identical session scripts state.
1295 1 : const scriptKey = action.value;
1296 1 : const scriptData = state.scripts[scriptKey];
1297 1 : if (!scriptData || scriptData.pendingDiff == null) {
1298 1 : return state;
1299 : }
1300 1 : const priorText = scriptData.pendingDiff.priorText;
1301 : // Rewrite the script text in-place and re-analyze through the path-aware helper.
1302 1 : replaceScriptSessionText(scriptData.scriptSession, priorText);
1303 1 : const nextScriptData = analyzeScriptData(scriptData, logger);
1304 1 : nextScriptData.pendingDiff = null;
1305 :
1306 1 : const nextState: SessionScripts = {
1307 1 : ...clearSemanticUserFocus(state),
1308 1 : scripts: {
1309 1 : ...state.scripts,
1310 1 : [scriptKey]: nextScriptData,
1311 1 : },
1312 1 : };
1313 : // The text changed and was reloaded into the catalog; mark the other scripts of the
1314 : // catalog outdated so cross-script references re-resolve (mirrors SET_SCRIPT_TEXT).
1315 1 : markOtherScriptsOutdatedInPlace(nextState.scripts, nextScriptData);
1316 :
1317 : // Persist only the updated script (same tail as SET_SCRIPT_TEXT)
1318 1 : const sql = nextScriptData.scriptSession.getText();
1319 1 : if (nextScriptData.fileName) {
1320 1 : storage.write(
1321 1 : groupScriptWrites(nextState.sessionId, nextScriptData.fileName),
1322 1 : { type: WRITE_SCRIPT, value: [nextState.sessionId, nextScriptData.fileName, sql] },
1323 : DEBOUNCE_DURATION_SCRIPT_WRITE
1324 1 : );
1325 : }
1326 1 : return nextState;
1327 1 : }
1328 :
1329 1 : }
1330 1 : }
1331 :
1332 : /// The change from one text to another: the range between their common prefix and suffix
1333 1 : export function textChange(from: string, to: string): ScriptTextChange {
1334 1 : let prefix = 0;
1335 1 : const max = Math.min(from.length, to.length);
1336 1 : while (prefix < max && from.charCodeAt(prefix) === to.charCodeAt(prefix)) ++prefix;
1337 1 : let suffix = 0;
1338 1 : while (suffix < max - prefix && from.charCodeAt(from.length - 1 - suffix) === to.charCodeAt(to.length - 1 - suffix)) ++suffix;
1339 1 : return { from: prefix, to: from.length - suffix, insert: to.slice(prefix, to.length - suffix) };
1340 1 : }
1341 :
1342 1 : export function clearSemanticUserFocus<V extends SessionScripts>(state: V): V {
1343 1 : return { ...state, semanticUserFocus: null };
1344 1 : }
1345 :
1346 : /// Mark the other scripts in the catalog of a script as outdated in a scripts map that the caller
1347 : /// already owns. Scripts in other catalogs can't reference the script.
1348 : ///
1349 : /// Scripts that are already outdated keep their identity.
1350 : /// This runs for every keystroke and every new ScriptData object re-renders its editor and card.
1351 1 : function markOtherScriptsOutdatedInPlace(scripts: ScriptDataMap, changed: ScriptData) {
1352 1 : for (const key in scripts) {
1353 1 : if (+key === changed.scriptKey) continue;
1354 1 : const script = scripts[key];
1355 1 : if (script.analysisOutdated || script.connection.catalog !== changed.connection.catalog) continue;
1356 1 : scripts[key] = {
1357 1 : ...script,
1358 1 : analysisOutdated: true,
1359 1 : };
1360 1 : }
1361 1 : }
1362 1 : function destroyScriptData(data: ScriptData) {
1363 1 : data.scriptSession.destroy();
1364 1 : }
1365 :
1366 : /// Drop a script from its catalog and destroy it
1367 1 : function dropScriptData(data: ScriptData) {
1368 1 : try {
1369 1 : data.scriptSession.dropFromCatalog();
1370 1 : } catch {
1371 : // The script may not have completed analysis and therefore may not be in the catalog.
1372 0 : }
1373 1 : destroyScriptData(data);
1374 1 : }
1375 :
1376 : /// Move a script to another connection, the previous script data is dropped once the reduction returned.
1377 : /// A failure leaves the previous script data untouched.
1378 1 : function moveScriptData(storage: SessionScriptsStorage, instance: core.DashQL, prev: ScriptData, connection: ScriptConnection): [number, ScriptData] {
1379 1 : const moved = createMovedScriptData(instance, prev, connection);
1380 1 : release(storage, () => dropScriptData(prev));
1381 1 : return moved;
1382 1 : }
1383 :
1384 : /// Create the script data of a script that moves to another connection. The script keeps its key,
1385 : /// its text and its results, the caller drops the previous script data afterwards.
1386 1 : function createMovedScriptData(instance: core.DashQL, prev: ScriptData, connection: ScriptConnection): [number, ScriptData] {
1387 1 : const [scriptKey, scriptData] = createEmptyScriptData(instance, connection, prev.fileName, prev.scriptKey);
1388 1 : try {
1389 1 : replaceScriptSessionText(scriptData.scriptSession, prev.scriptSession.getText());
1390 1 : } catch (e) {
1391 0 : destroyScriptData(scriptData);
1392 0 : throw e;
1393 0 : }
1394 1 : return [scriptKey, { ...scriptData, latestQueryId: prev.latestQueryId, latestResultQueryId: prev.latestResultQueryId }];
1395 1 : }
1396 :
1397 1 : export function destroySessionScripts(state: SessionScripts): SessionScripts {
1398 : // Drop the session-owned scripts from the connection catalog.
1399 1 : for (const scriptData of Object.values(state.scripts)) {
1400 1 : scriptData.scriptSession.dropFromCatalog();
1401 : }
1402 : // Destroy all the script data
1403 1 : for (const key in state.scripts) {
1404 1 : const script = state.scripts[key];
1405 1 : destroyScriptData(script);
1406 1 : }
1407 1 : dropSessionTableParameters(state.sessionId);
1408 1 : void unmountSessionAttachments(state.sessionId);
1409 1 : return state;
1410 1 : }
1411 :
1412 : /// Replace persisted session content without scheduling writes back to storage.
1413 : ///
1414 : /// Scripts that remain at the same file path on the same connection retain their WASM identity,
1415 : /// query history and UI references. A script that moved to another connection gets a new script
1416 : /// session in the catalog of that connection. Added and removed paths allocate/free their WASM state
1417 : /// here, where the catalog and script-registry lifetime rules are already centralized.
1418 : ///
1419 : /// Every new script session is created before anything of the current state is changed or freed, a
1420 : /// failure leaves the current state intact.
1421 1 : export function replaceSessionScriptsFromStorage(
1422 1 : state: SessionScripts,
1423 1 : snapshot: SessionScriptsStorageSnapshot,
1424 1 : _logger: Logger,
1425 1 : ): SessionScripts {
1426 1 : const existingByPath = new Map<string, ScriptData>();
1427 1 : for (const script of Object.values(state.scripts)) {
1428 1 : if (script.fileName) {
1429 1 : existingByPath.set(script.fileName, script);
1430 : }
1431 : }
1432 :
1433 : // Phase 1: create the script sessions of added and moved scripts, nothing is freed yet
1434 1 : const stored = snapshot.scripts.filter((entry, i) => snapshot.scripts.findIndex(other => other.name === entry.name) === i);
1435 1 : const created = new Map<string, ScriptData>();
1436 1 : try {
1437 1 : for (const entry of stored) {
1438 1 : const existing = existingByPath.get(entry.name);
1439 1 : if (existing != null && existing.connection.connectionId === entry.connection.connectionId) continue;
1440 : // A script that moved to another connection keeps its key and its results
1441 1 : const [, scriptData] = existing != null
1442 1 : ? createMovedScriptData(state.instance, existing, entry.connection)
1443 1 : : createEmptyScriptData(state.instance, entry.connection, entry.name);
1444 1 : created.set(entry.name, scriptData);
1445 1 : replaceScriptSessionText(scriptData.scriptSession, entry.sql);
1446 1 : }
1447 1 : } catch (e) {
1448 1 : for (const scriptData of created.values()) destroyScriptData(scriptData);
1449 1 : throw e;
1450 1 : }
1451 :
1452 : // Phase 2: update the scripts that stay and free the replaced and removed ones
1453 1 : const scripts: ScriptDataMap = {};
1454 1 : const scriptRefs: { [fileName: string]: ScriptRef } = {};
1455 1 : const scriptOrder: string[] = [];
1456 1 : let contentChanged = false;
1457 1 : for (const entry of stored) {
1458 1 : const existing = existingByPath.get(entry.name);
1459 1 : existingByPath.delete(entry.name);
1460 1 : let scriptData = created.get(entry.name);
1461 1 : if (scriptData != null) {
1462 1 : if (existing != null) dropScriptData(existing);
1463 1 : contentChanged = true;
1464 1 : } else {
1465 1 : scriptData = existing!;
1466 1 : if (scriptData.scriptSession.getText() !== entry.sql) {
1467 1 : replaceScriptSessionText(scriptData.scriptSession, entry.sql);
1468 1 : scriptData = {
1469 1 : ...scriptData,
1470 1 : pendingDiff: null,
1471 1 : completion: null,
1472 1 : analysisOutdated: true,
1473 1 : };
1474 1 : contentChanged = true;
1475 1 : }
1476 1 : }
1477 1 : if (scriptData.unresolvedConnectionId !== entry.unresolvedConnectionId) {
1478 1 : scriptData = { ...scriptData, unresolvedConnectionId: entry.unresolvedConnectionId };
1479 : }
1480 1 : scripts[scriptData.scriptKey] = scriptData;
1481 1 : scriptRefs[entry.name] = createScriptRef(scriptData.scriptKey, entry.name);
1482 1 : scriptOrder.push(entry.name);
1483 1 : }
1484 :
1485 : // Files no longer present on disk own WASM objects that must be detached before destruction.
1486 1 : for (const removed of existingByPath.values()) {
1487 1 : dropScriptData(removed);
1488 1 : contentChanged = true;
1489 1 : }
1490 :
1491 1 : const previousFile = state.scriptFocus.fileName;
1492 1 : const fileName = scriptRefs[previousFile] ? previousFile : (scriptOrder[0] ?? '');
1493 :
1494 1 : let next: SessionScripts = {
1495 1 : ...clearSemanticUserFocus(state),
1496 1 : scripts,
1497 1 : scriptRefs,
1498 1 : scriptOrder,
1499 1 : scriptFocus: {
1500 1 : ...state.scriptFocus,
1501 1 : fileName,
1502 1 : },
1503 1 : };
1504 :
1505 1 : if (contentChanged) {
1506 : // Cross-script references can be affected by any add/remove/content change.
1507 : // Invalidate the session and let each script reanalyze at its next explicit use.
1508 1 : for (const key in next.scripts) {
1509 1 : next.scripts[key] = {
1510 1 : ...next.scripts[key],
1511 1 : analysisOutdated: true,
1512 1 : };
1513 : }
1514 : }
1515 1 : return next;
1516 1 : }
1517 :
1518 : /// Drop the scripts that no file refers to anymore, once the reduction returned
1519 1 : function destroyDeadScripts(storage: SessionScriptsStorage, state: SessionScripts): SessionScripts {
1520 : // Determine script liveness from the committed cell collection.
1521 1 : let deadScripts = new Map<number, ScriptData>();
1522 1 : for (const key in state.scripts) {
1523 1 : deadScripts.set(+key, state.scripts[key]);
1524 : }
1525 1 : for (const ref of Object.values(state.scriptRefs)) {
1526 1 : deadScripts.delete(ref.scriptId);
1527 : }
1528 : // Nothing to cleanup?
1529 1 : if (deadScripts.size == 0) {
1530 0 : return state;
1531 : }
1532 : // Copy scripts
1533 1 : const cleanedScripts: ScriptDataMap = { ...state.scripts };
1534 : // Delete scripts
1535 1 : for (const [k, v] of deadScripts) {
1536 1 : release(storage, () => {
1537 1 : v.scriptSession.dropFromCatalog();
1538 1 : destroyScriptData(v);
1539 1 : });
1540 1 : delete cleanedScripts[k];
1541 1 : }
1542 : // The remaining scripts of their catalogs may have referenced their declarations
1543 1 : for (const v of deadScripts.values()) {
1544 1 : markOtherScriptsOutdatedInPlace(cleanedScripts, v);
1545 : }
1546 1 : return { ...state, scripts: cleanedScripts };
1547 1 : }
1548 :
1549 1 : export function rotateScriptStatistics(
1550 1 : log: Immutable.List<core.buffers.editor.EditorProcessingStatisticsT>,
1551 1 : stats: core.buffers.editor.EditorProcessingStatisticsT | null,
1552 1 : ) {
1553 1 : if (stats == null) {
1554 1 : return log;
1555 : } else {
1556 1 : return log.withMutations(m => {
1557 1 : m.push(stats);
1558 1 : if (m.size > STATS_HISTORY_LIMIT) {
1559 1 : m.shift();
1560 : }
1561 1 : });
1562 : }
1563 1 : }
1564 :
1565 1 : function deriveScriptAnnotations(
1566 1 : update: core.buffers.editor.EditorUpdateT | null | undefined,
1567 1 : scriptSession: core.DashQLScriptSession,
1568 1 : connectorType: ConnectorType,
1569 1 : logger?: LoggerLike,
1570 1 : ): ScriptAnnotations {
1571 1 : if (!update?.analysisAvailable) {
1572 1 : return createEmptyAnnotations();
1573 : }
1574 1 : const tableDefsFlat = update.scriptAnnotations?.tableDefinitions
1575 1 : .map(table => typeof table.name === 'string' ? table.name : null)
1576 1 : .filter((name): name is string => name != null)
1577 1 : .sort() ?? [];
1578 :
1579 1 : const visualizeQuery = compileVisualizeQuery(scriptSession, connectorType, logger);
1580 :
1581 1 : const flow = update.scriptAnnotations?.flowPosition;
1582 1 : const board = update.scriptAnnotations?.boardPosition;
1583 1 : const lastStatement = (update.scriptAnnotations?.statements ?? [])
1584 1 : .reduce<core.buffers.editor.EditorStatementT | null>((last, statement) => last == null || statement.statementId > last.statementId ? statement : last, null);
1585 1 : return {
1586 1 : tableDefs: tableDefsFlat,
1587 1 : visualizeQuery,
1588 1 : flowPosition: flow != null
1589 1 : ? { statementId: flow.statementId, x: flow.x, y: flow.y, width: flow.width, height: flow.height }
1590 1 : : null,
1591 1 : boardPosition: board != null
1592 1 : ? { statementId: board.statementId, row: board.row, column: board.column, width: board.width, height: board.height }
1593 1 : : null,
1594 1 : lastStatementIsQuery: lastStatement?.statementType === core.buffers.parser.StatementType.SELECT,
1595 1 : manual: update.scriptAnnotations?.manual ?? false,
1596 1 : };
1597 1 : }
1598 :
1599 : /// Compute a staged, statement-level semantic diff from a script's prior text to a new text.
1600 : ///
1601 : /// `sourceSession` still holds the old text and analysis. The new text is loaded into a throwaway
1602 : /// target script created in a fresh catalog so it
1603 : /// never touches the session's catalog. Returns null (and logs) on any failure, so a diff problem
1604 : /// never blocks applying the rewrite. The returned FlatBufferPtr is owned by the caller (stored on
1605 : /// ScriptData.pendingDiff, freed when the diff is superseded, accepted/rejected, or the script is
1606 : /// destroyed).
1607 1 : function computePendingDiff(
1608 1 : instance: core.DashQL,
1609 1 : sourceSession: core.DashQLScriptSession,
1610 1 : newText: string,
1611 1 : priorText: string,
1612 1 : logger: Logger,
1613 1 : ): DashQLPendingDiff | null {
1614 1 : let targetCatalog: core.DashQLCatalog | null = null;
1615 1 : let targetScript: core.DashQLScript | null = null;
1616 1 : try {
1617 : // Ensure the source session is parsed (the diff walks the parsed AST).
1618 1 : const analysis = sourceSession.analyze();
1619 1 : if (analysis.status !== core.buffers.editor.EditorUpdateStatus.OK || !analysis.analysisAvailable) {
1620 0 : throw new Error('Failed to analyze source script for diff');
1621 : }
1622 : // Seed a throwaway target with the new text in its own catalog (kept out of the session's).
1623 1 : targetCatalog = instance.createCatalog();
1624 1 : targetScript = instance.createScript(targetCatalog);
1625 1 : targetScript.insertTextAt(0, newText);
1626 1 : targetScript.parse();
1627 1 : const diffBuffer = sourceSession.computeDiff(targetScript);
1628 1 : return { priorText, diffBuffer };
1629 1 : } catch (e: any) {
1630 0 : logger.warn("Failed to compute script diff", { error: stringifyError(e) }, LOG_CTX);
1631 0 : return null;
1632 1 : } finally {
1633 1 : targetScript?.destroy();
1634 1 : targetCatalog?.destroy();
1635 1 : }
1636 1 : }
1637 :
1638 : /// Analyze a script and publish it into the catalog of its connection.
1639 : ///
1640 : /// A failed analysis is kept on the script (an update without available analysis) instead of thrown, a
1641 : /// throw inside a reducer would drop the whole action. The script is not outdated afterwards: analyzing
1642 : /// the same text against the same catalog fails again, and a change of either marks it outdated.
1643 1 : export function analyzeScriptData(scriptData: ScriptData, logger: Logger): ScriptData {
1644 1 : const next: ScriptData = { ...scriptData };
1645 :
1646 : // Analyze the script
1647 1 : const update = next.scriptSession.analyze();
1648 1 : next.analysisOutdated = false;
1649 1 : next.editorUpdate = update;
1650 1 : if (update.status !== core.buffers.editor.EditorUpdateStatus.OK || !update.analysisAvailable) {
1651 1 : logger.error("Failed to analyze script", {
1652 1 : scriptKey: next.scriptKey.toString(),
1653 1 : status: core.buffers.editor.EditorUpdateStatus[update.status] ?? update.status.toString(),
1654 1 : message: typeof update.statusMessage === 'string' ? update.statusMessage : '',
1655 1 : }, LOG_CTX);
1656 1 : return next;
1657 1 : }
1658 : // Rotate the script statistics
1659 1 : if (update.analysisUpdated) {
1660 1 : next.statistics = rotateScriptStatistics(next.statistics, update.processingStatistics);
1661 : }
1662 : // Derive script annotations (incl. the resolved visualization)
1663 1 : next.annotations = deriveScriptAnnotations(
1664 1 : update,
1665 1 : next.scriptSession,
1666 1 : next.connection.connectorInfo.connectorType,
1667 1 : logger,
1668 1 : );
1669 :
1670 1 : try {
1671 1 : next.scriptSession.loadIntoCatalog();
1672 1 : } catch (e: any) {
1673 1 : logger.error("Failed to publish script into the catalog", {
1674 1 : scriptKey: next.scriptKey.toString(),
1675 1 : error: stringifyError(e),
1676 1 : }, LOG_CTX);
1677 1 : }
1678 1 : return next;
1679 1 : }
1680 :
1681 1 : export function replaceScriptSessionText(scriptSession: core.DashQLScriptSession, text: string): void {
1682 1 : const update = scriptSession.replaceText(scriptSession.getTextVersion(), text);
1683 1 : if (update.status !== core.buffers.editor.EditorUpdateStatus.OK) {
1684 1 : const status = core.buffers.editor.EditorUpdateStatus[update.status] ?? update.status.toString();
1685 1 : throw new Error(`Failed to replace editor session text: ${status}`);
1686 1 : }
1687 1 : }
1688 :
1689 : /// Is the analysis of a script outdated? The flag marks the changes that the app knows about, the versions also
1690 : /// catch the ones it missed: the text of the session or its catalog changed since the analysis. A script that
1691 : /// published its own declarations changed its catalog too, so a current script may analyze once more.
1692 1 : export function isAnalysisOutdated(scriptData: ScriptData): boolean {
1693 1 : if (scriptData.analysisOutdated) return true;
1694 1 : const update = scriptData.editorUpdate;
1695 1 : if (update == null) return false;
1696 1 : return update.textVersion !== scriptData.scriptSession.getTextVersion()
1697 1 : || update.catalogRevision !== scriptData.scriptSession.getCatalogRevision();
1698 1 : }
1699 :
1700 1 : export function analyzeOutdatedScript<V extends SessionScripts>(state: V, scriptKey: number, logger: Logger): V {
1701 1 : const scriptData = state.scripts[scriptKey];
1702 1 : if (!scriptData || !isAnalysisOutdated(scriptData)) {
1703 1 : return state;
1704 : }
1705 : // Create the next session scripts state
1706 1 : const catalogRevision = scriptData.scriptSession.getCatalogRevision();
1707 1 : const nextScriptData = analyzeScriptData(scriptData, logger);
1708 1 : const next = {
1709 1 : ...state,
1710 1 : scripts: {
1711 1 : ...state.scripts,
1712 1 : [scriptKey]: nextScriptData
1713 1 : }
1714 1 : };
1715 : // The analysis ran against a newer catalog and may publish different declarations, e.g. a view
1716 : // over a table of another script. Then the scripts that resolve against them are outdated, too.
1717 1 : if (nextScriptData.scriptSession.getCatalogRevision() !== catalogRevision) {
1718 1 : markOtherScriptsOutdatedInPlace(next.scripts, nextScriptData);
1719 : }
1720 :
1721 : // Only the selected script drives the semantic focus.
1722 : // Scripts in the background are re-analyzed whenever another script changes and must not steal it.
1723 1 : if (getSelectedScriptRef(state)?.scriptId === scriptKey) {
1724 1 : next.semanticUserFocus = deriveFocusFromEditorUpdate(scriptKey, nextScriptData.editorUpdate);
1725 : }
1726 1 : return next;
1727 1 : }
|