LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - session_scripts.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 98.9 % 1188 1175
Test Date: 2026-11-09 20:22:13

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

Generated by: LCOV version 2.0-1