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