LCOV - code coverage report
Current view: top level - dashql-app/src/shell - api.ts Coverage Total Hit
Test: DashQL 915828b Lines: 91.2 % 566 516
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as shellModule from '@ankoh/dashql-shell-js';
       2              : import shellWasmUrl from '@ankoh/dashql-shell-wasm?url';
       3              : import { DashQL, DashQLCatalog, DashQLScript } from '@ankoh/dashql-core';
       4              : import * as dashql from '@ankoh/dashql-core';
       5              : import { stringifyError } from '../platform/logger/logger.js';
       6              : import { decodedContentLength } from '../utils/content_length.js';
       7              : 
       8            1 : export enum DashQLShellStatus {
       9            1 :     OK = 0,
      10            1 :     INVALID_ARGUMENT = 1,
      11            1 :     ARROW_ERROR = 2,
      12            1 :     INTERNAL_ERROR = 3,
      13            1 :     PENDING = 4,
      14            1 :     STALE_EFFECT = 5,
      15            1 :     BUSY = 6,
      16            1 : }
      17              : 
      18            1 : export enum DashQLShellEffectType {
      19            1 :     EXECUTE_QUERY = 1,
      20            1 :     EXECUTE_COMMAND = 2,
      21            1 : }
      22              : 
      23            1 : enum DashQLShellEffectCompletionStatus {
      24            1 :     SUCCESS = 0,
      25            1 :     ERROR = 1,
      26            1 :     CANCELLED = 2,
      27            1 : }
      28              : 
      29              : export interface DashQLShellEnvironment {
      30              :     executeQuery(
      31              :         query: string,
      32              :         signal?: AbortSignal,
      33              :         onProgress?: (message: string) => void,
      34              :         onResult?: (queryId: number, rowCount: number) => void,
      35              :         onTiming?: (label: string, elapsedMs: number) => void,
      36              :     ): Promise<Uint8Array>;
      37              : }
      38              : 
      39              : export interface DashQLShellCommandContext {
      40              :     readonly signal?: AbortSignal;
      41              :     readonly onProgress?: (message: string) => void;
      42              : }
      43              : 
      44              : export type DashQLShellCommandFunction = (
      45              :     args: readonly string[],
      46              :     context: DashQLShellCommandContext,
      47              : ) => string | void | Promise<string | void>;
      48              : 
      49              : export type DashQLShellCommand = readonly [
      50              :     name: string,
      51              :     description: string,
      52              :     execute: DashQLShellCommandFunction,
      53              : ];
      54              : 
      55              : interface DashQLShellOperation {
      56              :     status: DashQLShellStatus;
      57              :     /// The output, the error message or the effect description
      58              :     text: string;
      59              :     effect: DashQLShellEffect | null;
      60              : }
      61              : 
      62              : interface DashQLShellEffect {
      63              :     id: bigint;
      64              :     type: DashQLShellEffectType;
      65              :     payload: string;
      66              : }
      67              : 
      68              : interface DashQLShellEffectResult {
      69              :     status: DashQLShellEffectCompletionStatus;
      70              :     data: Uint8Array;
      71              : }
      72              : 
      73              : interface DashQLShellSettings {
      74              :     timer: boolean;
      75              : }
      76              : 
      77              : export interface DashQLShellOptions {
      78              :     environment: DashQLShellEnvironment;
      79              :     trackSessionRelations?: boolean;
      80              :     autoQualifyNonDefaultDatabaseTables?: boolean;
      81              :     commands?: readonly DashQLShellCommand[];
      82              :     terminalColumns?: number;
      83              :     onProgress?: (progress: DashQLShellInstantiationProgress) => void;
      84              :     wasmBinary?: Uint8Array;
      85              :     wasmUrl?: string | URL;
      86              : }
      87              : 
      88              : export interface DashQLShellInstantiationProgress {
      89              :     bytesLoaded: number;
      90              :     bytesTotal: number;
      91              : }
      92              : 
      93              : interface DashQLShellModuleCacheEntry {
      94              :     promise: Promise<void>;
      95              :     progress: DashQLShellInstantiationProgress | null;
      96              :     listeners: Set<(progress: DashQLShellInstantiationProgress) => void>;
      97              : }
      98              : 
      99            1 : const shellModuleGlobal = globalThis as typeof globalThis & {
     100              :     __dashqlShellModuleCache?: DashQLShellModuleCacheEntry;
     101              : };
     102              : 
     103              : /// Fetch the shell module, reporting the download progress
     104            1 : async function fetchShellWasm(
     105            1 :     wasmUrl: string,
     106            1 :     onProgress: (progress: DashQLShellInstantiationProgress) => void,
     107            1 : ): Promise<ArrayBuffer> {
     108            1 :     const response = await fetch(wasmUrl);
     109            1 :     if (!response.ok) throw new Error(`failed to fetch shell Wasm: ${response.status} ${response.statusText}`);
     110            1 :     const bytesTotal = decodedContentLength(response);
     111            1 :     let bytesLoaded = 0;
     112            1 :     onProgress({ bytesLoaded, bytesTotal });
     113            1 :     const body = response.body?.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
     114            1 :         transform(chunk, controller) {
     115            1 :             bytesLoaded += chunk.byteLength;
     116            1 :             onProgress({ bytesLoaded, bytesTotal });
     117            1 :             controller.enqueue(chunk);
     118            1 :         },
     119            1 :     }));
     120            1 :     return body == null ? await response.arrayBuffer() : await new Response(body).arrayBuffer();
     121            1 : }
     122              : 
     123              : /// Instantiate the wasm-bindgen shell module. It is instantiated once per page; later calls share it.
     124            1 : async function instantiateShellModule(options: DashQLShellOptions): Promise<void> {
     125            1 :     let entry = shellModuleGlobal.__dashqlShellModuleCache;
     126            1 :     if (entry == null) {
     127            1 :         const listeners = new Set<(progress: DashQLShellInstantiationProgress) => void>();
     128            1 :         const created: DashQLShellModuleCacheEntry = {
     129            1 :             progress: null,
     130            1 :             listeners,
     131            1 :             promise: Promise.resolve(),
     132            1 :         };
     133            1 :         const onProgress = (progress: DashQLShellInstantiationProgress) => {
     134            1 :             created.progress = progress;
     135            1 :             for (const listener of listeners) listener(progress);
     136            1 :         };
     137            1 :         const wasmUrl = options.wasmUrl?.toString() ?? shellWasmUrl;
     138            1 :         created.promise = (async () => {
     139            1 :             const moduleOrPath = options.wasmBinary ?? await fetchShellWasm(wasmUrl, onProgress);
     140            1 :             await shellModule.default({ module_or_path: moduleOrPath });
     141            1 :         })().catch(error => {
     142            1 :             if (shellModuleGlobal.__dashqlShellModuleCache === created) {
     143            1 :                 delete shellModuleGlobal.__dashqlShellModuleCache;
     144              :             }
     145            1 :             throw error;
     146            1 :         });
     147            1 :         shellModuleGlobal.__dashqlShellModuleCache = created;
     148            1 :         entry = created;
     149            1 :     }
     150            1 :     if (options.onProgress != null) {
     151            1 :         entry.listeners.add(options.onProgress);
     152            1 :         if (entry.progress != null) options.onProgress(entry.progress);
     153            1 :     }
     154            1 :     try {
     155            1 :         await entry.promise;
     156            1 :     } finally {
     157            1 :         if (options.onProgress != null) entry.listeners.delete(options.onProgress);
     158            1 :     }
     159            1 : }
     160              : 
     161              : export interface DashQLShellPrompt {
     162              :     revision: bigint;
     163              :     cursorByteOffset: number;
     164              :     text: string;
     165              :     message: string;
     166              :     action: DashQLShellPromptAction;
     167              : }
     168              : 
     169              : export interface DashQLShellTerminalOutput {
     170              :     action: DashQLShellPromptAction;
     171              :     data: string;
     172              : }
     173              : 
     174            1 : export enum DashQLShellPromptInput {
     175            1 :     TEXT = 0,
     176            1 :     ENTER = 1,
     177            1 :     FORCE_SUBMIT = 2,
     178            1 :     TAB = 3,
     179            1 :     BACKSPACE = 4,
     180            1 :     DELETE = 5,
     181            1 :     LEFT = 6,
     182            1 :     RIGHT = 7,
     183            1 :     HISTORY_PREVIOUS = 8,
     184            1 :     HISTORY_NEXT = 9,
     185            1 :     CANCEL = 10,
     186            1 :     ESCAPE = 11,
     187            1 :     UP = 12,
     188            1 :     DOWN = 13,
     189            1 :     START = 14,
     190            1 :     END = 15,
     191            1 :     LINE_START = 16,
     192            1 :     LINE_END = 17,
     193            1 :     WORD_LEFT = 18,
     194            1 :     WORD_RIGHT = 19,
     195            1 :     KILL_LINE = 20,
     196            1 :     BACKWARD_KILL_LINE = 21,
     197            1 :     KILL_WORD = 22,
     198            1 :     BACKWARD_KILL_WORD = 23,
     199            1 :     YANK = 24,
     200            1 :     TRANSPOSE = 25,
     201            1 :     CLEAR_SCREEN = 26,
     202            1 :     EOF = 27,
     203            1 :     ABORT = 28,
     204            1 : }
     205              : 
     206            1 : export enum DashQLShellPromptAction {
     207            1 :     NONE = 0,
     208            1 :     SUBMIT = 1,
     209            1 :     COMPLETE = 2,
     210            1 :     EXIT = 3,
     211            1 : }
     212              : 
     213              : export interface DashQLShellCompletionCandidate {
     214              :     displayText: string;
     215              :     completionText: string;
     216              :     targetOffset: number;
     217              :     targetLength: number;
     218              : }
     219              : 
     220            1 : export class DashQLShellError extends Error {
     221            1 :     constructor(
     222            1 :         public readonly status: DashQLShellStatus,
     223            1 :         message: string,
     224            1 :     ) {
     225            1 :         super(message);
     226            1 :         this.name = 'DashQLShellError';
     227            1 :     }
     228            1 : }
     229              : 
     230            1 : export class DashQLShell {
     231            1 :     protected readonly textDecoder = new TextDecoder();
     232            1 :     protected readonly textEncoder = new TextEncoder();
     233            1 :     protected shell: shellModule.Shell | null;
     234            1 :     protected readonly lifecycleAbort = new AbortController();
     235            1 :     protected activeExecution: AbortController | null = null;
     236            1 :     protected readonly catalogScripts: DashQLScript[] = [];
     237            1 :     protected readonly commands: Map<string, DashQLShellCommand>;
     238            1 :     protected readonly settings: DashQLShellSettings;
     239              : 
     240            1 :     protected constructor(
     241            1 :         public readonly core: DashQL,
     242            1 :         public readonly catalog: DashQLCatalog,
     243            1 :         protected readonly environment: DashQLShellEnvironment,
     244            1 :         terminalColumns: number,
     245            1 :         autoQualifyNonDefaultDatabaseTables: boolean,
     246            1 :         commands: Map<string, DashQLShellCommand>,
     247            1 :         settings: DashQLShellSettings,
     248            1 :     ) {
     249            1 :         this.commands = commands;
     250            1 :         this.settings = settings;
     251            1 :         this.shell = new shellModule.Shell(
     252            1 :             catalog.ptr.assertNotNull(),
     253            1 :             terminalColumns,
     254            1 :             autoQualifyNonDefaultDatabaseTables,
     255            1 :         );
     256            1 :         const status = this.shell.setCommands(Array.from(commands.keys()).join('\n')) as DashQLShellStatus;
     257            1 :         if (status !== DashQLShellStatus.OK) {
     258            0 :             this.shell.free();
     259            0 :             this.shell = null;
     260            0 :             throw new DashQLShellError(status, 'failed to configure shell commands');
     261            0 :         }
     262            1 :     }
     263              : 
     264            1 :     static async create(options: DashQLShellOptions): Promise<DashQLShell> {
     265            1 :         const settings: DashQLShellSettings = { timer: false };
     266            1 :         const commands = createShellCommands(options.commands ?? [], settings);
     267            1 :         await instantiateShellModule(options);
     268              :         // The shell module carries the core bindings, a shell takes catalogs of the same module
     269            1 :         const core = new DashQL({ ...shellModule });
     270            1 :         const catalog = core.createCatalog();
     271            1 :         const shell = new DashQLShell(
     272            1 :             core,
     273            1 :             catalog,
     274            1 :             options.environment,
     275            1 :             options.terminalColumns ?? 100,
     276            1 :             options.autoQualifyNonDefaultDatabaseTables ?? false,
     277            1 :             commands,
     278            1 :             settings,
     279            1 :         );
     280            1 :         if (options.trackSessionRelations) {
     281            1 :             const status = shell.alive().setSessionRelations(true) as DashQLShellStatus;
     282            1 :             if (status !== DashQLShellStatus.OK) {
     283            0 :                 shell.destroy();
     284            0 :                 throw new DashQLShellError(status, 'failed to enable session relation tracking');
     285            0 :             }
     286            1 :         }
     287            1 :         return shell;
     288            1 :     }
     289              : 
     290              :     /// Load a script of the connection catalog
     291            1 :     loadCatalogScript(text: string): DashQLScript {
     292            1 :         this.assertAlive();
     293            1 :         const script = this.core.createScript(this.catalog);
     294            1 :         try {
     295            1 :             script.replaceText(text);
     296            1 :             if (text.trim().length !== 0) {
     297            1 :                 script.analyze();
     298            1 :                 this.catalog.loadScript(script, dashql.buffers.catalog.CatalogLayer.CONNECTION);
     299            1 :             }
     300            1 :             this.catalogScripts.push(script);
     301            1 :             return script;
     302            1 :         } catch (error) {
     303            0 :             script.destroy();
     304            0 :             throw error;
     305            0 :         }
     306            1 :     }
     307              : 
     308            1 :     replaceCatalogScript(script: DashQLScript, text: string): void {
     309            1 :         this.assertAlive();
     310            1 :         script.replaceText(text);
     311            1 :         script.analyze();
     312            1 :         this.catalog.loadScript(script, dashql.buffers.catalog.CatalogLayer.CONNECTION);
     313            1 :     }
     314              : 
     315            1 :     resize(terminalColumns: number): void {
     316            1 :         this.alive().resize(terminalColumns);
     317            1 :     }
     318              : 
     319              :     /// Set the color of the terminal prompt as 0xRRGGBB, bold in the default foreground color if null
     320            1 :     setTerminalPromptColor(rgb: number | null): void {
     321            1 :         this.alive().setTerminalPromptColor(rgb ?? undefined);
     322            1 :     }
     323              : 
     324            1 :     setKeywordCase(keywordCase: dashql.buffers.formatting.KeywordCase): void {
     325            1 :         this.alive().setKeywordCase(keywordCase);
     326            1 :     }
     327              : 
     328            1 :     setPrompt(text: string): DashQLShellPrompt {
     329            1 :         return this.readPrompt(this.alive().setPrompt(text));
     330            1 :     }
     331              : 
     332            0 :     insertPrompt(text: string): DashQLShellPrompt {
     333            0 :         return this.readPrompt(this.alive().insertPrompt(text));
     334            0 :     }
     335              : 
     336            1 :     movePromptLeft(): DashQLShellPrompt {
     337            1 :         return this.readPrompt(this.alive().movePromptLeft());
     338            1 :     }
     339              : 
     340            1 :     movePromptRight(): DashQLShellPrompt {
     341            1 :         return this.readPrompt(this.alive().movePromptRight());
     342            1 :     }
     343              : 
     344            0 :     deletePromptBackward(): DashQLShellPrompt {
     345            0 :         return this.readPrompt(this.alive().deletePromptBackward());
     346            0 :     }
     347              : 
     348            0 :     deletePromptForward(): DashQLShellPrompt {
     349            0 :         return this.readPrompt(this.alive().deletePromptForward());
     350            0 :     }
     351              : 
     352            1 :     completePrompt(limit = 20): DashQLShellCompletionCandidate[] {
     353            1 :         const shell = this.alive();
     354            1 :         let candidates: shellModule.ShellCompletionCandidate[];
     355            1 :         try {
     356            1 :             candidates = shell.completePrompt(limit);
     357            1 :         } catch (error) {
     358            0 :             throw new DashQLShellError(DashQLShellStatus.INTERNAL_ERROR, 'failed to complete shell prompt');
     359            0 :         }
     360            1 :         return candidates.map(candidate => {
     361            1 :             try {
     362            1 :                 return {
     363            1 :                     displayText: candidate.displayText,
     364            1 :                     completionText: candidate.completionText,
     365            1 :                     targetOffset: candidate.targetOffset,
     366            1 :                     targetLength: candidate.targetLength,
     367            1 :                 };
     368            1 :             } finally {
     369            1 :                 candidate.free();
     370            1 :             }
     371            1 :         });
     372            1 :     }
     373              : 
     374            0 :     applyCompletion(candidate: DashQLShellCompletionCandidate): DashQLShellPrompt {
     375            0 :         return this.readPrompt(this.alive().applyCompletion(
     376            0 :             candidate.displayText,
     377            0 :             candidate.completionText,
     378            0 :             candidate.targetOffset,
     379            0 :             candidate.targetLength,
     380            0 :         ));
     381            0 :     }
     382              : 
     383            1 :     consumePromptInput(key: DashQLShellPromptInput, text = ''): DashQLShellPrompt {
     384            1 :         return this.readPrompt(this.alive().consumePromptInput(key, text));
     385            1 :     }
     386              : 
     387            1 :     openTerminal(prompt = 'dashql> '): DashQLShellTerminalOutput {
     388            1 :         return this.readTerminal(this.alive().openTerminal(prompt));
     389            1 :     }
     390              : 
     391            1 :     consumeTerminalInput(key: DashQLShellPromptInput, text = ''): DashQLShellTerminalOutput {
     392            1 :         return this.readTerminal(this.alive().consumeTerminalInput(key, text));
     393            1 :     }
     394              : 
     395            1 :     finishTerminalQuery(output: string, error = false): DashQLShellTerminalOutput {
     396            1 :         return this.readTerminal(this.alive().finishTerminalQuery(output, error));
     397            1 :     }
     398              : 
     399            1 :     renderTerminalQueryProgress(message = '', advanceFrame = false): DashQLShellTerminalOutput {
     400            1 :         return this.readTerminal(this.alive().renderTerminalQueryProgress(message, advanceFrame));
     401            1 :     }
     402              : 
     403            1 :     clearTerminalQueryProgress(): DashQLShellTerminalOutput {
     404            1 :         return this.readTerminal(this.alive().clearTerminalQueryProgress());
     405            1 :     }
     406              : 
     407            1 :     renderTerminalStatus(message: string): DashQLShellTerminalOutput {
     408            1 :         return this.readTerminal(this.alive().renderTerminalStatus(message));
     409            1 :     }
     410              : 
     411            1 :     exportHistory(): Uint8Array {
     412            1 :         return this.alive().exportHistory();
     413            1 :     }
     414              : 
     415            1 :     importHistory(data: Uint8Array): void {
     416            1 :         const status = this.alive().importHistory(data) as DashQLShellStatus;
     417            1 :         if (status !== DashQLShellStatus.OK) {
     418            0 :             throw new DashQLShellError(status, 'failed to import shell history');
     419              :         }
     420            1 :     }
     421              : 
     422            1 :     async submitPrompt(
     423            1 :         signal?: AbortSignal,
     424            1 :         onProgress?: (message: string) => void,
     425            1 :         onResult?: (queryId: number, rowCount: number) => void,
     426            1 :     ): Promise<string> {
     427            1 :         this.assertAlive();
     428            1 :         return await this.executeOperation(() => this.readOperation(this.alive().submitPrompt()), signal, onProgress, onResult);
     429            1 :     }
     430              : 
     431            1 :     async executeQuery(
     432            1 :         query: string,
     433            1 :         signal?: AbortSignal,
     434            1 :         onProgress?: (message: string) => void,
     435            1 :         onResult?: (queryId: number, rowCount: number) => void,
     436            1 :     ): Promise<string> {
     437            1 :         return await this.executeOperation(() => this.readOperation(this.alive().startQuery(query)), signal, onProgress, onResult);
     438            1 :     }
     439              : 
     440            1 :     protected async executeOperation(
     441            1 :         start: () => DashQLShellOperation,
     442            1 :         signal?: AbortSignal,
     443            1 :         onProgress?: (message: string) => void,
     444            1 :         onResult?: (queryId: number, rowCount: number) => void,
     445            1 :     ): Promise<string> {
     446            1 :         this.assertAlive();
     447            1 :         if (this.activeExecution != null) {
     448            0 :             throw new DashQLShellError(DashQLShellStatus.BUSY, 'the shell already has an active operation');
     449              :         }
     450            1 :         const executionAbort = new AbortController();
     451            1 :         this.activeExecution = executionAbort;
     452            1 :         const abortExecution = () => executionAbort.abort();
     453            1 :         this.lifecycleAbort.signal.addEventListener('abort', abortExecution, { once: true });
     454            1 :         signal?.addEventListener('abort', abortExecution, { once: true });
     455              : 
     456            1 :         try {
     457            1 :             let operation = start();
     458            1 :             let queryStartedAt: number | null = null;
     459            1 :             const queryTimings: Array<{ label: string; elapsedMs: number }> = [];
     460            1 :             while (operation.status === DashQLShellStatus.PENDING) {
     461            1 :                 const effect = this.requireEffect(operation);
     462            1 :                 if (effect.type === DashQLShellEffectType.EXECUTE_QUERY && this.settings.timer) {
     463            1 :                     queryStartedAt = performance.now();
     464              :                 }
     465            1 :                 const completion = await this.runEffect(
     466            1 :                     effect,
     467            1 :                     executionAbort.signal,
     468            1 :                     onProgress,
     469            1 :                     onResult,
     470            1 :                     queryStartedAt == null
     471            1 :                         ? undefined
     472            1 :                         : (label, elapsedMs) => { queryTimings.push({ label, elapsedMs }); },
     473            1 :                 );
     474            1 :                 if (this.shell == null) {
     475            1 :                     throw new DashQLShellError(DashQLShellStatus.STALE_EFFECT, 'DashQL shell was destroyed');
     476              :                 }
     477            1 :                 operation = completion.status === DashQLShellEffectCompletionStatus.CANCELLED
     478            1 :                     ? this.cancelEffect(effect.id)
     479            1 :                     : this.completeEffect(effect.id, completion.status, completion.data);
     480            1 :             }
     481            1 :             const output = this.requireComplete(operation);
     482            1 :             if (queryTimings.length === 0) return output;
     483            1 :             const separator = output.length === 0 || /(?:\r\n|\n|\r)$/.test(output) ? '' : '\r\n';
     484            1 :             const timingsByLabel = new Map<string, number>();
     485            1 :             for (const timing of queryTimings) {
     486            1 :                 timingsByLabel.set(timing.label, (timingsByLabel.get(timing.label) ?? 0) + timing.elapsedMs);
     487              :             }
     488            1 :             const timings = Array.from(timingsByLabel, ([label, elapsedMs]) => `${label}: ${formatElapsed(elapsedMs)}`)
     489            1 :                 .join(' | ');
     490            1 :             return `${output}${separator}${timings}\r\n`;
     491            1 :         } finally {
     492            1 :             this.lifecycleAbort.signal.removeEventListener('abort', abortExecution);
     493            1 :             signal?.removeEventListener('abort', abortExecution);
     494            1 :             if (this.activeExecution === executionAbort) {
     495            1 :                 this.activeExecution = null;
     496              :             }
     497            1 :         }
     498            1 :     }
     499              : 
     500            1 :     protected readPrompt(result: shellModule.ShellPromptResult): DashQLShellPrompt {
     501            1 :         try {
     502            1 :             const status = result.status as DashQLShellStatus;
     503            1 :             const message = result.message;
     504            1 :             if (status !== DashQLShellStatus.OK) {
     505            0 :                 throw new DashQLShellError(status, message);
     506              :             }
     507            1 :             return {
     508            1 :                 revision: result.revision,
     509            1 :                 cursorByteOffset: result.cursorByteOffset,
     510            1 :                 text: result.text,
     511            1 :                 message,
     512            1 :                 action: result.action as DashQLShellPromptAction,
     513            1 :             };
     514            1 :         } finally {
     515            1 :             result.free();
     516            1 :         }
     517            1 :     }
     518              : 
     519            1 :     protected readTerminal(result: shellModule.ShellTerminalResult): DashQLShellTerminalOutput {
     520            1 :         try {
     521            1 :             const status = result.status as DashQLShellStatus;
     522            1 :             const output = result.data;
     523            1 :             if (status !== DashQLShellStatus.OK) throw new DashQLShellError(status, output);
     524            1 :             return {
     525            1 :                 action: result.action as DashQLShellPromptAction,
     526            1 :                 data: output,
     527            1 :             };
     528            1 :         } finally {
     529            1 :             result.free();
     530            1 :         }
     531            1 :     }
     532              : 
     533            1 :     protected readOperation(operation: shellModule.ShellOperation): DashQLShellOperation {
     534            1 :         try {
     535            1 :             const status = operation.status as DashQLShellStatus;
     536            1 :             const effectId = operation.effectId;
     537            1 :             const effectType = operation.effectType;
     538            1 :             return {
     539            1 :                 status,
     540            1 :                 text: operation.text,
     541            1 :                 effect: status === DashQLShellStatus.PENDING && effectId != null && effectType != null
     542            1 :                     ? { id: effectId, type: effectType as DashQLShellEffectType, payload: operation.effectPayload ?? '' }
     543            1 :                     : null,
     544            1 :             };
     545            1 :         } finally {
     546            1 :             operation.free();
     547            1 :         }
     548            1 :     }
     549              : 
     550            1 :     protected async runEffect(
     551            1 :         effect: DashQLShellEffect,
     552            1 :         signal?: AbortSignal,
     553            1 :         onProgress?: (message: string) => void,
     554            1 :         onResult?: (queryId: number, rowCount: number) => void,
     555            1 :         onTiming?: (label: string, elapsedMs: number) => void,
     556            1 :     ): Promise<DashQLShellEffectResult> {
     557            1 :         if (
     558            1 :             effect.type !== DashQLShellEffectType.EXECUTE_QUERY &&
     559            1 :             effect.type !== DashQLShellEffectType.EXECUTE_COMMAND
     560              :         ) {
     561            0 :             return {
     562            0 :                 status: DashQLShellEffectCompletionStatus.ERROR,
     563            0 :                 data: this.textEncoder.encode(`unsupported shell effect ${effect.type}`),
     564            0 :             };
     565              :         }
     566              : 
     567            1 :         return await new Promise<DashQLShellEffectResult>((resolve) => {
     568            1 :             let settled = false;
     569            1 :             const finish = (completion: DashQLShellEffectResult) => {
     570            1 :                 if (settled) return;
     571            1 :                 settled = true;
     572            1 :                 signal?.removeEventListener('abort', onAbort);
     573            1 :                 resolve(completion);
     574            1 :             };
     575            1 :             const onAbort = () => finish({
     576            1 :                 status: DashQLShellEffectCompletionStatus.CANCELLED,
     577            1 :                 data: new Uint8Array(),
     578            1 :             });
     579            1 :             if (signal?.aborted) {
     580            0 :                 onAbort();
     581            0 :                 return;
     582            0 :             }
     583            1 :             signal?.addEventListener('abort', onAbort, { once: true });
     584              : 
     585            1 :             const effectInput = effect.payload;
     586            1 :             Promise.resolve()
     587            1 :                 .then(async () => {
     588            1 :                     if (effect.type === DashQLShellEffectType.EXECUTE_QUERY) {
     589            1 :                         return await this.environment.executeQuery(
     590            1 :                             effectInput,
     591            1 :                             signal,
     592            1 :                             onProgress,
     593            1 :                             onResult,
     594            1 :                             onTiming,
     595            1 :                         );
     596              :                     }
     597            1 :                     const command = await this.executeCommand(effectInput, signal, onProgress);
     598            1 :                     const output = this.textEncoder.encode(withTrailingNewline(command.output));
     599            1 :                     const encoded = new Uint8Array(output.byteLength + 1);
     600            1 :                     encoded[0] = command.clearTerminal ? 1 : 0;
     601            1 :                     encoded.set(output, 1);
     602            1 :                     return encoded;
     603            1 :                 })
     604            1 :                 .then(
     605            1 :                     data => finish({
     606            1 :                         status: DashQLShellEffectCompletionStatus.SUCCESS,
     607            1 :                         data,
     608            1 :                     }),
     609            1 :                     error => {
     610            1 :                         finish({
     611            1 :                             status: DashQLShellEffectCompletionStatus.ERROR,
     612            1 :                             data: this.textEncoder.encode(withTrailingNewline(stringifyError(error))),
     613            1 :                         });
     614            1 :                     },
     615            1 :                 );
     616            1 :         });
     617            1 :     }
     618              : 
     619            1 :     protected async executeCommand(
     620            1 :         input: string,
     621            1 :         signal?: AbortSignal,
     622            1 :         onProgress?: (message: string) => void,
     623            1 :     ): Promise<{ output: string; clearTerminal: boolean }> {
     624            1 :         const [name, ...args] = input.trim().substring(1).split(/\s+/);
     625            1 :         const command = this.commands.get(name.toLowerCase());
     626            1 :         if (command == null) throw new Error(`unknown command: .${name}`);
     627            1 :         return {
     628            1 :             output: await command[2](args, { signal, onProgress }) ?? '',
     629            1 :             clearTerminal: name.toLowerCase() === 'clear',
     630            1 :         };
     631            1 :     }
     632              : 
     633            1 :     protected completeEffect(
     634            1 :         effectId: bigint,
     635            1 :         status: DashQLShellEffectCompletionStatus,
     636            1 :         data: Uint8Array,
     637            1 :     ): DashQLShellOperation {
     638            1 :         return this.readOperation(this.alive().completeEffect(effectId, status, data));
     639            1 :     }
     640              : 
     641            1 :     protected cancelEffect(effectId: bigint): DashQLShellOperation {
     642            1 :         return this.readOperation(this.alive().cancelEffect(effectId));
     643            1 :     }
     644              : 
     645            1 :     protected requireComplete(operation: DashQLShellOperation): string {
     646            1 :         if (operation.status !== DashQLShellStatus.OK) {
     647            0 :             throw new DashQLShellError(operation.status, operation.text);
     648              :         }
     649            1 :         return operation.text;
     650            1 :     }
     651              : 
     652            1 :     protected requireEffect(operation: DashQLShellOperation): DashQLShellEffect {
     653            1 :         if (operation.status !== DashQLShellStatus.PENDING) {
     654            0 :             throw new DashQLShellError(operation.status, operation.text);
     655              :         }
     656            1 :         if (operation.effect == null) {
     657            0 :             throw new DashQLShellError(DashQLShellStatus.INTERNAL_ERROR, 'invalid shell effect envelope');
     658              :         }
     659            1 :         return operation.effect;
     660            1 :     }
     661              : 
     662            1 :     destroy(): void {
     663            1 :         if (this.shell != null) {
     664            1 :             this.lifecycleAbort.abort();
     665            1 :             this.shell.free();
     666            1 :             this.shell = null;
     667            1 :             for (let i = this.catalogScripts.length - 1; i >= 0; --i) {
     668            1 :                 this.catalogScripts[i].destroy();
     669              :             }
     670            1 :             this.catalogScripts.length = 0;
     671            1 :             this.catalog.destroy();
     672            1 :         }
     673            1 :     }
     674              : 
     675            1 :     protected alive(): shellModule.Shell {
     676            1 :         if (this.shell == null) {
     677            1 :             throw new DashQLShellError(DashQLShellStatus.INVALID_ARGUMENT, 'DashQL shell has been destroyed');
     678              :         }
     679            1 :         return this.shell;
     680            1 :     }
     681              : 
     682            1 :     protected assertAlive(): void {
     683            1 :         this.alive();
     684            1 :     }
     685            1 : }
     686              : 
     687            1 : function createShellCommands(
     688            1 :     extensions: readonly DashQLShellCommand[],
     689            1 :     settings: DashQLShellSettings,
     690            1 : ): Map<string, DashQLShellCommand> {
     691            1 :     const commands = new Map<string, DashQLShellCommand>();
     692            1 :     const register = (command: DashQLShellCommand) => {
     693            1 :         const name = command[0].toLowerCase();
     694            1 :         if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`invalid shell command name: ${command[0]}`);
     695            1 :         if (commands.has(name)) throw new Error(`duplicate shell command: .${name}`);
     696            1 :         commands.set(name, [name, command[1], command[2]]);
     697            1 :     };
     698            1 :     register(['clear', 'Clear the terminal screen', () => undefined]);
     699            1 :     register([
     700            1 :         'timer',
     701            1 :         'Set query timer: on or off',
     702            1 :         args => {
     703            1 :             if (args.length > 1 || (args.length === 1 && !['on', 'off'].includes(args[0]))) {
     704            1 :                 throw new Error('usage: .timer [on|off]');
     705              :             }
     706            1 :             if (args.length === 1) settings.timer = args[0] === 'on';
     707            1 :             return `Timer: ${settings.timer ? 'on' : 'off'}`;
     708            1 :         },
     709            1 :     ]);
     710            1 :     register([
     711            1 :         'help',
     712            1 :         'List available dot commands',
     713            1 :         () => {
     714            1 :             const width = Math.max(...Array.from(commands.keys(), name => name.length)) + 3;
     715            1 :             return Array.from(commands.values(), command => `.${command[0]}`.padEnd(width) + command[1]).join('\r\n') + '\r\n';
     716            1 :         },
     717            1 :     ]);
     718            1 :     for (const command of extensions) register(command);
     719            1 :     return commands;
     720            1 : }
     721              : 
     722            1 : function withTrailingNewline(output: string): string {
     723            1 :     if (output.length === 0) return output;
     724            1 :     return output.replace(/(?:\r\n|\n|\r)+$/, '') + '\r\n';
     725            1 : }
     726              : 
     727            1 : function formatElapsed(elapsedMs: number): string {
     728            1 :     if (elapsedMs < 1000) return `${Math.round(elapsedMs)} ms`;
     729            1 :     if (elapsedMs < 60_000) return `${(elapsedMs / 1000).toFixed(3)} s`;
     730            0 :     const totalSeconds = Math.floor(elapsedMs / 1000);
     731            0 :     const milliseconds = Math.floor(elapsedMs) % 1000;
     732            0 :     const seconds = totalSeconds % 60;
     733            0 :     const totalMinutes = Math.floor(totalSeconds / 60);
     734            0 :     const minutes = totalMinutes % 60;
     735            0 :     const hours = Math.floor(totalMinutes / 60);
     736            0 :     const suffix = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(milliseconds).padStart(3, '0')}`;
     737            0 :     return hours === 0 ? suffix : `${String(hours).padStart(2, '0')}:${suffix}`;
     738            1 : }
     739              : 
     740            1 : export async function createDashQLShell(options: DashQLShellOptions): Promise<DashQLShell> {
     741            1 :     return await DashQLShell.create(options);
     742            1 : }
        

Generated by: LCOV version 2.0-1