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