LCOV - code coverage report
Current view: top level - dashql-app/src/storage - session_validation.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 83 83
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type { SessionData, SessionEntry } from './storage_backend.js';
       2              : import { getConnectorInfoForParams, ConnectorType } from '../connections/connector_info.js';
       3              : import { SESSION_FORMAT_VERSION } from './migration/versions.js';
       4              : 
       5              : /// A syntactically valid UUID (any version), e.g. the output of `crypto.randomUUID()`.
       6              : ///
       7              : /// Matched case-insensitively and deliberately not pinned to v4: the only requirement is the
       8              : /// canonical 8-4-4-4-12 hex shape, which is enough to treat the value as an authoritative session
       9              : /// key. Anything else (a legacy `opfs://sessions/<uuid>` path, a slug, an empty string) fails.
      10            1 : const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
      11              : 
      12              : /// Whether a string is a syntactically valid UUID.
      13            1 : export function isValidUuid(value: string): boolean {
      14            1 :     return UUID_PATTERN.test(value);
      15            1 : }
      16              : 
      17              : /// Why a persisted session was rejected during validation.
      18              : ///
      19              : /// These are *metadata* problems detected before any heavy restoration work runs. A session that
      20              : /// validates here may still hit non-fatal hiccups later (e.g. a catalog that fails to load), but
      21              : /// such hiccups never make the session "invalid" — they degrade gracefully as before.
      22            1 : export enum SessionValidationError {
      23              :     /// The session does not declare the current persistence format version.
      24            1 :     UnsupportedFormatVersion = 'unsupported_format_version',
      25              :     /// The session has no `sessionId`, or it is empty.
      26            1 :     MissingSessionId = 'missing_session_id',
      27              :     /// The `sessionId` is present but is not a syntactically valid UUID.
      28            1 :     InvalidSessionId = 'invalid_session_id',
      29              :     /// The connection list of the session is missing, empty or malformed.
      30            1 :     InvalidConnections = 'invalid_connections',
      31              :     /// A connection has no valid UUID `connectionId`, or the id is listed twice.
      32            1 :     InvalidConnectionId = 'invalid_connection_id',
      33              :     /// A connection has no `params`.
      34            1 :     MissingConnectionParams = 'missing_connection_params',
      35              :     /// The connection `params` do not match any known connector.
      36            1 :     UnknownConnector = 'unknown_connector',
      37              :     /// The session is registered in the manifest but its files can't be read (e.g. a native session
      38              :     /// whose folder was moved or deleted on disk, or a corrupt/absent OPFS session). Unlike the
      39              :     /// errors above this is detected by an actual load failure rather than by inspecting metadata,
      40              :     /// but it lands in the same bucket: the session can't be opened and should be surfaced as invalid
      41              :     /// so the user can remove the stale entry.
      42            1 :     SessionUnreadable = 'session_unreadable',
      43              :     /// The session metadata is valid, but restoring the session failed, e.g. its scripts could not be read
      44            1 :     SessionNotRestored = 'session_not_restored',
      45            1 : }
      46              : 
      47              : /// A short, human-readable explanation for each validation error, shown in the session workbench.
      48            1 : export function describeSessionValidationError(error: SessionValidationError): string {
      49            1 :     switch (error) {
      50            1 :         case SessionValidationError.UnsupportedFormatVersion:
      51            1 :             return 'Unsupported session format version';
      52            1 :         case SessionValidationError.MissingSessionId:
      53            1 :             return 'Missing session id';
      54            1 :         case SessionValidationError.InvalidSessionId:
      55            1 :             return 'Invalid session id';
      56            1 :         case SessionValidationError.InvalidConnections:
      57            1 :             return 'Session must list at least one connection';
      58            1 :         case SessionValidationError.InvalidConnectionId:
      59            1 :             return 'Invalid connection id';
      60            1 :         case SessionValidationError.MissingConnectionParams:
      61            1 :             return 'Missing connection parameters';
      62            1 :         case SessionValidationError.UnknownConnector:
      63            1 :             return 'Unknown connector';
      64            1 :         case SessionValidationError.SessionUnreadable:
      65            1 :             return 'Session files missing';
      66            1 :         case SessionValidationError.SessionNotRestored:
      67            1 :             return 'Session could not be loaded';
      68            1 :     }
      69            1 : }
      70              : 
      71              : /// The result of validating a session's metadata.
      72              : export type SessionValidationResult =
      73              :     | { ok: true }
      74              :     | { ok: false; error: SessionValidationError };
      75              : 
      76              : /// Validate a session's metadata before loading it.
      77              : ///
      78              : /// This is a fail-fast gate run up front in the loader: it rejects sessions whose metadata is
      79              : /// structurally unusable (unsupported format, no id, or an invalid connection).
      80              : /// It deliberately does NOT attempt the full connection decode — `getConnectorInfoForParams` is a
      81              : /// non-throwing connector probe, so a session that passes here can still be decoded by
      82              : /// `decodeConnectionFromProto` during restore.
      83            1 : export function validateSessionData(data: SessionData): SessionValidationResult {
      84            1 :     if ((data as { formatVersion?: unknown }).formatVersion !== SESSION_FORMAT_VERSION) {
      85            1 :         return { ok: false, error: SessionValidationError.UnsupportedFormatVersion };
      86              :     }
      87            1 :     if (!data.sessionId) {
      88            1 :         return { ok: false, error: SessionValidationError.MissingSessionId };
      89              :     }
      90            1 :     if (!isValidUuid(data.sessionId)) {
      91            1 :         return { ok: false, error: SessionValidationError.InvalidSessionId };
      92              :     }
      93            1 :     const connections: unknown = (data as { connections?: unknown }).connections;
      94            1 :     if (!Array.isArray(connections) || connections.length === 0) {
      95            1 :         return { ok: false, error: SessionValidationError.InvalidConnections };
      96              :     }
      97            1 :     const connectionIds = new Set<string>();
      98            1 :     for (const connection of connections as SessionData['connections']) {
      99            1 :         if (!connection || typeof connection.connectionId !== 'string' || !isValidUuid(connection.connectionId)
     100            1 :             || connectionIds.has(connection.connectionId)) {
     101            1 :             return { ok: false, error: SessionValidationError.InvalidConnectionId };
     102              :         }
     103            1 :         connectionIds.add(connection.connectionId);
     104            1 :         if (!connection.params) {
     105            1 :             return { ok: false, error: SessionValidationError.MissingConnectionParams };
     106              :         }
     107            1 :         if (getConnectorInfoForParams(connection.params as any) == null) {
     108            1 :             return { ok: false, error: SessionValidationError.UnknownConnector };
     109              :         }
     110            1 :         if (connection.paramsDraft != null && getConnectorInfoForParams(connection.paramsDraft as any)?.connectorType
     111            1 :             !== getConnectorInfoForParams(connection.params as any)?.connectorType) {
     112            1 :             return { ok: false, error: SessionValidationError.UnknownConnector };
     113              :         }
     114            1 :     }
     115            1 :     return { ok: true };
     116            1 : }
     117              : 
     118            1 : export function isLocalHyperWasmParams(params: SessionData['connections'][number]['params']): boolean {
     119            1 :     return 'hyper' in params && (params.hyper?.protocol ?? 'WASM') === 'WASM';
     120            1 : }
     121              : 
     122              : /// A session that failed metadata validation and was refused a load.
     123              : ///
     124              : /// Carried out of the loader so the session workbench can surface it (marked invalid, blocked from
     125              : /// opening, still deletable). It holds just enough to render and to delete the session: the bare
     126              : /// UUID (the storage key), a best-effort title, a connector type for the icon when one could be
     127              : /// inferred, and the reason it was rejected.
     128              : export interface InvalidSession {
     129              :     /// The bare session UUID — the key used for routing and for `deleteSession`.
     130              :     sessionId: string;
     131              :     /// A best-effort display title (falls back to the display path / UUID).
     132              :     title: string;
     133              :     /// The connector type if it could be inferred from the params, else null.
     134              :     connectorType: ConnectorType | null;
     135              :     /// Why the session was rejected.
     136              :     error: SessionValidationError;
     137              : }
     138              : 
     139              : /// Build an `InvalidSession` record from whatever metadata is available.
     140              : ///
     141              : /// `data` may be absent (e.g. the session file failed to load/parse entirely), in which case we
     142              : /// fall back to the manifest entry's path for the id and title.
     143            1 : export function describeInvalidSession(
     144            1 :     entry: SessionEntry,
     145            1 :     error: SessionValidationError,
     146            1 :     data: SessionData | null,
     147            1 : ): InvalidSession {
     148              :     // The manifest entry's path is the authoritative registry key, so it is what `deleteSession`
     149              :     // must be handed to evict the session — not `data.sessionId`, which is exactly the field that
     150              :     // may be missing or malformed on an invalid session.
     151            1 :     const sessionId = entry.path;
     152            1 :     const params = Array.isArray(data?.connections) ? data.connections[0]?.params : undefined;
     153            1 :     const connectorInfo = params
     154            1 :         ? getConnectorInfoForParams(params as any)
     155            1 :         : null;
     156            1 :     return {
     157            1 :         sessionId,
     158            1 :         title: data?.name || data?.sessionPath || sessionId,
     159            1 :         connectorType: connectorInfo?.connectorType ?? null,
     160            1 :         error,
     161            1 :     };
     162            1 : }
        

Generated by: LCOV version 2.0-1