@statewalker/db-duckdb-browser 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kotelnikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # @statewalker/db-duckdb-browser
2
+
3
+ DuckDB WASM driver implementing `@statewalker/db-api` for browser environments.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @statewalker/db-duckdb-browser
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { createDuckDbBrowserClient } from "@statewalker/db-duckdb-browser";
15
+
16
+ const db = await createDuckDbBrowserClient();
17
+ await db.execute("CREATE TABLE t (x INTEGER)");
18
+ ```
19
+
20
+ ## API
21
+
22
+ - `createDuckDbBrowserClient(options?)` — spins up an in-browser DuckDB WASM instance and returns a `DbClient`.
23
+
24
+ ## Related
25
+
26
+ - `@statewalker/db-api` — interface contract.
27
+ - `@statewalker/db-duckdb-node` — Node-side counterpart.
@@ -0,0 +1,28 @@
1
+ import * as duckdb from "@duckdb/duckdb-wasm";
2
+ import { Db, Db as Db$1, DbEntry, DbOptions, DbOptions as DbOptions$1 } from "@statewalker/db-api";
3
+ //#region src/browser-duckdb.d.ts
4
+ /** Browser-specific options for {@link newBrowserDuckDb}. */
5
+ interface BrowserDuckDbOptions extends DbOptions$1 {
6
+ /**
7
+ * Self-hosted DuckDB bundle URLs (served same-origin, e.g. via a bundler's
8
+ * `?url` import). Passing these makes the factory spawn a **same-origin**
9
+ * worker, which OPFS database persistence requires — a cross-origin worker
10
+ * (the jsDelivr fallback, loaded via a Blob `importScripts` shim) crashes on
11
+ * OPFS file I/O. Omit to load from jsDelivr (in-memory use only).
12
+ */
13
+ bundles?: duckdb.DuckDBBundles;
14
+ }
15
+ /**
16
+ * Create a DuckDB-backed {@link Db} using WebAssembly in the browser.
17
+ *
18
+ * With `options.path` set to an `opfs://` URL **and** self-hosted
19
+ * `options.bundles`, the database persists across reloads on OPFS. Otherwise it
20
+ * runs in-memory.
21
+ *
22
+ * @param options.path `opfs://` path for persistent storage. Omit for in-memory.
23
+ * @param options.bundles Same-origin bundle URLs (required for OPFS).
24
+ */
25
+ declare function newBrowserDuckDb(options?: BrowserDuckDbOptions): Promise<Db$1>;
26
+ //#endregion
27
+ export { type BrowserDuckDbOptions, type Db, type DbEntry, type DbOptions, newBrowserDuckDb };
28
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/browser-duckdb.ts"],"mappings":";;;;UAoBiB,6BAA6B;;;;;;;;EAQ5C,UAAU,OAAO;;;;;;;;;;;;iBAaG,iBAAiB,UAAU,uBAAuB,QAAQ"}
package/dist/index.mjs ADDED
@@ -0,0 +1,67 @@
1
+ import * as duckdb from "@duckdb/duckdb-wasm";
2
+ //#region src/browser-duckdb.ts
3
+ /**
4
+ * Convert an Arrow Table (returned by duckdb-wasm queries) to plain JS objects.
5
+ */
6
+ function arrowToObjects(table) {
7
+ const fields = table.schema.fields.map((f) => f.name);
8
+ return table.toArray().map((row) => {
9
+ const obj = {};
10
+ for (const field of fields) obj[field] = row[field];
11
+ return obj;
12
+ });
13
+ }
14
+ /**
15
+ * Create a DuckDB-backed {@link Db} using WebAssembly in the browser.
16
+ *
17
+ * With `options.path` set to an `opfs://` URL **and** self-hosted
18
+ * `options.bundles`, the database persists across reloads on OPFS. Otherwise it
19
+ * runs in-memory.
20
+ *
21
+ * @param options.path `opfs://` path for persistent storage. Omit for in-memory.
22
+ * @param options.bundles Same-origin bundle URLs (required for OPFS).
23
+ */
24
+ async function newBrowserDuckDb(options) {
25
+ const selfHosted = options?.bundles;
26
+ const bundle = await duckdb.selectBundle(selfHosted ?? duckdb.getJsDelivrBundles());
27
+ let worker;
28
+ let blobUrl;
29
+ if (selfHosted) worker = new Worker(bundle.mainWorker);
30
+ else {
31
+ blobUrl = URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker ?? ""}");`], { type: "text/javascript" }));
32
+ worker = new Worker(blobUrl);
33
+ }
34
+ const logger = new duckdb.ConsoleLogger();
35
+ const db = new duckdb.AsyncDuckDB(logger, worker);
36
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
37
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
38
+ const persistent = Boolean(options?.path) && typeof navigator !== "undefined" && Boolean(navigator.storage);
39
+ if (options?.path && persistent) try {
40
+ await db.open({
41
+ path: options.path,
42
+ accessMode: duckdb.DuckDBAccessMode.READ_WRITE
43
+ });
44
+ } catch {}
45
+ const conn = await db.connect();
46
+ return {
47
+ async query(sql, params) {
48
+ if (params && params.length > 0) return arrowToObjects(await (await conn.prepare(sql)).query(...params));
49
+ return arrowToObjects(await conn.query(sql));
50
+ },
51
+ async exec(sql) {
52
+ await conn.query(sql);
53
+ },
54
+ async flush() {
55
+ if (persistent) await conn.query("CHECKPOINT");
56
+ },
57
+ async close() {
58
+ await conn.close();
59
+ await db.terminate();
60
+ worker.terminate();
61
+ }
62
+ };
63
+ }
64
+ //#endregion
65
+ export { newBrowserDuckDb };
66
+
67
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/browser-duckdb.ts"],"sourcesContent":["import * as duckdb from \"@duckdb/duckdb-wasm\";\nimport type { Db, DbEntry, DbOptions } from \"@statewalker/db-api\";\n\ntype Table = Awaited<ReturnType<duckdb.AsyncPreparedStatement[\"query\"]>>;\n\n/**\n * Convert an Arrow Table (returned by duckdb-wasm queries) to plain JS objects.\n */\nfunction arrowToObjects<T>(table: Table): T[] {\n const fields = table.schema.fields.map((f: { name: string }) => f.name);\n return table.toArray().map((row: Record<string, unknown>) => {\n const obj: Record<string, unknown> = {};\n for (const field of fields) {\n obj[field] = row[field];\n }\n return obj as T;\n });\n}\n\n/** Browser-specific options for {@link newBrowserDuckDb}. */\nexport interface BrowserDuckDbOptions extends DbOptions {\n /**\n * Self-hosted DuckDB bundle URLs (served same-origin, e.g. via a bundler's\n * `?url` import). Passing these makes the factory spawn a **same-origin**\n * worker, which OPFS database persistence requires — a cross-origin worker\n * (the jsDelivr fallback, loaded via a Blob `importScripts` shim) crashes on\n * OPFS file I/O. Omit to load from jsDelivr (in-memory use only).\n */\n bundles?: duckdb.DuckDBBundles;\n}\n\n/**\n * Create a DuckDB-backed {@link Db} using WebAssembly in the browser.\n *\n * With `options.path` set to an `opfs://` URL **and** self-hosted\n * `options.bundles`, the database persists across reloads on OPFS. Otherwise it\n * runs in-memory.\n *\n * @param options.path `opfs://` path for persistent storage. Omit for in-memory.\n * @param options.bundles Same-origin bundle URLs (required for OPFS).\n */\nexport async function newBrowserDuckDb(options?: BrowserDuckDbOptions): Promise<Db> {\n const selfHosted = options?.bundles;\n const bundle = await duckdb.selectBundle(selfHosted ?? duckdb.getJsDelivrBundles());\n\n // OPFS file I/O needs a same-origin worker. Self-hosted bundles give a\n // same-origin URL we load directly; the jsDelivr fallback is cross-origin and\n // must be wrapped in a Blob that `importScripts` it.\n let worker: Worker;\n let blobUrl: string | undefined;\n if (selfHosted) {\n worker = new Worker(bundle.mainWorker as string);\n } else {\n blobUrl = URL.createObjectURL(\n new Blob([`importScripts(\"${bundle.mainWorker ?? \"\"}\");`], {\n type: \"text/javascript\",\n }),\n );\n worker = new Worker(blobUrl);\n }\n\n const logger = new duckdb.ConsoleLogger();\n const db = new duckdb.AsyncDuckDB(logger, worker);\n await db.instantiate(bundle.mainModule, bundle.pthreadWorker);\n if (blobUrl) URL.revokeObjectURL(blobUrl);\n\n // Enable OPFS persistence when a path is provided and the API is available.\n const persistent =\n Boolean(options?.path) && typeof navigator !== \"undefined\" && Boolean(navigator.storage);\n if (options?.path && persistent) {\n try {\n await db.open({\n path: options.path,\n accessMode: duckdb.DuckDBAccessMode.READ_WRITE,\n });\n } catch {\n // Fall back to in-memory if OPFS is unavailable\n }\n }\n\n const conn = await db.connect();\n\n return {\n async query<T = DbEntry>(sql: string, params?: unknown[]): Promise<T[]> {\n if (params && params.length > 0) {\n const stmt = await conn.prepare(sql);\n const result = await stmt.query(...params);\n return arrowToObjects<T>(result);\n }\n const result = await conn.query(sql);\n return arrowToObjects<T>(result);\n },\n\n async exec(sql: string): Promise<void> {\n await conn.query(sql);\n },\n\n async flush(): Promise<void> {\n // CHECKPOINT writes the WAL into the OPFS database file so committed\n // changes survive a reload. Crucial for OPFS persistence.\n if (persistent) await conn.query(\"CHECKPOINT\");\n },\n\n async close(): Promise<void> {\n await conn.close();\n await db.terminate();\n worker.terminate();\n },\n };\n}\n"],"mappings":";;;;;AAQA,SAAS,eAAkB,OAAmB;CAC5C,MAAM,SAAS,MAAM,OAAO,OAAO,KAAK,MAAwB,EAAE,IAAI;CACtE,OAAO,MAAM,QAAQ,CAAC,CAAC,KAAK,QAAiC;EAC3D,MAAM,MAA+B,CAAC;EACtC,KAAK,MAAM,SAAS,QAClB,IAAI,SAAS,IAAI;EAEnB,OAAO;CACT,CAAC;AACH;;;;;;;;;;;AAwBA,eAAsB,iBAAiB,SAA6C;CAClF,MAAM,aAAa,SAAS;CAC5B,MAAM,SAAS,MAAM,OAAO,aAAa,cAAc,OAAO,mBAAmB,CAAC;CAKlF,IAAI;CACJ,IAAI;CACJ,IAAI,YACF,SAAS,IAAI,OAAO,OAAO,UAAoB;MAC1C;EACL,UAAU,IAAI,gBACZ,IAAI,KAAK,CAAC,kBAAkB,OAAO,cAAc,GAAG,IAAI,GAAG,EACzD,MAAM,kBACR,CAAC,CACH;EACA,SAAS,IAAI,OAAO,OAAO;CAC7B;CAEA,MAAM,SAAS,IAAI,OAAO,cAAc;CACxC,MAAM,KAAK,IAAI,OAAO,YAAY,QAAQ,MAAM;CAChD,MAAM,GAAG,YAAY,OAAO,YAAY,OAAO,aAAa;CAC5D,IAAI,SAAS,IAAI,gBAAgB,OAAO;CAGxC,MAAM,aACJ,QAAQ,SAAS,IAAI,KAAK,OAAO,cAAc,eAAe,QAAQ,UAAU,OAAO;CACzF,IAAI,SAAS,QAAQ,YACnB,IAAI;EACF,MAAM,GAAG,KAAK;GACZ,MAAM,QAAQ;GACd,YAAY,OAAO,iBAAiB;EACtC,CAAC;CACH,QAAQ,CAER;CAGF,MAAM,OAAO,MAAM,GAAG,QAAQ;CAE9B,OAAO;EACL,MAAM,MAAmB,KAAa,QAAkC;GACtE,IAAI,UAAU,OAAO,SAAS,GAG5B,OAAO,eAAkB,OADJ,MADF,KAAK,QAAQ,GAAG,EAAA,CACT,MAAM,GAAG,MAAM,CACV;GAGjC,OAAO,eAAkB,MADJ,KAAK,MAAM,GAAG,CACJ;EACjC;EAEA,MAAM,KAAK,KAA4B;GACrC,MAAM,KAAK,MAAM,GAAG;EACtB;EAEA,MAAM,QAAuB;GAG3B,IAAI,YAAY,MAAM,KAAK,MAAM,YAAY;EAC/C;EAEA,MAAM,QAAuB;GAC3B,MAAM,KAAK,MAAM;GACjB,MAAM,GAAG,UAAU;GACnB,OAAO,UAAU;EACnB;CACF;AACF"}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@statewalker/db-duckdb-browser",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "DuckDB WASM driver for @statewalker/db-api, targeting browser environments.",
7
+ "homepage": "https://github.com/statewalker/statewalker-db",
8
+ "author": {
9
+ "name": "Mikhail Kotelnikov",
10
+ "email": "mikhail.kotelnikov@gmail.com"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+ssh://git@github.com/statewalker/statewalker-db.git"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.ts"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "dependencies": {
25
+ "@duckdb/duckdb-wasm": "^1.33.1-dev57.0",
26
+ "@statewalker/db-api": "0.1.1"
27
+ },
28
+ "devDependencies": {
29
+ "@playwright/test": "^1.62.1",
30
+ "@vitest/browser": "^4.1.10",
31
+ "@vitest/browser-playwright": "^4.1.10",
32
+ "playwright": "^1.62.1",
33
+ "rimraf": "^6.1.3",
34
+ "tsdown": "^0.22.14",
35
+ "typescript": "^7.0.2",
36
+ "vitest": "^4.1.10",
37
+ "@statewalker/db-tests": "0.1.1"
38
+ },
39
+ "sideEffects": false,
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsdown",
45
+ "dev": "tsdown --watch",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "test:browser": "vitest run --config vitest.browser.config.ts",
49
+ "typecheck": "tsc --noEmit",
50
+ "clean": "rimraf dist",
51
+ "lint": "biome check --write .",
52
+ "format": "biome format --write ."
53
+ }
54
+ }
@@ -0,0 +1,171 @@
1
+ import type { Db } from "@statewalker/db-api";
2
+ import { runDbConformance } from "@statewalker/db-tests";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { newBrowserDuckDb } from "./browser-duckdb.js";
5
+
6
+ /**
7
+ * The DuckDB-WASM browser adapter, exercised in a REAL browser (Vitest browser
8
+ * mode + Playwright/Chromium). Launched only by `vitest.browser.config.ts` (the
9
+ * `test:browser` script) — the Node config excludes `*.browser.test.ts`.
10
+ *
11
+ * With no `bundles`/`path`, the adapter loads the default DuckDB bundle from
12
+ * jsDelivr and runs in-memory (a Worker inside the browser page), so the run
13
+ * needs outbound network access from Chromium. The node-only file-persistence
14
+ * scenario is skipped via `skipFilePersistence`.
15
+ */
16
+
17
+ // Shared contract: CRUD, parameter binding, injection safety, cardinality,
18
+ // close, flush, error paths. File persistence is node-only, hence skipped here.
19
+ runDbConformance(() => newBrowserDuckDb(), {
20
+ // duckdb dialect: `$1` placeholder; rows are already plain objects, so the
21
+ // spread normalizer is an identity-style copy.
22
+ placeholder: "$1",
23
+ normalizeRow: (row) => ({ ...row }),
24
+ skipFilePersistence: true,
25
+ });
26
+
27
+ // Dialect-specific browser behavior the shared suite does not cover: DuckDB's
28
+ // FTS extension and VSS/HNSW vector index.
29
+ describe("newBrowserDuckDb", () => {
30
+ let db: Db;
31
+
32
+ afterEach(async () => {
33
+ if (db) {
34
+ await db.close();
35
+ }
36
+ });
37
+
38
+ describe("FTS extension", () => {
39
+ it("returns empty results for unmatched search terms", async () => {
40
+ db = await newBrowserDuckDb();
41
+ await db.exec("INSTALL fts; LOAD fts;");
42
+ await db.exec("CREATE TABLE docs2 (id INTEGER, content VARCHAR)");
43
+ await db.exec("INSERT INTO docs2 VALUES (1, 'hello world'), (2, 'goodbye world')");
44
+ await db.exec("PRAGMA create_fts_index('docs2', 'id', 'content')");
45
+
46
+ const rows = await db.query<{ id: number; score: number | null }>(`
47
+ SELECT id, score FROM (
48
+ SELECT *, fts_main_docs2.match_bm25(id, 'nonexistent') AS score FROM docs2
49
+ ) sq WHERE score IS NOT NULL
50
+ `);
51
+ expect(rows).toHaveLength(0);
52
+ });
53
+
54
+ it("searches across multiple text columns", async () => {
55
+ db = await newBrowserDuckDb();
56
+ await db.exec("INSTALL fts; LOAD fts;");
57
+ await db.exec("CREATE TABLE articles (id INTEGER, title VARCHAR, body VARCHAR)");
58
+ await db.exec(`
59
+ INSERT INTO articles VALUES
60
+ (1, 'Travel Guide', 'Visit the beautiful lakes of Switzerland'),
61
+ (2, 'Cooking Tips', 'How to make a perfect lake trout'),
62
+ (3, 'Programming', 'Learn about SQL databases')
63
+ `);
64
+ await db.exec("PRAGMA create_fts_index('articles', 'id', 'title', 'body')");
65
+
66
+ const rows = await db.query<{ id: number; score: number | null }>(`
67
+ SELECT id, score FROM (
68
+ SELECT *, fts_main_articles.match_bm25(id, 'lake') AS score FROM articles
69
+ ) sq WHERE score IS NOT NULL
70
+ `);
71
+ expect(rows.length).toBe(2);
72
+ const ids = rows.map((r) => r.id);
73
+ expect(ids).toContain(1);
74
+ expect(ids).toContain(2);
75
+ });
76
+
77
+ it("creates an FTS index and performs full-text search", async () => {
78
+ db = await newBrowserDuckDb();
79
+ await db.exec("INSTALL fts; LOAD fts;");
80
+ await db.exec("CREATE TABLE docs (id INTEGER, content VARCHAR)");
81
+ await db.exec(`
82
+ INSERT INTO docs VALUES
83
+ (1, 'the quick brown fox jumps over the lazy dog'),
84
+ (2, 'a lazy cat sleeps on the mat'),
85
+ (3, 'the fox and the hound are friends')
86
+ `);
87
+ await db.exec("PRAGMA create_fts_index('docs', 'id', 'content')");
88
+
89
+ const rows = await db.query<{
90
+ id: number;
91
+ content: string;
92
+ score: number | null;
93
+ }>(`
94
+ SELECT id, content, score
95
+ FROM (
96
+ SELECT *, fts_main_docs.match_bm25(id, 'fox') AS score
97
+ FROM docs
98
+ ) sq
99
+ WHERE score IS NOT NULL
100
+ ORDER BY score
101
+ `);
102
+
103
+ expect(rows.length).toBeGreaterThanOrEqual(1);
104
+ const ids = rows.map((r) => r.id);
105
+ expect(ids).toContain(1);
106
+ expect(ids).toContain(3);
107
+ });
108
+ });
109
+
110
+ describe("VSS extension", () => {
111
+ it("creates HNSW index and performs vector similarity search", async () => {
112
+ db = await newBrowserDuckDb();
113
+ await db.exec("INSTALL vss; LOAD vss;");
114
+ await db.exec("CREATE TABLE embeddings (id INTEGER, vec FLOAT[3])");
115
+ await db.exec(`
116
+ INSERT INTO embeddings VALUES
117
+ (1, [1.0, 0.0, 0.0]),
118
+ (2, [0.0, 1.0, 0.0]),
119
+ (3, [0.0, 0.0, 1.0])
120
+ `);
121
+ await db.exec("CREATE INDEX vec_idx ON embeddings USING HNSW (vec)");
122
+
123
+ const rows = await db.query<{ id: number }>(
124
+ "SELECT id FROM embeddings ORDER BY array_distance(vec, [1.0, 0.1, 0.0]::FLOAT[3]) LIMIT 1",
125
+ );
126
+
127
+ expect(rows).toHaveLength(1);
128
+ expect(rows[0]?.id).toBe(1);
129
+ });
130
+
131
+ it("returns k nearest neighbors ordered by distance", async () => {
132
+ db = await newBrowserDuckDb();
133
+ await db.exec("INSTALL vss; LOAD vss;");
134
+ await db.exec("CREATE TABLE vectors (id INTEGER, vec FLOAT[3])");
135
+ await db.exec(`
136
+ INSERT INTO vectors VALUES
137
+ (1, [1.0, 0.0, 0.0]),
138
+ (2, [0.9, 0.1, 0.0]),
139
+ (3, [0.0, 1.0, 0.0]),
140
+ (4, [0.0, 0.0, 1.0])
141
+ `);
142
+ await db.exec("CREATE INDEX vec_idx2 ON vectors USING HNSW (vec)");
143
+
144
+ const rows = await db.query<{ id: number }>(
145
+ "SELECT id FROM vectors ORDER BY array_distance(vec, [1.0, 0.0, 0.0]::FLOAT[3]) LIMIT 2",
146
+ );
147
+
148
+ expect(rows).toHaveLength(2);
149
+ expect(rows[0]?.id).toBe(1);
150
+ expect(rows[1]?.id).toBe(2);
151
+ });
152
+
153
+ it("works without HNSW index (brute-force scan)", async () => {
154
+ db = await newBrowserDuckDb();
155
+ await db.exec("INSTALL vss; LOAD vss;");
156
+ await db.exec("CREATE TABLE vecs_no_idx (id INTEGER, vec FLOAT[3])");
157
+ await db.exec(`
158
+ INSERT INTO vecs_no_idx VALUES
159
+ (1, [1.0, 0.0, 0.0]),
160
+ (2, [0.0, 1.0, 0.0])
161
+ `);
162
+
163
+ const rows = await db.query<{ id: number }>(
164
+ "SELECT id FROM vecs_no_idx ORDER BY array_distance(vec, [0.0, 0.9, 0.1]::FLOAT[3]) LIMIT 1",
165
+ );
166
+
167
+ expect(rows).toHaveLength(1);
168
+ expect(rows[0]?.id).toBe(2);
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ /**
4
+ * Node-safe smoke test. The real browser coverage (shared conformance suite +
5
+ * FTS/VSS) lives in `browser-duckdb.browser.test.ts`, run via Vitest browser
6
+ * mode (`pnpm test:browser`); this file only checks the module loads and
7
+ * exports its factory, since duckdb-wasm needs a browser Worker to run.
8
+ */
9
+ describe("browser-duckdb module", () => {
10
+ it("exports newBrowserDuckDb function", async () => {
11
+ const mod = await import("./browser-duckdb.js");
12
+ expect(typeof mod.newBrowserDuckDb).toBe("function");
13
+ });
14
+ });
@@ -0,0 +1,110 @@
1
+ import * as duckdb from "@duckdb/duckdb-wasm";
2
+ import type { Db, DbEntry, DbOptions } from "@statewalker/db-api";
3
+
4
+ type Table = Awaited<ReturnType<duckdb.AsyncPreparedStatement["query"]>>;
5
+
6
+ /**
7
+ * Convert an Arrow Table (returned by duckdb-wasm queries) to plain JS objects.
8
+ */
9
+ function arrowToObjects<T>(table: Table): T[] {
10
+ const fields = table.schema.fields.map((f: { name: string }) => f.name);
11
+ return table.toArray().map((row: Record<string, unknown>) => {
12
+ const obj: Record<string, unknown> = {};
13
+ for (const field of fields) {
14
+ obj[field] = row[field];
15
+ }
16
+ return obj as T;
17
+ });
18
+ }
19
+
20
+ /** Browser-specific options for {@link newBrowserDuckDb}. */
21
+ export interface BrowserDuckDbOptions extends DbOptions {
22
+ /**
23
+ * Self-hosted DuckDB bundle URLs (served same-origin, e.g. via a bundler's
24
+ * `?url` import). Passing these makes the factory spawn a **same-origin**
25
+ * worker, which OPFS database persistence requires — a cross-origin worker
26
+ * (the jsDelivr fallback, loaded via a Blob `importScripts` shim) crashes on
27
+ * OPFS file I/O. Omit to load from jsDelivr (in-memory use only).
28
+ */
29
+ bundles?: duckdb.DuckDBBundles;
30
+ }
31
+
32
+ /**
33
+ * Create a DuckDB-backed {@link Db} using WebAssembly in the browser.
34
+ *
35
+ * With `options.path` set to an `opfs://` URL **and** self-hosted
36
+ * `options.bundles`, the database persists across reloads on OPFS. Otherwise it
37
+ * runs in-memory.
38
+ *
39
+ * @param options.path `opfs://` path for persistent storage. Omit for in-memory.
40
+ * @param options.bundles Same-origin bundle URLs (required for OPFS).
41
+ */
42
+ export async function newBrowserDuckDb(options?: BrowserDuckDbOptions): Promise<Db> {
43
+ const selfHosted = options?.bundles;
44
+ const bundle = await duckdb.selectBundle(selfHosted ?? duckdb.getJsDelivrBundles());
45
+
46
+ // OPFS file I/O needs a same-origin worker. Self-hosted bundles give a
47
+ // same-origin URL we load directly; the jsDelivr fallback is cross-origin and
48
+ // must be wrapped in a Blob that `importScripts` it.
49
+ let worker: Worker;
50
+ let blobUrl: string | undefined;
51
+ if (selfHosted) {
52
+ worker = new Worker(bundle.mainWorker as string);
53
+ } else {
54
+ blobUrl = URL.createObjectURL(
55
+ new Blob([`importScripts("${bundle.mainWorker ?? ""}");`], {
56
+ type: "text/javascript",
57
+ }),
58
+ );
59
+ worker = new Worker(blobUrl);
60
+ }
61
+
62
+ const logger = new duckdb.ConsoleLogger();
63
+ const db = new duckdb.AsyncDuckDB(logger, worker);
64
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
65
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
66
+
67
+ // Enable OPFS persistence when a path is provided and the API is available.
68
+ const persistent =
69
+ Boolean(options?.path) && typeof navigator !== "undefined" && Boolean(navigator.storage);
70
+ if (options?.path && persistent) {
71
+ try {
72
+ await db.open({
73
+ path: options.path,
74
+ accessMode: duckdb.DuckDBAccessMode.READ_WRITE,
75
+ });
76
+ } catch {
77
+ // Fall back to in-memory if OPFS is unavailable
78
+ }
79
+ }
80
+
81
+ const conn = await db.connect();
82
+
83
+ return {
84
+ async query<T = DbEntry>(sql: string, params?: unknown[]): Promise<T[]> {
85
+ if (params && params.length > 0) {
86
+ const stmt = await conn.prepare(sql);
87
+ const result = await stmt.query(...params);
88
+ return arrowToObjects<T>(result);
89
+ }
90
+ const result = await conn.query(sql);
91
+ return arrowToObjects<T>(result);
92
+ },
93
+
94
+ async exec(sql: string): Promise<void> {
95
+ await conn.query(sql);
96
+ },
97
+
98
+ async flush(): Promise<void> {
99
+ // CHECKPOINT writes the WAL into the OPFS database file so committed
100
+ // changes survive a reload. Crucial for OPFS persistence.
101
+ if (persistent) await conn.query("CHECKPOINT");
102
+ },
103
+
104
+ async close(): Promise<void> {
105
+ await conn.close();
106
+ await db.terminate();
107
+ worker.terminate();
108
+ },
109
+ };
110
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type { Db, DbEntry, DbOptions } from "@statewalker/db-api";
2
+ export {
3
+ type BrowserDuckDbOptions,
4
+ newBrowserDuckDb,
5
+ } from "./browser-duckdb.js";