diff --git a/packages/react-duckdb/README.md b/packages/react-duckdb/README.md new file mode 100644 index 000000000..04b6e1657 --- /dev/null +++ b/packages/react-duckdb/README.md @@ -0,0 +1,89 @@ +# DuckDBProvider + +The `DuckDBProvider` is a React component that provides a connection to a DuckDB database using the `@duckdb/duckdb-wasm` library. It manages the database instance, connection pool, and allows establishing connections to the database. + +## Installation + +To use the `DuckDBProvider`, you need to install the required dependencies: + +``` +npm install @duckdb/duckdb-wasm +``` + +## Usage + +Wrap your application or the components that need access to the DuckDB database with the `DuckDBProvider`: + +```ts +import React from 'react'; +import { DuckDBProvider } from '@duckdb/react-duckdb'; + +const DUCKDB_BUNDLES: DuckDBBundles = { + mvp: { + mainModule: '/duckdb/duckdb-mvp.wasm', + mainWorker: '/duckdb/duckdb-browser-mvp.worker.js', + }, + eh: { + mainModule: '/duckdb/duckdb-eh.wasm', + mainWorker: '/duckdb/duckdb-browser-eh.worker.js', + }, + coi: { + mainModule: '/duckdb/duckdb-coi.wasm', + mainWorker: '/duckdb/duckdb-browser-coi.worker.js', + pthreadWorker: '/duckdb/duckdb-browser-coi.pthread.worker.js', + }, +}; + +function App() { + return {/* Your application components */}; +} +``` + +The `DuckDBProvider` requires the `bundles` prop, which is an object specifying the paths to the DuckDB WASM modules and workers. + +## Accessing the Database and Connections + +To access the DuckDB database instance and establish connections, use the `useDuckDB` hook within a component wrapped by the `DuckDBProvider`: + +```ts +import React from 'react'; +import { useDuckDB } from '@duckdb/react-duckdb'; + +function MyComponent() { + const { database, connection, isConnecting } = useDuckDB(); + + if (isConnecting) { + return
Establishing connection...
; + } + + if (!connection) { + return
No connection available
; + } + + // Use the connection to execute queries + // ... + + return
Connected to DuckDB!
; +} +``` + +The `useDuckDB` hook returns an object with the following properties: + +- `database`: The `AsyncDuckDB` instance (if available). +- `connection`: The established `AsyncDuckDBConnection` (if available). +- `isConnecting`: A boolean indicating if a connection is currently being established. + You can use the `connection` to execute queries and interact with the DuckDB database. + +## Configuration + +The `DuckDBProvider` accepts additional props for configuration: + +- `bundle`: The name of the bundle to use (if multiple bundles are provided). +- `logger`: A custom logger instance (default is `ConsoleLogger`). +- `config`: Additional configuration options for the DuckDB database. + +```ts + + {/* Your application components */} + +``` diff --git a/packages/react-duckdb/src/connection_provider.tsx b/packages/react-duckdb/src/connection_provider.tsx deleted file mode 100644 index 8119cbda8..000000000 --- a/packages/react-duckdb/src/connection_provider.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import * as imm from 'immutable'; -import * as duckdb from '@duckdb/duckdb-wasm'; -import { useDuckDB, useDuckDBResolver } from './database_provider'; - -type DialerFn = (id?: number) => void; - -export const poolCtx = React.createContext>(imm.Map()); -export const dialerCtx = React.createContext(null); - -export const useDuckDBConnection = (id?: number): duckdb.AsyncDuckDBConnection | null => - React.useContext(poolCtx)?.get(id || 0) || null; -export const useDuckDBConnectionDialer = (): DialerFn => React.useContext(dialerCtx)!; - -type DuckDBConnectionProps = { - /// The children - children: React.ReactElement | React.ReactElement[]; - /// The epoch - epoch?: number; -}; - -export const DuckDBConnectionProvider: React.FC = (props: DuckDBConnectionProps) => { - const db = useDuckDB(); - const resolveDB = useDuckDBResolver(); - const [pending, setPending] = React.useState>(imm.List()); - const [pool, setPool] = React.useState>(imm.Map()); - - const inFlight = React.useRef>(new Map()); - - // Resolve request assuming that the database is ready - const dialer = async (id: number) => { - if (inFlight.current.get(id)) { - return; - } - const conn = await db!.value!.connect(); - setPool(p => p.set(id, conn)); - inFlight.current.delete(id); - }; - - // Resolve request or remember as pending - const dialerCallback = React.useCallback( - (id?: number) => { - if (db.value != null) { - dialer(id || 0); - } else if (!db.resolving()) { - resolveDB(); - setPending(pending.push(id || 0)); - } - }, - [db], - ); - - // Process pending if possible - React.useEffect(() => { - if (db.value == null) { - return; - } - const claimed = pending; - setPending(imm.List()); - for (const id of claimed) { - dialer(id); - } - }, [db, pending]); - - return ( - - {props.children} - - ); -}; diff --git a/packages/react-duckdb/src/database_provider.tsx b/packages/react-duckdb/src/database_provider.tsx deleted file mode 100644 index 39e9fa97f..000000000 --- a/packages/react-duckdb/src/database_provider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import React, { ReactElement } from 'react'; -import * as duckdb from '@duckdb/duckdb-wasm'; -import { useDuckDBLogger, useDuckDBBundleResolver } from './platform_provider'; -import { Resolvable, Resolver } from './resolvable'; - -const setupCtx = React.createContext | null>(null); -const resolverCtx = React.createContext | null>(null); - -export const useDuckDB = (): Resolvable => - React.useContext(setupCtx)!; -export const useDuckDBResolver = (): Resolver => React.useContext(resolverCtx)!; - -type DuckDBProps = { - children: React.ReactElement | ReactElement[]; - config?: duckdb.DuckDBConfig; - value?: duckdb.AsyncDuckDB; -}; - -export const DuckDBProvider: React.FC = (props: DuckDBProps) => { - const logger = useDuckDBLogger(); - const resolveBundle = useDuckDBBundleResolver(); - const [setup, updateSetup] = React.useState>( - new Resolvable(), - ); - - const worker = React.useRef(null); - React.useEffect( - () => () => { - if (worker.current != null) { - worker.current.terminate(); - worker.current = null; - } - }, - [], - ); - - const inFlight = React.useRef | null>(null); - const resolver = React.useCallback(async () => { - // Run only once - if (inFlight.current) return await inFlight.current; - inFlight.current = (async () => { - // Resolve bundle - const bundle = await resolveBundle(); - if (bundle == null) { - updateSetup(s => s.failWith('invalid bundle')); - return null; - } - - // Create worker and next database - let worker: Worker; - let next: duckdb.AsyncDuckDB; - try { - worker = new Worker(bundle.mainWorker!); - next = new duckdb.AsyncDuckDB(logger, worker); - } catch (e: any) { - updateSetup(s => s.failWith(e)); - return null; - } - - // Instantiate the database asynchronously - try { - await next.instantiate(bundle.mainModule, bundle.pthreadWorker, (p: duckdb.InstantiationProgress) => { - try { - updateSetup(s => s.updateRunning(p)); - } catch (e: any) { - console.warn(`progress handler failed with error: ${e.toString()}`); - } - }); - if (props.config !== undefined) { - await next.open(props.config!); - } - } catch (e: any) { - updateSetup(s => s.failWith(e)); - return null; - } - updateSetup(s => s.completeWith(next)); - return next; - })(); - return await inFlight.current; - }, [logger]); - - return ( - - {props.children} - - ); -}; diff --git a/packages/react-duckdb/src/duckdb_provider.tsx b/packages/react-duckdb/src/duckdb_provider.tsx new file mode 100644 index 000000000..e8e6c00ed --- /dev/null +++ b/packages/react-duckdb/src/duckdb_provider.tsx @@ -0,0 +1,184 @@ +"use client"; + +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { + AsyncDuckDB, + type AsyncDuckDBConnection, + ConsoleLogger, + type DuckDBBundle, + type DuckDBBundles, + type DuckDBConfig, + type InstantiationProgress, + type Logger, + selectBundle, +} from '@duckdb/duckdb-wasm'; +import { Resolvable } from './resolvable'; + +function isDuckDBBundle(bundle: unknown): bundle is DuckDBBundle { + return ( + typeof bundle === 'object' && + bundle !== null && + 'mainModule' in bundle && + typeof bundle.mainModule === 'string' && + 'mainWorker' in bundle && + (typeof bundle.mainWorker === 'string' || bundle.mainWorker === null) && + 'pthreadWorker' in bundle && + (typeof bundle.pthreadWorker === 'string' || bundle.pthreadWorker === null) + ); +} + +type ConnectionPool = Record; + +type DuckDBContextValue = { + database: AsyncDuckDB | null; + connectionPool: ConnectionPool; + establishConnection: (id?: number) => Promise; +}; + +const DuckDBContext = createContext(null); + +// TODO: consider adding support for passing in an existing AsyncDuckDB instance +export type DuckDBProviderProps = { + children: React.ReactNode; + bundles: DuckDBBundles; + bundle?: keyof DuckDBBundles; + logger?: Logger; + config?: DuckDBConfig; +}; + +export function DuckDBProvider({ + children, + bundles, + bundle: bundleName, + logger = new ConsoleLogger(), + config, +}: DuckDBProviderProps) { + const [bundle, setBundle] = useState>(new Resolvable()); + const [database, setDatabase] = useState>(new Resolvable()); + const [connectionPool, setConnectionPool] = useState({}); + + const resolveBundle = useCallback(async () => { + if (bundle.isResolving) return; + + try { + setBundle(bundle.updateRunning()); + const selectedBundle = bundleName ? bundles[bundleName] : await selectBundle(bundles); + if (isDuckDBBundle(selectedBundle)) { + setBundle(bundle.completeWith(selectedBundle)); + } else { + throw new Error('No valid bundle selected'); + } + } catch (error) { + setBundle(bundle.failWith(JSON.stringify(error))); + } + }, [bundle, bundleName]); + + const resolveDatabase = useCallback(async () => { + if (database.isResolving || bundle.value == null) return; + + try { + const worker = new Worker(bundle.value.mainWorker!); + const duckdb = new AsyncDuckDB(logger, worker); + setDatabase(database.updateRunning()); + + await duckdb.instantiate( + bundle.value.mainModule, + bundle.value.pthreadWorker, + (progress: InstantiationProgress) => { + setDatabase(database.updateRunning(progress)); + }, + ); + + if (config) await duckdb.open(config); + setDatabase(database.completeWith(duckdb)); + } catch (error) { + setDatabase(database.failWith(JSON.stringify(error))); + } + }, [database, bundle.value, config]); + + useEffect(() => { + resolveBundle(); + }, [resolveBundle]); + + useEffect(() => { + if (bundle.value) resolveDatabase(); + }, [resolveDatabase, bundle.value]); + + const establishConnection = useCallback( + async (connectionId?: number) => { + // If the database is not available, return early + if (!database.value) return; + + // Establish a new connection using the database instance + const conn = await database.value.connect(); + + // Update the connection pool by adding the new connection with the given ID + setConnectionPool((prevPool: ConnectionPool) => ({ + ...prevPool, + // Use the provided ID if it exists, otherwise use 0 as the default ID + // This allows establishing a connection with a specific ID or using 0 as a fallback + [connectionId || 0]: conn, + })); + }, + [database], + ); + + return ( + + {children} + + ); +} + +/** + * Establish a connection to DuckDB by id, or, if no id is provided, from the pool. + * + * @param connectionId Optional ID of the connection to retrieve from the pool. + * @returns An object containing the `database` instance, `connection` (if available), and a boolean `isConnecting` indicating if a connection is being established. + * + * @example + * const { database, connection, isConnecting } = useDuckDB(); + * if (isConnecting) { + * // Handle the case when a connection is being established + * } else if (connection) { + * // Use the established connection + * } else { + * // Handle the case when no connection is available + * } + * if (database) { + * // Use the AsyncDuckDB instance + * } + */ +export function useDuckDB(connectionId?: number): { + database: AsyncDuckDB | null; + connection: AsyncDuckDBConnection | null; + isConnecting: boolean; +} { + const context = useContext(DuckDBContext); + if (!context) { + throw new Error("useDuckDB must be used within a DuckDBProvider"); + } + + const { database, connectionPool, establishConnection } = context; + + // Check if a connection exists in the pool for the given ID + const connection: AsyncDuckDBConnection | null = + connectionPool[connectionId || 0] || null; + + // Determine if a connection is currently being established + const isConnecting = !connection && !connectionPool[connectionId || 0]; + + useEffect(() => { + // If no connection exists and it's not currently being established, + // trigger the establishConnection function to create a new connection + if (isConnecting) establishConnection(connectionId); + }, [connectionId, isConnecting, establishConnection]); + + return { database, connection, isConnecting }; +} diff --git a/packages/react-duckdb/src/platform_provider.tsx b/packages/react-duckdb/src/platform_provider.tsx deleted file mode 100644 index a96d0b93b..000000000 --- a/packages/react-duckdb/src/platform_provider.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import * as duckdb from '@duckdb/duckdb-wasm'; -import { Resolvable, Resolver } from './resolvable'; - -type PlatformProps = { - children: React.ReactElement | React.ReactElement[]; - logger: duckdb.Logger; - bundles: duckdb.DuckDBBundles; -}; - -const loggerCtx = React.createContext(null); -const bundleCtx = React.createContext | null>(null); -const resolverCtx = React.createContext | null>(null); -export const useDuckDBLogger = (): duckdb.Logger => React.useContext(loggerCtx)!; -export const useDuckDBBundle = (): Resolvable => React.useContext(bundleCtx)!; -export const useDuckDBBundleResolver = (): Resolver => React.useContext(resolverCtx)!; - -export const DuckDBPlatform: React.FC = (props: PlatformProps) => { - const [bundle, setBundle] = React.useState>(new Resolvable()); - - const inFlight = React.useRef | null>(null); - const resolver = React.useCallback(async () => { - if (bundle.error) return null; - if (bundle.value) return bundle.value; - if (inFlight.current) return await inFlight.current; - inFlight.current = (async () => { - try { - const params = new URLSearchParams(window.location.search); - const bundleName = params.get('bundle') as keyof duckdb.DuckDBBundles | null; - setBundle(b => b.updateRunning()); - - const bundle = bundleName !== null ? props.bundles[bundleName] : null; - const next = (bundle || (await duckdb.selectBundle(props.bundles))) as duckdb.DuckDBBundle; - inFlight.current = null; - setBundle(b => b.completeWith(next)); - return next; - } catch (e: any) { - inFlight.current = null; - console.error(e); - setBundle(b => b.failWith(e)); - return null; - } - })(); - return await inFlight.current; - }, [props.bundles]); - - return ( - - - {props.children} - - - ); -}; diff --git a/packages/react-duckdb/src/resolvable.ts b/packages/react-duckdb/src/resolvable.ts index 7063afc1a..a552316dd 100644 --- a/packages/react-duckdb/src/resolvable.ts +++ b/packages/react-duckdb/src/resolvable.ts @@ -1,3 +1,7 @@ +// Enum representing the different statuses of a Resolvable +/** + * Enum representing the different statuses of an async operation + */ export enum ResolvableStatus { NONE, RUNNING, @@ -5,31 +9,50 @@ export enum ResolvableStatus { COMPLETED, } -export type Resolver = () => Promise; - +/** + * Utility class for managing asynchronous operations with status, value, error, and progress + */ export class Resolvable { - public readonly status: ResolvableStatus; - public readonly value: Value | null; - public readonly error: Error | null; - public readonly progress: Progress | null; + constructor( + public readonly status: ResolvableStatus = ResolvableStatus.NONE, + public readonly value: Value | null = null, + public readonly error: Error | null = null, + public readonly progress: Progress | null = null, + ) {} - constructor(status?: ResolvableStatus, value?: Value | null, error?: Error | null, progress?: Progress | null) { - this.status = status || ResolvableStatus.NONE; - this.value = value || null; - this.error = error || null; - this.progress = progress || null; + /** + * Method to check if the async operation is currently resolving (status is not NONE) + */ + get isResolving(): boolean { + return this.status !== ResolvableStatus.NONE; } - public resolving(): boolean { - return this.status != ResolvableStatus.NONE; - } - public completeWith(value: Value, progress: Progress | null = null): Resolvable { + /** + * Method to create a new async instance with a COMPLETED status and the provided value and progress + * @param value - The value to set for the completed async operation + * @param progress - Optional progress value to set for the completed async operation + * @returns A new async instance with a COMPLETED status + */ + completeWith(value: Value, progress: Progress | null = null): Resolvable { return new Resolvable(ResolvableStatus.COMPLETED, value, this.error, progress); } - public failWith(error: Error, progress: Progress | null = null): Resolvable { + + /** + * Method to create a new async instance with a FAILED status and the provided error and progress + * @param error - The error to set for the failed async operation + * @param progress - Optional progress value to set for the failed async operation + * @returns A new async instance with a FAILED status + */ + failWith(error: Error, progress: Progress | null = null): Resolvable { return new Resolvable(ResolvableStatus.FAILED, this.value, error, progress); } - public updateRunning(progress: Progress | null = null): Resolvable { + + /** + * Method to create a new async instance with a RUNNING status and the provided progress + * @param progress - Optional progress value to set for the running async operation + * @returns A new async instance with a RUNNING status + */ + updateRunning(progress: Progress | null = null): Resolvable { return new Resolvable(ResolvableStatus.RUNNING, this.value, this.error, progress); } }