LCOV - code coverage report
Current view: top level - dashql-app/src/storage - storage_writer.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 98.0 % 589 577
Test Date: 2026-11-09 20:22:13

            Line data    Source code
       1              : import * as Immutable from 'immutable';
       2              : 
       3              : import { Logger, stringifyError } from '../platform/logger/logger.js';
       4              : import { VariantKind } from '../utils/variant.js';
       5              : import type { StorageBackend, SessionData, SessionMetadata as StorageSessionMetadata, SessionConnection, ConnectionEntry, ConnectionParams, SessionRegistryBackend, SessionScript } from './storage_backend.js';
       6              : import { ExternalScriptChangeError, STORAGE_SCRIPTS_FOLDER, STORAGE_SESSION_FILE, STORAGE_CONNECTIONS_FOLDER, isSessionRegistryBackend } from './storage_backend.js';
       7              : import { SESSION_FORMAT_VERSION } from './migration/versions.js';
       8              : import { sessionAttachments, withSessionScripts } from './session_files.js';
       9              : 
      10            1 : const LOG_CTX = 'storage_writer';
      11              : 
      12              : /// Order-independent deep equality for plain JSON values (objects, arrays, primitives).
      13              : /// Session manifests are plain JSON (no Dates/Maps/functions), so this is sufficient.
      14            1 : function jsonDeepEqual(a: any, b: any): boolean {
      15            1 :     if (a === b) {
      16            1 :         return true;
      17              :     }
      18            1 :     if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') {
      19            1 :         return false;
      20              :     }
      21            1 :     if (Array.isArray(a) || Array.isArray(b)) {
      22            1 :         if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
      23            1 :             return false;
      24              :         }
      25            1 :         return a.every((v, i) => jsonDeepEqual(v, b[i]));
      26            1 :     }
      27            1 :     const aKeys = Object.keys(a);
      28            1 :     const bKeys = Object.keys(b);
      29            1 :     if (aKeys.length !== bKeys.length) {
      30            1 :         return false;
      31              :     }
      32            1 :     return aKeys.every(k => Object.prototype.hasOwnProperty.call(b, k) && jsonDeepEqual(a[k], b[k]));
      33            1 : }
      34              : 
      35              : /// Compare the manifest fields the writer owns (formatVersion, sessionId, name, connections, scripts, attachments, metadata).
      36              : /// Other fields (storageType/nativePath/sessionPath) are display- or registry-only and are not
      37              : /// written into the session file, so they're deliberately ignored.
      38              : ///
      39              : /// Both sides are round-tripped through JSON first so this compares exactly what would be
      40              : /// persisted: serialization drops `undefined`-valued keys, so a freshly built params object with
      41              : /// `{ foo: undefined }` must compare equal to the reloaded `{}` it serializes to.
      42            1 : function sessionManifestEquals(a: SessionData, b: SessionData): boolean {
      43            1 :     const project = (s: SessionData) => JSON.parse(JSON.stringify({
      44            1 :         formatVersion: s.formatVersion,
      45            1 :         sessionId: s.sessionId,
      46            1 :         name: s.name,
      47            1 :         connections: s.connections,
      48            1 :         scripts: s.scripts,
      49            1 :         attachments: s.attachments,
      50            1 :         metadata: s.metadata,
      51            1 :     }));
      52            1 :     return jsonDeepEqual(project(a), project(b));
      53            1 : }
      54              : 
      55              : export const DEBOUNCE_DURATION_SESSION_WRITE = 100;
      56              : export const DEBOUNCE_DURATION_SCRIPT_WRITE = 100;
      57              : 
      58            1 : export const WRITE_SESSION_MANIFEST = Symbol('WRITE_SESSION_MANIFEST');
      59            1 : export const WRITE_SESSION_PROPERTIES = Symbol('WRITE_SESSION_PROPERTIES');
      60              : 
      61              : /// The session fields that are owned by the user, not derived from scripts or connections.
      62              : /// They are written together since writes of the same manifest replace each other while debounced.
      63              : export interface SessionProperties {
      64              :     /// The session name, null if the user never named the session
      65              :     name: string | null;
      66              :     /// Does the session stay ephemeral? A write without it promotes the session
      67              :     ephemeral?: boolean;
      68              : }
      69              : /// A session that is written as a whole, its scripts first and its manifest last
      70              : export interface SessionReplacement {
      71              :     /// The session id
      72              :     sessionId: string;
      73              :     /// The session name, null keeps the name on disk
      74              :     name: string | null;
      75              :     /// The script files, their text is read when the write runs. The script list of the session file
      76              :     /// is written by `WRITE_SESSION_SCRIPTS` alone, it is the only writer of the list.
      77              :     scripts: Array<{ fileName: string; readText: () => string }>;
      78              :     /// A connection of a session that is not stored yet, the script list follows with the others
      79              :     connection: SessionConnection | null;
      80              :     /// The file name the session was created from, if any
      81              :     originalFileName: string | undefined;
      82              : }
      83            1 : export const REPLACE_SESSION_SCRIPTS = Symbol('REPLACE_SESSION_SCRIPTS');
      84            1 : export const WRITE_CONNECTION_ENTRY = Symbol('WRITE_CONNECTION_ENTRY');
      85            1 : export const DELETE_CONNECTION_ENTRY = Symbol('DELETE_CONNECTION_ENTRY');
      86            1 : export const WRITE_CONNECTION_FILE = Symbol('WRITE_CONNECTION_FILE');
      87            1 : export const PATCH_SESSION_CONNECTION = Symbol('PATCH_SESSION_CONNECTION');
      88            1 : export const WRITE_SCRIPT = Symbol('WRITE_SCRIPT');
      89            1 : export const DELETE_SCRIPT = Symbol('DELETE_SCRIPT');
      90            1 : export const RENAME_SCRIPT = Symbol('RENAME_SCRIPT');
      91            1 : export const WRITE_SESSION_SCRIPTS = Symbol('WRITE_SESSION_SCRIPTS');
      92            1 : export const REFRESH_SESSION_ATTACHMENTS = Symbol('REFRESH_SESSION_ATTACHMENTS');
      93              : 
      94              : /// The script list of a session
      95              : export interface SessionScriptList {
      96              :     /// The scripts in feed order with their connections
      97              :     scripts: SessionScript[];
      98              :     /// Copies of the connections of the scripts that the session file may not hold yet
      99              :     connections: SessionConnection[];
     100              : }
     101              : 
     102              : export type StorageWriteTaskVariant =
     103              :     | VariantKind<typeof WRITE_SESSION_MANIFEST, [string, SessionConnection[]] | [string, SessionConnection[], { ephemeral: boolean }]>
     104              :     | VariantKind<typeof WRITE_SESSION_PROPERTIES, [string, SessionProperties]>
     105              :     | VariantKind<typeof REPLACE_SESSION_SCRIPTS, SessionReplacement>
     106              :     | VariantKind<typeof WRITE_CONNECTION_ENTRY, ConnectionEntry>
     107              :     | VariantKind<typeof DELETE_CONNECTION_ENTRY, string>
     108              :     /// The text of a file, or how to read it when the write runs, null if there is nothing to write anymore
     109              :     | VariantKind<typeof WRITE_CONNECTION_FILE, [string, string, string | (() => string | null)]>
     110              :     | VariantKind<typeof PATCH_SESSION_CONNECTION, [string, SessionConnection] | [string, SessionConnection, string[]]>
     111              :     | VariantKind<typeof WRITE_SCRIPT, [string, string, string]>
     112              :     | VariantKind<typeof DELETE_SCRIPT, [string, string]>
     113              :     | VariantKind<typeof RENAME_SCRIPT, [string, string, string]>
     114              :     | VariantKind<typeof WRITE_SESSION_SCRIPTS, [string, SessionScriptList]>
     115              :     | VariantKind<typeof REFRESH_SESSION_ATTACHMENTS, string>
     116              :     ;
     117              : 
     118              : /// The key of a write. Writes with the same key coalesce, the latest task wins.
     119              : ///
     120              : /// Keys are logical paths, they are not the paths of the files:
     121              : ///   - Session keys start with the session UUID, e.g. `<sessionId>/scripts/01_query.sql`. The
     122              : ///     per-session operations (pause, pending writes, cancellation) select keys by this prefix.
     123              : ///   - Keys of the app storage start with `@`, e.g. `@manifest/connections/<id>` for a connection
     124              : ///     entry of the app manifest and `@connections/<id>/<file>` for the files of a connection in
     125              : ///     `connections/<id>/`. A UUID never starts with `@`, so these keys never match the prefix of a
     126              : ///     session and are never paused or cancelled with one. `storageWriteChainKey` orders them.
     127              : export type StorageWriteKey = string;
     128              : /// The manifest is keyed on its real file path (`<sessionId>/dashql-session.json`) so the key it is
     129              : /// *scheduled* under matches the path its *completed* write is recorded under — otherwise the manifest
     130              : /// shows up as two rows in the stats view (a phantom `<sessionId>/` schedule row plus the real
     131              : /// file's write row). Keying both on the file also means a scheduled-but-not-yet-flushed manifest
     132              : /// write coalesces onto the same statistics row as its completion.
     133            1 : export const groupSessionManifestWrites = (sessionId: string) => `${sessionId}/${STORAGE_SESSION_FILE}`;
     134            1 : export const groupSessionWrites = (sessionId: string) => `${sessionId}/${STORAGE_SCRIPTS_FOLDER}`;
     135              : /// The connection entries are written into the app manifest, one key per connection.
     136            1 : export const groupConnectionEntryWrites = (connectionId: string) => `@manifest/${STORAGE_CONNECTIONS_FOLDER}/${connectionId}`;
     137              : /// The files of a connection, e.g. its catalog relations
     138            1 : export const groupConnectionFileWrites = (connectionId: string, fileName: string) => `@${STORAGE_CONNECTIONS_FOLDER}/${connectionId}/${fileName}`;
     139              : /// The copy of a connection's params in a session. Copies of different connections must not
     140              : /// coalesce, but they serialize with every other write of the session.
     141            1 : export const groupSessionConnectionCopyWrites = (sessionId: string, connectionId: string) => `${sessionId}/${STORAGE_SESSION_FILE}:connection/${connectionId}`;
     142              : /// The script list of a session and the connections it uses. It is patched into the session file and
     143              : /// must not coalesce with the other writes of the session file.
     144            1 : export const groupSessionScriptListWrites = (sessionId: string) => `${sessionId}/${STORAGE_SESSION_FILE}:scripts`;
     145              : /// The attachment list of a session
     146            1 : export const groupSessionAttachmentListWrites = (sessionId: string) => `${sessionId}/${STORAGE_SESSION_FILE}:attachments`;
     147              : /// A cached query result of a session and the marker of its last access
     148            1 : export const groupScriptWrites = (sessionId: string, fileName: string) =>
     149            1 :     `${sessionId}/${STORAGE_SCRIPTS_FOLDER}/${fileName}`;
     150            1 : export const groupScriptDeletes = (sessionId: string, scriptName: string) =>
     151            1 :     `${sessionId}/${STORAGE_SCRIPTS_FOLDER}/${scriptName}:delete`;
     152              : /// A page/script rename lives in its own `:rename` keyspace, keyed by the *source* path. Keeping it
     153              : /// off the write/delete keyspaces means a later content write (or delete) of the destination never
     154              : /// coalesces onto — and so never clobbers — a still-pending rename of the same name. The action lives
     155              : /// in a `:rename` *suffix* rather than a prefix so the key still starts with the session id and
     156              : /// sorts/scopes like every other file key.
     157            1 : export const groupScriptRenames = (sessionId: string, oldScriptName: string) =>
     158            1 :     `${sessionId}/${STORAGE_SCRIPTS_FOLDER}/${oldScriptName}:rename`;
     159              : 
     160              : /// Whether a statistics key belongs to a given session. Every write key is a path rooted at the
     161              : /// session id (`<sessionId>/…`), with the `:delete`/`:rename` action namespaces living in a suffix
     162              : /// (see the group* helpers above), so the session owns the key when it is the session id itself or a
     163              : /// descendant of it. Used to scope the storage-writer stats view to the active session.
     164            1 : export function storageWriteKeyBelongsToSession(key: StorageWriteKey, sessionId: string): boolean {
     165            1 :     return key === sessionId || key.startsWith(`${sessionId}/`);
     166            1 : }
     167              : 
     168              : /// Strip the session-id prefix from a write key for display, keeping any `:delete`/`:rename` suffix
     169              : /// so the action stays legible. Once the stats view is scoped to a single session the session id is
     170              : /// redundant on every row, so e.g. `<sessionId>/scripts/page-1/01.sql` renders as
     171              : /// `scripts/page-1/01.sql` and `<sessionId>/scripts/page-1:rename` as `scripts/page-1:rename`.
     172              : /// The bare session key collapses to an empty string. Keys that don't belong to the session are
     173              : /// returned unchanged.
     174            1 : export function storageWriteKeyWithinSession(key: StorageWriteKey, sessionId: string): string {
     175            1 :     if (!storageWriteKeyBelongsToSession(key, sessionId)) {
     176            0 :         return key;
     177              :     }
     178            1 :     return key === sessionId ? '' : key.slice(sessionId.length + 1);
     179            1 : }
     180              : 
     181              : /// The outcome of a scheduled write
     182              : export type StorageWriteResult =
     183              :     /// The task was executed (a task may still skip writing content that is already persisted)
     184              :     | { status: 'written' }
     185              :     /// A newer task with the same key replaced the task before it was executed
     186              :     | { status: 'superseded' }
     187              :     /// The task was discarded before it was executed
     188              :     | { status: 'cancelled' }
     189              :     /// The writer or the session is paused, the task stays pending and is executed on resume
     190              :     | { status: 'deferred' }
     191              :     /// Executing the task failed
     192              :     | { status: 'failed'; error: unknown };
     193              : 
     194              : /// The chain key of a write key, tasks with the same chain key are executed one after another.
     195              : /// Session keys chain per session (`<sessionId>/…`), so mutations and their index rebuilds cannot
     196              : /// overlap. Global keys start with `@` and chain per entity
     197              : /// (`@manifest/…`, `@connections/<id>/…`).
     198            1 : export function storageWriteChainKey(key: StorageWriteKey): string {
     199            1 :     const parts = key.split('/');
     200            1 :     if (key.startsWith('@connections/') && parts.length > 1) {
     201            1 :         return `${parts[0]}/${parts[1]}`;
     202              :     }
     203            1 :     return parts[0];
     204            1 : }
     205              : 
     206              : interface AsyncStorageWriteTask {
     207              :     /// The latest task
     208              :     latestTask: StorageWriteTaskVariant;
     209              :     /// Resolve the caller of the latest task
     210              :     resolveLatestTask: (result: StorageWriteResult) => void;
     211              :     /// The time when this task was first scheduled
     212              :     scheduledAt: Date;
     213              :     /// The debounce duration in milliseconds
     214              :     debounceDurationMs: number;
     215              :     /// The timer
     216              :     timer: ReturnType<typeof setTimeout>;
     217              : }
     218              : 
     219              : export interface StorageWriterStatistics {
     220              :     /// The number of writes we scheduled
     221              :     totalScheduledWrites: number;
     222              :     /// The number of writes we performed
     223              :     totalWrites: number;
     224              :     /// The number of bytes written in total to storage
     225              :     totalWrittenBytes: number;
     226              :     /// The accumulated time spent writing
     227              :     totalWriteTime: number;
     228              :     /// The last write
     229              :     lastWrite: Date | null;
     230              : }
     231              : 
     232              : export type StorageWriteStatisticsMap = Immutable.Map<StorageWriteKey, StorageWriterStatistics>;
     233              : export type StorageWriteStatisticsSubscriber = (stats: StorageWriteStatisticsMap) => void;
     234              : 
     235              : /// A session whose files are not available, e.g. a native folder that was moved or deleted
     236            1 : export class SessionUnavailableError extends Error {
     237              :     /// The session id
     238            1 :     readonly sessionId: string;
     239              : 
     240            1 :     constructor(sessionId: string, cause: unknown) {
     241            1 :         super(`session ${sessionId} is not available: ${stringifyError(cause)}`);
     242            1 :         this.name = 'SessionUnavailableError';
     243            1 :         this.sessionId = sessionId;
     244            1 :     }
     245            1 : }
     246              : 
     247              : /// Subscriber for scripts that changed outside the app
     248              : export type ExternalScriptChangeSubscriber = (sessionId: string) => void;
     249              : 
     250              : /// Replace the copies of a connection's params in a session.
     251              : /// References of the replaced connections (connections that were merged into this one) are rewritten
     252              : /// to the connection id. Returns null when the session doesn't reference the connection or the copies
     253              : /// are up to date.
     254            1 : export function patchSessionConnection(session: SessionData, copy: SessionConnection, replaced: readonly string[] = []): SessionData | null {
     255            1 :     const connectionId = copy.connectionId;
     256            1 :     const references = (id: string) => id === connectionId || replaced.includes(id);
     257            1 :     const next: SessionData = {
     258            1 :         ...session,
     259            1 :         connections: dedupeConnections(session.connections.map(connection =>
     260            1 :             references(connection.connectionId) ? copy : connection)),
     261            1 :         scripts: session.scripts.map(script => replaced.includes(script.connectionId) ? { ...script, connectionId } : script),
     262            1 :     };
     263            1 :     return sessionManifestEquals(session, next) ? null : next;
     264            1 : }
     265              : 
     266              : /// Drop connections that an earlier connection of the list references already.
     267              : /// Consolidated connections can turn distinct references into the same one.
     268            1 : export function dedupeConnections<T extends { connectionId: string }>(connections: T[]): T[] {
     269            1 :     const seen = new Set<string>();
     270            1 :     return connections.filter(connection => {
     271            1 :         if (seen.has(connection.connectionId)) return false;
     272            1 :         seen.add(connection.connectionId);
     273            1 :         return true;
     274            1 :     });
     275            1 : }
     276              : 
     277              : /// Insert or replace a connection entry, keeping its creation time
     278            1 : export function upsertConnectionEntry(connections: ConnectionEntry[] | undefined, entry: ConnectionEntry): ConnectionEntry[] | null {
     279            1 :     const entries = connections ?? [];
     280            1 :     const index = entries.findIndex(e => e.id === entry.id);
     281            1 :     const prev = index >= 0 ? entries[index] : null;
     282            1 :     const next: ConnectionEntry = {
     283            1 :         ...entry,
     284            1 :         ...(entry.createdAt == null && prev?.createdAt != null ? { createdAt: prev.createdAt } : {}),
     285            1 :     };
     286            1 :     if (prev != null && jsonDeepEqual(JSON.parse(JSON.stringify(prev)), JSON.parse(JSON.stringify(next)))) {
     287            0 :         return null;
     288              :     }
     289            1 :     return index >= 0 ? entries.map((e, i) => (i === index ? next : e)) : [...entries, next];
     290            1 : }
     291              : 
     292            1 : export class StorageWriter {
     293              :     /// The logger
     294            1 :     logger: Logger;
     295              :     /// The storage backend
     296            1 :     backend: StorageBackend;
     297              :     /// The pending tasks
     298            1 :     pendingTasks: Map<StorageWriteKey, AsyncStorageWriteTask>;
     299              :     /// The statistics
     300            1 :     statistics: StorageWriteStatisticsMap;
     301              :     /// The listeners for
     302            1 :     statisticsSubscribers: Set<StorageWriteStatisticsSubscriber>;
     303              :     /// Is the writer paused? While paused, debounced timers don't process tasks.
     304            1 :     paused: boolean;
     305              :     /// Sessions temporarily held by external reload/conflict handling.
     306            1 :     pausedSessions: Set<string>;
     307              :     /// Deleted sessions, writes for them are discarded until a session with the id is registered again.
     308            1 :     deletedSessions: Set<string>;
     309              :     /// The executions that are currently in flight (used by flush() to await completion).
     310            1 :     inFlight: Set<Promise<void>>;
     311              :     /// Latest execution per chain key, so mutations and their index rebuilds cannot overlap.
     312            1 :     chainExecutions: Map<string, Promise<void>>;
     313              :     /// Subscribers for scripts that changed outside the app
     314            1 :     externalChangeSubscribers: Set<ExternalScriptChangeSubscriber>;
     315              :     /// Monotonic scheduled-write generation per key, used to revalidate destructive prompts.
     316            1 :     writeKeyGenerations: Map<string, number>;
     317            1 :     nextWriteGeneration: number;
     318              : 
     319            1 :     constructor(logger: Logger, backend: StorageBackend) {
     320            1 :         this.logger = logger;
     321            1 :         this.backend = backend;
     322            1 :         this.pendingTasks = new Map();
     323            1 :         this.statistics = Immutable.Map();
     324            1 :         this.statisticsSubscribers = new Set();
     325            1 :         this.paused = false;
     326            1 :         this.pausedSessions = new Set();
     327            1 :         this.deletedSessions = new Set();
     328            1 :         this.inFlight = new Set();
     329            1 :         this.chainExecutions = new Map();
     330            1 :         this.externalChangeSubscribers = new Set();
     331            1 :         this.writeKeyGenerations = new Map();
     332            1 :         this.nextWriteGeneration = 1;
     333            1 :     }
     334              : 
     335              :     /// Pause the writer.
     336              :     /// Cancels all pending debounce timers so nothing is written until resume()/flush() is called.
     337              :     /// Tasks scheduled while paused stay pending and are re-armed on resume().
     338            1 :     public pause(): void {
     339            1 :         this.paused = true;
     340            1 :         for (const task of this.pendingTasks.values()) {
     341            1 :             clearTimeout(task.timer);
     342              :         }
     343            1 :     }
     344              : 
     345              :     /// Resume the writer, re-arming debounce timers for any tasks accumulated while paused.
     346            1 :     public resume(): void {
     347            1 :         if (!this.paused) {
     348            0 :             return;
     349              :         }
     350            1 :         this.paused = false;
     351            1 :         for (const [key, task] of this.pendingTasks) {
     352            1 :             if ([...this.pausedSessions].some(sessionId => storageWriteKeyBelongsToSession(key, sessionId))) {
     353            0 :                 continue;
     354              :             }
     355            1 :             task.timer = setTimeout(() => this.processTask(key), task.debounceDurationMs);
     356            1 :         }
     357            1 :     }
     358              : 
     359              :     /// Flush the writer: process every pending task immediately and await all in-flight writes.
     360              :     /// Works regardless of the paused state and leaves the paused state unchanged.
     361            1 :     public async flush(): Promise<void> {
     362            1 :         const wasPaused = this.paused;
     363              :         // Temporarily allow processing even if paused.
     364            1 :         this.paused = false;
     365            1 :         const keys = [...this.pendingTasks.keys()];
     366            1 :         for (const key of keys) {
     367            1 :             const task = this.pendingTasks.get(key);
     368            1 :             if (task) {
     369            1 :                 clearTimeout(task.timer);
     370              :             }
     371            1 :             await this.processTask(key, true);
     372            1 :         }
     373            1 :         this.paused = wasPaused;
     374              :         // Await any executions that were already in flight (e.g. a timer that fired just before).
     375            1 :         while (this.inFlight.size > 0) {
     376            1 :             await Promise.all([...this.inFlight]);
     377              :         }
     378            1 :     }
     379              : 
     380            1 :     public getStatistics(): StorageWriteStatisticsMap {
     381            1 :         return this.statistics;
     382            1 :     }
     383              : 
     384            1 :     public pauseSession(sessionId: string): void {
     385            1 :         this.pausedSessions.add(sessionId);
     386            1 :         for (const [key, task] of this.pendingTasks) {
     387            1 :             if (storageWriteKeyBelongsToSession(key, sessionId)) {
     388            1 :                 clearTimeout(task.timer);
     389              :             }
     390              :         }
     391            1 :     }
     392              : 
     393            1 :     public resumeSession(sessionId: string): void {
     394            1 :         if (!this.pausedSessions.delete(sessionId) || this.paused) {
     395            0 :             return;
     396              :         }
     397            1 :         for (const [key, task] of this.pendingTasks) {
     398            1 :             if (storageWriteKeyBelongsToSession(key, sessionId)) {
     399            1 :                 task.timer = setTimeout(() => this.processTask(key), task.debounceDurationMs);
     400              :             }
     401              :         }
     402            1 :     }
     403              : 
     404              :     /// Pending write keys below a session id. This is intentionally prefix-based: every writer key
     405              :     /// is rooted at the session id, including the delete/rename action namespaces.
     406            1 :     public getPendingKeysForSession(sessionId: string, include: (key: string) => boolean = () => true): string[] {
     407            1 :         return [...this.pendingTasks.keys()].filter(key => storageWriteKeyBelongsToSession(key, sessionId) && include(key));
     408            1 :     }
     409              : 
     410              :     /// Discard pending writes for a session, resolving their promises as not executed. Used only
     411              :     /// after the user explicitly chooses an externally-written disk version over local debounced
     412              :     /// edits. Writes already in flight cannot be cancelled and are handled by settle().
     413            1 :     public cancelPendingWritesForSession(sessionId: string, include: (key: string) => boolean = () => true): void {
     414            1 :         for (const [key, task] of this.pendingTasks) {
     415            1 :             if (!storageWriteKeyBelongsToSession(key, sessionId) || !include(key)) {
     416            0 :                 continue;
     417              :             }
     418            1 :             clearTimeout(task.timer);
     419            1 :             this.pendingTasks.delete(key);
     420            1 :             task.resolveLatestTask({ status: 'cancelled' });
     421            1 :         }
     422            1 :     }
     423              : 
     424              :     /// Discard the pending writes of the files of a connection that is deleted, they would recreate its folder
     425            1 :     public cancelPendingConnectionFileWrites(connectionId: string): void {
     426            1 :         const prefix = `@${STORAGE_CONNECTIONS_FOLDER}/${connectionId}/`;
     427            1 :         for (const [key, task] of this.pendingTasks) {
     428            1 :             if (key.startsWith(prefix)) {
     429            1 :                 clearTimeout(task.timer);
     430            1 :                 this.pendingTasks.delete(key);
     431            1 :                 task.resolveLatestTask({ status: 'cancelled' });
     432            1 :             }
     433              :         }
     434            1 :     }
     435              : 
     436              :     /// Delete a session from the storage.
     437              :     /// The pending writes of the session are discarded and writes that already started finish first,
     438              :     /// no write recreates files of the deleted session afterwards.
     439            1 :     public async deleteSession(sessionId: string): Promise<void> {
     440            1 :         this.deletedSessions.add(sessionId);
     441            1 :         this.cancelPendingWritesForSession(sessionId);
     442            1 :         await this.settle();
     443            1 :         try {
     444            1 :             await this.backend.deleteSession(sessionId);
     445            1 :         } catch (error) {
     446              :             // The session stays in the storage, it may be written again
     447            0 :             this.deletedSessions.delete(sessionId);
     448            0 :             throw error;
     449            0 :         }
     450            1 :         this.logger.info("Deleted session", { sessionId }, LOG_CTX);
     451            1 :     }
     452              : 
     453              :     /// A session with the id of a deleted session was registered again, e.g. by importing the same bundle.
     454              :     /// Writes for the id are accepted again, the writes discarded while it was deleted stay discarded.
     455            1 :     public reviveSession(sessionId: string): void {
     456            1 :         this.deletedSessions.delete(sessionId);
     457            1 :     }
     458              : 
     459              :     /// Is a session held, e.g. after one of its scripts changed outside the app?
     460              :     /// Its writes stay pending until the session resumes.
     461            1 :     public isSessionPaused(sessionId: string): boolean {
     462            1 :         return this.paused || this.pausedSessions.has(sessionId);
     463            1 :     }
     464              : 
     465              :     /// Write the pending tasks of a session right away and wait until they are persisted.
     466              :     ///
     467              :     /// Resolves true when every pending write of the session reached the storage. A held writer or
     468              :     /// session keeps its writes pending and resolves false. Unlike flush(), a held session is never
     469              :     /// forced, its writes would overwrite the external change. A failed write stays pending as well,
     470              :     /// unless a newer write of its key replaced it, and the next flush writes it again.
     471            1 :     public async flushSession(sessionId: string): Promise<boolean> {
     472            1 :         if (this.isSessionPaused(sessionId)) return false;
     473            1 :         const failed = new Map<StorageWriteKey, AsyncStorageWriteTask>();
     474            1 :         for (const key of this.getPendingKeysForSession(sessionId)) {
     475            1 :             const task = this.pendingTasks.get(key);
     476            1 :             if (task == null) continue;
     477            1 :             const resolve = task.resolveLatestTask;
     478            1 :             task.resolveLatestTask = result => {
     479            1 :                 if (result.status === 'failed') failed.set(key, task);
     480            1 :                 resolve(result);
     481            1 :             };
     482            1 :             clearTimeout(task.timer);
     483            1 :             void this.processTask(key);
     484            1 :         }
     485            1 :         await this.settle();
     486            1 :         for (const [key, task] of failed) {
     487            1 :             if (this.pendingTasks.has(key)) continue;
     488            1 :             task.resolveLatestTask = () => { };
     489            1 :             task.timer = setTimeout(() => this.processTask(key), task.debounceDurationMs);
     490            1 :             this.pendingTasks.set(key, task);
     491            1 :         }
     492              :         // A write that would overwrite an external change pauses the session and stays pending
     493            1 :         return failed.size === 0 && !this.isSessionPaused(sessionId);
     494            1 :     }
     495              : 
     496              :     /// Wait for writes that have already started without flushing pending debounced work.
     497            1 :     public async settle(): Promise<void> {
     498            1 :         while (this.inFlight.size > 0) {
     499            1 :             await Promise.all([...this.inFlight]);
     500              :         }
     501            1 :     }
     502              : 
     503            1 :     public getSessionWriteGeneration(sessionId: string, include: (key: string) => boolean = () => true): number {
     504            1 :         let generation = 0;
     505            1 :         for (const [key, value] of this.writeKeyGenerations) {
     506            1 :             if (storageWriteKeyBelongsToSession(key, sessionId) && include(key)) {
     507            1 :                 generation = Math.max(generation, value);
     508              :             }
     509              :         }
     510            1 :         return generation;
     511            1 :     }
     512              : 
     513              :     /// Subscribe to script writes that are held because the script changed outside the app
     514            1 :     public subscribeExternalChanges(subscriber: ExternalScriptChangeSubscriber): () => void {
     515            1 :         this.externalChangeSubscribers.add(subscriber);
     516            1 :         return () => this.externalChangeSubscribers.delete(subscriber);
     517            1 :     }
     518              : 
     519            1 :     public subscribeStatisticsListener(listener: StorageWriteStatisticsSubscriber) {
     520            1 :         this.statisticsSubscribers.add(listener);
     521              : 
     522            1 :     }
     523            1 :     public unsubscribeStatisticsListener(listener: StorageWriteStatisticsSubscriber) {
     524            1 :         this.statisticsSubscribers.delete(listener);
     525            1 :     }
     526              : 
     527              :     /// Schedule a debounced write, resolves true when the task was executed.
     528              :     /// A newer write with the same key replaces a pending task and resolves its caller with false.
     529            1 :     public async write(key: string, task: StorageWriteTaskVariant, debounceFor: number = 0): Promise<boolean> {
     530            1 :         const result = await this.schedule(key, task, debounceFor);
     531            1 :         return result.status === 'written';
     532            1 :     }
     533              : 
     534              :     /// Write without debouncing and wait until the content is persisted.
     535              :     ///
     536              :     /// The task replaces any pending task with the same key and is executed right away, chained after
     537              :     /// running tasks of the same chain key. A paused writer or session resolves with `deferred` and
     538              :     /// keeps the task pending, it is executed on resume.
     539            1 :     public writeDurable(key: string, task: StorageWriteTaskVariant): Promise<StorageWriteResult> {
     540            1 :         const result = this.schedule(key, task, 0);
     541            1 :         const pending = this.pendingTasks.get(key)!;
     542            1 :         clearTimeout(pending.timer);
     543            1 :         if (this.isPausedKey(key)) {
     544            1 :             pending.resolveLatestTask({ status: 'deferred' });
     545            1 :             pending.resolveLatestTask = () => { };
     546            1 :         } else {
     547            1 :             void this.processTask(key);
     548              :         }
     549            1 :         return result;
     550            1 :     }
     551              : 
     552              :     /// Is a key held by a paused writer or a paused session?
     553            1 :     protected isPausedKey(key: string): boolean {
     554            1 :         return this.paused || [...this.pausedSessions].some(sessionId => storageWriteKeyBelongsToSession(key, sessionId));
     555            1 :     }
     556              : 
     557            1 :     protected schedule(key: string, task: StorageWriteTaskVariant, debounceFor: number): Promise<StorageWriteResult> {
     558              :         // Writes that were queued while a session was deleted must not recreate its files
     559            1 :         if ([...this.deletedSessions].some(sessionId => storageWriteKeyBelongsToSession(key, sessionId))) {
     560            1 :             return Promise.resolve({ status: 'cancelled' });
     561              :         }
     562            1 :         this.writeKeyGenerations.set(key, this.nextWriteGeneration++);
     563              :         // Is there a previous task with the same key?
     564            1 :         const prevTask = this.pendingTasks.get(key);
     565            1 :         let scheduledAt: Date;
     566            1 :         let debounceDurationMs: number = debounceFor;
     567            1 :         let timer: ReturnType<typeof setTimeout>;
     568            1 :         if (prevTask) {
     569              :             // Tell the former write call that the task was not executed
     570            1 :             prevTask.resolveLatestTask({ status: 'superseded' });
     571              :             // Overwrite the task
     572            1 :             scheduledAt = prevTask.scheduledAt;
     573            1 :             debounceDurationMs = prevTask.debounceDurationMs;
     574            1 :             timer = prevTask.timer;
     575            1 :         } else {
     576            1 :             scheduledAt = new Date();
     577            1 :             timer = setTimeout(() => this.processTask(key), debounceFor);
     578            1 :         }
     579            1 :         let resolveTask: ((result: StorageWriteResult) => void) | null = null;
     580            1 :         const result = new Promise<StorageWriteResult>(resolve => { resolveTask = resolve; });
     581              : 
     582              :         // Overwrite any previous task with the same key
     583            1 :         this.pendingTasks.set(key, {
     584            1 :             latestTask: task,
     585            1 :             resolveLatestTask: resolveTask!,
     586            1 :             scheduledAt: scheduledAt,
     587            1 :             debounceDurationMs,
     588            1 :             timer,
     589            1 :         });
     590            1 :         this.registerScheduledWrite(key);
     591            1 :         return result;
     592            1 :     }
     593              : 
     594            1 :     protected async processTask(key: string, force: boolean = false) {
     595            1 :         const task = this.pendingTasks.get(key);
     596            1 :         if (!task) {
     597            1 :             return;
     598              :         }
     599              :         // If a timer fires while paused, leave the task pending - it'll be re-armed on resume().
     600            1 :         if (!force && this.isPausedKey(key)) {
     601            1 :             return;
     602              :         }
     603            1 :         this.pendingTasks.delete(key);
     604            1 :         const chainKey = storageWriteChainKey(key);
     605            1 :         const previousExecution = this.chainExecutions.get(chainKey);
     606            1 :         const execution = (async () => {
     607            1 :             if (previousExecution) {
     608            1 :                 await previousExecution;
     609              :             }
     610            1 :             try {
     611            1 :                 await this.executeTask(key, task.latestTask);
     612            1 :                 task.resolveLatestTask({ status: 'written' });
     613            1 :             } catch (e: any) {
     614            1 :                 if (e instanceof ExternalScriptChangeError) {
     615            1 :                     this.holdExternallyChangedWrite(key, task, e);
     616            1 :                     return;
     617            1 :                 }
     618            1 :                 this.logger.error("executing write task failed", {
     619            1 :                     key: key,
     620            1 :                     error: stringifyError(e)
     621            1 :                 })
     622            1 :                 task.resolveLatestTask({ status: 'failed', error: e });
     623            1 :             }
     624            1 :         })();
     625            1 :         this.chainExecutions.set(chainKey, execution);
     626            1 :         this.inFlight.add(execution);
     627            1 :         try {
     628            1 :             await execution;
     629            1 :         } finally {
     630            1 :             this.inFlight.delete(execution);
     631            1 :             if (this.chainExecutions.get(chainKey) === execution) {
     632            1 :                 this.chainExecutions.delete(chainKey);
     633              :             }
     634            1 :         }
     635            1 :     }
     636              : 
     637              :     /// Keep a write that would overwrite an external change pending and pause its session.
     638              :     /// The subscribers decide between the disk and the app, then resume the session.
     639            1 :     protected holdExternallyChangedWrite(key: string, task: AsyncStorageWriteTask, error: ExternalScriptChangeError) {
     640            1 :         this.logger.warn("script changed outside the app, holding the write", { key, sessionId: error.sessionId, file: error.fileName }, LOG_CTX);
     641            1 :         this.pauseSession(error.sessionId);
     642            1 :         if (this.pendingTasks.has(key)) {
     643              :             // A newer task with the same key was scheduled while this one was executing
     644            0 :             task.resolveLatestTask({ status: 'superseded' });
     645              :         } else {
     646            1 :             this.pendingTasks.set(key, task);
     647              :         }
     648            1 :         for (const subscriber of this.externalChangeSubscribers) {
     649            1 :             subscriber(error.sessionId);
     650              :         }
     651            1 :     }
     652              : 
     653            1 :     protected updateStatistics(statistics: Immutable.Map<StorageWriteKey, StorageWriterStatistics>) {
     654            1 :         this.statistics = statistics;
     655            1 :         for (const subscriber of this.statisticsSubscribers) {
     656            1 :             subscriber(statistics);
     657              :         }
     658            1 :     }
     659              : 
     660            1 :     protected registerScheduledWrite(key: StorageWriteKey) {
     661              :         const ifNotSet: StorageWriterStatistics = {
     662            1 :             totalScheduledWrites: 1,
     663            1 :             totalWrites: 0,
     664            1 :             totalWrittenBytes: 0,
     665            1 :             totalWriteTime: 0,
     666            1 :             lastWrite: null,
     667              :         };
     668            1 :         const stats = this.statistics.update(key, ifNotSet, (stats) => ({ ...stats, totalScheduledWrites: stats.totalScheduledWrites + 1 }));
     669            1 :         this.updateStatistics(stats);
     670            1 :     }
     671              : 
     672            1 :     protected registerWrite(key: StorageWriteKey, writtenBytes: number, writeDurationMs: number) {
     673            1 :         const ifNotSet: StorageWriterStatistics = {
     674            1 :             totalScheduledWrites: 1,
     675            1 :             totalWrites: 1,
     676            1 :             totalWrittenBytes: writtenBytes,
     677            1 :             totalWriteTime: writeDurationMs,
     678            1 :             lastWrite: new Date(),
     679            1 :         };
     680            1 :         const stats = this.statistics.update(key, ifNotSet, (stats) => ({
     681            1 :             ...stats,
     682            1 :             totalWrites: stats.totalWrites + 1,
     683            1 :             totalWrittenBytes: stats.totalWrittenBytes + writtenBytes,
     684            1 :             totalWriteTime: stats.totalWriteTime + writeDurationMs,
     685            1 :             lastWrite: new Date(),
     686            1 :         }));
     687            1 :         this.updateStatistics(stats);
     688            1 :     }
     689              : 
     690              :     /// The backend as session registry, connection writes need one
     691            1 :     protected requireRegistry(): SessionRegistryBackend {
     692            1 :         if (!isSessionRegistryBackend(this.backend)) {
     693            1 :             throw new Error('the storage backend does not own a session registry');
     694              :         }
     695            1 :         return this.backend;
     696            1 :     }
     697              : 
     698              :     /// Patch an existing session file, the write is skipped when nothing changed.
     699              :     /// A missing file is not recreated, a moved native folder would otherwise come back as a new one.
     700            1 :     private async patchSessionFile(key: string, sessionId: string, patch: (session: SessionData) => SessionData): Promise<void> {
     701            1 :         let existing: SessionData;
     702            1 :         try {
     703            1 :             existing = await this.backend.loadSession(sessionId);
     704            1 :         } catch (e: any) {
     705            1 :             throw new SessionUnavailableError(sessionId, e);
     706            1 :         }
     707            1 :         const next = patch(existing);
     708            1 :         if (sessionManifestEquals(existing, next)) return;
     709            1 :         this.logger.info("Patching session", { key, sessionId }, LOG_CTX);
     710            1 :         await this.saveSessionFile(sessionId, next);
     711            1 :     }
     712              : 
     713              :     /// Save the session file of a session and record the write
     714            1 :     private async saveSessionFile(sessionId: string, session: SessionData): Promise<void> {
     715            1 :         const text = JSON.stringify(session, null, 2);
     716            1 :         await this.timedWrite(`${sessionId}/${STORAGE_SESSION_FILE}`, text, () => this.backend.saveSessionManifest(sessionId, session));
     717            1 :     }
     718              : 
     719              :     /// Write a file and record the write with its duration
     720            1 :     private async timedWrite(path: string, text: string, write: () => Promise<void>): Promise<void> {
     721            1 :         const timeBefore = new Date();
     722            1 :         await write();
     723            1 :         this.registerWrite(path, text.length, new Date().getTime() - timeBefore.getTime());
     724            1 :     }
     725              : 
     726            1 :     protected async executeTask(key: string, task: StorageWriteTaskVariant) {
     727            1 :         switch (task.type) {
     728            1 :             case WRITE_SESSION_MANIFEST: {
     729            1 :                 const [sessionId, connections, created] = task.value;
     730            1 :                 this.logger.info("Writing session", {
     731            1 :                     key,
     732            1 :                     sessionId,
     733            1 :                 }, LOG_CTX);
     734              : 
     735              :                 // Load whatever is already on disk so we can (a) preserve the original createdAt
     736              :                 // instead of churning a fresh timestamp on every write, and (b) skip the write
     737              :                 // entirely when nothing actually changed. WRITE_SESSION_MANIFEST is scheduled on
     738              :                 // every connection-state change, so most of these tasks rewrite identical content.
     739            1 :                 let existingSession: SessionData | null = null;
     740            1 :                 try {
     741            1 :                     existingSession = await this.backend.loadSession(sessionId);
     742            1 :                 } catch {
     743            1 :                     existingSession = null;
     744            1 :                 }
     745              : 
     746              :                 // For now, create minimal session metadata
     747            1 :                 const sessionMetadata: StorageSessionMetadata = {
     748            1 :                     ...existingSession?.metadata,
     749            1 :                     createdAt: existingSession?.metadata?.createdAt ?? new Date().toISOString(),
     750            1 :                     ...(created?.ephemeral ? { ephemeral: true } : {}),
     751            1 :                 };
     752              : 
     753              :                 // sessionPath is a display-only field, recomputed from the UUID + location for
     754              :                 // the UI; we don't persist it here. storageType/nativePath are stamped by the
     755              :                 // composite backend, which knows the session's physical location.
     756              :                 // The given connections replace the copies on disk, the scripts keep theirs
     757            1 :                 const connData: SessionData = withSessionScripts({
     758            1 :                     formatVersion: SESSION_FORMAT_VERSION,
     759            1 :                     sessionId,
     760            1 :                     ...(existingSession?.name ? { name: existingSession.name } : {}),
     761            1 :                     connections: [],
     762            1 :                     scripts: [],
     763            1 :                     attachments: existingSession?.attachments ?? [],
     764            1 :                     metadata: sessionMetadata,
     765            1 :                 }, existingSession?.scripts ?? [], [...connections, ...existingSession?.connections ?? []]);
     766              : 
     767              :                 // Skip the write if the persisted manifest already matches what we'd write.
     768            1 :                 if (existingSession != null && sessionManifestEquals(existingSession, connData)) {
     769            1 :                     this.logger.debug("Skipping session write: manifest unchanged", {
     770            1 :                         sessionId,
     771            1 :                         connectionIds: connections.map(connection => connection.connectionId).join(','),
     772            1 :                     }, LOG_CTX);
     773            1 :                     break;
     774            1 :                 }
     775              : 
     776            1 :                 await this.saveSessionFile(sessionId, connData);
     777            1 :                 break;
     778            1 :             }
     779            1 :             case WRITE_SESSION_PROPERTIES: {
     780            1 :                 const [sessionId, { name, ephemeral }] = task.value;
     781            1 :                 await this.patchSessionFile(key, sessionId, existing => {
     782            1 :                     const next: SessionData = { ...existing };
     783            1 :                     if (name) next.name = name;
     784            0 :                     else delete next.name;
     785            1 :                     if (!ephemeral && next.metadata?.ephemeral != null) {
     786            1 :                         const { ephemeral: _promoted, ...metadata } = next.metadata;
     787            1 :                         next.metadata = metadata;
     788            1 :                     }
     789            1 :                     return next;
     790            1 :                 });
     791            1 :                 break;
     792            1 :             }
     793            1 :             case REPLACE_SESSION_SCRIPTS: {
     794            1 :                 const session = task.value;
     795            1 :                 const sessionId = session.sessionId;
     796            1 :                 this.logger.info("Creating session", {
     797            1 :                     key,
     798            1 :                     sessionId,
     799            1 :                 }, LOG_CTX);
     800              : 
     801            1 :                 for (const script of session.scripts) {
     802            1 :                     const sql = script.readText();
     803            1 :                     await this.timedWrite(`${sessionId}/scripts/${script.fileName}`, sql, () => this.backend.saveScript(sessionId, script.fileName, sql));
     804            1 :                 }
     805              : 
     806              :                 // Save session last so it never references content that doesn't exist yet.
     807              :                 // Preserve the original createdAt across rewrites; only stamp it on the first write.
     808            1 :                 let connections: SessionData['connections'];
     809            1 :                 let createdAt: string;
     810              :                 // Preserve the user-supplied name across a session rewrite: this path rebuilds the
     811              :                 // whole manifest from session scripts, which carry no name, so we must carry over
     812              :                 // whatever the user already set on disk.
     813            1 :                 let existingName: string | undefined;
     814            1 :                 let existingMetadata: StorageSessionMetadata | undefined;
     815            1 :                 let existingAttachments: SessionData['attachments'] = [];
     816            1 :                 let existingScripts: SessionData['scripts'] = [];
     817            1 :                 try {
     818            1 :                     const existingSession = await this.backend.loadSession(sessionId);
     819            1 :                     existingScripts = existingSession.scripts ?? [];
     820            1 :                     connections = existingSession.connections;
     821            1 :                     createdAt = existingSession.metadata?.createdAt ?? new Date().toISOString();
     822            1 :                     existingName = existingSession.name;
     823            1 :                     existingMetadata = existingSession.metadata;
     824            1 :                     existingAttachments = existingSession.attachments ?? [];
     825            1 :                 } catch {
     826            1 :                     connections = session.connection == null ? [] : [session.connection];
     827            1 :                     createdAt = new Date().toISOString();
     828            1 :                 }
     829              : 
     830            1 :                 const sessionMetadata: StorageSessionMetadata = {
     831            1 :                     ...existingMetadata,
     832            1 :                     originalFileName: session.originalFileName,
     833            1 :                     createdAt,
     834            1 :                 };
     835              : 
     836            1 :                 const connData: SessionData = withSessionScripts({
     837            1 :                     formatVersion: SESSION_FORMAT_VERSION,
     838            1 :                     sessionId,
     839            1 :                     ...(session.name ? { name: session.name } : existingName ? { name: existingName } : {}),
     840            1 :                     connections,
     841            1 :                     scripts: [],
     842            1 :                     attachments: existingAttachments,
     843            1 :                     metadata: sessionMetadata,
     844            1 :                 }, existingScripts);
     845              : 
     846            1 :                 await this.saveSessionFile(sessionId, connData);
     847            1 :                 break;
     848            1 :             }
     849            1 :             case WRITE_SESSION_SCRIPTS: {
     850            1 :                 const [sessionId, list] = task.value;
     851            1 :                 await this.patchSessionFile(key, sessionId, session => withSessionScripts(session, list.scripts, list.connections));
     852            1 :                 break;
     853            1 :             }
     854            1 :             case REFRESH_SESSION_ATTACHMENTS: {
     855            1 :                 const sessionId = task.value;
     856            1 :                 const attachments = sessionAttachments((await this.backend.listDataFiles(sessionId)).map(file => file.name));
     857            1 :                 await this.patchSessionFile(key, sessionId, session => ({ ...session, attachments }));
     858            1 :                 break;
     859            1 :             }
     860            1 :             case WRITE_CONNECTION_ENTRY: {
     861            1 :                 const entry = task.value;
     862            1 :                 const registry = this.requireRegistry();
     863            1 :                 const timeBefore = new Date();
     864            1 :                 await registry.updateConnections(connections => upsertConnectionEntry(connections, entry));
     865            1 :                 this.registerWrite(key, JSON.stringify(entry).length, new Date().getTime() - timeBefore.getTime());
     866            1 :                 break;
     867            1 :             }
     868            1 :             case DELETE_CONNECTION_ENTRY: {
     869            1 :                 const connectionId = task.value;
     870            1 :                 const registry = this.requireRegistry();
     871            1 :                 this.logger.info("Deleting connection", { key, connectionId }, LOG_CTX);
     872            1 :                 await registry.updateConnections(connections => {
     873            1 :                     if (connections == null || !connections.some(e => e.id === connectionId)) return null;
     874            0 :                     return connections.filter(e => e.id !== connectionId);
     875            1 :                 });
     876            1 :                 await registry.deleteConnectionFiles(connectionId);
     877            1 :                 break;
     878            1 :             }
     879            1 :             case WRITE_CONNECTION_FILE: {
     880            1 :                 const [connectionId, fileName, content] = task.value;
     881            1 :                 const text = typeof content === 'function' ? content() : content;
     882            1 :                 if (text == null) break;
     883            1 :                 const registry = this.requireRegistry();
     884            1 :                 await this.timedWrite(`@${STORAGE_CONNECTIONS_FOLDER}/${connectionId}/${fileName}`, text, () => registry.saveConnectionFile(connectionId, fileName, text));
     885            1 :                 break;
     886            1 :             }
     887            1 :             case PATCH_SESSION_CONNECTION: {
     888            1 :                 const [sessionId, copy, replaced] = task.value;
     889              :                 // The copy is only patched into an existing session file. A missing file must not be
     890              :                 // recreated, a moved native folder would otherwise silently come back as a new one.
     891            1 :                 await this.patchSessionFile(key, sessionId, existing => patchSessionConnection(existing, copy, replaced) ?? existing);
     892            1 :                 break;
     893            1 :             }
     894            1 :             case WRITE_SCRIPT: {
     895            1 :                 const [sessionId, fileName, sql] = task.value;
     896              : 
     897            1 :                 this.logger.info("Writing session script", {
     898            1 :                     key,
     899            1 :                     sessionId,
     900            1 :                     file: fileName,
     901            1 :                 }, LOG_CTX);
     902              : 
     903            1 :                 await this.timedWrite(`${sessionId}/scripts/${fileName}`, sql, () => this.backend.saveScript(sessionId, fileName, sql));
     904            1 :                 break;
     905            1 :             }
     906            1 :             case DELETE_SCRIPT: {
     907            1 :                 const [sessionId, scriptName] = task.value;
     908            1 :                 this.logger.info("Deleting session script", {
     909            1 :                     task: key,
     910            1 :                     sessionId,
     911            1 :                     scriptName,
     912            1 :                 }, LOG_CTX);
     913            1 :                 await this.backend.deleteScript(sessionId, scriptName);
     914            1 :                 break;
     915            1 :             }
     916            1 :             case RENAME_SCRIPT: {
     917            1 :                 const [sessionId, oldScriptName, newScriptName] = task.value;
     918            1 :                 this.logger.info("Renaming session script", {
     919            1 :                     task: key,
     920            1 :                     sessionId,
     921            1 :                     oldScriptName,
     922            1 :                     newScriptName,
     923            1 :                 }, LOG_CTX);
     924            1 :                 await this.backend.renameScript(sessionId, oldScriptName, newScriptName);
     925            1 :                 break;
     926            1 :             }
     927            1 :         }
     928            1 :     }
     929            1 : }
        

Generated by: LCOV version 2.0-1