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