@statewalker/db-duckdb-node 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-node
2
+
3
+ DuckDB Node.js driver implementing `@statewalker/db-api`, backed by `@duckdb/node-api`.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @statewalker/db-duckdb-node
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { createDuckDbNodeClient } from "@statewalker/db-duckdb-node";
15
+
16
+ const db = await createDuckDbNodeClient({ path: "./data.duckdb" });
17
+ await db.execute("CREATE TABLE t (x INTEGER)");
18
+ ```
19
+
20
+ ## API
21
+
22
+ - `createDuckDbNodeClient(options)` — opens an on-disk or in-memory DuckDB file and returns a `DbClient`.
23
+
24
+ ## Related
25
+
26
+ - `@statewalker/db-api` — interface contract.
27
+ - `@statewalker/db-duckdb-browser` — Browser-side counterpart.
@@ -0,0 +1,11 @@
1
+ import { Db, Db as Db$1, DbEntry, DbOptions, DbOptions as DbOptions$1 } from "@statewalker/db-api";
2
+ //#region src/node-duckdb.d.ts
3
+ /**
4
+ * Create a DuckDB-backed {@link Db} using native Node.js bindings.
5
+ *
6
+ * @param options.path File path for persistent storage. Omit for in-memory.
7
+ */
8
+ declare function newNodeDuckDb(options?: DbOptions$1): Promise<Db$1>;
9
+ //#endregion
10
+ export { type Db, type DbEntry, type DbOptions, newNodeDuckDb };
11
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/node-duckdb.ts"],"mappings":";;;;;;;iBASsB,cAAc,UAAU,cAAY,QAAQ"}
package/dist/index.mjs ADDED
@@ -0,0 +1,32 @@
1
+ import { DuckDBInstance } from "@duckdb/node-api";
2
+ //#region src/node-duckdb.ts
3
+ /**
4
+ * Create a DuckDB-backed {@link Db} using native Node.js bindings.
5
+ *
6
+ * @param options.path File path for persistent storage. Omit for in-memory.
7
+ */
8
+ async function newNodeDuckDb(options) {
9
+ const instance = await DuckDBInstance.create(options?.path ?? ":memory:");
10
+ const connection = await instance.connect();
11
+ return {
12
+ async query(sql, params) {
13
+ if (params && params.length > 0) {
14
+ const prepared = await connection.prepare(sql);
15
+ prepared.bind(params);
16
+ return (await prepared.runAndReadAll()).getRowObjectsJS();
17
+ }
18
+ return (await connection.runAndReadAll(sql)).getRowObjectsJS();
19
+ },
20
+ async exec(sql) {
21
+ await connection.run(sql);
22
+ },
23
+ async close() {
24
+ connection.closeSync();
25
+ instance.closeSync();
26
+ }
27
+ };
28
+ }
29
+ //#endregion
30
+ export { newNodeDuckDb };
31
+
32
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/node-duckdb.ts"],"sourcesContent":["import type { DuckDBValue } from \"@duckdb/node-api\";\nimport { DuckDBInstance } from \"@duckdb/node-api\";\nimport type { Db, DbEntry, DbOptions } from \"@statewalker/db-api\";\n\n/**\n * Create a DuckDB-backed {@link Db} using native Node.js bindings.\n *\n * @param options.path File path for persistent storage. Omit for in-memory.\n */\nexport async function newNodeDuckDb(options?: DbOptions): Promise<Db> {\n const instance = await DuckDBInstance.create(options?.path ?? \":memory:\");\n const connection = await instance.connect();\n\n return {\n async query<T = DbEntry>(sql: string, params?: unknown[]): Promise<T[]> {\n if (params && params.length > 0) {\n const prepared = await connection.prepare(sql);\n prepared.bind(params as DuckDBValue[]);\n const reader = await prepared.runAndReadAll();\n return reader.getRowObjectsJS() as T[];\n }\n const reader = await connection.runAndReadAll(sql);\n return reader.getRowObjectsJS() as T[];\n },\n\n async exec(sql: string): Promise<void> {\n await connection.run(sql);\n },\n\n async close(): Promise<void> {\n connection.closeSync();\n instance.closeSync();\n },\n };\n}\n"],"mappings":";;;;;;;AASA,eAAsB,cAAc,SAAkC;CACpE,MAAM,WAAW,MAAM,eAAe,OAAO,SAAS,QAAQ,UAAU;CACxE,MAAM,aAAa,MAAM,SAAS,QAAQ;CAE1C,OAAO;EACL,MAAM,MAAmB,KAAa,QAAkC;GACtE,IAAI,UAAU,OAAO,SAAS,GAAG;IAC/B,MAAM,WAAW,MAAM,WAAW,QAAQ,GAAG;IAC7C,SAAS,KAAK,MAAuB;IAErC,QAAO,MADc,SAAS,cAAc,EAAA,CAC9B,gBAAgB;GAChC;GAEA,QAAO,MADc,WAAW,cAAc,GAAG,EAAA,CACnC,gBAAgB;EAChC;EAEA,MAAM,KAAK,KAA4B;GACrC,MAAM,WAAW,IAAI,GAAG;EAC1B;EAEA,MAAM,QAAuB;GAC3B,WAAW,UAAU;GACrB,SAAS,UAAU;EACrB;CACF;AACF"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@statewalker/db-duckdb-node",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "DuckDB Node.js driver for @statewalker/db-api using @duckdb/node-api.",
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/node-api": "^1.5.5-r.4",
26
+ "@statewalker/db-api": "0.1.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^26.2.0",
30
+ "rimraf": "^6.1.3",
31
+ "tsdown": "^0.22.14",
32
+ "typescript": "^7.0.2",
33
+ "vitest": "^4.1.10",
34
+ "@statewalker/db-tests": "0.1.1"
35
+ },
36
+ "sideEffects": false,
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "dev": "tsdown --watch",
43
+ "test": "vitest run",
44
+ "test:watch": "vitest",
45
+ "typecheck": "tsc --noEmit",
46
+ "clean": "rimraf dist",
47
+ "lint": "biome check --write .",
48
+ "format": "biome format --write ."
49
+ }
50
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { Db, DbEntry, DbOptions } from "@statewalker/db-api";
2
+ export { newNodeDuckDb } from "./node-duckdb.js";
@@ -0,0 +1,172 @@
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 { newNodeDuckDb } from "./node-duckdb.js";
5
+
6
+ // Common contract (CRUD, parameter binding, cardinality, close, file
7
+ // persistence, error paths) is covered by the shared conformance suite.
8
+ // duckdb dialect: `$1` placeholder; rows are already plain, so the spread
9
+ // normalizer is an identity-style copy.
10
+ runDbConformance(newNodeDuckDb, {
11
+ placeholder: "$1",
12
+ normalizeRow: (row) => ({ ...row }),
13
+ });
14
+
15
+ // Dialect-specific behavior kept per-adapter: DuckDB FTS extension + VSS/HNSW.
16
+ describe("newNodeDuckDb", () => {
17
+ let db: Db;
18
+
19
+ afterEach(async () => {
20
+ if (db) {
21
+ await db.close();
22
+ }
23
+ });
24
+
25
+ describe("FTS extension", () => {
26
+ it("returns empty results for unmatched search terms", async () => {
27
+ db = await newNodeDuckDb();
28
+ await db.exec("INSTALL fts");
29
+ await db.exec("LOAD fts");
30
+ await db.exec("CREATE TABLE docs2 (id INTEGER, content VARCHAR)");
31
+ await db.exec("INSERT INTO docs2 VALUES (1, 'hello world'), (2, 'goodbye world')");
32
+ await db.exec("PRAGMA create_fts_index('docs2', 'id', 'content')");
33
+
34
+ const rows = await db.query<{ id: number; score: number | null }>(`
35
+ SELECT id, score FROM (
36
+ SELECT *, fts_main_docs2.match_bm25(id, 'nonexistent') AS score FROM docs2
37
+ ) sq WHERE score IS NOT NULL
38
+ `);
39
+ expect(rows).toHaveLength(0);
40
+ });
41
+
42
+ it("searches across multiple text columns", async () => {
43
+ db = await newNodeDuckDb();
44
+ await db.exec("INSTALL fts");
45
+ await db.exec("LOAD fts");
46
+ await db.exec("CREATE TABLE articles (id INTEGER, title VARCHAR, body VARCHAR)");
47
+ await db.exec(`
48
+ INSERT INTO articles VALUES
49
+ (1, 'Travel Guide', 'Visit the beautiful lakes of Switzerland'),
50
+ (2, 'Cooking Tips', 'How to make a perfect lake trout'),
51
+ (3, 'Programming', 'Learn about SQL databases')
52
+ `);
53
+ await db.exec("PRAGMA create_fts_index('articles', 'id', 'title', 'body')");
54
+
55
+ const rows = await db.query<{ id: number; score: number | null }>(`
56
+ SELECT id, score FROM (
57
+ SELECT *, fts_main_articles.match_bm25(id, 'lake') AS score FROM articles
58
+ ) sq WHERE score IS NOT NULL
59
+ `);
60
+ expect(rows.length).toBe(2);
61
+ const ids = rows.map((r) => r.id);
62
+ expect(ids).toContain(1);
63
+ expect(ids).toContain(2);
64
+ });
65
+
66
+ it("creates an FTS index and performs full-text search", async () => {
67
+ db = await newNodeDuckDb();
68
+
69
+ await db.exec("INSTALL fts");
70
+ await db.exec("LOAD fts");
71
+
72
+ await db.exec("CREATE TABLE docs (id INTEGER, content VARCHAR)");
73
+ await db.exec(`
74
+ INSERT INTO docs VALUES
75
+ (1, 'the quick brown fox jumps over the lazy dog'),
76
+ (2, 'a lazy cat sleeps on the mat'),
77
+ (3, 'the fox and the hound are friends')
78
+ `);
79
+
80
+ await db.exec("PRAGMA create_fts_index('docs', 'id', 'content')");
81
+
82
+ const rows = await db.query<{
83
+ id: number;
84
+ content: string;
85
+ score: number | null;
86
+ }>(`
87
+ SELECT id, content, score
88
+ FROM (
89
+ SELECT *, fts_main_docs.match_bm25(id, 'fox') AS score
90
+ FROM docs
91
+ ) sq
92
+ WHERE score IS NOT NULL
93
+ ORDER BY score
94
+ `);
95
+
96
+ expect(rows.length).toBeGreaterThanOrEqual(1);
97
+ const ids = rows.map((r) => r.id);
98
+ expect(ids).toContain(1);
99
+ expect(ids).toContain(3);
100
+ });
101
+ });
102
+
103
+ describe("VSS extension", () => {
104
+ it("creates HNSW index and performs vector similarity search", async () => {
105
+ db = await newNodeDuckDb();
106
+
107
+ await db.exec("INSTALL vss");
108
+ await db.exec("LOAD vss");
109
+
110
+ await db.exec("CREATE TABLE embeddings (id INTEGER, vec FLOAT[3])");
111
+ await db.exec(`
112
+ INSERT INTO embeddings VALUES
113
+ (1, [1.0, 0.0, 0.0]),
114
+ (2, [0.0, 1.0, 0.0]),
115
+ (3, [0.0, 0.0, 1.0])
116
+ `);
117
+
118
+ await db.exec("CREATE INDEX vec_idx ON embeddings USING HNSW (vec)");
119
+
120
+ const rows = await db.query<{ id: number }>(
121
+ "SELECT id FROM embeddings ORDER BY array_distance(vec, [1.0, 0.1, 0.0]::FLOAT[3]) LIMIT 1",
122
+ );
123
+
124
+ expect(rows).toHaveLength(1);
125
+ expect(rows[0]?.id).toBe(1);
126
+ });
127
+
128
+ it("returns k nearest neighbors ordered by distance", async () => {
129
+ db = await newNodeDuckDb();
130
+ await db.exec("INSTALL vss");
131
+ await db.exec("LOAD vss");
132
+
133
+ await db.exec("CREATE TABLE vectors (id INTEGER, vec FLOAT[3])");
134
+ await db.exec(`
135
+ INSERT INTO vectors VALUES
136
+ (1, [1.0, 0.0, 0.0]),
137
+ (2, [0.9, 0.1, 0.0]),
138
+ (3, [0.0, 1.0, 0.0]),
139
+ (4, [0.0, 0.0, 1.0])
140
+ `);
141
+ await db.exec("CREATE INDEX vec_idx2 ON vectors USING HNSW (vec)");
142
+
143
+ const rows = await db.query<{ id: number }>(
144
+ "SELECT id FROM vectors ORDER BY array_distance(vec, [1.0, 0.0, 0.0]::FLOAT[3]) LIMIT 2",
145
+ );
146
+
147
+ expect(rows).toHaveLength(2);
148
+ expect(rows[0]?.id).toBe(1);
149
+ expect(rows[1]?.id).toBe(2);
150
+ });
151
+
152
+ it("works without HNSW index (brute-force scan)", async () => {
153
+ db = await newNodeDuckDb();
154
+ await db.exec("INSTALL vss");
155
+ await db.exec("LOAD vss");
156
+
157
+ await db.exec("CREATE TABLE vecs_no_idx (id INTEGER, vec FLOAT[3])");
158
+ await db.exec(`
159
+ INSERT INTO vecs_no_idx VALUES
160
+ (1, [1.0, 0.0, 0.0]),
161
+ (2, [0.0, 1.0, 0.0])
162
+ `);
163
+
164
+ const rows = await db.query<{ id: number }>(
165
+ "SELECT id FROM vecs_no_idx ORDER BY array_distance(vec, [0.0, 0.9, 0.1]::FLOAT[3]) LIMIT 1",
166
+ );
167
+
168
+ expect(rows).toHaveLength(1);
169
+ expect(rows[0]?.id).toBe(2);
170
+ });
171
+ });
172
+ });
@@ -0,0 +1,35 @@
1
+ import type { DuckDBValue } from "@duckdb/node-api";
2
+ import { DuckDBInstance } from "@duckdb/node-api";
3
+ import type { Db, DbEntry, DbOptions } from "@statewalker/db-api";
4
+
5
+ /**
6
+ * Create a DuckDB-backed {@link Db} using native Node.js bindings.
7
+ *
8
+ * @param options.path File path for persistent storage. Omit for in-memory.
9
+ */
10
+ export async function newNodeDuckDb(options?: DbOptions): Promise<Db> {
11
+ const instance = await DuckDBInstance.create(options?.path ?? ":memory:");
12
+ const connection = await instance.connect();
13
+
14
+ return {
15
+ async query<T = DbEntry>(sql: string, params?: unknown[]): Promise<T[]> {
16
+ if (params && params.length > 0) {
17
+ const prepared = await connection.prepare(sql);
18
+ prepared.bind(params as DuckDBValue[]);
19
+ const reader = await prepared.runAndReadAll();
20
+ return reader.getRowObjectsJS() as T[];
21
+ }
22
+ const reader = await connection.runAndReadAll(sql);
23
+ return reader.getRowObjectsJS() as T[];
24
+ },
25
+
26
+ async exec(sql: string): Promise<void> {
27
+ await connection.run(sql);
28
+ },
29
+
30
+ async close(): Promise<void> {
31
+ connection.closeSync();
32
+ instance.closeSync();
33
+ },
34
+ };
35
+ }