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

            Line data    Source code
       1              : import { isAttachmentFileName, type SessionAttachment, type SessionConnection, type SessionData, type SessionScript } from './storage_backend.js';
       2              : import { isValidUuid } from './session_validation.js';
       3              : 
       4              : /// The draft script of the editor, never a script of a session
       5            1 : const DRAFT_SCRIPT_FILE = 'dashql-draft.sql';
       6              : /// The maximum number of scripts and the maximum number of attachments of a session
       7              : export const MAX_SESSION_FILES = 2048;
       8              : 
       9            1 : const NATURAL_SORT = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
      10              : 
      11              : /// Compare file names in natural order ("2_a.sql" before "10_a.sql")
      12            1 : export function compareFileNamesNaturally(a: string, b: string): number {
      13            1 :     return NATURAL_SORT.compare(a, b);
      14            1 : }
      15              : 
      16              : /// Is the value a plain file name that can't escape its folder?
      17            1 : export function isSafeFileName(value: unknown): value is string {
      18            1 :     return typeof value === 'string'
      19            1 :         && value.length > 0
      20            1 :         && value.length <= 255
      21            1 :         && value !== '.'
      22            1 :         && value !== '..'
      23            1 :         && !value.includes('/')
      24            1 :         && !value.includes('\\')
      25            1 :         && !value.includes('\0');
      26            1 : }
      27              : 
      28              : /// The key under which two file names denote the same file on a case-insensitive file system
      29            1 : function fileNameKey(name: string): string {
      30            1 :     return name.normalize('NFC').toLowerCase();
      31            1 : }
      32              : 
      33              : /// Is the value the name of a script file?
      34            1 : function isScriptFileName(value: unknown): value is string {
      35            1 :     return isSafeFileName(value)
      36            1 :         && value.toLowerCase().endsWith('.sql')
      37            1 :         && fileNameKey(value) !== DRAFT_SCRIPT_FILE;
      38            1 : }
      39              : 
      40              : /// The file lists of a session after the unusable entries were removed
      41              : export interface SanitizedSessionFiles {
      42              :     /// The scripts in feed order
      43              :     scripts: SessionScript[];
      44              :     /// The attachments in natural order
      45              :     attachments: SessionAttachment[];
      46              :     /// False when entries were removed
      47              :     valid: boolean;
      48              : }
      49              : 
      50              : /// Remove the entries of the file lists that can't be used: names that are not plain file names,
      51              : /// scripts without a valid connection id, duplicates and entries beyond the limit.
      52              : ///
      53              : /// The lists of a session file are edited by hand, a bad entry therefore does not reject the session.
      54            1 : export function sanitizeSessionFiles(session: SessionData): SanitizedSessionFiles {
      55            1 :     let valid = true;
      56            1 :     const scripts: SessionScript[] = [];
      57            1 :     const scriptKeys = new Set<string>();
      58            1 :     const rawScripts: unknown = (session as { scripts?: unknown }).scripts;
      59            1 :     if (!Array.isArray(rawScripts)) valid = false;
      60            1 :     for (const script of Array.isArray(rawScripts) ? rawScripts : []) {
      61            1 :         if (scripts.length >= MAX_SESSION_FILES) {
      62            1 :             valid = false;
      63            1 :             break;
      64            1 :         }
      65            1 :         if (!isPlainObject(script) || !isScriptFileName(script.name)
      66            1 :             || typeof script.connectionId !== 'string' || !isValidUuid(script.connectionId)
      67            1 :             || scriptKeys.has(fileNameKey(script.name))) {
      68            1 :             valid = false;
      69            1 :             continue;
      70            1 :         }
      71            1 :         scriptKeys.add(fileNameKey(script.name));
      72            1 :         scripts.push({ name: script.name.normalize('NFC'), connectionId: script.connectionId });
      73            1 :     }
      74            1 :     const attachmentNames: string[] = [];
      75            1 :     const attachmentKeys = new Set<string>();
      76            1 :     const rawAttachments: unknown = (session as { attachments?: unknown }).attachments;
      77            1 :     if (!Array.isArray(rawAttachments)) valid = false;
      78            1 :     for (const attachment of Array.isArray(rawAttachments) ? rawAttachments : []) {
      79            1 :         if (attachmentNames.length >= MAX_SESSION_FILES) {
      80            1 :             valid = false;
      81            1 :             break;
      82            1 :         }
      83              :         // A session attaches Parquet files only
      84            1 :         if (!isPlainObject(attachment) || !isSafeFileName(attachment.name) || !isAttachmentFileName(attachment.name)
      85            1 :             || attachmentKeys.has(fileNameKey(attachment.name))) {
      86            1 :             valid = false;
      87            1 :             continue;
      88            1 :         }
      89            1 :         attachmentKeys.add(fileNameKey(attachment.name));
      90            1 :         attachmentNames.push(attachment.name.normalize('NFC'));
      91            1 :     }
      92            1 :     return { scripts, attachments: sessionAttachments(attachmentNames), valid };
      93            1 : }
      94              : 
      95              : /// The scripts of a session reconciled with the script files
      96              : export interface ReconciledSessionScripts {
      97              :     /// The scripts in feed order, one entry per file
      98              :     scripts: SessionScript[];
      99              :     /// Did the reconciliation change the scripts?
     100              :     changed: boolean;
     101              : }
     102              : 
     103              : /// Reconcile the scripts of a session with the script files that exist.
     104              : ///
     105              : /// Entries keep their order. An entry without a file is dropped, the first of several entries for
     106              : /// a file wins and an entry that names a file with a different case or normalization takes the name
     107              : /// of the file. Files without an entry are appended in natural order, they run on the connection of
     108              : /// the script before them or, without scripts, on the given connection.
     109            1 : export function reconcileSessionScripts(
     110            1 :     scripts: readonly SessionScript[],
     111            1 :     fileNames: readonly string[],
     112            1 :     fallbackConnectionId: string,
     113            1 : ): ReconciledSessionScripts {
     114            1 :     const filesByName = new Set(fileNames);
     115            1 :     const filesByKey = new Map<string, string>();
     116            1 :     for (const name of fileNames) {
     117            1 :         if (!filesByKey.has(fileNameKey(name))) filesByKey.set(fileNameKey(name), name);
     118              :     }
     119            1 :     const listed = new Set<string>();
     120            1 :     const result: SessionScript[] = [];
     121            1 :     let changed = false;
     122            1 :     for (const script of scripts) {
     123            1 :         const name = filesByName.has(script.name) ? script.name : filesByKey.get(fileNameKey(script.name));
     124            1 :         if (name == null || listed.has(name)) {
     125            1 :             changed = true;
     126            1 :             continue;
     127            1 :         }
     128            1 :         if (name !== script.name) changed = true;
     129            1 :         listed.add(name);
     130            1 :         result.push({ name, connectionId: script.connectionId });
     131            1 :     }
     132            1 :     const unlisted = fileNames.filter(name => !listed.has(name)).sort(compareFileNamesNaturally);
     133            1 :     for (const name of unlisted) {
     134            1 :         listed.add(name);
     135            1 :         result.push({ name, connectionId: result[result.length - 1]?.connectionId ?? fallbackConnectionId });
     136            1 :         changed = true;
     137            1 :     }
     138            1 :     return { scripts: result, changed };
     139            1 : }
     140              : 
     141              : /// The attachment list of a session for the given file names, in natural order
     142            1 : export function sessionAttachments(fileNames: readonly string[]): SessionAttachment[] {
     143            1 :     return [...fileNames].sort(compareFileNamesNaturally).map(name => ({ name }));
     144            1 : }
     145              : 
     146              : /// The ids of the connections that a session uses: the connections of its scripts in the order the
     147              : /// scripts first use them. A session without scripts uses the connection its first script will run on.
     148            1 : export function sessionConnectionIdsOf(scripts: readonly SessionScript[], emptyConnectionId: string | null = null): string[] {
     149            1 :     if (scripts.length === 0) return emptyConnectionId == null ? [] : [emptyConnectionId];
     150            1 :     return [...new Set(scripts.map(script => script.connectionId))];
     151            1 : }
     152              : 
     153              : /// The connection that the first script of a session without scripts runs on
     154              : export function sessionEmptyConnectionId(session: SessionData): string | null {
     155              :     return session.scripts.length === 0 ? session.connections[0]?.connectionId ?? null : null;
     156              : }
     157              : 
     158              : /// The session with a new script list.
     159              : ///
     160              : /// The connections of a session are derived from its scripts: the session holds a copy of every
     161              : /// connection that a script uses, nothing else. A copy that the session holds wins over a given one,
     162              : /// saving a connection patches the copies of the sessions that use it. A connection without any copy
     163              : /// is left out, the loader then runs its scripts on the embedded database.
     164              : ///
     165              : /// A session without scripts keeps one connection, the one its first script will run on: the first
     166              : /// given copy, or the first connection the session held.
     167            1 : export function withSessionScripts(session: SessionData, scripts: SessionScript[], copies: readonly SessionConnection[] = []): SessionData {
     168            1 :     const known = new Map<string, SessionConnection>();
     169            1 :     for (const copy of [...session.connections, ...copies]) {
     170            1 :         if (!known.has(copy.connectionId)) known.set(copy.connectionId, copy);
     171              :     }
     172            1 :     const emptyConnectionId = copies[0]?.connectionId ?? session.connections[0]?.connectionId ?? null;
     173            1 :     const connections = sessionConnectionIdsOf(scripts, emptyConnectionId)
     174            1 :         .flatMap(connectionId => known.get(connectionId) ?? []);
     175            1 :     return { ...session, connections, scripts };
     176            1 : }
     177              : 
     178              : /// Replace the connection ids of the scripts, ids without a replacement are kept
     179            1 : export function remapSessionScripts(scripts: readonly SessionScript[], connectionIds: ReadonlyMap<string, string>): SessionScript[] {
     180            1 :     return scripts.map(script => ({ ...script, connectionId: connectionIds.get(script.connectionId) ?? script.connectionId }));
     181            1 : }
     182              : 
     183              : /// Do two script lists hold the same scripts in the same order?
     184              : export function sessionScriptsEqual(a: readonly SessionScript[], b: readonly SessionScript[]): boolean {
     185              :     return a.length === b.length && a.every((script, i) => script.name === b[i].name && script.connectionId === b[i].connectionId);
     186              : }
     187              : 
     188            1 : function isPlainObject(value: unknown): value is Record<string, unknown> {
     189            1 :     return value != null && typeof value === 'object' && !Array.isArray(value);
     190            1 : }
        

Generated by: LCOV version 2.0-1