LCOV - code coverage report
Current view: top level - dashql-app/src/shell - api.ts Coverage Total Hit
Test: DashQL d805a4b Lines: 91.3 % 508 464
Test Date: 2026-11-09 20:22:13

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

Generated by: LCOV version 2.0-1