LCOV - code coverage report
Current view: top level - dashql-app/src/session/scripts - session_scripts.ts Coverage Total Hit
Test: DashQL 915828b Lines: 98.9 % 1187 1174
Test Date: 2026-11-06 19:30:32

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

Generated by: LCOV version 2.0-1