diff --git a/packages/duckdb-wasm/src/bindings/bindings_base.ts b/packages/duckdb-wasm/src/bindings/bindings_base.ts index f395bdb10..e61ebb7c5 100644 --- a/packages/duckdb-wasm/src/bindings/bindings_base.ts +++ b/packages/duckdb-wasm/src/bindings/bindings_base.ts @@ -1,5 +1,5 @@ import { DuckDBModule, PThread } from './duckdb_module'; -import { DuckDBConfig } from './config'; +import { DuckDBAccessMode, DuckDBConfig } from './config'; import { Logger } from '../log'; import { InstantiationProgress } from './progress'; import { DuckDBBindings } from './bindings_interface'; @@ -469,9 +469,9 @@ export abstract class DuckDBBindingsBase implements DuckDBBindings { } dropResponseBuffers(this.mod); } - public async prepareFileHandle(fileName: string, protocol: DuckDBDataProtocol): Promise { + public async prepareFileHandle(fileName: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode): Promise { if (protocol === DuckDBDataProtocol.BROWSER_FSACCESS && this._runtime.prepareFileHandles) { - const list = await this._runtime.prepareFileHandles([fileName], DuckDBDataProtocol.BROWSER_FSACCESS); + const list = await this._runtime.prepareFileHandles([fileName], DuckDBDataProtocol.BROWSER_FSACCESS, accessMode); for (const item of list) { const { handle, path: filePath, fromCached } = item; if (!fromCached && handle.getSize()) { @@ -482,10 +482,10 @@ export abstract class DuckDBBindingsBase implements DuckDBBindings { } throw new Error(`prepareFileHandle: unsupported protocol ${protocol}`); } - /** Prepare a file handle that could only be acquired aschronously */ - public async prepareDBFileHandle(path: string, protocol: DuckDBDataProtocol): Promise { + /** Prepare a file handle that could only be acquired asynchronously */ + public async prepareDBFileHandle(path: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode): Promise { if (protocol === DuckDBDataProtocol.BROWSER_FSACCESS && this._runtime.prepareDBFileHandle) { - const list = await this._runtime.prepareDBFileHandle(path, DuckDBDataProtocol.BROWSER_FSACCESS); + const list = await this._runtime.prepareDBFileHandle(path, DuckDBDataProtocol.BROWSER_FSACCESS, accessMode); for (const item of list) { const { handle, path: filePath, fromCached } = item; if (!fromCached && handle.getSize()) { diff --git a/packages/duckdb-wasm/src/bindings/bindings_interface.ts b/packages/duckdb-wasm/src/bindings/bindings_interface.ts index 271a42ef9..3a5e3049d 100644 --- a/packages/duckdb-wasm/src/bindings/bindings_interface.ts +++ b/packages/duckdb-wasm/src/bindings/bindings_interface.ts @@ -1,4 +1,4 @@ -import { DuckDBConfig, DuckDBConnection, DuckDBDataProtocol, FileStatistics, InstantiationProgress } from '.'; +import { DuckDBAccessMode, DuckDBConfig, DuckDBConnection, DuckDBDataProtocol, FileStatistics, InstantiationProgress } from '.'; import { CSVInsertOptions, JSONInsertOptions, ArrowInsertOptions } from './insert_options'; import { ScriptTokens } from './tokens'; import { WebFile } from './web_file'; @@ -54,8 +54,8 @@ export interface DuckDBBindings { protocol: DuckDBDataProtocol, directIO: boolean, ): Promise; - prepareFileHandle(path: string, protocol: DuckDBDataProtocol): Promise; - prepareDBFileHandle(path: string, protocol: DuckDBDataProtocol): Promise; + prepareFileHandle(path: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode): Promise; + prepareDBFileHandle(path: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode): Promise; globFiles(path: string): WebFile[]; dropFile(name: string): void; dropFiles(): void; diff --git a/packages/duckdb-wasm/src/bindings/runtime.ts b/packages/duckdb-wasm/src/bindings/runtime.ts index b720e6877..569af3cdd 100644 --- a/packages/duckdb-wasm/src/bindings/runtime.ts +++ b/packages/duckdb-wasm/src/bindings/runtime.ts @@ -1,7 +1,10 @@ +import { DuckDBAccessMode } from './config'; import { DuckDBModule } from './duckdb_module'; import { UDFFunction } from './udf_function'; import * as udf_rt from './udf_runtime'; +export { DuckDBAccessMode }; + /** Wrapper for TextDecoder to support shared array buffers */ function TextDecoderWrapper(): (input?: BufferSource) => string { const decoder = new TextDecoder(); @@ -156,10 +159,10 @@ export interface DuckDBRuntime { checkFile(mod: DuckDBModule, pathPtr: number, pathLen: number): boolean; removeFile(mod: DuckDBModule, pathPtr: number, pathLen: number): void; - // Prepare a file handle that could only be acquired aschronously - prepareFileHandle?: (path: string, protocol: DuckDBDataProtocol) => Promise; - prepareFileHandles?: (path: string[], protocol: DuckDBDataProtocol) => Promise; - prepareDBFileHandle?: (path: string, protocol: DuckDBDataProtocol) => Promise; + // Prepare a file handle that could only be acquired asynchronously + prepareFileHandle?: (path: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode) => Promise; + prepareFileHandles?: (path: string[], protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode) => Promise; + prepareDBFileHandle?: (path: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode) => Promise; // Call a scalar UDF function callScalarUDF( diff --git a/packages/duckdb-wasm/src/bindings/runtime_browser.ts b/packages/duckdb-wasm/src/bindings/runtime_browser.ts index 0b4ebedca..db86baea2 100644 --- a/packages/duckdb-wasm/src/bindings/runtime_browser.ts +++ b/packages/duckdb-wasm/src/bindings/runtime_browser.ts @@ -4,6 +4,7 @@ import {addS3Headers, getHTTPUrl} from '../utils'; import { callSRet, dropResponseBuffers, + DuckDBAccessMode, DuckDBDataProtocol, DuckDBFileInfo, DuckDBGlobalFileInfo, @@ -110,8 +111,11 @@ export const BROWSER_RUNTIME: DuckDBRuntime & { BROWSER_RUNTIME._opfsRoot = await navigator.storage.getDirectory(); } }, - /** Prepare a file handle that could only be acquired aschronously */ - async prepareFileHandles(filePaths: string[], protocol: DuckDBDataProtocol): Promise { + /** Prepare a file handle that could only be acquired asynchronously */ + async prepareFileHandles(filePaths: string[], protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode): Promise { + // DuckDBAccessMode.UNDEFINED will be treated as READ_WRITE + // See: https://github.com/duckdb/duckdb/blob/5f5512b827df6397afd31daedb4bbdee76520019/src/main/database.cpp#L442-L444 + const isReadOnly = accessMode !== undefined && accessMode === DuckDBAccessMode.READ_ONLY; if (protocol === DuckDBDataProtocol.BROWSER_FSACCESS) { await BROWSER_RUNTIME.assignOPFSRoot(); const prepare = async (path: string): Promise => { @@ -133,15 +137,21 @@ export const BROWSER_RUNTIME: DuckDBRuntime & { if (!fileName) { throw new Error(`Invalid path ${path}`); } - // mkdir -p - for (const folder of folders) { - dirHandle = await dirHandle.getDirectoryHandle(folder, { create: true }); + // Check read-only + if (!isReadOnly) { + // mkdir -p + for (const folder of folders) { + dirHandle = await dirHandle.getDirectoryHandle(folder, { create: true }); + } } } const fileHandle = await dirHandle.getFileHandle(fileName, { create: false }).catch(e => { if (e?.name === 'NotFoundError') { - console.debug(`File ${path} does not exists yet, creating...`); - return dirHandle.getFileHandle(fileName, { create: true }); + if (!isReadOnly) { + console.debug(`File ${path} does not exists yet, creating...`); + return dirHandle.getFileHandle(fileName, { create: true }); + } + console.debug(`File ${path} does not exists, aborting as we are in read-only mode`); } throw e; }); @@ -166,11 +176,11 @@ export const BROWSER_RUNTIME: DuckDBRuntime & { } throw new Error(`Unsupported protocol ${protocol} for paths ${filePaths} with protocol ${protocol}`); }, - /** Prepare a file handle that could only be acquired aschronously */ - async prepareDBFileHandle(dbPath: string, protocol: DuckDBDataProtocol): Promise { + /** Prepare a file handle that could only be acquired asynchronously */ + async prepareDBFileHandle(dbPath: string, protocol: DuckDBDataProtocol, accessMode?: DuckDBAccessMode): Promise { if (protocol === DuckDBDataProtocol.BROWSER_FSACCESS && this.prepareFileHandles) { const filePaths = [dbPath, `${dbPath}.wal`]; - return this.prepareFileHandles(filePaths, protocol); + return this.prepareFileHandles(filePaths, protocol, accessMode); } throw new Error(`Unsupported protocol ${protocol} for path ${dbPath} with protocol ${protocol}`); }, diff --git a/packages/duckdb-wasm/src/parallel/worker_dispatcher.ts b/packages/duckdb-wasm/src/parallel/worker_dispatcher.ts index 3a5a8f295..402b83e45 100644 --- a/packages/duckdb-wasm/src/parallel/worker_dispatcher.ts +++ b/packages/duckdb-wasm/src/parallel/worker_dispatcher.ts @@ -136,8 +136,9 @@ export abstract class AsyncDuckDBDispatcher implements Logger { case WorkerRequestType.OPEN: { const path = request.data.path; + const accessMode = request.data.accessMode; if (path?.startsWith('opfs://')) { - await this._bindings.prepareDBFileHandle(path, DuckDBDataProtocol.BROWSER_FSACCESS); + await this._bindings.prepareDBFileHandle(path, DuckDBDataProtocol.BROWSER_FSACCESS, accessMode); request.data.useDirectIO = true; } this._bindings.open(request.data); diff --git a/packages/duckdb-wasm/test/opfs.test.ts b/packages/duckdb-wasm/test/opfs.test.ts index eaf1a0fcc..af9abe392 100644 --- a/packages/duckdb-wasm/test/opfs.test.ts +++ b/packages/duckdb-wasm/test/opfs.test.ts @@ -278,30 +278,75 @@ export function testOPFS(baseDir: string, bundle: () => duckdb.DuckDBBundle): vo }); }); - async function removeFiles() { - const opfsRoot = await navigator.storage.getDirectory(); - await opfsRoot.removeEntry('test.db').catch(() => { - }); - await opfsRoot.removeEntry('test.db.wal').catch(() => { - }); - await opfsRoot.removeEntry('test.csv').catch(() => { - }); - await opfsRoot.removeEntry('test1.csv').catch(() => { - }); - await opfsRoot.removeEntry('test2.csv').catch(() => { - }); - await opfsRoot.removeEntry('test3.csv').catch(() => { + describe('Open database in OPFS', () => { + it('should not open a non-existent DB file in read-only', async () => { + const logger = new duckdb.ConsoleLogger(LogLevel.ERROR); + const worker = new Worker(bundle().mainWorker!); + const db_ = new duckdb.AsyncDuckDB(logger, worker); + await db_.instantiate(bundle().mainModule, bundle().pthreadWorker); + + await expectAsync(db_.open({ + path: 'opfs://non_existent.db', + accessMode: duckdb.DuckDBAccessMode.READ_ONLY, + })).toBeRejectedWithError(Error, /file or directory could not be found/); + + await db_.terminate(); + await worker.terminate(); + + // Files should not be found with DuckDBAccessMode.READ_ONLY + const opfsRoot = await navigator.storage.getDirectory(); + await expectAsync(opfsRoot.getFileHandle('non_existent.db', { create: false })) + .toBeRejectedWithError(Error, /file or directory could not be found/); }); - await opfsRoot.removeEntry('test.parquet').catch(() => { + + it('should open a non-existent DB file in read-write and create files', async () => { + const logger = new duckdb.ConsoleLogger(LogLevel.ERROR); + const worker = new Worker(bundle().mainWorker!); + const db_ = new duckdb.AsyncDuckDB(logger, worker); + await db_.instantiate(bundle().mainModule, bundle().pthreadWorker); + + const opfsRoot = await navigator.storage.getDirectory(); + + // Ensure files do not exist + await expectAsync(opfsRoot.getFileHandle('non_existent_2.db', { create: false })) + .toBeRejectedWithError(Error, /file or directory could not be found/); + await expectAsync(opfsRoot.getFileHandle('non_existent_2.db.wal', { create: false })) + .toBeRejectedWithError(Error, /file or directory could not be found/); + + await expectAsync(db_.open({ + path: 'opfs://non_existent_2.db', + accessMode: duckdb.DuckDBAccessMode.READ_WRITE, + })).toBeResolved(); + + await db_.terminate(); + await worker.terminate(); + + // Files should be found with DuckDBAccessMode.READ_WRITE + await expectAsync(opfsRoot.getFileHandle('non_existent_2.db', { create: false })).toBeResolved(); + await expectAsync(opfsRoot.getFileHandle('non_existent_2.db.wal', { create: false })).toBeResolved(); }); + }) + + async function removeFiles() { + const opfsRoot = await navigator.storage.getDirectory(); + await opfsRoot.removeEntry('test.db').catch(() => {}); + await opfsRoot.removeEntry('test.db.wal').catch(() => {}); + await opfsRoot.removeEntry('test.csv').catch(() => {}); + await opfsRoot.removeEntry('test1.csv').catch(() => {}); + await opfsRoot.removeEntry('test2.csv').catch(() => {}); + await opfsRoot.removeEntry('test3.csv').catch(() => {}); + await opfsRoot.removeEntry('test.parquet').catch(() => {}); try { const datadir = await opfsRoot.getDirectoryHandle('datadir'); - datadir.removeEntry('test.parquet').catch(() => { - }); + datadir.removeEntry('test.parquet').catch(() => {}); } catch (e) { // } - await opfsRoot.removeEntry('datadir').catch(() => { - }); + await opfsRoot.removeEntry('datadir').catch(() => {}); + // In case of failure caused leftovers + await opfsRoot.removeEntry('non_existent.db').catch(() => {}); + await opfsRoot.removeEntry('non_existent.db.wal').catch(() => {}); + await opfsRoot.removeEntry('non_existent_2.db').catch(() => {}); + await opfsRoot.removeEntry('non_existent_2.db.wal').catch(() => {}); } }