LCOV - code coverage report
Current view: top level - dashql-app/src/session/persistence - app_state_loader.ts Coverage Total Hit
Test: DashQL 915828b Lines: 98.6 % 497 490
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type { DashQL } from '@ankoh/dashql-core';
       2              : import type { Logger } from '../../platform/logger/logger.js';
       3              : import { stringifyError } from '../../platform/logger/logger.js';
       4              : import { ProgressCounter } from '../../utils/progress.js';
       5              : import type { ConnectionState } from '../../connections/connection_state.js';
       6              : import { registerConnection, sessionConnectionFromState, type ConnectionRegistry } from '../../connections/connection_registry.js';
       7              : import type { SessionScripts, ScriptData } from '../scripts/session_scripts.js';
       8              : import { addSessionScriptsForConnection, copySessionScriptsRegistry, type SessionScriptsRegistry } from '../scripts/session_scripts_registry.js';
       9              : import { createEmptyScriptData, destroySessionScripts, isEphemeralSession, replaceScriptSessionText, scriptConnectionOf, sessionConnectionIds } from '../scripts/session_scripts.js';
      10              : import { readSessionAttachments, type SessionAttachment } from '../attachments/session_attachments.js';
      11              : import { connectionStateFromStorage, destroyUnregisteredConnection, restoreConnectionCatalog } from '../../connections/connection_restore.js';
      12              : import { CONNECTOR_INFOS, CONNECTOR_TYPES, ConnectorType } from '../../connections/connector_info.js';
      13              : import type { StorageBackend, SessionEntry, SessionData, SessionRegistryBackend, ConnectionEntry, SessionConnection, SessionScript } from '../../storage/storage_backend.js';
      14              : import { StorageBackendType, isSessionRegistryBackend } from '../../storage/storage_backend.js';
      15              : import { connectionParamsEqual, createDefaultConnectionParamsForConnector } from '../../connections/connection_params.js';
      16              : import { findEmbeddedConnection } from '../../connections/embedded_connection.js';
      17              : import { patchSessionConnection, upsertConnectionEntry } from '../../storage/storage_writer.js';
      18              : import { sanitizeSessionFiles, sessionConnectionIdsOf, withSessionScripts } from '../../storage/session_files.js';
      19              : import { resolveSessionScripts, sessionScriptsResolutionChanged, type SessionConnectionResolver } from './session_script_connections.js';
      20              : import { validateSessionData, describeInvalidSession, isLocalHyperWasmParams, isValidUuid, SessionValidationError, type InvalidSession } from '../../storage/session_validation.js';
      21              : import type { BatchProcessor } from '../../connections/catalog/catalog_batch.js';
      22              : 
      23            1 : const LOG_CTX = "app_state_loader";
      24              : 
      25              : export interface RestoredAppState {
      26              :     connectionStates: Map<string, ConnectionState>;
      27              :     connectionStatesByType: string[][];
      28              :     sessionScripts: Map<string, SessionScripts>;
      29              :     sessionScriptsByConnection: Map<string, string[]>;
      30              :     /// Sessions whose metadata failed validation and were refused a load (keyed by bare UUID).
      31              :     /// These never enter the connection/session maps; the session workbench surfaces them as
      32              :     /// invalid (blocked from opening, still deletable).
      33              :     invalidSessions: Map<string, InvalidSession>;
      34              : }
      35              : 
      36              : /// Thrown by `restoreSessionEntry` when a session's metadata fails the up-front validation gate.
      37              : ///
      38              : /// Distinguished from an arbitrary restore error so the loader can record it as a (skipped)
      39              : /// invalid session and surface it in the UI, rather than counting it as a hard failure.
      40            1 : class InvalidSessionError extends Error {
      41            1 :     constructor(public readonly invalid: InvalidSession) {
      42            1 :         super(`invalid session ${invalid.sessionId}: ${invalid.error}`);
      43            1 :         this.name = 'InvalidSessionError';
      44            1 :     }
      45            1 : }
      46              : 
      47              : export interface AppStateRestorationProgress {
      48              :     restoreConnections: ProgressCounter;
      49              :     restoreCatalogs: ProgressCounter;
      50              :     restoreSessionScripts: ProgressCounter;
      51              : }
      52              : 
      53              : /// The progress counters of a restoration, every change is reported to the consumer
      54            1 : class RestoreProgress {
      55              :     /// The restored connections of the sessions
      56            1 :     readonly connections = new ProgressCounter();
      57              :     /// The restored catalogs of the sessions
      58            1 :     readonly catalogs = new ProgressCounter();
      59              :     /// The restored session scripts
      60            1 :     readonly sessionScripts = new ProgressCounter();
      61              : 
      62            1 :     constructor(private readonly consumer: (progress: AppStateRestorationProgress) => void) { }
      63              : 
      64              :     /// Report the current progress
      65            1 :     report(): void {
      66            1 :         this.consumer({
      67            1 :             restoreConnections: this.connections.clone(),
      68            1 :             restoreCatalogs: this.catalogs.clone(),
      69            1 :             restoreSessionScripts: this.sessionScripts.clone(),
      70            1 :         });
      71            1 :     }
      72            1 : }
      73              : 
      74              : /// Restores session scripts from storage
      75            1 : async function restoreSessionScripts(
      76            1 :     core: DashQL,
      77            1 :     backend: StorageBackend,
      78            1 :     sessionId: string,
      79            1 :     name: string | null,
      80            1 :     sessionData: SessionData,
      81            1 :     fallbackConnection: () => ConnectionState,
      82            1 :     resolveConnection: SessionConnectionResolver,
      83            1 :     logger: Logger
      84            1 : ): Promise<{ sessionScripts: SessionScripts; session: SessionData }> {
      85            1 :     const scripts: Record<number, ScriptData> = {};
      86              : 
      87            1 :     try {
      88            1 :         logger.info("Loading scripts", { sessionId }, LOG_CTX);
      89            1 :         const storedScripts = await backend.loadScripts(sessionId);
      90            1 :         logger.info("Scripts loaded", {
      91            1 :             sessionId,
      92            1 :             scriptCount: storedScripts.length.toString()
      93            1 :         }, LOG_CTX);
      94              : 
      95              :         // The session file orders the scripts and names their connections
      96            1 :         const sqlByName = new Map(storedScripts.map(script => [script.name, script.sql]));
      97            1 :         const resolved = resolveSessionScripts(sessionData, storedScripts.map(script => script.name), fallbackConnection, resolveConnection,
      98            1 :             connectionId => logger.warn("Script references an unknown connection, using the embedded database", { sessionId, connectionId }, LOG_CTX));
      99            1 :         const scriptRefs: { [fileName: string]: { scriptId: number; fileName: string } } = {};
     100            1 :         for (const entry of resolved.scripts) {
     101            1 :             const [scriptKey, created] = createEmptyScriptData(core, scriptConnectionOf(entry.connection), entry.name);
     102            1 :             const scriptData = entry.unresolvedConnectionId == null ? created : { ...created, unresolvedConnectionId: entry.unresolvedConnectionId };
     103            1 :             scripts[scriptKey] = scriptData;
     104              : 
     105              :             // Set SQL content. Ordinary session scripts remain outdated until first use.
     106            1 :             replaceScriptSessionText(scriptData.scriptSession, sqlByName.get(entry.name) ?? '');
     107              : 
     108            1 :             scriptRefs[entry.name] = {
     109            1 :                 scriptId: scriptKey,
     110            1 :                 fileName: entry.name,
     111            1 :             };
     112            1 :         }
     113            1 :         const scriptOrder = resolved.scripts.map(entry => entry.name);
     114              :         // The attachments of the session, a Parquet attachment is a table parameter of the scripts
     115            1 :         let attachments: SessionAttachment[] = [];
     116            1 :         try {
     117            1 :             attachments = readSessionAttachments(await backend.listDataFiles(sessionId));
     118            1 :         } catch (error) {
     119            1 :             logger.warn("Listing the attachments failed", { sessionId, error: stringifyError(error) }, LOG_CTX);
     120            1 :         }
     121              : 
     122              :         const sessionScripts: SessionScripts = {
     123            1 :             instance: core,
     124            1 :             sessionId,
     125            1 :             name,
     126            1 :             pendingConnection: resolved.pendingConnection == null ? null : scriptConnectionOf(resolved.pendingConnection),
     127            1 :             sessionMetadata: sessionData.metadata,
     128            1 :             scripts,
     129            1 :             scriptRefs,
     130            1 :             scriptOrder,
     131            1 :             scriptFocus: { fileName: scriptOrder[0] ?? '' },
     132            1 :             semanticUserFocus: null,
     133            1 :             attachments,
     134              :         };
     135              : 
     136            1 :         return { sessionScripts, session: resolved.session };
     137            1 :     } catch (error) {
     138            1 :         for (const scriptData of Object.values(scripts)) {
     139            1 :             scriptData.scriptSession.destroy();
     140              :         }
     141            1 :         throw error;
     142            1 :     }
     143            1 : }
     144              : 
     145              : /// The state that the restoration of connections and sessions shares.
     146              : ///
     147              : /// Connections are entities of their own, the app manifest is their source of truth. They are
     148              : /// restored first, sessions then resolve their references against them. Sessions keep copies of
     149              : /// the connection params to stay self-contained, a copy that disagrees with the manifest is
     150              : /// rewritten, and a reference to an unknown connection registers the connection from the copy.
     151              : interface RestoreContext {
     152              :     /// The core instance
     153              :     core: DashQL;
     154              :     /// The storage backend
     155              :     backend: StorageBackend;
     156              :     /// The backend as registry, null if the backend doesn't own the connections
     157              :     registry: SessionRegistryBackend | null;
     158              :     /// The logger
     159              :     logger: Logger;
     160              :     /// The batch processor for catalog scripts
     161              :     processor?: BatchProcessor;
     162              :     /// The connections (connectionId -> state)
     163              :     connectionStates: Map<string, ConnectionState>;
     164              :     /// The connections by connector type
     165              :     connectionStatesByType: string[][];
     166              :     /// The connections whose catalog is restored or live
     167              :     catalogsRestored: Set<string>;
     168              :     /// The connections that were registered while restoring sessions
     169              :     newConnections: Map<string, ConnectionState>;
     170              :     /// The writes that bring the stored state in line with the restored one
     171              :     pendingWrites: RestorePendingWrites;
     172              : }
     173              : 
     174              : /// The writes that bring the stored state in line with the restored one
     175              : interface RestorePendingWrites {
     176              :     /// Connections that sessions registered, they are missing in the app manifest
     177              :     connectionEntries: ConnectionEntry[];
     178              :     /// Catalog files of connections that hold dropped relations or changes, they are cleared
     179              :     staleConnectionFiles: Array<{ connectionId: string; fileName: string }>;
     180              :     /// Sessions whose copies of connection params are stale
     181              :     sessionCopies: Array<{ sessionId: string; connectionId: string }>;
     182              :     /// Sessions whose script lists or connections disagree with the restored scripts
     183              :     sessionScriptLists: Array<{ sessionId: string; scripts: SessionScript[]; connections: SessionConnection[] }>;
     184              :     /// Ephemeral sessions that hold scripts, the app stopped before their promotion was written
     185              :     promotedSessions: string[];
     186              : }
     187              : 
     188            1 : function createRestoreContext(core: DashQL, backend: StorageBackend, logger: Logger, processor: BatchProcessor | undefined): RestoreContext {
     189            1 :     return {
     190            1 :         core,
     191            1 :         backend,
     192            1 :         registry: isSessionRegistryBackend(backend) ? backend : null,
     193            1 :         logger,
     194            1 :         processor,
     195            1 :         connectionStates: new Map(),
     196            1 :         connectionStatesByType: CONNECTOR_TYPES.map(() => []),
     197            1 :         catalogsRestored: new Set(),
     198            1 :         newConnections: new Map(),
     199            1 :         pendingWrites: { connectionEntries: [], staleConnectionFiles: [], sessionCopies: [], sessionScriptLists: [], promotedSessions: [] },
     200            1 :     };
     201            1 : }
     202              : 
     203              : /// Restore a connection of the app manifest
     204            1 : async function restoreConnectionEntry(ctx: RestoreContext, entry: ConnectionEntry): Promise<void> {
     205            1 :     let state: ConnectionState;
     206            1 :     try {
     207            1 :         state = connectionStateFromStorage(ctx.core, entry.id, entry);
     208            1 :     } catch (error) {
     209              :         // Sessions referencing the connection register it again from their copies
     210            1 :         ctx.logger.warn("Failed to restore connection", { connectionId: entry.id, error: stringifyError(error) }, LOG_CTX);
     211            1 :         return;
     212            1 :     }
     213            1 :     ctx.connectionStates.set(state.connectionId, state);
     214            1 :     ctx.connectionStatesByType[state.connectorInfo.connectorType].push(state.connectionId);
     215            1 :     await restoreCatalog(ctx, ctx.registry!, state, { connectionId: entry.id });
     216            1 : }
     217              : 
     218              : /// Restore the catalog of a connection once, returns false if the restoration failed
     219            1 : async function restoreCatalog(
     220            1 :     ctx: RestoreContext,
     221            1 :     registry: SessionRegistryBackend,
     222            1 :     state: ConnectionState,
     223            1 :     logContext: Record<string, string>,
     224            1 : ): Promise<boolean> {
     225            1 :     const connectionId = state.connectionId;
     226            1 :     ctx.catalogsRestored.add(connectionId);
     227            1 :     const restored = await restoreConnectionCatalog(ctx, registry, state, logContext);
     228            1 :     for (const fileName of restored.staleFiles) {
     229            1 :         ctx.pendingWrites.staleConnectionFiles.push({ connectionId, fileName });
     230              :     }
     231            1 :     return restored.succeeded;
     232            1 : }
     233              : 
     234              : /// Resolve a session's reference to a connection.
     235              : /// The app manifest wins over the copy in the session, an unknown connection is registered from the copy.
     236              : /// The app holds a single connection to the embedded database, an unknown one resolves to it.
     237            1 : function resolveSessionConnection(ctx: RestoreContext, sessionId: string, database: SessionConnection): ConnectionState {
     238            1 :     const connectionId = database.connectionId;
     239            1 :     const known = ctx.connectionStates.get(connectionId)
     240            1 :         ?? (isLocalHyperWasmParams(database.params) ? findEmbeddedConnection(ctx.connectionStates.values()) : null);
     241            1 :     if (known != null) {
     242            1 :         if (known.connectionId === connectionId
     243            1 :             && (database.paramsDraft != null || database.name != null || !connectionParamsEqual(database.params, known.params))) {
     244            1 :             ctx.pendingWrites.sessionCopies.push({ sessionId, connectionId });
     245              :         }
     246            1 :         return known;
     247            1 :     }
     248            1 :     const state = connectionStateFromStorage(ctx.core, connectionId, database);
     249            1 :     ctx.connectionStates.set(state.connectionId, state);
     250            1 :     ctx.connectionStatesByType[state.connectorInfo.connectorType].push(state.connectionId);
     251            1 :     ctx.newConnections.set(state.connectionId, state);
     252            1 :     ctx.pendingWrites.connectionEntries.push({
     253            1 :         id: state.connectionId,
     254            1 :         params: state.params,
     255            1 :         ...(database.paramsDraft == null ? {} : { paramsDraft: database.paramsDraft }),
     256            1 :         ...(state.name == null ? {} : { name: state.name }),
     257            1 :         createdAt: new Date().toISOString(),
     258            1 :     });
     259            1 :     if (database.paramsDraft != null || database.name != null) {
     260              :         // Drafts and the names of imported connections move into the app manifest
     261            1 :         ctx.pendingWrites.sessionCopies.push({ sessionId, connectionId });
     262              :     }
     263            1 :     return state;
     264            1 : }
     265              : 
     266              : /// The connection to the embedded database, registered from a copy of the session or anew when the app
     267              : /// has none. Scripts whose connections are unknown run on it.
     268            1 : function resolveEmbeddedConnection(ctx: RestoreContext, sessionId: string, copies: readonly SessionConnection[]): ConnectionState {
     269            1 :     return findEmbeddedConnection(ctx.connectionStates.values()) ?? resolveSessionConnection(ctx, sessionId,
     270            1 :         copies.find(copy => isLocalHyperWasmParams(copy.params)) ?? {
     271            0 :             connectionId: crypto.randomUUID(),
     272            0 :             params: createDefaultConnectionParamsForConnector(CONNECTOR_INFOS[ConnectorType.HYPER]),
     273            1 :         });
     274            1 : }
     275              : 
     276              : /// Restores a single session (connection references + catalog + session).
     277              : /// Returns the session as invalid when its scripts could not be restored.
     278            1 : async function restoreSessionEntry(
     279            1 :     ctx: RestoreContext,
     280            1 :     sessionEntry: SessionEntry,
     281            1 :     sessionScripts: Map<string, SessionScripts>,
     282            1 :     sessionScriptsByConnection: Map<string, string[]>,
     283            1 :     progress: RestoreProgress,
     284            1 : ): Promise<InvalidSession | null> {
     285            1 :     const { backend, logger } = ctx;
     286              :     // The session UUID is the authoritative identity and the key the backend routes on. Gate it up
     287              :     // front: a manifest entry whose path is not a valid UUID can't be loaded (the backend would
     288              :     // build a bogus path and throw), so surface it as an invalid session rather than a hard failure.
     289            1 :     const sessionId = sessionEntry.path;
     290            1 :     if (!isValidUuid(sessionId)) {
     291            1 :         const invalid = describeInvalidSession(sessionEntry, SessionValidationError.InvalidSessionId, null);
     292            1 :         logger.warn("Refusing to load session with an invalid id", {
     293            1 :             sessionId,
     294            1 :             reason: invalid.error,
     295            1 :         }, LOG_CTX);
     296            1 :         throw new InvalidSessionError(invalid);
     297            1 :     }
     298              : 
     299              :     // Phase 1: Resolve the connections
     300            1 :     logger.info("Restoring connection", { sessionId }, LOG_CTX);
     301            1 :     const connectionStartTime = performance.now();
     302            1 :     progress.connections.addStarted();
     303            1 :     progress.report();
     304              : 
     305            1 :     const sessionData = await loadValidSession(backend, logger, sessionEntry);
     306              : 
     307              :     // Validation above guarantees at least one connection and params that map to a known connector.
     308              :     // The connections of the session are the connections of its scripts, copies that no script uses
     309              :     // are not registered.
     310            1 :     const used = new Set(sessionConnectionIdsOf(sanitizeSessionFiles(sessionData).scripts, sessionData.connections[0].connectionId));
     311            1 :     const usedCopies = sessionData.connections.filter(copy => used.has(copy.connectionId));
     312            1 :     const connectionStates = usedCopies.length > 0
     313            1 :         ? usedCopies.map(copy => resolveSessionConnection(ctx, sessionId, copy))
     314            1 :         : [resolveEmbeddedConnection(ctx, sessionId, sessionData.connections)];
     315            1 :     const connectorInfo = connectionStates[0].connectorInfo;
     316            1 :     const resolveScriptConnection: SessionConnectionResolver = (connectionId, copy) => copy != null
     317            1 :         ? resolveSessionConnection(ctx, sessionId, copy)
     318            1 :         : ctx.connectionStates.get(connectionId) ?? null;
     319              : 
     320            1 :     const connectionDuration = performance.now() - connectionStartTime;
     321            1 :     logger.info("Connections restored", {
     322            1 :         sessionId,
     323            1 :         connectorTypes: connectionStates.map(state => ConnectorType[state.connectorInfo.connectorType]).join(','),
     324            1 :         durationMs: connectionDuration.toFixed(2)
     325            1 :     }, LOG_CTX);
     326              : 
     327            1 :     progress.connections.addSucceeded();
     328              : 
     329              :     // Phase 2: Restore the catalogs of the connections that the session registered. The connections
     330              :     // of the app manifest restored their catalogs already.
     331            1 :     logger.info("Restoring catalog", { sessionId }, LOG_CTX);
     332            1 :     progress.catalogs.addStarted();
     333            1 :     progress.report();
     334            1 :     let catalogsRestored = true;
     335            1 :     for (const connectionState of connectionStates) {
     336            1 :         if (ctx.registry == null || ctx.catalogsRestored.has(connectionState.connectionId)) continue;
     337            1 :         catalogsRestored = await restoreCatalog(ctx, ctx.registry, connectionState, { sessionId, connectionId: connectionState.connectionId }) && catalogsRestored;
     338            1 :     }
     339            1 :     if (catalogsRestored) {
     340            1 :         progress.catalogs.addSucceeded();
     341              :     } else {
     342            1 :         progress.catalogs.addFailed();
     343              :     }
     344              : 
     345              :     // Phase 3: Restore session scripts
     346            1 :     logger.info("Restoring session scripts", { sessionId }, LOG_CTX);
     347            1 :     const sessionScriptsStartTime = performance.now();
     348            1 :     progress.sessionScripts.addStarted();
     349            1 :     progress.report();
     350              : 
     351            1 :     let restoredSessionScripts: SessionScripts | null = null;
     352            1 :     try {
     353            1 :         const restored = await restoreSessionScripts(
     354            1 :             ctx.core,
     355            1 :             backend,
     356            1 :             sessionId,
     357            1 :             sessionData.name?.trim() || null,
     358            1 :             sessionData,
     359            1 :             () => resolveEmbeddedConnection(ctx, sessionId, sessionData.connections),
     360            1 :             resolveScriptConnection,
     361            1 :             logger
     362            1 :         );
     363            1 :         restoredSessionScripts = restored.sessionScripts;
     364            1 :         if (isEphemeralSession(restoredSessionScripts) && restoredSessionScripts.scriptOrder.length > 0) {
     365            1 :             const { ephemeral: _promoted, ...sessionMetadata } = restoredSessionScripts.sessionMetadata;
     366            1 :             restoredSessionScripts = { ...restoredSessionScripts, sessionMetadata };
     367            1 :             ctx.pendingWrites.promotedSessions.push(sessionId);
     368            1 :         }
     369            1 :         if (sessionScriptsResolutionChanged(sessionData, restored.session)) {
     370            1 :             const { scripts, connections } = restored.session;
     371            1 :             ctx.pendingWrites.sessionScriptLists.push({ sessionId, scripts, connections });
     372            1 :         }
     373              : 
     374            1 :         const sessionScriptsDuration = performance.now() - sessionScriptsStartTime;
     375            1 :         logger.info("Session scripts restored", {
     376            1 :             sessionId,
     377            1 :             scriptCount: Object.keys(restoredSessionScripts.scripts).length.toString(),
     378            1 :             durationMs: sessionScriptsDuration.toFixed(2)
     379            1 :         }, LOG_CTX);
     380              : 
     381            1 :         progress.sessionScripts.addSucceeded();
     382            1 :     } catch (sessionScriptsError) {
     383            1 :         const sessionScriptsDuration = performance.now() - sessionScriptsStartTime;
     384              : 
     385            1 :         logger.warn("Failed to restore session scripts", {
     386            1 :             sessionId,
     387            1 :             durationMs: sessionScriptsDuration.toFixed(2),
     388            1 :             error: stringifyError(sessionScriptsError),
     389            1 :             stack: (sessionScriptsError instanceof Error ? sessionScriptsError.stack : (sessionScriptsError as any)?.stack)?.substring(0, 500)
     390            1 :         }, LOG_CTX);
     391              : 
     392            1 :         progress.sessionScripts.addFailed();
     393            1 :     }
     394              : 
     395            1 :     progress.report();
     396            1 :     if (restoredSessionScripts == null) {
     397            1 :         return describeInvalidSession(sessionEntry, SessionValidationError.SessionNotRestored, sessionData);
     398              :     }
     399            1 :     sessionScripts.set(sessionId, restoredSessionScripts);
     400            1 :     for (const id of sessionConnectionIds(restoredSessionScripts)) {
     401            1 :         sessionScriptsByConnection.set(id, [...(sessionScriptsByConnection.get(id) ?? []), sessionId]);
     402              :     }
     403            1 :     return null;
     404            1 : }
     405              : 
     406              : /// Load a session and validate its metadata
     407            1 : async function loadValidSession(backend: StorageBackend, logger: Logger, sessionEntry: SessionEntry): Promise<SessionData> {
     408            1 :     const sessionId = sessionEntry.path;
     409            1 :     logger.info("Loading session data", { sessionId }, LOG_CTX);
     410              :     // The manifest registers this session, but its files may be gone (a native session whose folder
     411              :     // the user moved/deleted, or a corrupt/absent OPFS session). Treat a load failure the same as a
     412              :     // metadata rejection: surface it as an invalid session so the selector shows it blocked and
     413              :     // deletable, rather than letting it fall through to the loader's generic "failed to restore"
     414              :     // path — which logs an error on every launch and leaves the stale entry with no way to remove it.
     415            1 :     let sessionData: SessionData;
     416            1 :     try {
     417            1 :         sessionData = await backend.loadSession(sessionId);
     418            1 :     } catch (loadError) {
     419            1 :         const invalid = describeInvalidSession(sessionEntry, SessionValidationError.SessionUnreadable, null);
     420            1 :         logger.warn("Refusing to load session with unreadable files", {
     421            1 :             sessionId,
     422            1 :             reason: invalid.error,
     423            1 :             error: stringifyError(loadError),
     424            1 :         }, LOG_CTX);
     425            1 :         throw new InvalidSessionError(invalid);
     426            1 :     }
     427              : 
     428              :     // Fail-fast metadata validation: refuse to load a session whose metadata is structurally
     429              :     // unusable (unsupported format, no id, or invalid connection). This runs
     430              :     // before any heavy restore work and surfaces the session as invalid in the selector rather than
     431              :     // letting it blow up mid-restore. Runtime hiccups (catalog/session) remain non-fatal below.
     432            1 :     const validation = validateSessionData(sessionData);
     433            1 :     if (!validation.ok) {
     434            1 :         const invalid = describeInvalidSession(sessionEntry, validation.error, sessionData);
     435            1 :         logger.warn("Refusing to load invalid session", {
     436            1 :             sessionId,
     437            1 :             reason: invalid.error,
     438            1 :         }, LOG_CTX);
     439            1 :         throw new InvalidSessionError(invalid);
     440            1 :     }
     441            1 :     logger.info("Session data loaded", { sessionId }, LOG_CTX);
     442            1 :     return sessionData;
     443            1 : }
     444              : 
     445              : /// Perform the writes that bring the stored state in line with the restored one.
     446              : /// Failures are logged, the next load attempts them again.
     447              : ///
     448              : /// The writes go to the backend directly, not through the storage writer. They run before the restored
     449              : /// sessions join the registry, so no write of the storage writer touches these sessions yet, and the
     450              : /// backend serializes the updates of the app manifest.
     451            1 : async function flushRestoreWrites(ctx: RestoreContext): Promise<void> {
     452            1 :     const { registry, pendingWrites } = ctx;
     453            1 :     if (registry != null) {
     454            1 :         await flushRestoredConnectionWrites(ctx, registry);
     455              :     }
     456            1 :     for (const { sessionId, scripts, connections } of pendingWrites.sessionScriptLists) {
     457            1 :         await patchRestoredSession(ctx, sessionId, session => {
     458            1 :             const next = withSessionScripts(session, scripts, connections);
     459            1 :             return sessionScriptsResolutionChanged(session, next) ? next : null;
     460            1 :         });
     461              :     }
     462            1 :     for (const sessionId of pendingWrites.promotedSessions) {
     463            1 :         await patchRestoredSession(ctx, sessionId, session => {
     464            1 :             const { ephemeral: _promoted, ...metadata } = session.metadata;
     465            1 :             return { ...session, metadata };
     466            1 :         });
     467              :     }
     468            1 : }
     469              : 
     470              : /// Patch the file of a restored session, a failure is logged and the next load attempts it again
     471            1 : async function patchRestoredSession(ctx: RestoreContext, sessionId: string, patch: (session: SessionData) => SessionData | null): Promise<void> {
     472            1 :     try {
     473            1 :         const next = patch(await ctx.backend.loadSession(sessionId));
     474            1 :         if (next != null) {
     475            1 :             await ctx.backend.saveSessionManifest(sessionId, next);
     476              :         }
     477            1 :     } catch (error) {
     478            0 :         ctx.logger.warn("Failed to update a restored session", { sessionId, error: stringifyError(error) }, LOG_CTX);
     479            0 :     }
     480            1 : }
     481              : 
     482              : /// Persist the connections that the restoration registered
     483            1 : async function flushRestoredConnectionWrites(ctx: RestoreContext, registry: SessionRegistryBackend): Promise<void> {
     484            1 :     const { logger, pendingWrites } = ctx;
     485            1 :     try {
     486            1 :         if (pendingWrites.connectionEntries.length > 0) {
     487            1 :             await registry.updateConnections(connections => {
     488            1 :                 let next = connections ?? [];
     489            1 :                 for (const entry of pendingWrites.connectionEntries) {
     490            1 :                     next = upsertConnectionEntry(next, entry) ?? next;
     491              :                 }
     492            1 :                 return next;
     493            1 :             });
     494              :         }
     495            1 :         for (const { connectionId, fileName } of pendingWrites.staleConnectionFiles) {
     496            1 :             await registry.saveConnectionFile(connectionId, fileName, '');
     497              :         }
     498            1 :     } catch (error) {
     499            1 :         logger.warn("Failed to persist restored connections", { error: stringifyError(error) }, LOG_CTX);
     500            1 :         return;
     501            1 :     }
     502            1 :     for (const { sessionId, connectionId } of pendingWrites.sessionCopies) {
     503            1 :         const connection = ctx.connectionStates.get(connectionId);
     504            1 :         if (connection == null) continue;
     505            1 :         try {
     506            1 :             const existing = await ctx.backend.loadSession(sessionId);
     507            1 :             const next = patchSessionConnection(existing, sessionConnectionFromState(connection));
     508            1 :             if (next != null) {
     509            1 :                 await ctx.backend.saveSessionManifest(sessionId, next);
     510              :             }
     511            1 :         } catch (error) {
     512            1 :             logger.warn("Failed to update the connection copy of a session", { sessionId, connectionId, error: stringifyError(error) }, LOG_CTX);
     513            1 :         }
     514            1 :     }
     515            1 : }
     516              : 
     517              : /// The connection references + session a single session restored into.
     518              : export interface RestoredSession {
     519              :     /// The session id
     520              :     sessionId: string;
     521              :     /// The connections the session registered, connections that were already live are not included
     522              :     databases: ConnectionState[];
     523              :     /// The session scripts
     524              :     sessionScripts: SessionScripts;
     525              : }
     526              : 
     527            1 : export function mergeRestoredSessionIntoConnections(
     528            1 :     reg: ConnectionRegistry,
     529            1 :     restored: RestoredSession,
     530            1 : ): ConnectionRegistry {
     531            1 :     for (const database of restored.databases) {
     532            1 :         if (!reg.connections.has(database.connectionId)) {
     533            1 :             registerConnection(reg, database);
     534              :         }
     535              :     }
     536            1 :     return { ...reg };
     537            1 : }
     538              : 
     539            1 : export function mergeRestoredSessionIntoScripts(
     540            1 :     reg: SessionScriptsRegistry,
     541            1 :     restored: RestoredSession,
     542            1 : ): SessionScriptsRegistry {
     543            1 :     const next = copySessionScriptsRegistry(reg);
     544            1 :     next.sessionScriptsMap.set(restored.sessionId, restored.sessionScripts);
     545            1 :     for (const connectionId of sessionConnectionIds(restored.sessionScripts)) {
     546            1 :         addSessionScriptsForConnection(next, connectionId, restored.sessionId);
     547              :     }
     548            1 :     return next;
     549            1 : }
     550              : 
     551            1 : export function destroyRestoredSession(restored: RestoredSession): void {
     552            1 :     destroySessionScripts(restored.sessionScripts);
     553            1 :     for (const database of restored.databases) destroyUnregisteredConnection(database);
     554            1 : }
     555              : 
     556              : /// The live connections that a restored session resolves its references against
     557              : export type LiveConnections = Pick<ConnectionRegistry, 'connections'>;
     558              : 
     559              : /// Restore a single, already-persisted session (connection references + catalog + session) and
     560              : /// return just that session's pieces.
     561              : ///
     562              : /// This is the incremental counterpart to `restoreAppState`, used after a session is written to
     563              : /// storage at runtime (e.g. imported from a shared URL) so it can be merged into the already-live
     564              : /// registries without a full app reload. It reuses the exact same `restoreSessionEntry` path the boot
     565              : /// loader runs, so a URL-imported session is decoded and cataloged identically to one
     566              : /// loaded at startup. References to live connections reuse them, unknown connections are
     567              : /// registered and returned.
     568            1 : export async function restoreSingleSession(
     569            1 :     core: DashQL,
     570            1 :     backend: StorageBackend,
     571            1 :     logger: Logger,
     572            1 :     sessionId: string,
     573            1 :     live: LiveConnections,
     574            1 :     processor?: BatchProcessor,
     575            1 : ): Promise<RestoredSession> {
     576              :     // A freshly imported session is implicitly OPFS-backed and keyed by its UUID; that's all the
     577              :     // manifest entry `restoreSessionEntry` needs to route the load.
     578            1 :     const sessionEntry: SessionEntry = { path: sessionId, storageType: StorageBackendType.OPFS };
     579              : 
     580            1 :     const ctx = createRestoreContext(core, backend, logger, processor);
     581            1 :     for (const [connectionId, state] of live.connections) {
     582            1 :         ctx.connectionStates.set(connectionId, state);
     583            1 :         ctx.catalogsRestored.add(connectionId);
     584            1 :     }
     585            1 :     const sessionScripts = new Map<string, SessionScripts>();
     586            1 :     const sessionScriptsByConnection = new Map<string, string[]>();
     587              : 
     588            1 :     try {
     589            1 :         await restoreSessionEntry(
     590            1 :             ctx,
     591            1 :             sessionEntry,
     592            1 :             sessionScripts,
     593            1 :             sessionScriptsByConnection,
     594            1 :             new RestoreProgress(() => { }),
     595            1 :         );
     596            1 :     } catch (error) {
     597            1 :         for (const database of ctx.newConnections.values()) destroyUnregisteredConnection(database);
     598            1 :         throw error;
     599            1 :     }
     600              : 
     601            1 :     const scripts = sessionScripts.get(sessionId) ?? null;
     602            1 :     if (!scripts) {
     603            0 :         for (const database of ctx.newConnections.values()) destroyUnregisteredConnection(database);
     604            0 :         throw new Error(`imported session ${sessionId} did not restore its scripts`);
     605            0 :     }
     606            1 :     await flushRestoreWrites(ctx);
     607            1 :     return {
     608            1 :         sessionId,
     609            1 :         databases: [...ctx.newConnections.values()],
     610            1 :         sessionScripts: scripts,
     611            1 :     };
     612            1 : }
     613              : 
     614              : /**
     615              :  * Restores complete application state from storage
     616              :  */
     617            1 : export async function restoreAppState(
     618            1 :     core: DashQL,
     619            1 :     backend: StorageBackend,
     620            1 :     logger: Logger,
     621            1 :     progressConsumer: (progress: AppStateRestorationProgress) => void,
     622            1 :     processor?: BatchProcessor,
     623            1 : ): Promise<RestoredAppState> {
     624            1 :     logger.info("Starting app state restoration", {}, LOG_CTX);
     625            1 :     const startTime = performance.now();
     626              : 
     627            1 :     const ctx = createRestoreContext(core, backend, logger, processor);
     628            1 :     const sessionScripts = new Map<string, SessionScripts>();
     629            1 :     const sessionScriptsByConnection = new Map<string, string[]>();
     630            1 :     const invalidSessions = new Map<string, InvalidSession>();
     631              : 
     632            1 :     const progress = new RestoreProgress(progressConsumer);
     633              : 
     634            1 :     try {
     635              :         // Load manifest
     636            1 :         logger.info("Loading app manifest", {}, LOG_CTX);
     637            1 :         const manifestStartTime = performance.now();
     638            1 :         const sessionEntries = await backend.listSessions();
     639            1 :         let connectionEntries: ConnectionEntry[] = [];
     640            1 :         if (ctx.registry != null) {
     641            1 :             const manifest = await ctx.registry.loadManifest();
     642            1 :             connectionEntries = manifest.connections ?? [];
     643              :         }
     644            1 :         const manifestDuration = performance.now() - manifestStartTime;
     645              : 
     646            1 :         logger.info("Loaded app manifest", {
     647            1 :             sessionCount: sessionEntries.length.toString(),
     648            1 :             connectionCount: connectionEntries.length.toString(),
     649            1 :             durationMs: manifestDuration.toFixed(2)
     650            1 :         }, LOG_CTX);
     651              : 
     652              :         // Restore the connections before the sessions that reference them
     653            1 :         for (const entry of connectionEntries) {
     654            1 :             await restoreConnectionEntry(ctx, entry);
     655              :         }
     656              : 
     657              :         // Set totals.
     658            1 :         progress.connections.addTotal(sessionEntries.length);
     659            1 :         progress.catalogs.addTotal(sessionEntries.length);
     660            1 :         progress.sessionScripts.addTotal(sessionEntries.length);
     661              : 
     662            1 :         progress.report();
     663              : 
     664              :         // Process each session
     665            1 :         logger.info("Restoring sessions", { count: sessionEntries.length.toString() }, LOG_CTX);
     666            1 :         for (let i = 0; i < sessionEntries.length; i++) {
     667            1 :             const sessionEntry = sessionEntries[i];
     668            1 :             const sessionStartTime = performance.now();
     669              : 
     670            1 :             try {
     671            1 :                 logger.info("Restoring session", {
     672            1 :                     index: `${i + 1}/${sessionEntries.length}`,
     673            1 :                     sessionId: sessionEntry.path
     674            1 :                 }, LOG_CTX);
     675              : 
     676            1 :                 const invalid = await restoreSessionEntry(
     677            1 :                     ctx,
     678            1 :                     sessionEntry,
     679            1 :                     sessionScripts,
     680            1 :                     sessionScriptsByConnection,
     681            1 :                     progress,
     682            1 :                 );
     683            1 :                 if (invalid != null) {
     684              :                     // The session stays visible as invalid, the user can delete it
     685            1 :                     invalidSessions.set(invalid.sessionId, invalid);
     686              :                 }
     687              : 
     688            1 :                 const sessionDuration = performance.now() - sessionStartTime;
     689            1 :                 logger.info("Session restored", {
     690            1 :                     index: `${i + 1}/${sessionEntries.length}`,
     691            1 :                     sessionId: sessionEntry.path,
     692            1 :                     durationMs: sessionDuration.toFixed(2)
     693            1 :                 }, LOG_CTX);
     694            1 :             } catch (error) {
     695            1 :                 const sessionDuration = performance.now() - sessionStartTime;
     696              : 
     697            1 :                 if (error instanceof InvalidSessionError) {
     698              :                     // Metadata validation refused this session up front. Record it so the selector
     699              :                     // can show it as invalid (blocked, deletable), and account it as *skipped*
     700              :                     // rather than *failed* — nothing was attempted, the metadata was simply
     701              :                     // unusable. The session contributed no connection/catalog/scripts/scripts.
     702            1 :                     invalidSessions.set(error.invalid.sessionId, error.invalid);
     703            1 :                     progress.connections.addSkipped();
     704            1 :                     progress.catalogs.addSkipped();
     705            1 :                     progress.sessionScripts.addSkipped();
     706              : 
     707            1 :                     progress.report();
     708            1 :                     continue;
     709            1 :                 }
     710              : 
     711            1 :                 logger.warn("Failed to restore session", {
     712            1 :                     index: `${i + 1}/${sessionEntries.length}`,
     713            1 :                     sessionId: sessionEntry.path,
     714            1 :                     durationMs: sessionDuration.toFixed(2),
     715            1 :                     error: stringifyError(error)
     716            1 :                 }, LOG_CTX);
     717              : 
     718              :                 // The session stays visible as invalid, the user can delete it
     719            1 :                 invalidSessions.set(sessionEntry.path, describeInvalidSession(sessionEntry, SessionValidationError.SessionNotRestored, null));
     720            1 :                 progress.connections.addFailed();
     721            1 :                 progress.catalogs.addFailed();
     722            1 :                 progress.sessionScripts.addFailed();
     723            1 :                 progress.report();
     724            1 :             }
     725            1 :         }
     726            1 :     } catch (manifestError) {
     727            1 :         logger.warn("Failed to load manifest, starting with empty state", {
     728            1 :             error: stringifyError(manifestError)
     729            1 :         }, LOG_CTX);
     730            1 :     }
     731              : 
     732            1 :     await flushRestoreWrites(ctx);
     733              : 
     734            1 :     progress.report();
     735              : 
     736            1 :     const totalDuration = performance.now() - startTime;
     737            1 :     logger.info("Finished loading app state", {
     738            1 :         connections: ctx.connectionStates.size.toString(),
     739            1 :         sessionScripts: sessionScripts.size.toString(),
     740            1 :         invalidSessions: invalidSessions.size.toString(),
     741            1 :         connectionsSucceeded: progress.connections.succeeded.toString(),
     742            1 :         connectionsFailed: progress.connections.failed.toString(),
     743            1 :         connectionsSkipped: progress.connections.skipped.toString(),
     744            1 :         totalDurationMs: totalDuration.toFixed(2)
     745            1 :     }, LOG_CTX);
     746              : 
     747            1 :     return {
     748            1 :         connectionStates: ctx.connectionStates,
     749            1 :         connectionStatesByType: ctx.connectionStatesByType,
     750            1 :         sessionScripts,
     751            1 :         sessionScriptsByConnection,
     752            1 :         invalidSessions,
     753            1 :     };
     754            1 : }
        

Generated by: LCOV version 2.0-1