@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 +21 -0
- package/README.md +27 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +32 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +50 -0
- package/src/index.ts +2 -0
- package/src/node-duckdb.test.ts +172 -0
- package/src/node-duckdb.ts +35 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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,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
|
+
}
|