LCOV - code coverage report
Current view: top level - dashql-app/src/platform/hyperdb - hyperdb_wasm.ts Coverage Total Hit
Test: DashQL 915828b Lines: 96.8 % 498 482
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as arrow from 'apache-arrow';
       2              : import {
       3              :     HyperDB as HyperDBEngine,
       4              :     type HyperDBConnection as HyperDBEngineConnection,
       5              :     type MountedResource,
       6              :     type QueryInfo,
       7              : } from 'hyperdb-wasm';
       8              : 
       9              : import type {
      10              :     EmbeddedComputeDatabase,
      11              :     EmbeddedConnectionOptions,
      12              :     EmbeddedDatabaseAlias,
      13              :     EmbeddedExternalDirectoryDatabase,
      14              :     EmbeddedExternalFileDatabase,
      15              :     EmbeddedResultStream,
      16              :     ExternalDirectoryMode,
      17              :     EmbeddedTableImportConnection,
      18              :     EmbeddedTableInsertOptions,
      19              : } from '../database/embedded_database.js';
      20              : import { prepareHyperArrowInsert } from './hyperdb_arrow_insert.js';
      21              : 
      22              : export { HyperDBEngine };
      23              : export type { HyperDBSettings } from 'hyperdb-wasm';
      24              : /// The client of the engine worker. Tests share one between test frames, every frame wraps it in its own HyperDBEngine.
      25              : export type HyperDBClient = ConstructorParameters<typeof HyperDBEngine>[0];
      26              : /// A response of the engine client
      27              : export type HyperDBResult = Awaited<ReturnType<HyperDBClient['connect']>>;
      28              : 
      29              : /// The result of statements without result, Hyper sends no Arrow IPC for them.
      30            1 : const EMPTY_RESULT_IPC = arrow.tableToIPC(arrow.tableFromArrays({}), 'stream');
      31              : 
      32              : export interface HyperDBOptions {
      33              :     /// The database of the compute tables, created on startup and dropped on termination
      34              :     computeDatabasePath?: string;
      35              :     /// The database attached as "hyper", created on startup and dropped on termination
      36              :     defaultDatabasePath?: string;
      37              :     /// The database of the snippet search, attached as "__dashql_snippets", created on startup and dropped on termination
      38              :     snippetsDatabasePath?: string;
      39              :     /// Terminate the engine with the database. Tests share one engine client between databases.
      40              :     terminateEngine?: boolean;
      41              : }
      42              : 
      43              : /// The rows a statement affected.
      44              : /// Hyper reports a row count for every statement, but it only means affected rows for these kinds.
      45            1 : function readAffectedRowCount(info: QueryInfo | undefined): bigint | null {
      46            1 :     switch (info?.statementKind) {
      47            1 :         case 'COPY':
      48            1 :         case 'DELETE':
      49            1 :         case 'FETCH':
      50            1 :         case 'INSERT':
      51            1 :         case 'MERGE':
      52            1 :         case 'UPDATE':
      53            1 :             return info.rowCount;
      54            1 :         default:
      55            1 :             return null;
      56            1 :     }
      57            1 : }
      58              : 
      59            1 : async function* prependChunk(first: Uint8Array, rest: AsyncGenerator<Uint8Array, unknown>): AsyncGenerator<Uint8Array> {
      60            1 :     yield first;
      61            1 :     yield* rest;
      62            1 : }
      63              : 
      64            1 : async function* singleChunk(chunk: Uint8Array): AsyncGenerator<Uint8Array> {
      65            1 :     yield chunk;
      66            1 : }
      67              : 
      68            0 : function yieldToEventLoop(): Promise<void> {
      69            0 :     return new Promise(resolve => setTimeout(resolve, 0));
      70            0 : }
      71              : 
      72              : // Pooled connections stay attached to their default database when closed and are reused.
      73              : // ATTACH is expensive since Hyper loads the statistics of every table in the database, and the compute code
      74              : // opens a connection per statement. Connections are only pooled when the caller opts in.
      75              : // A session keeps the client context it was connected with, the sessions are pooled per client context.
      76            1 : const MAX_IDLE_SESSIONS = 4;
      77              : 
      78              : /// The key of the idle sessions with a default database and a client context
      79            1 : function poolKey(alias: string, clientContext: string | null): string {
      80            1 :     return clientContext == null ? alias : `${alias}:${clientContext}`;
      81            1 : }
      82              : 
      83            1 : export class HyperDB implements EmbeddedComputeDatabase, EmbeddedExternalFileDatabase, EmbeddedExternalDirectoryDatabase {
      84            1 :     private readonly sessions = new Set<HyperDBSession>();
      85              :     /// The idle pooled sessions by default database alias and client context, see poolKey()
      86            1 :     private readonly idleSessions = new Map<string, HyperDBSession[]>();
      87              :     /// The sessions of connect calls that are still attaching their database
      88            1 :     private readonly openingSessions = new Set<Promise<HyperDBSession>>();
      89            1 :     private initialization: Promise<void> | null = null;
      90            1 :     private termination: Promise<void> | null = null;
      91            1 :     private terminated = false;
      92            1 :     private databaseOperation: Promise<void> = Promise.resolve();
      93            1 :     private readonly terminateEngine: boolean;
      94            1 :     private readonly computeDatabasePath: string | null;
      95            1 :     private readonly defaultDatabasePath: string | null;
      96            1 :     private readonly snippetsDatabasePath: string | null;
      97            1 :     private readonly externalFiles = new Map<string, MountedResource>();
      98            1 :     private readonly externalDirectories = new Map<string, MountedResource>();
      99              : 
     100            1 :     constructor(
     101            1 :         private readonly engine: HyperDBEngine,
     102            1 :         options: HyperDBOptions = {},
     103            1 :     ) {
     104            1 :         this.computeDatabasePath = options.computeDatabasePath ?? null;
     105            1 :         this.defaultDatabasePath = options.defaultDatabasePath ?? null;
     106            1 :         this.snippetsDatabasePath = options.snippetsDatabasePath ?? null;
     107            1 :         this.terminateEngine = options.terminateEngine ?? true;
     108            1 :     }
     109              : 
     110            1 :     static async create(engine: HyperDBEngine, options?: HyperDBOptions): Promise<HyperDB> {
     111            1 :         const database = new HyperDB(engine, options);
     112            1 :         await database.initialize();
     113            1 :         return database;
     114            1 :     }
     115              : 
     116            1 :     async connect(options: EmbeddedConnectionOptions = {}): Promise<HyperDBConnection> {
     117            1 :         await this.initialize();
     118            1 :         if (this.termination || this.terminated) {
     119            1 :             throw new Error('database is terminated');
     120              :         }
     121            1 :         const alias = options.defaultDatabase ?? '__dashql_compute';
     122            1 :         const clientContext = options.clientContext == null ? null : JSON.stringify(options.clientContext);
     123            1 :         let pool: HyperDBSession[] | null = null;
     124            1 :         if (options.pooled) {
     125            1 :             const key = poolKey(alias, clientContext);
     126            1 :             pool = this.idleSessions.get(key) ?? [];
     127            1 :             this.idleSessions.set(key, pool);
     128            1 :         }
     129            1 :         const release = (session: HyperDBSession) => this.releaseSession(session, pool);
     130              : 
     131            1 :         const idle = pool?.pop();
     132            1 :         if (idle != null) {
     133            1 :             return new HyperDBConnection(idle, release);
     134              :         }
     135              : 
     136            1 :         const opening = this.openSession(this.databasePath(alias), alias, clientContext);
     137            1 :         this.openingSessions.add(opening);
     138            1 :         try {
     139            1 :             return new HyperDBConnection(await opening, release);
     140            1 :         } finally {
     141            1 :             this.openingSessions.delete(opening);
     142            1 :         }
     143            1 :     }
     144              : 
     145              :     /// The path of the database that is attached under an alias
     146            1 :     private databasePath(alias: EmbeddedDatabaseAlias): string | null {
     147            1 :         switch (alias) {
     148            1 :             case '__dashql_compute':
     149            1 :                 return this.computeDatabasePath;
     150            1 :             case 'hyper':
     151            1 :                 return this.defaultDatabasePath;
     152            1 :             case '__dashql_snippets':
     153            1 :                 return this.snippetsDatabasePath;
     154            1 :         }
     155            1 :     }
     156              : 
     157              :     /// The paths of the databases that are created on startup and dropped on termination
     158            1 :     private databasePaths(): string[] {
     159            1 :         return [this.computeDatabasePath, this.defaultDatabasePath, this.snippetsDatabasePath].filter(path => path != null);
     160            1 :     }
     161              : 
     162              :     /// Open an engine connection with the database attached under the alias.
     163              :     /// A termination waits for the opening sessions, they close themselves if it started while they were attaching.
     164            1 :     private async openSession(databasePath: string | null, alias: string, clientContext: string | null): Promise<HyperDBSession> {
     165              :         // Hyper logs the client context as ctx.client of the query events of the session
     166            1 :         const connection = await this.engine.connect(clientContext == null
     167            1 :             ? undefined
     168            1 :             : { settings: { external_client_context: clientContext } });
     169            1 :         if (this.termination || this.terminated) {
     170            1 :             await connection.close();
     171            1 :             throw new Error('database is terminated');
     172            1 :         }
     173              : 
     174            1 :         const session = new HyperDBSession(connection, this.mountFile, () => {
     175            1 :             this.sessions.delete(session);
     176            1 :         }, databasePath == null ? undefined : alias);
     177            1 :         try {
     178            1 :             if (databasePath != null) {
     179            1 :                 await session.execute(`ATTACH DATABASE ${quoteIdentifier(databasePath)} AS ${quoteIdentifier(alias)}`);
     180              :             }
     181            1 :             if (this.termination || this.terminated) {
     182            1 :                 throw new Error('database is terminated');
     183              :             }
     184            1 :         } catch (error) {
     185            1 :             await session.close();
     186            1 :             throw error;
     187            1 :         }
     188            1 :         this.sessions.add(session);
     189            1 :         return session;
     190            1 :     }
     191              : 
     192              :     /// Mount a file for a statement of a session
     193            1 :     private readonly mountFile = async (file: File, path: string): Promise<MountedResource> =>
     194            1 :         await this.engine.mount(file, { path });
     195              : 
     196            1 :     private async releaseSession(session: HyperDBSession, pool: HyperDBSession[] | null): Promise<void> {
     197            1 :         if (pool != null && session.reusable && !this.termination && !this.terminated
     198            1 :             && pool.length < MAX_IDLE_SESSIONS) {
     199            1 :             pool.push(session);
     200            1 :             return;
     201            1 :         }
     202            1 :         await session.close();
     203            1 :     }
     204              : 
     205            1 :     async getVersion(): Promise<string> {
     206            1 :         const connection = await this.connect();
     207            1 :         try {
     208            1 :             const result = await connection.query('SELECT version() AS version');
     209            1 :             const version = result.getChild('version')?.get(0);
     210            1 :             if (typeof version !== 'string') {
     211            0 :                 throw new Error('version query returned an invalid result');
     212              :             }
     213            1 :             return version;
     214            1 :         } finally {
     215            1 :             await connection.close();
     216            1 :         }
     217            1 :     }
     218              : 
     219            1 :     async registerExternalFile(name: string, blob: Blob, path?: string, signal?: AbortSignal): Promise<string> {
     220            1 :         return await this.runDatabaseOperation(async () => {
     221            1 :             throwIfAborted(signal);
     222            1 :             path ??= externalMountPath(name);
     223            1 :             validateExternalMountPath(path);
     224              :             // The engine wants a file name without separators, the last path component is one.
     225            1 :             const file = new File([blob], path.slice(path.lastIndexOf('/') + 1), { type: blob.type });
     226            1 :             const resource = await this.engine.mount(file, { path });
     227            1 :             if (signal?.aborted) {
     228            1 :                 try {
     229            1 :                     await resource.close();
     230            1 :                 } catch {
     231              :                     // Termination retries the removal.
     232            0 :                     this.externalFiles.set(path, resource);
     233            0 :                 }
     234            1 :                 throw signal.reason ?? new DOMException('The operation was aborted', 'AbortError');
     235            1 :             }
     236            1 :             this.externalFiles.set(path, resource);
     237            1 :             return path;
     238            1 :         });
     239            1 :     }
     240              : 
     241            1 :     async removeExternalFile(path: string): Promise<void> {
     242            1 :         await this.runDatabaseOperation(async () => {
     243            1 :             const resource = this.externalFiles.get(path);
     244            1 :             if (resource == null) throw new Error(`external file is not registered: ${path}`);
     245            1 :             await resource.close();
     246            1 :             this.externalFiles.delete(path);
     247            1 :         });
     248            1 :     }
     249              : 
     250            1 :     async registerExternalDirectory(
     251            1 :         handle: FileSystemDirectoryHandle,
     252            1 :         path: string | undefined,
     253            1 :         mode: ExternalDirectoryMode,
     254            1 :     ): Promise<string> {
     255            1 :         return await this.runDatabaseOperation(async () => {
     256            1 :             path ??= externalMountPath(handle.name);
     257            1 :             validateExternalMountPath(path);
     258            1 :             const resource = await this.engine.mount(handle, { path, mode });
     259            1 :             this.externalDirectories.set(path, resource);
     260            1 :             return path;
     261            1 :         });
     262            1 :     }
     263              : 
     264            1 :     async removeExternalDirectory(path: string): Promise<void> {
     265            1 :         await this.runDatabaseOperation(async () => {
     266            1 :             const resource = this.externalDirectories.get(path);
     267            1 :             if (resource == null) throw new Error(`external directory is not registered: ${path}`);
     268            1 :             await resource.close();
     269            1 :             this.externalDirectories.delete(path);
     270            1 :         });
     271            1 :     }
     272              : 
     273            1 :     async refreshExternalDirectory(path: string): Promise<void> {
     274            1 :         await this.runDatabaseOperation(async () => {
     275            1 :             const resource = this.externalDirectories.get(path);
     276            1 :             if (resource == null) throw new Error(`external directory is not registered: ${path}`);
     277            1 :             await resource.refresh();
     278            1 :         });
     279            1 :     }
     280              : 
     281            1 :     async terminate(): Promise<void> {
     282            1 :         if (this.terminated) {
     283            0 :             return;
     284              :         }
     285            1 :         if (!this.termination) {
     286            1 :             this.termination = (async () => {
     287            1 :                 try {
     288            1 :                     await this.databaseOperation;
     289            1 :                     await Promise.allSettled([...this.openingSessions]);
     290            1 :                     this.idleSessions.clear();
     291              :                     // A failed close must not leave the other closes running into a retried termination
     292            1 :                     const closes = await Promise.allSettled([...this.sessions].map(session => session.closeForTermination()));
     293            1 :                     const failed = closes.find(close => close.status === 'rejected');
     294            1 :                     if (failed) throw failed.reason;
     295            1 :                     for (const mounts of [this.externalFiles, this.externalDirectories]) {
     296            1 :                         for (const [path, resource] of mounts) {
     297            1 :                             await resource.close();
     298            1 :                             mounts.delete(path);
     299            1 :                         }
     300              :                     }
     301            1 :                     for (const path of this.databasePaths()) {
     302            1 :                         await this.executeDatabaseStatement(`DROP DATABASE ${quoteIdentifier(path)}`);
     303              :                     }
     304            1 :                     if (this.terminateEngine) {
     305            1 :                         await this.engine.terminate();
     306              :                     }
     307            1 :                     this.terminated = true;
     308            1 :                 } catch (error) {
     309            0 :                     this.termination = null;
     310            0 :                     throw error;
     311            0 :                 }
     312            1 :             })();
     313              :         }
     314            1 :         await this.termination;
     315            1 :     }
     316              : 
     317            1 :     private async initialize(): Promise<void> {
     318            1 :         if (this.terminated) {
     319            1 :             throw new Error('database is terminated');
     320              :         }
     321            1 :         if (!this.initialization) {
     322            1 :             this.initialization = (async () => {
     323            1 :                 await this.engine.ready();
     324            1 :                 for (const path of this.databasePaths()) {
     325            1 :                     await this.executeDatabaseStatement(`CREATE DATABASE ${quoteIdentifier(path)}`);
     326              :                 }
     327            1 :             })().catch(error => {
     328            0 :                 this.initialization = null;
     329            0 :                 throw error;
     330            1 :             });
     331              :         }
     332            1 :         await this.initialization;
     333            1 :     }
     334              : 
     335            1 :     private async runDatabaseOperation<T>(operation: () => Promise<T>): Promise<T> {
     336            1 :         const previous = this.databaseOperation;
     337            1 :         let release!: () => void;
     338            1 :         this.databaseOperation = new Promise(resolve => { release = resolve; });
     339            1 :         await previous;
     340            1 :         try {
     341            1 :             if (this.termination || this.terminated) throw new Error('database is terminated');
     342            1 :             return await operation();
     343            1 :         } finally {
     344            1 :             release();
     345            1 :         }
     346            1 :     }
     347              : 
     348            1 :     private async executeDatabaseStatement(statement: string): Promise<void> {
     349            1 :         const session = new HyperDBSession(await this.engine.connect(), this.mountFile, () => {});
     350            1 :         try {
     351            1 :             await session.execute(statement);
     352            1 :         } finally {
     353            1 :             await session.close();
     354            1 :         }
     355            1 :     }
     356            1 : }
     357              : 
     358            1 : function externalMountPath(name: string): string {
     359              :     // Mount paths allow only ASCII components and at most 200 bytes below /mnt/external/.
     360            1 :     const component = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
     361            1 :     return `/mnt/external/${!component || component === '.' || component === '..' ? 'file' : component}`;
     362            1 : }
     363              : 
     364            1 : function validateExternalMountPath(path: string): void {
     365              :     const prefix = '/mnt/external/';
     366            1 :     const relativePath = path.startsWith(prefix) ? path.slice(prefix.length) : '';
     367            1 :     if (
     368            1 :         relativePath.length === 0 ||
     369            1 :         new TextEncoder().encode(relativePath).byteLength > 200 ||
     370            1 :         relativePath.split('/').some(component => !/^[A-Za-z0-9._-]+$/.test(component) || component === '.' || component === '..')
     371              :     ) {
     372            1 :         throw new Error('external mount path must be below /mnt/external/ with ASCII path components');
     373              :     }
     374            1 : }
     375              : 
     376            1 : function throwIfAborted(signal?: AbortSignal): void {
     377            1 :     if (signal?.aborted) throw signal.reason ?? new DOMException('The operation was aborted', 'AbortError');
     378            1 : }
     379              : 
     380              : /// A connection lease handed out by HyperDB.connect().
     381              : /// Closing it releases the physical session, which HyperDB may hand to the next lease.
     382              : /// The lease stays closed, so a stale reference cannot run statements on a reused session.
     383            1 : export class HyperDBConnection implements EmbeddedTableImportConnection {
     384            1 :     private closed = false;
     385              : 
     386            1 :     constructor(
     387            1 :         private readonly session: HyperDBSession,
     388            1 :         private readonly release: (session: HyperDBSession) => Promise<void>,
     389            1 :     ) {}
     390              : 
     391            1 :     async close(): Promise<void> {
     392            1 :         if (this.closed) {
     393            1 :             return;
     394              :         }
     395            1 :         if (this.session.active) {
     396            1 :             throw new Error('connection has an active operation');
     397              :         }
     398            1 :         this.closed = true;
     399            1 :         await this.release(this.session);
     400            1 :     }
     401              : 
     402            1 :     async query(query: string, abort?: AbortSignal): Promise<arrow.Table> {
     403            1 :         this.checkOpen();
     404            1 :         return await this.session.query(query, abort);
     405            1 :     }
     406              : 
     407            1 :     async queryStream(query: string, abort?: AbortSignal): Promise<EmbeddedResultStream> {
     408            1 :         this.checkOpen();
     409            1 :         return await this.session.queryStream(query, abort);
     410            1 :     }
     411              : 
     412            1 :     async execute(statement: string, abort?: AbortSignal): Promise<bigint | null> {
     413            1 :         this.checkOpen();
     414            1 :         return await this.session.execute(statement, abort);
     415            1 :     }
     416              : 
     417            1 :     async insertArrowTable(table: arrow.Table, options: EmbeddedTableInsertOptions): Promise<void> {
     418            1 :         this.checkOpen();
     419            1 :         await this.session.insertArrowTable(table, options);
     420            1 :     }
     421              : 
     422            1 :     async createTableAs(name: string, query: string, abort?: AbortSignal): Promise<void> {
     423            1 :         this.checkOpen();
     424            1 :         await this.session.createTableAs(name, query, abort);
     425            1 :     }
     426              : 
     427            1 :     private checkOpen(): void {
     428            1 :         if (this.closed) {
     429            1 :             throw new Error('connection is closed');
     430              :         }
     431            1 :     }
     432            1 : }
     433              : 
     434              : /// The result batches of a session query.
     435              : /// Arrow readers never close their source, so the stream stops the query of the session itself.
     436            1 : class HyperDBResultStream implements EmbeddedResultStream {
     437            1 :     constructor(
     438              :         /// The reader of the Arrow IPC chunks
     439            1 :         private readonly reader: arrow.AsyncRecordBatchStreamReader,
     440              :         /// Stops the query and waits until the session is free
     441            1 :         private readonly stop: () => Promise<void>,
     442            1 :     ) {}
     443              : 
     444            1 :     get schema(): arrow.Schema {
     445            1 :         return this.reader.schema;
     446            1 :     }
     447              : 
     448            1 :     async next(): Promise<IteratorResult<arrow.RecordBatch>> {
     449            1 :         try {
     450            1 :             const next = await this.reader.next();
     451            1 :             if (next.done) await this.stop();
     452            1 :             return next;
     453            1 :         } catch (error) {
     454            1 :             await this.stop();
     455            1 :             throw error;
     456            1 :         }
     457            1 :     }
     458              : 
     459            1 :     async cancel(): Promise<void> {
     460            1 :         await this.stop();
     461            1 :     }
     462              : 
     463            1 :     async *[Symbol.asyncIterator](): AsyncGenerator<arrow.RecordBatch> {
     464            1 :         try {
     465            1 :             for (let next = await this.next(); !next.done; next = await this.next()) {
     466            1 :                 yield next.value;
     467              :             }
     468            1 :         } finally {
     469            1 :             await this.stop();
     470            1 :         }
     471            1 :     }
     472            1 : }
     473              : 
     474              : /// The running query of a session
     475              : interface HyperDBSessionQuery {
     476              :     /// The Arrow IPC chunks of the query
     477              :     chunks: AsyncGenerator<Uint8Array, QueryInfo | undefined>;
     478              :     /// Cancels the query when the session terminates
     479              :     cancellation: AbortController;
     480              : }
     481              : 
     482              : /// A physical engine connection.
     483            1 : class HyperDBSession {
     484            1 :     private closed = false;
     485              :     /// Set while an operation runs, the engine connection serves one at a time.
     486            1 :     active = false;
     487              :     /// Cleared when a query could not be cancelled or finished, the session must not be reused then.
     488            1 :     reusable = true;
     489            1 :     private closing: Promise<void> | null = null;
     490              :     /// The running query, if any
     491            1 :     private current: HyperDBSessionQuery | null = null;
     492              : 
     493            1 :     constructor(
     494            1 :         private readonly connection: HyperDBEngineConnection,
     495            1 :         private readonly mountFile: (file: File, path: string) => Promise<MountedResource>,
     496            1 :         private readonly onClose: () => void,
     497            1 :         private readonly databaseAlias?: string,
     498            1 :     ) {}
     499              : 
     500            1 :     async close(): Promise<void> {
     501            1 :         if (this.closed) {
     502            1 :             return;
     503              :         }
     504            1 :         if (this.active) {
     505            1 :             throw new Error('connection has an active operation');
     506              :         }
     507            1 :         await this.closeImpl();
     508            1 :     }
     509              : 
     510            1 :     async query(query: string, abort?: AbortSignal): Promise<arrow.Table> {
     511            1 :         const stream = await this.queryStream(query, abort);
     512            1 :         const batches: arrow.RecordBatch[] = [];
     513            1 :         for await (const batch of stream) {
     514            1 :             batches.push(batch);
     515              :         }
     516            1 :         return new arrow.Table(stream.schema, batches);
     517            1 :     }
     518              : 
     519            1 :     async queryStream(query: string, abort?: AbortSignal): Promise<EmbeddedResultStream> {
     520            1 :         this.begin();
     521            1 :         const chunks = this.startQuery(query, abort);
     522            1 :         const cancellation = this.current!.cancellation;
     523            1 :         const stop = async () => {
     524            1 :             cancellation.abort(new DOMException('The result stream was cancelled', 'AbortError'));
     525            1 :             await chunks.return(undefined).catch(() => {});
     526            1 :         };
     527            1 :         const first = await chunks.next();
     528            1 :         const source = first.done ? singleChunk(EMPTY_RESULT_IPC) : prependChunk(first.value, chunks);
     529            1 :         try {
     530            1 :             const reader = await arrow.RecordBatchReader.from(source);
     531            1 :             if (!reader.isAsync() || !reader.isStream()) {
     532            1 :                 throw new Error('query did not produce an Arrow stream');
     533              :             }
     534            1 :             await reader.open();
     535            1 :             return new HyperDBResultStream(reader, stop);
     536            1 :         } catch (error) {
     537            1 :             await stop();
     538            1 :             throw error;
     539            1 :         }
     540            1 :     }
     541              : 
     542            1 :     async execute(statement: string, abort?: AbortSignal): Promise<bigint | null> {
     543            1 :         this.begin();
     544            1 :         return await this.drain(this.startQuery(statement, abort));
     545            1 :     }
     546              : 
     547              :     /// Insert an Arrow table through an external scan of its Arrow IPC stream, mounted as a file.
     548            1 :     async insertArrowTable(table: arrow.Table, options: EmbeddedTableInsertOptions): Promise<void> {
     549            1 :         if (!options.name) {
     550            0 :             throw new Error('Arrow insert table name must not be empty');
     551              :         }
     552            1 :         const insert = prepareHyperArrowInsert(table);
     553            1 :         this.begin();
     554            1 :         let resource: MountedResource | null = null;
     555            1 :         try {
     556            1 :             const name = `__dashql_insert_${crypto.randomUUID().replace(/-/g, '_')}.arrows`;
     557            1 :             const path = `/mnt/external/${name}`;
     558              :             // tableToIPC writes into a fresh ArrayBuffer.
     559            1 :             const bytes = arrow.tableToIPC(insert.table, 'stream') as Uint8Array<ArrayBuffer>;
     560            1 :             resource = await this.mountFile(new File([bytes], name), path);
     561            1 :             const target = options.schema == null
     562            1 :                 ? quoteIdentifier(options.name)
     563            1 :                 : `${quoteIdentifier(options.schema)}.${quoteIdentifier(options.name)}`;
     564            1 :             const source = `SELECT * FROM external(${quoteLiteral(path)}, format => 'arrowstream', columns => descriptor(${insert.columns}))`;
     565            1 :             await this.drain(this.startQuery(
     566            1 :                 options.create ?? true ? `CREATE TABLE ${target} AS ${source}` : `INSERT INTO ${target} ${source}`,
     567            1 :             ));
     568            1 :         } finally {
     569            1 :             this.active = false;
     570            1 :             await resource?.close();
     571            1 :         }
     572            1 :     }
     573              : 
     574              :     /// Read the chunks of a statement to the end and return the rows it affected.
     575            1 :     private async drain(chunks: AsyncGenerator<Uint8Array, QueryInfo | undefined>): Promise<bigint | null> {
     576            1 :         let next = await chunks.next();
     577            1 :         while (!next.done) {
     578            1 :             next = await chunks.next();
     579              :         }
     580            1 :         return readAffectedRowCount(next.value);
     581            1 :     }
     582              : 
     583            1 :     async createTableAs(name: string, query: string, abort?: AbortSignal): Promise<void> {
     584            1 :         await this.execute(`CREATE TABLE ${quoteIdentifier(name)} AS ${query}`, abort);
     585            1 :     }
     586              : 
     587            1 :     async closeForTermination(): Promise<void> {
     588              :         // The running query of a close is its detach, aborting it would keep the database attached
     589            1 :         if (this.closing) {
     590            1 :             await this.closing;
     591            1 :             return;
     592            1 :         }
     593            1 :         const current = this.current;
     594            1 :         if (current != null) {
     595            1 :             current.cancellation.abort(new Error('database is terminating'));
     596            1 :             await current.chunks.return(undefined).catch(() => {});
     597              :             // A query that never started has nothing running in the engine.
     598            1 :             if (this.current === current) this.finishQuery();
     599            1 :         }
     600            1 :         while (this.active) {
     601            0 :             await yieldToEventLoop();
     602              :         }
     603            1 :         await this.closeImpl();
     604            1 :     }
     605              : 
     606              :     /// Start a query of the running operation, the returned chunks run it as they are read.
     607            1 :     private startQuery(query: string, abort?: AbortSignal): AsyncGenerator<Uint8Array, QueryInfo | undefined> {
     608            1 :         const cancellation = new AbortController();
     609            1 :         const signal = abort == null ? cancellation.signal : AbortSignal.any([abort, cancellation.signal]);
     610            1 :         const chunks = this.readChunks(query, signal);
     611            1 :         this.current = { chunks, cancellation };
     612            1 :         return chunks;
     613            1 :     }
     614              : 
     615            1 :     private async *readChunks(query: string, signal: AbortSignal): AsyncGenerator<Uint8Array, QueryInfo | undefined> {
     616            1 :         const stream = this.connection.queryStreamIPC(query, { signal });
     617            1 :         const chunks = stream[Symbol.asyncIterator]();
     618            1 :         try {
     619            1 :             for (;;) {
     620            1 :                 const next = await chunks.next();
     621            1 :                 if (next.done) break;
     622            1 :                 yield next.value;
     623            1 :             }
     624            1 :             return await stream.info;
     625            1 :         } finally {
     626              :             // An aborted scan rejects at once, return() waits until the engine connection finished the query.
     627            1 :             await chunks.return?.(undefined).catch(() => {});
     628              :             // The query information is only missing when the query could not be cancelled or finished.
     629            1 :             if (!signal.aborted && !await stream.info.then(() => true, () => false)) {
     630            1 :                 this.reusable = false;
     631              :             }
     632            1 :             this.finishQuery();
     633            1 :         }
     634            1 :     }
     635              : 
     636            1 :     private begin(): void {
     637            1 :         this.checkOpen();
     638            1 :         if (this.active) {
     639            1 :             throw new Error('connection has an active operation');
     640              :         }
     641            1 :         this.active = true;
     642            1 :     }
     643              : 
     644            1 :     private finishQuery(): void {
     645            1 :         this.current = null;
     646            1 :         this.active = false;
     647            1 :     }
     648              : 
     649            1 :     private async closeImpl(): Promise<void> {
     650            1 :         if (!this.closing) {
     651            1 :             this.closing = (async () => {
     652            1 :                 if (this.databaseAlias != null) {
     653            1 :                     await this.execute(`DETACH DATABASE ${quoteIdentifier(this.databaseAlias)}`);
     654              :                 }
     655            1 :                 await this.connection.close();
     656            1 :                 this.closed = true;
     657            1 :                 this.onClose();
     658            1 :             })().catch(error => {
     659            0 :                 this.closing = null;
     660            0 :                 throw error;
     661            1 :             });
     662              :         }
     663            1 :         await this.closing;
     664            1 :     }
     665              : 
     666            1 :     private checkOpen(): void {
     667            1 :         if (this.closed) {
     668            1 :             throw new Error('connection is closed');
     669              :         }
     670            1 :     }
     671            1 : }
     672              : 
     673            1 : function quoteLiteral(value: string): string {
     674            1 :     return `'${value.replace(/'/g, "''")}'`;
     675            1 : }
     676              : 
     677            1 : export function quoteIdentifier(value: string): string {
     678            1 :     return `"${value.replace(/"/g, '""')}"`;
     679            1 : }
        

Generated by: LCOV version 2.0-1