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

            Line data    Source code
       1              : import { connectionParamsShareEqual, type ConnectionParams } from '../../connections/connection_params.js';
       2              : import type { ConnectionEntry, SessionConnection, SessionData } from '../../storage/storage_backend.js';
       3              : import { dedupeConnections } from '../../storage/storage_writer.js';
       4              : import { isLocalHyperWasmParams } from '../../storage/session_validation.js';
       5              : 
       6              : /// A connection that a session brings along
       7              : export interface ImportedConnectionRef {
       8              :     /// The id of the connection in the session
       9              :     connectionId: string;
      10              :     /// The params of the connection in the session, shared sessions carry them without secrets
      11              :     params: ConnectionParams;
      12              :     /// The name the connection had in the exporting app
      13              :     name?: string;
      14              : }
      15              : 
      16              : /// How an imported connection relates to the connections of the app
      17              : export type ConnectionImportDecision =
      18              :     /// The connection is unknown, it is registered as a new connection
      19              :     | { kind: 'register' }
      20              :     /// The connection is known and describes the same database, the session uses it
      21              :     | { kind: 'link'; connectionId: string }
      22              :     /// Connections of the app may describe the same database, the user decides
      23              :     | { kind: 'ask'; candidates: ConnectionEntry[] };
      24              : 
      25              : /// The decision for a connection of an imported session
      26              : export interface ConnectionImportPlanItem {
      27              :     /// The connection of the session
      28              :     ref: ImportedConnectionRef;
      29              :     /// The decision
      30              :     decision: ConnectionImportDecision;
      31              : }
      32              : 
      33              : /// What the user chose for a connection that may consolidate with an existing one
      34              : export type ConnectionImportChoice =
      35              :     /// Use an existing connection
      36              :     | { kind: 'link'; connectionId: string }
      37              :     /// Keep the connection of the session as a connection of its own
      38              :     | { kind: 'separate' };
      39              : 
      40              : /// Find the entry of a connection id
      41            1 : function findEntry(entries: readonly ConnectionEntry[], connectionId: string): ConnectionEntry | null {
      42            1 :     return entries.find(entry => entry.id === connectionId) ?? null;
      43            1 : }
      44              : 
      45              : /// Decide how the connections of an imported session map to the connections of the app.
      46              : ///
      47              : /// A session that comes back with the id of a known connection and the same params (ignoring
      48              : /// secrets, sharing strips them) simply uses the known connection. Every other connection that
      49              : /// describes the same database, or shares the id but not the params, is a candidate for
      50              : /// consolidation that the user decides on. Exports usually regenerate the ids, so the candidates are
      51              : /// found by params.
      52              : ///
      53              : /// The embedded database is not a choice: the app holds a single connection to it, every embedded
      54              : /// database of a session uses that one. It is registered only when the app doesn't have one.
      55            1 : export function planConnectionImport(session: SessionData, entries: readonly ConnectionEntry[]): ConnectionImportPlanItem[] {
      56            1 :     const refs: ImportedConnectionRef[] = session.connections.map(connection => ({
      57            1 :         connectionId: connection.connectionId,
      58            1 :         params: connection.params,
      59            1 :         ...(connection.name == null ? {} : { name: connection.name }),
      60            1 :     }));
      61            1 :     const embedded = entries.find(entry => isLocalHyperWasmParams(entry.params)) ?? null;
      62            1 :     return refs.map(ref => {
      63            1 :         if (isLocalHyperWasmParams(ref.params)) {
      64            1 :             return { ref, decision: embedded == null ? { kind: 'register' } : { kind: 'link', connectionId: embedded.id } };
      65              :         }
      66            1 :         const sameId = findEntry(entries, ref.connectionId);
      67            1 :         if (sameId != null && connectionParamsShareEqual(sameId.params, ref.params)) {
      68            1 :             return { ref, decision: { kind: 'link', connectionId: sameId.id } };
      69              :         }
      70            1 :         const candidates = [
      71            1 :             ...(sameId == null ? [] : [sameId]),
      72            1 :             ...entries.filter(entry => entry !== sameId && connectionParamsShareEqual(entry.params, ref.params)),
      73            1 :         ];
      74            1 :         if (candidates.length === 0) {
      75            1 :             return { ref, decision: { kind: 'register' } };
      76              :         }
      77            1 :         return { ref, decision: { kind: 'ask', candidates } };
      78            1 :     });
      79            1 : }
      80              : 
      81              : /// The plan items that need a decision of the user
      82            1 : export function connectionImportQuestions(plan: readonly ConnectionImportPlanItem[]): ConnectionImportPlanItem[] {
      83            1 :     return plan.filter(item => item.decision.kind === 'ask');
      84            1 : }
      85              : 
      86              : /// An imported session with resolved connection references
      87              : export interface ConnectionImport {
      88              :     /// The session with the rewritten references
      89              :     session: SessionData;
      90              :     /// The ids that the connections of the imported session got (imported id -> id)
      91              :     connectionIds: Map<string, string>;
      92              : }
      93              : 
      94              : /// Rewrite the connection references of an imported session.
      95              : ///
      96              : /// Linked references take the id and the full params of the app's connection, the session then
      97              : /// carries the secrets the same way every other copy does, and the app's connection keeps its name.
      98              : /// Separate connections keep their id unless the app already uses it for another connection, and
      99              : /// keep their name to be registered with it. Unanswered questions keep the connection separate.
     100            1 : export function applyConnectionImportPlan(
     101            1 :     session: SessionData,
     102            1 :     plan: readonly ConnectionImportPlanItem[],
     103            1 :     choices: ReadonlyMap<string, ConnectionImportChoice>,
     104            1 :     entries: readonly ConnectionEntry[],
     105            1 :     randomUUID: () => string = () => crypto.randomUUID(),
     106            1 : ): ConnectionImport {
     107            1 :     const byRef = new Map(plan.map(item => [item.ref.connectionId, item]));
     108            1 :     const freshIds = new Map<string, string>();
     109            1 :     const resolve = (connectionId: string): { connectionId: string; entry: ConnectionEntry | null } => {
     110            1 :         const item = byRef.get(connectionId);
     111            1 :         let link: string | null = null;
     112            1 :         if (item?.decision.kind === 'link') {
     113            1 :             link = item.decision.connectionId;
     114            1 :         } else if (item?.decision.kind === 'ask') {
     115            1 :             const choice = choices.get(connectionId);
     116            1 :             if (choice?.kind === 'link') link = choice.connectionId;
     117            1 :         }
     118            1 :         if (link != null) {
     119            1 :             const entry = entries.find(e => e.id === link);
     120            1 :             if (entry != null) return { connectionId: entry.id, entry };
     121            1 :         }
     122              :         // A separate connection must not take over the id of an existing one
     123            1 :         if (findEntry(entries, connectionId) == null) {
     124            1 :             return { connectionId, entry: null };
     125              :         }
     126            1 :         let freshId = freshIds.get(connectionId);
     127            1 :         if (freshId == null) {
     128            1 :             freshId = randomUUID();
     129            1 :             freshIds.set(connectionId, freshId);
     130            1 :         }
     131            1 :         return { connectionId: freshId, entry: null };
     132            1 :     };
     133            1 :     const rewrite = (connection: SessionConnection): SessionConnection => {
     134            1 :         const resolved = resolve(connection.connectionId);
     135            1 :         if (resolved.entry != null) return { connectionId: resolved.connectionId, params: resolved.entry.params };
     136            1 :         return {
     137            1 :             connectionId: resolved.connectionId,
     138            1 :             params: connection.params,
     139            1 :             ...(connection.name == null ? {} : { name: connection.name }),
     140            1 :         };
     141            1 :     };
     142              :     // The scripts follow their connections
     143            1 :     return {
     144            1 :         session: {
     145            1 :             ...session,
     146            1 :             connections: dedupeConnections(session.connections.map(rewrite)),
     147            1 :             scripts: session.scripts.map(script => ({ ...script, connectionId: resolve(script.connectionId).connectionId })),
     148            1 :         },
     149            1 :         connectionIds: new Map(session.connections.map(connection => [connection.connectionId, resolve(connection.connectionId).connectionId])),
     150            1 :     };
     151            1 : }
     152              : 
     153              : /// Did resolving the connections change the references of the session?
     154            1 : export function connectionRefsChanged(before: SessionData, after: SessionData): boolean {
     155            1 :     const refs = (session: SessionData) => JSON.stringify([
     156            1 :         ...session.connections.flatMap(connection => [connection.connectionId, connection.params]),
     157            1 :         ...session.scripts.map(script => script.connectionId),
     158            1 :     ]);
     159            1 :     return refs(before) !== refs(after);
     160            1 : }
        

Generated by: LCOV version 2.0-1