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

            Line data    Source code
       1              : import type * as core from '@ankoh/dashql-core';
       2              : 
       3              : import { ATTACHMENT_EXTENSION, isAttachmentFileName, type DataFile } from '../../storage/storage_backend.js';
       4              : 
       5              : /// The directory that the embedded Hyper mounts the attachments of the sessions in, one folder per session
       6            1 : export const ATTACHMENT_MOUNT_ROOT = '/mnt/external/attachments';
       7              : 
       8              : /// An attachment of a session, a Parquet file in the `attachments` folder of the session. It is the table parameter of
       9              : /// its name, `sales.parquet` is `:sales` (docs/design/script_references.md).
      10              : export interface SessionAttachment {
      11              :     /// The file name
      12              :     fileName: string;
      13              :     /// The name of the table parameter, the file name without `.parquet`
      14              :     name: string;
      15              :     /// The size of the file
      16              :     sizeBytes: number;
      17              :     /// The time of the last modification, 0 if the storage doesn't know it
      18              :     mtimeMs: number;
      19              :     /// The columns of the file, null until they were read
      20              :     columns: string[] | null;
      21              : }
      22              : 
      23              : /// The name of the table parameter of an attachment, the file name without `.parquet`. Null for a file that is no
      24              : /// attachment.
      25            1 : export function attachmentParameterName(fileName: string): string | null {
      26            1 :     return isAttachmentFileName(fileName) ? fileName.slice(0, -ATTACHMENT_EXTENSION.length) : null;
      27            1 : }
      28              : 
      29              : /// The names of the table parameters of attachments, which no script may take
      30            1 : export function attachmentNames(attachments: readonly SessionAttachment[] | undefined): string[] {
      31            1 :     return (attachments ?? []).map(attachment => attachment.name);
      32            1 : }
      33              : 
      34              : /// A file name for a new attachment whose table parameter takes no name of a script or another attachment, the
      35              : /// same name in another case neither: `sales.parquet` becomes `sales-2.parquet`. A file with the name of an attachment
      36              : /// replaces it and keeps its name.
      37            1 : export function uniqueAttachmentFileName(fileName: string, scriptNames: Iterable<string>, attachments: readonly SessionAttachment[]): string {
      38            1 :     if (attachments.some(attachment => attachment.fileName === fileName)) return fileName;
      39            1 :     return freeAttachmentFileName(fileName, scriptNames, attachments);
      40            1 : }
      41              : 
      42              : /// The file name of a renamed attachment: the requested name with `.parquet` unless it has it, past the names of the
      43              : /// scripts and the other attachments like a new attachment. Null for a name that is no plain file name.
      44            1 : export function renamedAttachmentFileName(
      45            1 :     fileName: string,
      46            1 :     requested: string,
      47            1 :     scriptNames: Iterable<string>,
      48            1 :     attachments: readonly SessionAttachment[],
      49            1 : ): string | null {
      50            1 :     const trimmed = requested.trim();
      51            1 :     const name = isAttachmentFileName(trimmed) ? trimmed : `${trimmed}${ATTACHMENT_EXTENSION}`;
      52            1 :     if (!isAttachmentFileName(name) || /[/\\\0]/.test(name)) return null;
      53            1 :     return freeAttachmentFileName(name, scriptNames, attachments.filter(attachment => attachment.fileName !== fileName));
      54            1 : }
      55              : 
      56              : /// A file name whose table parameter takes no name of a script or another attachment in any case, suffixing "-2",
      57              : /// "-3", ... to the name
      58            1 : function freeAttachmentFileName(fileName: string, scriptNames: Iterable<string>, attachments: readonly SessionAttachment[]): string {
      59            1 :     const base = attachmentParameterName(fileName) ?? fileName;
      60            1 :     const extension = fileName.slice(base.length);
      61            1 :     const taken = new Set([...scriptNames, ...attachmentNames(attachments)].map(name => name.toLowerCase()));
      62            1 :     let candidate = base;
      63            1 :     for (let suffix = 2; taken.has(candidate.toLowerCase()); ++suffix) {
      64            1 :         candidate = `${base}-${suffix}`;
      65              :     }
      66            1 :     return `${candidate}${extension}`;
      67            1 : }
      68              : 
      69              : /// What changes when the file of an attachment changes
      70            1 : export function attachmentStamp(attachment: SessionAttachment): string {
      71            1 :     return `${attachment.sizeBytes}-${attachment.mtimeMs}`;
      72            1 : }
      73              : 
      74              : /// The attachments of a listing of the data files of a session, other files are no attachments. An attachment whose
      75              : /// file is unchanged keeps the columns of `previous`.
      76            1 : export function readSessionAttachments(files: readonly DataFile[], previous: readonly SessionAttachment[] = []): SessionAttachment[] {
      77            1 :     const before = new Map(previous.map(attachment => [attachment.fileName, attachment]));
      78            1 :     return files.flatMap(file => {
      79            1 :         const name = attachmentParameterName(file.name);
      80            1 :         if (name == null) return [];
      81            1 :         const attachment: SessionAttachment = {
      82            1 :             fileName: file.name,
      83            1 :             name,
      84            1 :             sizeBytes: file.sizeBytes,
      85            1 :             mtimeMs: file.mtimeMs ?? 0,
      86            1 :             columns: null,
      87            1 :         };
      88            1 :         const kept = before.get(file.name);
      89            1 :         if (kept != null && attachmentStamp(kept) === attachmentStamp(attachment)) attachment.columns = kept.columns;
      90            1 :         return [attachment];
      91            1 :     });
      92            1 : }
      93              : 
      94              : /// Do two lists hold the same attachments?
      95            1 : export function sameSessionAttachments(l: readonly SessionAttachment[], r: readonly SessionAttachment[]): boolean {
      96            1 :     return l.length === r.length && l.every((a, i) => {
      97            1 :         const b = r[i];
      98            1 :         return a.fileName === b.fileName && attachmentStamp(a) === attachmentStamp(b)
      99            1 :             && (a.columns ?? []).join('\u0001') === (b.columns ?? []).join('\u0001') && (a.columns == null) === (b.columns == null);
     100            1 :     });
     101            1 : }
     102              : 
     103              : /// The directory that the embedded Hyper mounts the attachments of a session in
     104            1 : export function attachmentPrefix(sessionId: string): string {
     105            1 :     return `${ATTACHMENT_MOUNT_ROOT}/${sessionId}`;
     106            1 : }
     107              : 
     108              : /// The path of an attachment that the embedded Hyper reads
     109            1 : export function attachmentPath(sessionId: string, fileName: string): string {
     110            1 :     return `${attachmentPrefix(sessionId)}/${fileName}`;
     111            1 : }
     112              : 
     113              : /// The attachments of a session that table parameters read, for a request of the core
     114            1 : export function planAttachments(attachments: readonly SessionAttachment[] | undefined): core.SessionPlanAttachment[] {
     115            1 :     return (attachments ?? []).map(attachment => ({
     116            1 :         name: attachment.name,
     117            1 :         file: attachment.fileName,
     118            1 :         stamp: attachmentStamp(attachment),
     119            1 :         columns: attachment.columns,
     120            1 :     }));
     121            1 : }
        

Generated by: LCOV version 2.0-1