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

            Line data    Source code
       1              : import JSZip from 'jszip';
       2              : 
       3              : import * as app_event from '@ankoh/dashql-jsonschema/app_event.js';
       4              : 
       5              : import type { StorageBackend, SessionData, ScriptData, DataFileData, ConnectionParams } from '../../storage/storage_backend.js';
       6              : import {
       7              :     STORAGE_SESSION_FILE,
       8              :     STORAGE_ATTACHMENTS_FOLDER,
       9              :     validateDataFileName,
      10              :     STORAGE_SCRIPTS_FOLDER,
      11              :     STORAGE_CATALOG_FUNCTIONS,
      12              :     STORAGE_CATALOG_RELATIONS,
      13              : } from '../../storage/storage_backend.js';
      14              : import { BASE64URL_CODEC } from '../../utils/base64.js';
      15              : import { SESSION_FORMAT_VERSION } from '../../storage/migration/versions.js';
      16              : import { sanitizeConnectionParamsForSharing } from '../../connections/connection_params.js';
      17              : import {
      18              :     bundleCatalogPath,
      19              :     pruneBundleConnections,
      20              :     readSessionBundle,
      21              :     regenerateBundleConnectionIds,
      22              :     type SessionBundle,
      23              :     type SessionBundleCatalog,
      24              : } from '../../storage/session_bundle.js';
      25              : 
      26              : /// The target platform a shared session link points at.
      27            1 : export enum SessionLinkTarget {
      28            1 :     NATIVE,
      29            1 :     WEB
      30            1 : }
      31              : 
      32              : /// Options controlling how a session is exported to a ZIP.
      33              : export interface SessionExportOptions {
      34              :     /// Include the catalogs of the connections of the session when they exist.
      35              :     withCatalog?: boolean;
      36              :     /// Transform the session metadata and the catalogs after they are loaded from the backend and
      37              :     /// before they are written into the ZIP. Scripts are exported verbatim from disk. The sharing
      38              :     /// path uses this to sanitize connection secrets, strip the login hint, or give the session and
      39              :     /// its connections fresh ids, the catalogs follow their connections.
      40              :     transformBundle?: (bundle: Pick<SessionBundle, 'session' | 'catalogs'>) => Pick<SessionBundle, 'session' | 'catalogs'>;
      41              : }
      42              : 
      43              : /// Options applied after sanitizing session connection parameters for sharing.
      44              : export interface SharedSessionExportOptions {
      45              :     /// Carry the sharer's resolved account username in the shared connection identity.
      46              :     withLoginHint?: boolean;
      47              :     /// Include the catalogs of the connections of the session when present.
      48              :     withCatalog?: boolean;
      49              :     /// Keep the session and connection UUIDs instead of generating export-only identities.
      50              :     preserveUUIDs?: boolean;
      51              :     /// The names the user gave the connections (connectionId -> name), an import names new connections after them.
      52              :     connectionNames?: ReadonlyMap<string, string>;
      53              : }
      54              : 
      55              : type SharedSessionExportOptionsInput = SharedSessionExportOptions | boolean;
      56              : 
      57              : /// Creates a ZIP file from session data and script folders
      58            1 : export async function createSessionZip(
      59            1 :     sessionData: SessionData,
      60            1 :     scripts: ScriptData[],
      61            1 :     catalogs: readonly SessionBundleCatalog[] = [],
      62            1 :     data: readonly DataFileData[] = [],
      63            1 : ): Promise<Blob> {
      64            1 :     const zip = new JSZip();
      65              : 
      66              :     // Add session metadata
      67            1 :     zip.file(STORAGE_SESSION_FILE, JSON.stringify({
      68            1 :         ...sessionData,
      69            1 :         connections: sessionData.connections.map(connection => ({
      70            1 :             connectionId: connection.connectionId,
      71            1 :             params: sanitizeConnectionParamsForSharing(connection.params),
      72            1 :             ...(connection.name == null ? {} : { name: connection.name }),
      73            1 :         })),
      74            1 :     }, null, 2));
      75              :     // The catalogs of the connections, an import seeds the connections that are new to the app
      76            1 :     for (const catalog of catalogs) {
      77            1 :         if (catalog.relations != null) zip.file(bundleCatalogPath(catalog.connectionId, STORAGE_CATALOG_RELATIONS), catalog.relations);
      78            1 :         if (catalog.functions != null) zip.file(bundleCatalogPath(catalog.connectionId, STORAGE_CATALOG_FUNCTIONS), catalog.functions);
      79            1 :     }
      80              : 
      81              :     // Add the flat ordered scripts.
      82            1 :     const scriptsFolder = zip.folder(STORAGE_SCRIPTS_FOLDER);
      83            1 :     if (!scriptsFolder) {
      84            0 :         throw new Error('Failed to create scripts folder in ZIP');
      85              :     }
      86              : 
      87            1 :     for (const script of scripts) {
      88            1 :         if (script.name.toLowerCase() === 'dashql-draft.sql') continue;
      89            1 :         scriptsFolder.file(script.name, script.sql);
      90            1 :     }
      91            1 :     for (const file of data) {
      92            1 :         validateDataFileName(file.name);
      93            1 :         zip.file(`${STORAGE_ATTACHMENTS_FOLDER}/${file.name}`, file.bytes);
      94            1 :     }
      95              : 
      96              :     // Generate ZIP blob with compression
      97            1 :     return await zip.generateAsync({
      98            1 :         type: 'blob',
      99            1 :         compression: 'DEFLATE',
     100            1 :         compressionOptions: { level: 6 }
     101            1 :     });
     102            1 : }
     103              : 
     104              : /// Exports a session as a ZIP file by loading from storage backend.
     105              : ///
     106              : /// Scripts are always exported exactly as they exist on disk. The export carries only the connections
     107              : /// that the scripts use. Pass `options.transformBundle` to adjust the session metadata on the way out
     108              : /// (e.g. sanitize connection secrets or drop the login hint when sharing).
     109            1 : export async function exportSessionAsZip(
     110            1 :     sessionId: string,
     111            1 :     backend: StorageBackend,
     112            1 :     options: SessionExportOptions = {}
     113            1 : ): Promise<Blob> {
     114            1 :     const bundle = pruneBundleConnections(await readSessionBundle(sessionId, backend, options.withCatalog ?? false));
     115              : 
     116              :     // Apply the caller's transform (sharing sanitization, name override, ...)
     117            1 :     const out = options.transformBundle ? options.transformBundle(bundle) : bundle;
     118              :     // Create ZIP from loaded data
     119            1 :     return await createSessionZip(out.session, bundle.scripts, out.catalogs, bundle.data);
     120            1 : }
     121              : 
     122              : /// Export a session as a shareable ZIP.
     123              : ///
     124              : /// Scripts and the session name are read straight from disk (via `exportSessionAsZip`). Connection
     125              : /// params are replaced with their live values and sanitized, the connections carry the given names. By default the exported session and
     126              : /// connections receive fresh UUIDs so importing a copy cannot collide with existing runtime state.
     127            1 : export async function exportSessionAsSharedZip(
     128            1 :     backend: StorageBackend,
     129            1 :     sessionId: string,
     130            1 :     connectionParams: ReadonlyMap<string, ConnectionParams>,
     131            1 :     optionsInput: SharedSessionExportOptionsInput = {},
     132            1 : ): Promise<Blob> {
     133            1 :     const options = typeof optionsInput === 'boolean' ? { withLoginHint: optionsInput } : optionsInput;
     134            1 :     const withLoginHint = options.withLoginHint ?? true;
     135            1 :     return await exportSessionAsZip(sessionId, backend, {
     136            1 :         withCatalog: options.withCatalog ?? true,
     137            1 :         transformBundle: bundle => {
     138            1 :             const { session } = bundle;
     139            1 :             const sanitized: SessionData = {
     140            1 :                 ...session,
     141            1 :                 connections: session.connections.map(connection => {
     142            1 :                     const name = options.connectionNames?.get(connection.connectionId);
     143            1 :                     return {
     144            1 :                         ...connection,
     145            1 :                         params: sanitizeConnectionParamsForSharing(
     146            1 :                             connectionParams.get(connection.connectionId) ?? connection.params,
     147            1 :                             withLoginHint,
     148            1 :                         ),
     149            1 :                         ...(name == null ? {} : { name }),
     150            1 :                     };
     151            1 :                 }),
     152            1 :             };
     153            1 :             return options.preserveUUIDs
     154            1 :                 ? { ...bundle, session: sanitized }
     155            1 :                 : regenerateBundleConnectionIds({ ...bundle, session: { ...sanitized, sessionId: crypto.randomUUID() } });
     156            1 :         },
     157            1 :     });
     158            1 : }
     159              : 
     160              : /// Export a session as a shareable link that carries the session ZIP inline.
     161            1 : export async function exportSessionAsUrl(
     162            1 :     backend: StorageBackend,
     163            1 :     sessionId: string,
     164            1 :     connectionParams: any,
     165            1 :     target: SessionLinkTarget,
     166            1 :     optionsInput: SharedSessionExportOptionsInput = {},
     167            1 : ): Promise<URL> {
     168            1 :     const options = typeof optionsInput === 'boolean' ? { withLoginHint: optionsInput } : optionsInput;
     169            1 :     const zipBlob = await exportSessionAsSharedZip(
     170            1 :         backend,
     171            1 :         sessionId,
     172            1 :         connectionParams,
     173            1 :         { ...options, withCatalog: options.withCatalog ?? false },
     174            1 :     );
     175            1 :     return await encodeSessionZipAsUrl(zipBlob, target);
     176            1 : }
     177              : 
     178              : /// Export a connection as a shareable link: the link carries an empty session that holds only the connection.
     179              : /// The params are sanitized, the session and the connection receive fresh ids.
     180            1 : export async function exportConnectionAsUrl(
     181            1 :     params: ConnectionParams,
     182            1 :     name: string | null,
     183            1 :     target: SessionLinkTarget,
     184            1 :     options: Pick<SharedSessionExportOptions, 'withLoginHint'> = {},
     185            1 : ): Promise<URL> {
     186              :     const session: SessionData = {
     187            1 :         formatVersion: SESSION_FORMAT_VERSION,
     188            1 :         sessionId: crypto.randomUUID(),
     189            1 :         connections: [{
     190            1 :             connectionId: crypto.randomUUID(),
     191            1 :             params: sanitizeConnectionParamsForSharing(params, options.withLoginHint ?? true),
     192            1 :             ...(name == null ? {} : { name }),
     193            1 :         }],
     194            1 :         scripts: [],
     195            1 :         attachments: [],
     196            1 :         metadata: { createdAt: new Date().toISOString() },
     197              :     };
     198            1 :     return await encodeSessionZipAsUrl(await createSessionZip(session, []), target);
     199            1 : }
     200              : 
     201              : /// Encode a session ZIP as a link that carries it inline.
     202            1 : async function encodeSessionZipAsUrl(zipBlob: Blob, target: SessionLinkTarget): Promise<URL> {
     203            1 :     const zipBytes = new Uint8Array(await zipBlob.arrayBuffer());
     204              : 
     205              :     // Wrap the zip in AppEventData - convert to base64 string as required by JSON schema
     206            1 :     const eventData: app_event.AppEventData = {
     207            1 :         session: BASE64URL_CODEC.encode(zipBytes.buffer)
     208            1 :     };
     209              : 
     210              :     // Encode the JSON to base64
     211            1 :     const eventDataJson = JSON.stringify(eventData);
     212            1 :     const eventDataBytes = new TextEncoder().encode(eventDataJson);
     213            1 :     const eventDataBase64 = BASE64URL_CODEC.encode(eventDataBytes.buffer);
     214              : 
     215            1 :     switch (target) {
     216            1 :         case SessionLinkTarget.WEB:
     217            1 :             return new URL(`${process.env.DASHQL_APP_URL!}?data=${eventDataBase64}`);
     218            0 :         case SessionLinkTarget.NATIVE:
     219            0 :             return new URL(`dashql://localhost?data=${eventDataBase64}`);
     220            1 :     }
     221            1 : }
        

Generated by: LCOV version 2.0-1