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

            Line data    Source code
       1              : import type { DashQL, FlatBufferPtr, buffers } from '@ankoh/dashql-core';
       2              : import type { DataFileData, SessionData, ScriptData, StorageBackend } from './storage_backend.js';
       3              : import {
       4              :     isSessionRegistryBackend,
       5              :     STORAGE_CATALOG_FUNCTIONS,
       6              :     STORAGE_CATALOG_RELATIONS,
       7              :     STORAGE_CONNECTIONS_FOLDER,
       8              :     STORAGE_SESSION_FILE,
       9              :     StorageBackendType,
      10              :     validateDataFileName,
      11              : } from './storage_backend.js';
      12              : import type { MigrationFolder } from './migration/migration_folder.js';
      13              : import { migrateSessionFolder, SessionMigrationResult } from './migration/session_migration.js';
      14              : import { SESSION_CATALOG_COPY_FORMAT_VERSION } from './migration/versions.js';
      15              : import {
      16              :     describeSessionValidationError,
      17              :     isValidUuid,
      18              :     SessionValidationError,
      19              :     validateSessionData,
      20              : } from './session_validation.js';
      21              : import { reconcileSessionScripts, remapSessionScripts, sanitizeSessionFiles, sessionAttachments, sessionConnectionIdsOf } from './session_files.js';
      22              : 
      23              : /// The durable, portable subset of a session. Derived cache files and storage-location metadata
      24              : /// are intentionally not represented here.
      25              : export interface SessionBundle {
      26              :     /// The session file. Its script list names every script of `scripts` in feed order and its
      27              :     /// attachment list names every file of `data`.
      28              :     session: SessionData;
      29              :     /// The catalogs of the connections of the session. Sessions don't store a catalog, bundles carry
      30              :     /// the catalogs of their connections so that a connection new to the app starts with one.
      31              :     catalogs: SessionBundleCatalog[];
      32              :     /// The script files
      33              :     scripts: ScriptData[];
      34              :     /// The attachments
      35              :     data?: DataFileData[];
      36              : }
      37              : 
      38              : /// The catalog of a connection that a bundle carries
      39              : export interface SessionBundleCatalog {
      40              :     /// The connection
      41              :     connectionId: string;
      42              :     /// The catalog relations
      43              :     relations: string | null;
      44              :     /// The catalog functions
      45              :     functions: string | null;
      46              : }
      47              : 
      48              : /// The path of a catalog file of a connection in a bundle, e.g. `connections/<id>/dashql-catalog-relations.sql`.
      49              : /// The app ignores the folder of a session folder except when the session is imported.
      50            1 : export function bundleCatalogPath(connectionId: string, fileName: string): string {
      51            1 :     return `${STORAGE_CONNECTIONS_FOLDER}/${connectionId}/${fileName}`;
      52            1 : }
      53              : 
      54              : /// The format version of the session file of a folder, null if it has none
      55            1 : async function readFormatVersion(folder: MigrationFolder): Promise<unknown> {
      56            1 :     return (await readSessionField(folder, 'formatVersion')) ?? null;
      57            1 : }
      58              : 
      59              : /// Read a top-level field of the session file, undefined when the file is unreadable
      60            1 : async function readSessionField(folder: MigrationFolder, field: string): Promise<unknown> {
      61            1 :     try {
      62            1 :         const text = await folder.readText(STORAGE_SESSION_FILE);
      63            1 :         return text == null ? undefined : (JSON.parse(text) as Record<string, unknown>)?.[field];
      64            1 :     } catch {
      65            0 :         return undefined;
      66            0 :     }
      67            1 : }
      68              : 
      69              : /// Is a path of a bundle the path of a catalog file of a connection?
      70            1 : export function isBundleCatalogPath(path: string): boolean {
      71            1 :     const parts = path.split('/');
      72            1 :     return parts.length === 3 && parts[0] === STORAGE_CONNECTIONS_FOLDER
      73            1 :         && (parts[2] === STORAGE_CATALOG_RELATIONS || parts[2] === STORAGE_CATALOG_FUNCTIONS);
      74            1 : }
      75              : 
      76              : /// The session file and the catalogs of a session bundle
      77              : export interface SessionBundleHead extends Pick<SessionBundle, 'session' | 'catalogs'> {
      78              :     /// False when unusable entries were removed from the file lists of the session file
      79              :     filesValid: boolean;
      80              : }
      81              : 
      82              : /// Complete a bundle from its top-level files and the files of its folders.
      83              : ///
      84              : /// The file lists of the session file are reconciled with the files: scripts without a file are
      85              : /// dropped and script files without an entry are appended on the connection of the script before them.
      86            1 : export function assembleSessionBundle(
      87            1 :     head: Pick<SessionBundle, 'session' | 'catalogs'>,
      88            1 :     scripts: ScriptData[],
      89            1 :     data: DataFileData[] = [],
      90            1 : ): SessionBundle {
      91            1 :     const reconciled = reconcileSessionScripts(head.session.scripts, scripts.map(script => script.name), firstConnectionId(head.session));
      92            1 :     return {
      93            1 :         session: {
      94            1 :             ...head.session,
      95            1 :             scripts: reconciled.scripts,
      96            1 :             attachments: sessionAttachments(data.map(file => file.name)),
      97            1 :         },
      98            1 :         catalogs: head.catalogs,
      99            1 :         scripts,
     100            1 :         data,
     101            1 :     };
     102            1 : }
     103              : 
     104              : /// Reconcile the file lists of a stored session file with the stored files, writes the session file
     105              : /// only when the lists changed. Must not race with writes of the session file.
     106            1 : export async function reconcileStoredSessionFiles(backend: StorageBackend, sessionId: string): Promise<SessionData> {
     107            1 :     const [session, scripts, dataFiles] = await Promise.all([
     108            1 :         backend.loadSession(sessionId),
     109            1 :         backend.loadScripts(sessionId),
     110            1 :         backend.listDataFiles(sessionId),
     111            1 :     ]);
     112            1 :     const sanitized = sanitizeSessionFiles(session);
     113            1 :     const reconciled = reconcileSessionScripts(sanitized.scripts, scripts.map(script => script.name), firstConnectionId(session));
     114            1 :     const attachments = sessionAttachments(dataFiles.map(file => file.name));
     115            1 :     const next: SessionData = { ...session, scripts: reconciled.scripts, attachments };
     116            1 :     if (!sanitized.valid || reconciled.changed || !sessionAttachmentsEqual(session.attachments, attachments)) {
     117            1 :         await backend.saveSessionManifest(sessionId, next);
     118              :     }
     119            1 :     return next;
     120            1 : }
     121              : 
     122              : /// The first connection of a session, script files without an entry run on it when no script precedes them
     123            1 : function firstConnectionId(session: SessionData): string {
     124            1 :     return session.connections[0]?.connectionId ?? '';
     125            1 : }
     126              : 
     127              : /// Do two attachment lists name the same files in the same order?
     128            1 : function sessionAttachmentsEqual(a: SessionData['attachments'] | undefined, b: SessionData['attachments']): boolean {
     129            1 :     return a != null && a.length === b.length && a.every((file, i) => file.name === b[i].name);
     130            1 : }
     131              : 
     132              : /// Migrate the top-level files of a session to the current format and read them with the catalogs
     133              : /// of the connections in `connections/<id>/`.
     134              : ///
     135              : /// The folder is usually held in memory, e.g. the files of an import or an `OverlayFolder` over a
     136              : /// folder that must stay untouched. Errors start with `errorPrefix`, e.g. "Invalid ZIP".
     137            1 : export async function readSessionBundleHead(folder: MigrationFolder, errorPrefix: string): Promise<SessionBundleHead> {
     138              :     // Older sessions copied the catalog of their default connection into the top level of the
     139              :     // folder. It is read before the migration removes the copy and the default connection.
     140            1 :     const migration = await migrateSessionFolder(folder, SESSION_CATALOG_COPY_FORMAT_VERSION);
     141            1 :     const hasCatalogCopy = migration !== SessionMigrationResult.Missing
     142            1 :         && await readFormatVersion(folder) === SESSION_CATALOG_COPY_FORMAT_VERSION;
     143            1 :     const [legacyRelations, legacyFunctions, legacyConnectionId] = !hasCatalogCopy ? [null, null, null] : await Promise.all([
     144            1 :         folder.readText(STORAGE_CATALOG_RELATIONS),
     145            1 :         folder.readText(STORAGE_CATALOG_FUNCTIONS),
     146            1 :         readSessionField(folder, 'defaultConnectionId'),
     147            1 :     ]);
     148            1 :     await migrateSessionFolder(folder);
     149            1 :     if (migration === SessionMigrationResult.Missing) {
     150            1 :         throw new Error(`${errorPrefix}: missing ${STORAGE_SESSION_FILE}`);
     151              :     }
     152            1 :     const text = await folder.readText(STORAGE_SESSION_FILE);
     153            1 :     if (text == null) {
     154              :         // Metadata of an unsupported version under an older name
     155            0 :         throw new Error(`${errorPrefix}: invalid ${STORAGE_SESSION_FILE}: ${describeSessionValidationError(SessionValidationError.UnsupportedFormatVersion)}`);
     156              :     }
     157            1 :     let parsed: unknown;
     158            1 :     try {
     159            1 :         parsed = JSON.parse(text);
     160            1 :     } catch {
     161            0 :         throw new Error(`${errorPrefix}: invalid ${STORAGE_SESSION_FILE}`);
     162            0 :     }
     163            1 :     if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
     164            0 :         throw new Error(`${errorPrefix}: invalid ${STORAGE_SESSION_FILE}`);
     165              :     }
     166            1 :     const parsedSession = parsed as SessionData;
     167            1 :     const validation = validateSessionData(parsedSession);
     168            1 :     if (!validation.ok) {
     169            1 :         throw new Error(`${errorPrefix}: invalid ${STORAGE_SESSION_FILE}: ${describeSessionValidationError(validation.error)}`);
     170              :     }
     171            1 :     const files = sanitizeSessionFiles(parsedSession);
     172            1 :     const session: SessionData = { ...parsedSession, scripts: files.scripts, attachments: files.attachments };
     173            1 :     const catalogs: SessionBundleCatalog[] = [];
     174            1 :     for (const { connectionId } of session.connections) {
     175            1 :         const [relations, functions] = await Promise.all([
     176            1 :             folder.readText(bundleCatalogPath(connectionId, STORAGE_CATALOG_RELATIONS)),
     177            1 :             folder.readText(bundleCatalogPath(connectionId, STORAGE_CATALOG_FUNCTIONS)),
     178            1 :         ]);
     179            1 :         if (relations != null || functions != null) {
     180            1 :             catalogs.push({ connectionId, relations, functions });
     181            1 :         } else if (connectionId === (typeof legacyConnectionId === 'string' ? legacyConnectionId : session.connections[0].connectionId)
     182            1 :             && (legacyRelations != null || legacyFunctions != null)) {
     183            1 :             catalogs.push({ connectionId, relations: legacyRelations, functions: legacyFunctions });
     184              :         }
     185            1 :     }
     186            1 :     return { session, catalogs, filesValid: files.valid };
     187            1 : }
     188              : 
     189              : export interface SessionCatalogValidationResult {
     190              :     /// The bundle without the malformed catalog files
     191              :     bundle: SessionBundle;
     192              :     /// The paths of the malformed catalog files in the bundle
     193              :     invalidFiles: string[];
     194              : }
     195              : 
     196              : /// Give the connections of a bundle fresh ids, the scripts and the catalogs follow them
     197            1 : export function regenerateBundleConnectionIds<B extends Pick<SessionBundle, 'session' | 'catalogs'>>(bundle: B): B {
     198            1 :     const { session } = bundle;
     199            1 :     const connectionIds = new Map(session.connections.map(connection => [connection.connectionId, crypto.randomUUID()]));
     200            1 :     return {
     201            1 :         ...bundle,
     202            1 :         session: {
     203            1 :             ...session,
     204            1 :             connections: session.connections.map(connection => ({ ...connection, connectionId: connectionIds.get(connection.connectionId)! })),
     205            1 :             scripts: remapSessionScripts(session.scripts, connectionIds),
     206            1 :         },
     207            1 :         catalogs: renameBundleCatalogs(bundle.catalogs, connectionIds),
     208            1 :     };
     209            1 : }
     210              : 
     211              : /// Keep only the connections of a bundle that its scripts use, in the order the scripts first use them,
     212              : /// and their catalogs.
     213              : /// A session without scripts keeps the connection its first script will run on.
     214            1 : export function pruneBundleConnections<B extends Pick<SessionBundle, 'session' | 'catalogs'>>(bundle: B): B {
     215            1 :     const { session } = bundle;
     216            1 :     const used = sessionConnectionIdsOf(session.scripts, session.connections[0]?.connectionId ?? null);
     217            1 :     const copies = new Map(session.connections.map(connection => [connection.connectionId, connection]));
     218            1 :     return {
     219            1 :         ...bundle,
     220            1 :         session: { ...session, connections: used.flatMap(connectionId => copies.get(connectionId) ?? []) },
     221            1 :         catalogs: bundle.catalogs.filter(catalog => used.includes(catalog.connectionId)),
     222            1 :     };
     223            1 : }
     224              : 
     225              : /// Rename the connections of the catalogs of a bundle (connectionId -> new id).
     226              : /// Connections that become one keep the first catalog.
     227            1 : export function renameBundleCatalogs(catalogs: readonly SessionBundleCatalog[], connectionIds: ReadonlyMap<string, string>): SessionBundleCatalog[] {
     228            1 :     const renamed = new Map<string, SessionBundleCatalog>();
     229            1 :     for (const catalog of catalogs) {
     230            1 :         const connectionId = connectionIds.get(catalog.connectionId) ?? catalog.connectionId;
     231            1 :         if (!renamed.has(connectionId)) renamed.set(connectionId, { ...catalog, connectionId });
     232            1 :     }
     233            1 :     return [...renamed.values()];
     234            1 : }
     235              : 
     236              : /// Parse catalog files on scratch scripts and omit malformed SQL before it can reach storage.
     237              : /// Analysis is intentionally excluded: imported catalogs may reference types or objects that are
     238              : /// only available once their connection has been restored.
     239            1 : export function filterInvalidSessionCatalogs(
     240            1 :     core: DashQL,
     241            1 :     bundle: SessionBundle,
     242            1 : ): SessionCatalogValidationResult {
     243            1 :     const invalidFiles: string[] = [];
     244            1 :     let changed = false;
     245            1 :     const catalogs = bundle.catalogs.map(catalog => {
     246            1 :         const validate = (sql: string | null, fileName: string) => {
     247            1 :             if (sql == null || parsesWithoutErrors(core, sql)) return sql;
     248            1 :             invalidFiles.push(bundleCatalogPath(catalog.connectionId, fileName));
     249            1 :             changed = true;
     250            1 :             return null;
     251            1 :         };
     252            1 :         return {
     253            1 :             ...catalog,
     254            1 :             relations: validate(catalog.relations, STORAGE_CATALOG_RELATIONS),
     255            1 :             functions: validate(catalog.functions, STORAGE_CATALOG_FUNCTIONS),
     256            1 :         };
     257            1 :     });
     258            1 :     return { bundle: changed ? { ...bundle, catalogs } : bundle, invalidFiles };
     259            1 : }
     260              : 
     261            1 : function parsesWithoutErrors(core: DashQL, sql: string): boolean {
     262            1 :     const catalog = core.createCatalog();
     263            1 :     const script = core.createScript(catalog);
     264            1 :     let parsed: FlatBufferPtr<buffers.parser.ParsedScript> | null = null;
     265            1 :     try {
     266            1 :         script.replaceText(sql);
     267            1 :         script.parse();
     268            1 :         parsed = script.getParsed();
     269            1 :         const reader = parsed.read();
     270            1 :         return reader.scannerErrorsLength() === 0 && reader.parserErrorsLength() === 0;
     271            1 :     } catch {
     272            0 :         return false;
     273            1 :     } finally {
     274            1 :         script.destroy();
     275            1 :         catalog.destroy();
     276            1 :     }
     277            1 : }
     278              : 
     279              : export interface SessionBundleWriteOptions {
     280              :     /// Destination UUID. When omitted, preserve the source session's authoritative UUID.
     281              :     targetSessionId?: string;
     282              :     /// Add " (copy)" to a nonblank source name. Intended for an explicit Create-new operation.
     283              :     suffixNameWithCopy?: boolean;
     284              :     /// The caller guarantees the destination did not exist before this write. Only then may a
     285              :     /// failed whole-bundle write remove the destination as rollback.
     286              :     targetIsFresh?: boolean;
     287              : }
     288              : 
     289              : /// Read the portable durable session data from a backend. The catalogs of the connections of the
     290              : /// session are only read for exports that ask for them, and only from a backend that holds the connections.
     291            1 : export async function readSessionBundle(
     292            1 :     sessionId: string,
     293            1 :     backend: StorageBackend,
     294            1 :     withCatalog: boolean = false,
     295            1 : ): Promise<SessionBundle> {
     296            1 :     const [stored, scripts] = await Promise.all([
     297            1 :         backend.loadSession(sessionId),
     298            1 :         backend.loadScripts(sessionId),
     299            1 :     ]);
     300            1 :     const registry = withCatalog && isSessionRegistryBackend(backend) ? backend : null;
     301            1 :     const catalogs: SessionBundleCatalog[] = [];
     302            1 :     for (const { connectionId } of registry == null ? [] : stored.connections) {
     303            1 :         const [relations, functions] = await Promise.all([
     304            1 :             registry!.loadConnectionFile(connectionId, STORAGE_CATALOG_RELATIONS),
     305            1 :             registry!.loadConnectionFile(connectionId, STORAGE_CATALOG_FUNCTIONS),
     306            1 :         ]);
     307            1 :         if (relations != null || functions != null) catalogs.push({ connectionId, relations, functions });
     308            1 :     }
     309            1 :     const data = await Promise.all((await backend.listDataFiles(sessionId))
     310            1 :         .map(async file => ({ name: file.name, bytes: await backend.loadDataFile(sessionId, file.name) })));
     311            1 :     const files = sanitizeSessionFiles(stored);
     312            1 :     const session: SessionData = { ...stored, scripts: files.scripts, attachments: files.attachments };
     313            1 :     return assembleSessionBundle({ session, catalogs }, scripts, data);
     314            1 : }
     315              : 
     316              : /// Write a fully parsed bundle to storage. This is deliberately not a replacement transaction:
     317              : /// callers replacing an existing session must stage and coordinate that operation separately.
     318            1 : export async function writeSessionBundle(
     319            1 :     bundle: SessionBundle,
     320            1 :     backend: StorageBackend,
     321            1 :     options: SessionBundleWriteOptions = {},
     322            1 : ): Promise<string> {
     323            1 :     const validation = validateSessionData(bundle.session);
     324            1 :     if (!validation.ok) {
     325            0 :         throw new Error(`Invalid source session: ${describeSessionValidationError(validation.error)}`);
     326              :     }
     327            1 :     const sessionId = options.targetSessionId ?? bundle.session.sessionId;
     328            1 :     if (!isValidUuid(sessionId)) {
     329            0 :         throw new Error(`Invalid target session id: ${sessionId}`);
     330              :     }
     331            1 :     if (options.targetIsFresh && options.targetSessionId == null) {
     332            0 :         throw new Error('targetIsFresh requires an explicit targetSessionId');
     333              :     }
     334            1 :     for (const file of bundle.data ?? []) validateDataFileName(file.name);
     335              : 
     336            1 :     const session: SessionData = {
     337            1 :         ...bundle.session,
     338            1 :         sessionId,
     339            1 :         scripts: reconcileSessionScripts(bundle.session.scripts ?? [], bundle.scripts.map(script => script.name),
     340            1 :             firstConnectionId(bundle.session)).scripts,
     341            1 :         attachments: sessionAttachments((bundle.data ?? []).map(file => file.name)),
     342            1 :     };
     343            1 :     if (backend.getBackendType() === StorageBackendType.OPFS) {
     344            1 :         delete session.sessionPath;
     345            1 :         delete session.storageType;
     346            1 :         delete session.nativePath;
     347            1 :     }
     348            1 :     if (options.suffixNameWithCopy) {
     349            0 :         const name = session.name?.trim();
     350            0 :         if (name) {
     351            0 :             session.name = `${name} (copy)`;
     352              :         }
     353            0 :     }
     354              : 
     355            1 :     try {
     356            1 :         await backend.saveSessionManifest(sessionId, session);
     357            1 :         for (const script of bundle.scripts) {
     358            1 :             await backend.saveScript(sessionId, script.name, script.sql);
     359              :         }
     360            1 :         for (const file of bundle.data ?? []) {
     361            1 :             await backend.saveDataFile(sessionId, file.name, file.bytes);
     362              :         }
     363            1 :     } catch (error) {
     364            1 :         if (options.targetIsFresh) {
     365            1 :             try {
     366            1 :                 await backend.deleteSession(sessionId);
     367            1 :             } catch {
     368              :                 // Preserve the write error when best-effort rollback also fails.
     369            0 :             }
     370              :         }
     371            1 :         throw error;
     372            1 :     }
     373              : 
     374            1 :     return sessionId;
     375            1 : }
     376              : 
     377              : /// Seed the catalogs of the connections from a bundle that was written as session `sessionId`.
     378              : ///
     379              : /// A connection that has a catalog keeps it, the catalog of a bundle only describes a connection that
     380              : /// is new to the app. Backends without connections are left alone.
     381            1 : export async function seedConnectionCatalogs(
     382            1 :     backend: StorageBackend,
     383            1 :     bundle: Pick<SessionBundle, 'session' | 'catalogs'>,
     384            1 :     sessionId: string,
     385            1 : ): Promise<void> {
     386            1 :     if (!isSessionRegistryBackend(backend)) return;
     387            1 :     for (const catalog of bundle.catalogs) {
     388            1 :         const files = [
     389            1 :             [STORAGE_CATALOG_RELATIONS, catalog.relations],
     390            1 :             [STORAGE_CATALOG_FUNCTIONS, catalog.functions],
     391            1 :         ] as const;
     392            1 :         const existing = await Promise.all(files.map(([fileName]) => backend.loadConnectionFile(catalog.connectionId, fileName)));
     393            1 :         if (existing.some(text => text != null)) continue;
     394            1 :         for (const [fileName, text] of files) {
     395            1 :             if (text == null) continue;
     396            1 :             await backend.saveConnectionFile(catalog.connectionId, fileName, text);
     397            1 :         }
     398            1 :     }
     399            1 : }
     400              : 
     401              : /// Compare all portable durable content. Used to verify a staged replacement before touching the
     402              : /// live session UUID.
     403            1 : export function sessionBundlesEqual(a: SessionBundle, b: SessionBundle): boolean {
     404            1 :     const { data: aData = [], ...aText } = a;
     405            1 :     const { data: bData = [], ...bText } = b;
     406            1 :     return JSON.stringify(aText) === JSON.stringify(bText)
     407            1 :         && aData.length === bData.length
     408            1 :         && aData.every((file, i) => file.name === bData[i].name
     409            1 :             && file.bytes.byteLength === bData[i].bytes.byteLength
     410            1 :             && file.bytes.every((byte, j) => byte === bData[i].bytes[j]));
     411            1 : }
        

Generated by: LCOV version 2.0-1