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