logosdb 0.7.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/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # LogosDB - Node.js Bindings
2
+
3
+ Fast semantic vector database (HNSW + mmap) for Node.js. Zero-copy memory-mapped storage with local-only deployment.
4
+
5
+ ## Features
6
+
7
+ - **Memory-efficient**: Uses mmap() — RAM scales with queries, not data size
8
+ - **Fast**: HNSW approximate nearest neighbor (O(log n) queries)
9
+ - **Local-only**: No cloud dependencies, data never leaves your machine
10
+ - **TypeScript support**: Full type definitions included
11
+ - **Cross-platform**: Linux, macOS, Windows (x64, arm64)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install logosdb
17
+ ```
18
+
19
+ Prebuilt binaries are provided for common platforms. If a prebuilt binary is not available, npm will compile from source (requires Python, C++ compiler, and CMake).
20
+
21
+ ## Quick Start
22
+
23
+ ```javascript
24
+ const { DB, DIST_COSINE } = require('logosdb');
25
+
26
+ // Create database
27
+ const db = new DB('/tmp/mydb', {
28
+ dim: 384, // Vector dimension
29
+ distance: DIST_COSINE // Auto-normalizes vectors
30
+ });
31
+
32
+ // Insert documents with embeddings
33
+ const embedding = [0.1, 0.2, /* ... 384 floats ... */];
34
+ const id = db.put(embedding, 'My document text', '2025-01-01T00:00:00Z');
35
+
36
+ // Search
37
+ const query = [0.15, 0.25, /* ... */];
38
+ const hits = db.search(query, 5);
39
+
40
+ for (const hit of hits) {
41
+ console.log(`${hit.score.toFixed(4)} ${hit.text}`);
42
+ }
43
+
44
+ // Close
45
+ db.close();
46
+ ```
47
+
48
+ ## API
49
+
50
+ ### `new DB(path, options)`
51
+
52
+ Create a new database instance.
53
+
54
+ **Options:**
55
+ - `dim` (number): Vector dimension (default: 128)
56
+ - `maxElements` (number): Maximum capacity (default: 1,000,000)
57
+ - `efConstruction` (number): HNSW build quality (default: 200)
58
+ - `M` (number): HNSW graph degree (default: 16)
59
+ - `efSearch` (number): HNSW search width (default: 50)
60
+ - `distance` (number): Distance metric (`DIST_IP`, `DIST_COSINE`, `DIST_L2`)
61
+
62
+ ### `db.put(embedding, text?, timestamp?)`
63
+
64
+ Insert a vector. Returns the assigned row ID.
65
+
66
+ ### `db.search(queryEmbedding, topK?)`
67
+
68
+ Search for similar vectors. Returns array of `SearchHit` objects.
69
+
70
+ ### `db.searchTsRange(queryEmbedding, options)`
71
+
72
+ Search with timestamp filter.
73
+
74
+ **Options:**
75
+ - `topK` (number): Number of results
76
+ - `tsFrom` (string): Start timestamp (ISO 8601)
77
+ - `tsTo` (string): End timestamp (ISO 8601)
78
+ - `candidateK` (number): Internal multiplier for filtering
79
+
80
+ ### `db.update(id, embedding, text?, timestamp?)`
81
+
82
+ Update a row (marks old as deleted, creates new). Returns new ID.
83
+
84
+ ### `db.delete(id)`
85
+
86
+ Delete a row by ID.
87
+
88
+ ### `db.count()` / `db.countLive()`
89
+
90
+ Get total/live row counts.
91
+
92
+ ### `db.close()`
93
+
94
+ Close the database.
95
+
96
+ ## Distance Metrics
97
+
98
+ - `DIST_IP` (0): Inner product (default, requires L2-normalized vectors)
99
+ - `DIST_COSINE` (1): Cosine similarity (auto-normalizes)
100
+ - `DIST_L2` (2): Euclidean distance
101
+
102
+ ## Memory Model
103
+
104
+ LogosDB uses memory-mapped files:
105
+
106
+ | Dataset | Disk | Typical Query RAM |
107
+ |---------|------|-------------------|
108
+ | 100K × 384-dim | 153 MB | <20 MB |
109
+ | 1M × 384-dim | 1.5 GB | <100 MB |
110
+ | 10M × 384-dim | 15 GB | <200 MB |
111
+
112
+ RAM scales with query patterns, not dataset size.
113
+
114
+ ## TypeScript
115
+
116
+ ```typescript
117
+ import { DB, SearchHit, DIST_COSINE } from 'logosdb';
118
+
119
+ const db = new DB('/tmp/mydb', { dim: 384, distance: DIST_COSINE });
120
+ const hits: SearchHit[] = db.search(embedding, 5);
121
+ ```
122
+
123
+ ## Building from Source
124
+
125
+ ```bash
126
+ npm install --build-from-source
127
+ ```
128
+
129
+ Requirements:
130
+ - Python 3.x
131
+ - C++17 compiler (GCC, Clang, MSVC)
132
+ - CMake 3.15+
133
+ - Node.js 16+
134
+
135
+ ## License
136
+
137
+ MIT — see [LICENSE](../LICENSE)
138
+
139
+ ## Links
140
+
141
+ - GitHub: https://github.com/jose-compu/logosdb
142
+ - Issues: https://github.com/jose-compu/logosdb/issues
143
+ - Python bindings: `pip install logosdb`
package/binding.gyp ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "variables": {
3
+ "openssl_fips%": ""
4
+ },
5
+ "targets": [
6
+ {
7
+ "target_name": "logosdb",
8
+ "sources": [
9
+ "src/node_logosdb.cpp",
10
+ "../src/logosdb.cpp",
11
+ "../src/storage.cpp",
12
+ "../src/metadata.cpp",
13
+ "../src/hnsw_index.cpp",
14
+ "../src/wal.cpp",
15
+ "../src/platform.cpp"
16
+ ],
17
+ "include_dirs": [
18
+ "<!@(node -p \"require('node-addon-api').include\")",
19
+ "../include",
20
+ "../src",
21
+ "../third_party"
22
+ ],
23
+ "cflags!": ["-fno-exceptions"],
24
+ "cflags_cc!": ["-fno-exceptions"],
25
+ "cflags_cc": [
26
+ "-std=c++17",
27
+ "-fexceptions"
28
+ ],
29
+ "conditions": [
30
+ ["OS=='mac'", {
31
+ "xcode_settings": {
32
+ "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
33
+ "CLANG_CXX_LIBRARY": "libc++",
34
+ "MACOSX_DEPLOYMENT_TARGET": "11.0",
35
+ "OTHER_CPLUSPLUSFLAGS": [
36
+ "-std=c++17",
37
+ "-fexceptions"
38
+ ]
39
+ }
40
+ }],
41
+ ["OS=='linux'", {
42
+ "cflags_cc": [
43
+ "-std=c++17",
44
+ "-fexceptions"
45
+ ],
46
+ "ldflags": [
47
+ "-Wl,--gc-sections"
48
+ ]
49
+ }],
50
+ ["OS=='win'", {
51
+ "msvs_settings": {
52
+ "VCCLCompilerTool": {
53
+ "ExceptionHandling": 1,
54
+ "AdditionalOptions": ["/std:c++17"]
55
+ }
56
+ }
57
+ }]
58
+ ],
59
+ "defines": [
60
+ "NAPI_CPP_EXCEPTIONS",
61
+ "NAPI_VERSION=8"
62
+ ]
63
+ }
64
+ ]
65
+ }
package/lib/index.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * LogosDB - Fast semantic vector database (HNSW + mmap)
3
+ * Node.js bindings
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const bindings = require('bindings')('logosdb');
9
+
10
+ /**
11
+ * Search result hit
12
+ * @typedef {Object} SearchHit
13
+ * @property {number} id - Row ID
14
+ * @property {number} score - Similarity score (0-1)
15
+ * @property {string|null} text - Associated text
16
+ * @property {string|null} timestamp - ISO 8601 timestamp
17
+ */
18
+
19
+ /**
20
+ * LogosDB database instance
21
+ *
22
+ * @example
23
+ * const { DB } = require('logosdb');
24
+ *
25
+ * const db = new DB('/tmp/mydb', { dim: 128 });
26
+ *
27
+ * // Insert with embedding
28
+ * const id = db.put(embedding, 'My text', '2025-01-01T00:00:00Z');
29
+ *
30
+ * // Search
31
+ * const hits = db.search(queryEmbedding, 5);
32
+ * console.log(hits[0].text, hits[0].score);
33
+ */
34
+ class DB {
35
+ /**
36
+ * Create a new LogosDB instance
37
+ * @param {string} path - Database directory path
38
+ * @param {Object} [options] - Database options
39
+ * @param {number} [options.dim=128] - Vector dimension
40
+ * @param {number} [options.maxElements=1000000] - Maximum capacity
41
+ * @param {number} [options.efConstruction=200] - HNSW construction parameter
42
+ * @param {number} [options.M=16] - HNSW M parameter
43
+ * @param {number} [options.efSearch=50] - HNSW search parameter
44
+ * @param {number} [options.distance=DIST_IP] - Distance metric (DIST_IP, DIST_COSINE, DIST_L2)
45
+ */
46
+ constructor(path, options = {}) {
47
+ this._db = new bindings.DB(path, options);
48
+ this._closed = false;
49
+ }
50
+
51
+ /**
52
+ * Insert a vector with optional text and timestamp
53
+ * @param {number[]} embedding - Float vector
54
+ * @param {string} [text] - Optional text metadata
55
+ * @param {string} [timestamp] - Optional ISO 8601 timestamp
56
+ * @returns {number} Assigned row ID
57
+ */
58
+ put(embedding, text, timestamp) {
59
+ this._checkClosed();
60
+ return this._db.put(embedding, text, timestamp);
61
+ }
62
+
63
+ /**
64
+ * Search for similar vectors
65
+ * @param {number[]} queryEmbedding - Query vector
66
+ * @param {number} [topK=10] - Number of results
67
+ * @returns {SearchHit[]} Search results
68
+ */
69
+ search(queryEmbedding, topK = 10) {
70
+ this._checkClosed();
71
+ return this._db.search(queryEmbedding, topK);
72
+ }
73
+
74
+ /**
75
+ * Search with timestamp range filter
76
+ * @param {number[]} queryEmbedding - Query vector
77
+ * @param {Object} options - Search options
78
+ * @param {number} [options.topK=10] - Number of results
79
+ * @param {string} [options.tsFrom] - Start timestamp (inclusive)
80
+ * @param {string} [options.tsTo] - End timestamp (inclusive)
81
+ * @param {number} [options.candidateK] - Internal candidate multiplier
82
+ * @returns {SearchHit[]} Search results
83
+ */
84
+ searchTsRange(queryEmbedding, options = {}) {
85
+ this._checkClosed();
86
+ return this._db.searchTsRange(queryEmbedding, options);
87
+ }
88
+
89
+ /**
90
+ * Update an existing row (marks old as deleted, creates new)
91
+ * @param {number} id - Row ID to update
92
+ * @param {number[]} embedding - New embedding
93
+ * @param {string} [text] - New text
94
+ * @param {string} [timestamp] - New timestamp
95
+ * @returns {number} New row ID
96
+ */
97
+ update(id, embedding, text, timestamp) {
98
+ this._checkClosed();
99
+ return this._db.update(id, embedding, text, timestamp);
100
+ }
101
+
102
+ /**
103
+ * Delete a row by ID
104
+ * @param {number} id - Row ID to delete
105
+ */
106
+ delete(id) {
107
+ this._checkClosed();
108
+ this._db.delete(id);
109
+ }
110
+
111
+ /**
112
+ * Get total row count (including deleted)
113
+ * @returns {number}
114
+ */
115
+ count() {
116
+ this._checkClosed();
117
+ return this._db.count();
118
+ }
119
+
120
+ /**
121
+ * Get live row count (excluding deleted)
122
+ * @returns {number}
123
+ */
124
+ countLive() {
125
+ this._checkClosed();
126
+ return this._db.countLive();
127
+ }
128
+
129
+ /**
130
+ * Get vector dimension
131
+ * @returns {number}
132
+ */
133
+ dim() {
134
+ return this._db.dim();
135
+ }
136
+
137
+ /**
138
+ * Close the database
139
+ */
140
+ close() {
141
+ if (!this._closed) {
142
+ this._db.close();
143
+ this._closed = true;
144
+ }
145
+ }
146
+
147
+ _checkClosed() {
148
+ if (this._closed) {
149
+ throw new Error('Database is closed');
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Inner product distance (default, requires L2-normalized vectors)
156
+ * @constant {number}
157
+ */
158
+ const DIST_IP = bindings.DIST_IP;
159
+
160
+ /**
161
+ * Cosine similarity (auto-normalizes vectors)
162
+ * @constant {number}
163
+ */
164
+ const DIST_COSINE = bindings.DIST_COSINE;
165
+
166
+ /**
167
+ * Euclidean distance (L2 space)
168
+ * @constant {number}
169
+ */
170
+ const DIST_L2 = bindings.DIST_L2;
171
+
172
+ /**
173
+ * Library version string
174
+ * @constant {string}
175
+ */
176
+ const VERSION = bindings.VERSION;
177
+
178
+ module.exports = {
179
+ DB,
180
+ DIST_IP,
181
+ DIST_COSINE,
182
+ DIST_L2,
183
+ VERSION,
184
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "logosdb",
3
+ "version": "0.7.1",
4
+ "description": "Fast semantic vector database (HNSW + mmap) - Node.js bindings",
5
+ "main": "lib/index.js",
6
+ "types": "types/index.d.ts",
7
+ "scripts": {
8
+ "install": "prebuild-install || node-gyp rebuild",
9
+ "build": "node-gyp rebuild",
10
+ "test": "mocha test/test.js",
11
+ "prebuild": "prebuild --runtime napi --all --strip",
12
+ "prebuild-upload": "prebuild --runtime napi --all --strip --upload-all"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/jose-compu/logosdb.git"
17
+ },
18
+ "keywords": [
19
+ "vector-database",
20
+ "hnsw",
21
+ "embeddings",
22
+ "semantic-search",
23
+ "nearest-neighbor",
24
+ "native-addon"
25
+ ],
26
+ "author": "Jose",
27
+ "license": "MIT",
28
+ "bugs": {
29
+ "url": "https://github.com/jose-compu/logosdb/issues"
30
+ },
31
+ "homepage": "https://github.com/jose-compu/logosdb#readme",
32
+ "dependencies": {
33
+ "bindings": "^1.5.0",
34
+ "node-addon-api": "^7.0.0",
35
+ "prebuild-install": "^7.1.1"
36
+ },
37
+ "devDependencies": {
38
+ "mocha": "^10.2.0",
39
+ "prebuild": "^12.1.0"
40
+ },
41
+ "binary": {
42
+ "napi_versions": [
43
+ 8
44
+ ]
45
+ },
46
+ "gypfile": true,
47
+ "engines": {
48
+ "node": ">=16.0.0"
49
+ },
50
+ "os": [
51
+ "darwin",
52
+ "linux",
53
+ "win32"
54
+ ],
55
+ "cpu": [
56
+ "x64",
57
+ "arm64"
58
+ ]
59
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Node.js N-API bindings for LogosDB
3
+ *
4
+ * Provides JavaScript bindings for the LogosDB C++ API.
5
+ */
6
+
7
+ #include <napi.h>
8
+ #include <logosdb/logosdb.h>
9
+ #include <cstring>
10
+ #include <string>
11
+ #include <vector>
12
+
13
+ // Helper to convert JS array to float vector
14
+ static std::vector<float> JsArrayToFloatVector(const Napi::Array& arr) {
15
+ std::vector<float> vec;
16
+ vec.reserve(arr.Length());
17
+ for (size_t i = 0; i < arr.Length(); i++) {
18
+ vec.push_back(arr.Get(i).As<Napi::Number>().FloatValue());
19
+ }
20
+ return vec;
21
+ }
22
+
23
+ // Helper to convert SearchHit to JS object
24
+ static Napi::Object SearchHitToJsObject(Napi::Env env, const logosdb_search_result_t* result, int index) {
25
+ Napi::Object obj = Napi::Object::New(env);
26
+
27
+ uint64_t id = logosdb_result_id(result, index);
28
+ float score = logosdb_result_score(result, index);
29
+ const char* text = logosdb_result_text(result, index);
30
+ const char* timestamp = logosdb_result_timestamp(result, index);
31
+
32
+ obj.Set("id", Napi::Number::New(env, static_cast<double>(id)));
33
+ obj.Set("score", Napi::Number::New(env, score));
34
+ if (text) {
35
+ obj.Set("text", Napi::String::New(env, text));
36
+ } else {
37
+ obj.Set("text", env.Null());
38
+ }
39
+ if (timestamp) {
40
+ obj.Set("timestamp", Napi::String::New(env, timestamp));
41
+ } else {
42
+ obj.Set("timestamp", env.Null());
43
+ }
44
+
45
+ return obj;
46
+ }
47
+
48
+ // DB wrapper class
49
+ class DBWrapper : public Napi::ObjectWrap<DBWrapper> {
50
+ public:
51
+ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
52
+ Napi::Function func = DefineClass(env, "DB", {
53
+ InstanceMethod("put", &DBWrapper::Put),
54
+ InstanceMethod("search", &DBWrapper::Search),
55
+ InstanceMethod("searchTsRange", &DBWrapper::SearchTsRange),
56
+ InstanceMethod("update", &DBWrapper::Update),
57
+ InstanceMethod("delete", &DBWrapper::Delete),
58
+ InstanceMethod("count", &DBWrapper::Count),
59
+ InstanceMethod("countLive", &DBWrapper::CountLive),
60
+ InstanceMethod("dim", &DBWrapper::Dim),
61
+ InstanceMethod("close", &DBWrapper::Close),
62
+ });
63
+
64
+ exports.Set("DB", func);
65
+ return exports;
66
+ }
67
+
68
+ DBWrapper(const Napi::CallbackInfo& info) : Napi::ObjectWrap<DBWrapper>(info) {
69
+ Napi::Env env = info.Env();
70
+
71
+ if (info.Length() < 1 || !info[0].IsString()) {
72
+ throw Napi::TypeError::New(env, "Path (string) expected as first argument");
73
+ }
74
+
75
+ std::string path = info[0].As<Napi::String>().Utf8Value();
76
+ int dim = 128; // default
77
+ size_t max_elements = 1000000;
78
+ int ef_construction = 200;
79
+ int M = 16;
80
+ int ef_search = 50;
81
+ int distance = 0; // LOGOSDB_DIST_IP
82
+
83
+ // Parse options object if provided
84
+ if (info.Length() > 1 && info[1].IsObject()) {
85
+ Napi::Object options = info[1].As<Napi::Object>();
86
+
87
+ if (options.Has("dim")) {
88
+ dim = options.Get("dim").As<Napi::Number>().Int32Value();
89
+ }
90
+ if (options.Has("maxElements")) {
91
+ max_elements = static_cast<size_t>(
92
+ options.Get("maxElements").As<Napi::Number>().Int64Value()
93
+ );
94
+ }
95
+ if (options.Has("efConstruction")) {
96
+ ef_construction = options.Get("efConstruction").As<Napi::Number>().Int32Value();
97
+ }
98
+ if (options.Has("M")) {
99
+ M = options.Get("M").As<Napi::Number>().Int32Value();
100
+ }
101
+ if (options.Has("efSearch")) {
102
+ ef_search = options.Get("efSearch").As<Napi::Number>().Int32Value();
103
+ }
104
+ if (options.Has("distance")) {
105
+ distance = options.Get("distance").As<Napi::Number>().Int32Value();
106
+ }
107
+ }
108
+
109
+ // Create options
110
+ logosdb_options_t* opts = logosdb_options_create();
111
+ logosdb_options_set_dim(opts, dim);
112
+ logosdb_options_set_max_elements(opts, max_elements);
113
+ logosdb_options_set_ef_construction(opts, ef_construction);
114
+ logosdb_options_set_M(opts, M);
115
+ logosdb_options_set_ef_search(opts, ef_search);
116
+ logosdb_options_set_distance(opts, distance);
117
+
118
+ char* err = nullptr;
119
+ db_ = logosdb_open(path.c_str(), opts, &err);
120
+ logosdb_options_destroy(opts);
121
+
122
+ if (!db_) {
123
+ std::string error_msg = err ? err : "Unknown error opening database";
124
+ if (err) free(err);
125
+ throw Napi::Error::New(env, error_msg);
126
+ }
127
+
128
+ dim_ = dim;
129
+ }
130
+
131
+ ~DBWrapper() {
132
+ if (db_) {
133
+ logosdb_close(db_);
134
+ db_ = nullptr;
135
+ }
136
+ }
137
+
138
+ private:
139
+ logosdb_t* db_ = nullptr;
140
+ int dim_ = 0;
141
+
142
+ Napi::Value Put(const Napi::CallbackInfo& info) {
143
+ Napi::Env env = info.Env();
144
+
145
+ if (info.Length() < 1 || !info[0].IsArray()) {
146
+ throw Napi::TypeError::New(env, "Embedding (array) expected as first argument");
147
+ }
148
+
149
+ Napi::Array embeddingArr = info[0].As<Napi::Array>();
150
+ std::vector<float> embedding = JsArrayToFloatVector(embeddingArr);
151
+
152
+ if ((int)embedding.size() != dim_) {
153
+ throw Napi::Error::New(env, "Embedding dimension mismatch");
154
+ }
155
+
156
+ const char* text = nullptr;
157
+ const char* timestamp = nullptr;
158
+
159
+ if (info.Length() > 1 && info[1].IsString()) {
160
+ text_str_ = info[1].As<Napi::String>().Utf8Value();
161
+ text = text_str_.c_str();
162
+ }
163
+
164
+ if (info.Length() > 2 && info[2].IsString()) {
165
+ ts_str_ = info[2].As<Napi::String>().Utf8Value();
166
+ timestamp = ts_str_.c_str();
167
+ }
168
+
169
+ char* err = nullptr;
170
+ uint64_t id = logosdb_put(db_, embedding.data(), dim_, text, timestamp, &err);
171
+
172
+ if (id == UINT64_MAX) {
173
+ std::string error_msg = err ? err : "Failed to insert";
174
+ if (err) free(err);
175
+ throw Napi::Error::New(env, error_msg);
176
+ }
177
+
178
+ if (err) free(err);
179
+
180
+ return Napi::Number::New(env, static_cast<double>(id));
181
+ }
182
+
183
+ // Storage for string references during put calls
184
+ std::string text_str_;
185
+ std::string ts_str_;
186
+
187
+ Napi::Value Search(const Napi::CallbackInfo& info) {
188
+ Napi::Env env = info.Env();
189
+
190
+ if (info.Length() < 1 || !info[0].IsArray()) {
191
+ throw Napi::TypeError::New(env, "Query embedding (array) expected as first argument");
192
+ }
193
+
194
+ Napi::Array queryArr = info[0].As<Napi::Array>();
195
+ std::vector<float> query = JsArrayToFloatVector(queryArr);
196
+
197
+ if ((int)query.size() != dim_) {
198
+ throw Napi::Error::New(env, "Query dimension mismatch");
199
+ }
200
+
201
+ int top_k = 10;
202
+ if (info.Length() > 1 && info[1].IsNumber()) {
203
+ top_k = info[1].As<Napi::Number>().Int32Value();
204
+ }
205
+
206
+ char* err = nullptr;
207
+ logosdb_search_result_t* result = logosdb_search(db_, query.data(), dim_, top_k, &err);
208
+
209
+ if (!result) {
210
+ std::string error_msg = err ? err : "Search failed";
211
+ if (err) free(err);
212
+ throw Napi::Error::New(env, error_msg);
213
+ }
214
+
215
+ if (err) free(err);
216
+
217
+ int count = logosdb_result_count(result);
218
+ Napi::Array results = Napi::Array::New(env, count);
219
+
220
+ for (int i = 0; i < count; i++) {
221
+ results.Set(i, SearchHitToJsObject(env, result, i));
222
+ }
223
+
224
+ logosdb_result_free(result);
225
+
226
+ return results;
227
+ }
228
+
229
+ Napi::Value SearchTsRange(const Napi::CallbackInfo& info) {
230
+ Napi::Env env = info.Env();
231
+
232
+ if (info.Length() < 1 || !info[0].IsArray()) {
233
+ throw Napi::TypeError::New(env, "Query embedding (array) expected");
234
+ }
235
+
236
+ Napi::Array queryArr = info[0].As<Napi::Array>();
237
+ std::vector<float> query = JsArrayToFloatVector(queryArr);
238
+
239
+ if ((int)query.size() != dim_) {
240
+ throw Napi::Error::New(env, "Query dimension mismatch");
241
+ }
242
+
243
+ int top_k = 10;
244
+ std::string ts_from;
245
+ std::string ts_to;
246
+ int candidate_k = 0;
247
+
248
+ if (info.Length() > 1 && info[1].IsObject()) {
249
+ Napi::Object options = info[1].As<Napi::Object>();
250
+
251
+ if (options.Has("topK")) {
252
+ top_k = options.Get("topK").As<Napi::Number>().Int32Value();
253
+ }
254
+ if (options.Has("tsFrom")) {
255
+ ts_from = options.Get("tsFrom").As<Napi::String>().Utf8Value();
256
+ }
257
+ if (options.Has("tsTo")) {
258
+ ts_to = options.Get("tsTo").As<Napi::String>().Utf8Value();
259
+ }
260
+ if (options.Has("candidateK")) {
261
+ candidate_k = options.Get("candidateK").As<Napi::Number>().Int32Value();
262
+ }
263
+ }
264
+
265
+ if (candidate_k < top_k) {
266
+ candidate_k = top_k * 10; // Default 10x
267
+ }
268
+
269
+ char* err = nullptr;
270
+ logosdb_search_result_t* result = logosdb_search_ts_range(
271
+ db_, query.data(), dim_, top_k,
272
+ ts_from.empty() ? nullptr : ts_from.c_str(),
273
+ ts_to.empty() ? nullptr : ts_to.c_str(),
274
+ candidate_k,
275
+ &err
276
+ );
277
+
278
+ if (!result) {
279
+ std::string error_msg = err ? err : "Search failed";
280
+ if (err) free(err);
281
+ throw Napi::Error::New(env, error_msg);
282
+ }
283
+
284
+ if (err) free(err);
285
+
286
+ int count = logosdb_result_count(result);
287
+ Napi::Array results = Napi::Array::New(env, count);
288
+
289
+ for (int i = 0; i < count; i++) {
290
+ results.Set(i, SearchHitToJsObject(env, result, i));
291
+ }
292
+
293
+ logosdb_result_free(result);
294
+
295
+ return results;
296
+ }
297
+
298
+ Napi::Value Update(const Napi::CallbackInfo& info) {
299
+ Napi::Env env = info.Env();
300
+
301
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsArray()) {
302
+ throw Napi::TypeError::New(env, "Expected (id, embedding, [text], [timestamp])");
303
+ }
304
+
305
+ uint64_t id = static_cast<uint64_t>(info[0].As<Napi::Number>().Int64Value());
306
+
307
+ Napi::Array embeddingArr = info[1].As<Napi::Array>();
308
+ std::vector<float> embedding = JsArrayToFloatVector(embeddingArr);
309
+
310
+ if ((int)embedding.size() != dim_) {
311
+ throw Napi::Error::New(env, "Embedding dimension mismatch");
312
+ }
313
+
314
+ const char* text = nullptr;
315
+ const char* timestamp = nullptr;
316
+
317
+ if (info.Length() > 2 && info[2].IsString()) {
318
+ text_str_ = info[2].As<Napi::String>().Utf8Value();
319
+ text = text_str_.c_str();
320
+ }
321
+
322
+ if (info.Length() > 3 && info[3].IsString()) {
323
+ ts_str_ = info[3].As<Napi::String>().Utf8Value();
324
+ timestamp = ts_str_.c_str();
325
+ }
326
+
327
+ char* err = nullptr;
328
+ uint64_t new_id = logosdb_update(db_, id, embedding.data(), dim_, text, timestamp, &err);
329
+
330
+ if (new_id == UINT64_MAX) {
331
+ std::string error_msg = err ? err : "Update failed";
332
+ if (err) free(err);
333
+ throw Napi::Error::New(env, error_msg);
334
+ }
335
+
336
+ if (err) free(err);
337
+
338
+ return Napi::Number::New(env, static_cast<double>(new_id));
339
+ }
340
+
341
+ Napi::Value Delete(const Napi::CallbackInfo& info) {
342
+ Napi::Env env = info.Env();
343
+
344
+ if (info.Length() < 1 || !info[0].IsNumber()) {
345
+ throw Napi::TypeError::New(env, "ID (number) expected");
346
+ }
347
+
348
+ uint64_t id = static_cast<uint64_t>(info[0].As<Napi::Number>().Int64Value());
349
+
350
+ char* err = nullptr;
351
+ int rc = logosdb_delete(db_, id, &err);
352
+
353
+ if (rc != 0) {
354
+ std::string error_msg = err ? err : "Delete failed";
355
+ if (err) free(err);
356
+ throw Napi::Error::New(env, error_msg);
357
+ }
358
+
359
+ if (err) free(err);
360
+ return env.Undefined();
361
+ }
362
+
363
+ Napi::Value Count(const Napi::CallbackInfo& info) {
364
+ Napi::Env env = info.Env();
365
+ size_t count = logosdb_count(db_);
366
+ return Napi::Number::New(env, static_cast<double>(count));
367
+ }
368
+
369
+ Napi::Value CountLive(const Napi::CallbackInfo& info) {
370
+ Napi::Env env = info.Env();
371
+ size_t count = logosdb_count_live(db_);
372
+ return Napi::Number::New(env, static_cast<double>(count));
373
+ }
374
+
375
+ Napi::Value Dim(const Napi::CallbackInfo& info) {
376
+ Napi::Env env = info.Env();
377
+ return Napi::Number::New(env, dim_);
378
+ }
379
+
380
+ Napi::Value Close(const Napi::CallbackInfo& info) {
381
+ Napi::Env env = info.Env();
382
+ if (db_) {
383
+ logosdb_close(db_);
384
+ db_ = nullptr;
385
+ }
386
+ return env.Undefined();
387
+ }
388
+ };
389
+
390
+ // Constants
391
+ static void DefineConstants(Napi::Env env, Napi::Object exports) {
392
+ exports.Set("DIST_IP", Napi::Number::New(env, LOGOSDB_DIST_IP));
393
+ exports.Set("DIST_COSINE", Napi::Number::New(env, LOGOSDB_DIST_COSINE));
394
+ exports.Set("DIST_L2", Napi::Number::New(env, LOGOSDB_DIST_L2));
395
+ exports.Set("VERSION", Napi::String::New(env, LOGOSDB_VERSION_STRING));
396
+ }
397
+
398
+ // Module initialization
399
+ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
400
+ DBWrapper::Init(env, exports);
401
+ DefineConstants(env, exports);
402
+ return exports;
403
+ }
404
+
405
+ NODE_API_MODULE(logosdb, Init)
package/test/test.js ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * LogosDB Node.js bindings tests
3
+ */
4
+
5
+ 'use strict';
6
+
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const { DB, DIST_IP, DIST_COSINE, DIST_L2, VERSION } = require('../lib');
12
+
13
+ // Helper to generate random unit vector
14
+ function randomUnitVector(dim, seed = 0) {
15
+ const vec = new Array(dim);
16
+ let sum = 0;
17
+
18
+ // Simple pseudo-random for deterministic tests
19
+ let state = seed;
20
+ for (let i = 0; i < dim; i++) {
21
+ state = (state * 9301 + 49297) % 233280;
22
+ const rnd = state / 233280;
23
+ vec[i] = rnd * 2 - 1; // -1 to 1
24
+ sum += vec[i] * vec[i];
25
+ }
26
+
27
+ // Normalize
28
+ const norm = Math.sqrt(sum);
29
+ return vec.map(v => v / norm);
30
+ }
31
+
32
+ // Helper to create temp directory
33
+ function createTempDir(prefix = 'logosdb-test-') {
34
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
35
+ }
36
+
37
+ // Helper to cleanup
38
+ function cleanup(dir) {
39
+ try {
40
+ fs.rmSync(dir, { recursive: true, force: true });
41
+ } catch (e) {
42
+ // Ignore cleanup errors
43
+ }
44
+ }
45
+
46
+ describe('LogosDB Node.js', function() {
47
+ this.timeout(10000); // 10 second timeout for tests
48
+
49
+ describe('Constants', () => {
50
+ it('should export distance constants', () => {
51
+ assert.strictEqual(typeof DIST_IP, 'number');
52
+ assert.strictEqual(typeof DIST_COSINE, 'number');
53
+ assert.strictEqual(typeof DIST_L2, 'number');
54
+ });
55
+
56
+ it('should export version', () => {
57
+ assert.strictEqual(typeof VERSION, 'string');
58
+ assert.ok(VERSION.match(/^\d+\.\d+\.\d+/));
59
+ });
60
+ });
61
+
62
+ describe('DB Creation', () => {
63
+ let dbPath;
64
+
65
+ afterEach(() => {
66
+ if (dbPath) cleanup(dbPath);
67
+ });
68
+
69
+ it('should create a new database', () => {
70
+ dbPath = createTempDir();
71
+ const db = new DB(dbPath, { dim: 128 });
72
+ assert.ok(db);
73
+ assert.strictEqual(db.dim(), 128);
74
+ db.close();
75
+ });
76
+
77
+ it('should use default options', () => {
78
+ dbPath = createTempDir();
79
+ const db = new DB(dbPath);
80
+ assert.strictEqual(db.dim(), 128); // default
81
+ db.close();
82
+ });
83
+
84
+ it('should support custom options', () => {
85
+ dbPath = createTempDir();
86
+ const db = new DB(dbPath, {
87
+ dim: 256,
88
+ maxElements: 10000,
89
+ efConstruction: 100,
90
+ M: 8,
91
+ efSearch: 30,
92
+ distance: DIST_COSINE
93
+ });
94
+ assert.strictEqual(db.dim(), 256);
95
+ db.close();
96
+ });
97
+ });
98
+
99
+ describe('Basic Operations', () => {
100
+ let db, dbPath;
101
+
102
+ beforeEach(() => {
103
+ dbPath = createTempDir();
104
+ db = new DB(dbPath, { dim: 64 });
105
+ });
106
+
107
+ afterEach(() => {
108
+ if (db) db.close();
109
+ cleanup(dbPath);
110
+ });
111
+
112
+ it('should put and return an id', () => {
113
+ const vec = randomUnitVector(64, 1);
114
+ const id = db.put(vec, 'test text', '2025-01-01T00:00:00Z');
115
+ assert.strictEqual(typeof id, 'number');
116
+ assert.ok(id >= 0);
117
+ });
118
+
119
+ it('should put without text and timestamp', () => {
120
+ const vec = randomUnitVector(64, 2);
121
+ const id = db.put(vec);
122
+ assert.strictEqual(typeof id, 'number');
123
+ });
124
+
125
+ it('should count documents', () => {
126
+ assert.strictEqual(db.count(), 0);
127
+ assert.strictEqual(db.countLive(), 0);
128
+
129
+ db.put(randomUnitVector(64, 3));
130
+ assert.strictEqual(db.count(), 1);
131
+ assert.strictEqual(db.countLive(), 1);
132
+ });
133
+ });
134
+
135
+ describe('Search', () => {
136
+ let db, dbPath;
137
+
138
+ beforeEach(() => {
139
+ dbPath = createTempDir();
140
+ db = new DB(dbPath, { dim: 64, distance: DIST_COSINE });
141
+ });
142
+
143
+ afterEach(() => {
144
+ if (db) db.close();
145
+ cleanup(dbPath);
146
+ });
147
+
148
+ it('should search and return hits', () => {
149
+ const vec1 = randomUnitVector(64, 10);
150
+ const vec2 = randomUnitVector(64, 11);
151
+ const vec3 = randomUnitVector(64, 12);
152
+
153
+ db.put(vec1, 'first');
154
+ db.put(vec2, 'second');
155
+ db.put(vec3, 'third');
156
+
157
+ const hits = db.search(vec1, 3);
158
+ assert.ok(Array.isArray(hits));
159
+ assert.ok(hits.length <= 3);
160
+
161
+ if (hits.length > 0) {
162
+ assert.ok(hits[0].id >= 0);
163
+ assert.ok(hits[0].score > 0);
164
+ assert.ok(typeof hits[0].text === 'string' || hits[0].text === null);
165
+ }
166
+ });
167
+
168
+ it('should find similar vectors', () => {
169
+ // Use same seed to get identical vectors
170
+ const vec = randomUnitVector(64, 20);
171
+
172
+ db.put(vec, 'original');
173
+
174
+ // Search with same vector
175
+ const hits = db.search(vec, 1);
176
+ assert.strictEqual(hits.length, 1);
177
+ assert.strictEqual(hits[0].text, 'original');
178
+ assert.ok(hits[0].score > 0.99, 'Self-similarity should be ~1.0');
179
+ });
180
+ });
181
+
182
+ describe('Timestamp Range Search', () => {
183
+ let db, dbPath;
184
+
185
+ beforeEach(() => {
186
+ dbPath = createTempDir();
187
+ db = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
188
+ });
189
+
190
+ afterEach(() => {
191
+ if (db) db.close();
192
+ cleanup(dbPath);
193
+ });
194
+
195
+ it('should search with timestamp filter', () => {
196
+ const vec = randomUnitVector(32, 30);
197
+
198
+ db.put(vec, 'early', '2025-01-01T00:00:00Z');
199
+ db.put(vec, 'mid', '2025-01-15T00:00:00Z');
200
+ db.put(vec, 'late', '2025-02-01T00:00:00Z');
201
+
202
+ const hits = db.searchTsRange(vec, {
203
+ topK: 10,
204
+ tsFrom: '2025-01-01T00:00:00Z',
205
+ tsTo: '2025-01-31T23:59:59Z'
206
+ });
207
+
208
+ assert.ok(hits.length >= 2);
209
+ // Should find early and mid, but not late
210
+ const texts = hits.map(h => h.text);
211
+ assert.ok(texts.includes('early'));
212
+ assert.ok(texts.includes('mid'));
213
+ });
214
+ });
215
+
216
+ describe('Update and Delete', () => {
217
+ let db, dbPath;
218
+
219
+ beforeEach(() => {
220
+ dbPath = createTempDir();
221
+ db = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
222
+ });
223
+
224
+ afterEach(() => {
225
+ if (db) db.close();
226
+ cleanup(dbPath);
227
+ });
228
+
229
+ it('should update a row', () => {
230
+ const vec = randomUnitVector(32, 40);
231
+ const id = db.put(vec, 'original');
232
+
233
+ const newVec = randomUnitVector(32, 41);
234
+ const newId = db.update(id, newVec, 'updated');
235
+
236
+ assert.strictEqual(typeof newId, 'number');
237
+ assert.ok(newId > id);
238
+ assert.strictEqual(db.countLive(), 1); // Old marked deleted, new added
239
+ assert.strictEqual(db.count(), 2); // Total includes both
240
+ });
241
+
242
+ it('should delete a row', () => {
243
+ const vec = randomUnitVector(32, 50);
244
+ const id = db.put(vec, 'to delete');
245
+
246
+ assert.strictEqual(db.countLive(), 1);
247
+
248
+ db.delete(id);
249
+
250
+ assert.strictEqual(db.countLive(), 0);
251
+ assert.strictEqual(db.count(), 1); // Still counted but marked deleted
252
+ });
253
+ });
254
+
255
+ describe('Error Handling', () => {
256
+ let dbPath;
257
+
258
+ afterEach(() => {
259
+ cleanup(dbPath);
260
+ });
261
+
262
+ it('should throw on dimension mismatch', () => {
263
+ dbPath = createTempDir();
264
+ const db = new DB(dbPath, { dim: 64 });
265
+
266
+ assert.throws(() => {
267
+ db.put(randomUnitVector(32)); // Wrong dimension
268
+ }, /dimension/);
269
+
270
+ db.close();
271
+ });
272
+
273
+ it('should throw on closed database access', () => {
274
+ dbPath = createTempDir();
275
+ const db = new DB(dbPath, { dim: 64 });
276
+ db.close();
277
+
278
+ assert.throws(() => {
279
+ db.put(randomUnitVector(64));
280
+ }, /closed/);
281
+ });
282
+ });
283
+
284
+ describe('Persistence', () => {
285
+ let dbPath;
286
+
287
+ afterEach(() => {
288
+ cleanup(dbPath);
289
+ });
290
+
291
+ it('should persist data across reopen', () => {
292
+ dbPath = createTempDir();
293
+
294
+ // First session: insert
295
+ let db1 = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
296
+ const vec = randomUnitVector(32, 60);
297
+ const id = db1.put(vec, 'persistent');
298
+ db1.close();
299
+
300
+ // Second session: verify
301
+ let db2 = new DB(dbPath, { dim: 32, distance: DIST_COSINE });
302
+ assert.strictEqual(db2.countLive(), 1);
303
+
304
+ const hits = db2.search(vec, 1);
305
+ assert.strictEqual(hits.length, 1);
306
+ assert.strictEqual(hits[0].text, 'persistent');
307
+ db2.close();
308
+ });
309
+ });
310
+ });
311
+
312
+ // Run tests if executed directly
313
+ if (require.main === module) {
314
+ const Mocha = require('mocha');
315
+ const mocha = new Mocha();
316
+ mocha.suite.emit('pre-require', global, 'test.js', mocha);
317
+
318
+ // Simple test runner for standalone execution
319
+ console.log('Running LogosDB Node.js tests...\n');
320
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * LogosDB - Fast semantic vector database (HNSW + mmap)
3
+ * TypeScript definitions
4
+ */
5
+
6
+ /**
7
+ * Search result hit
8
+ */
9
+ export interface SearchHit {
10
+ /** Row ID */
11
+ id: number;
12
+ /** Similarity score (0-1) */
13
+ score: number;
14
+ /** Associated text */
15
+ text: string | null;
16
+ /** ISO 8601 timestamp */
17
+ timestamp: string | null;
18
+ }
19
+
20
+ /**
21
+ * Database options
22
+ */
23
+ export interface DBOptions {
24
+ /** Vector dimension (default: 128) */
25
+ dim?: number;
26
+ /** Maximum capacity (default: 1,000,000) */
27
+ maxElements?: number;
28
+ /** HNSW construction parameter (default: 200) */
29
+ efConstruction?: number;
30
+ /** HNSW M parameter (default: 16) */
31
+ M?: number;
32
+ /** HNSW search parameter (default: 50) */
33
+ efSearch?: number;
34
+ /** Distance metric (default: DIST_IP) */
35
+ distance?: number;
36
+ }
37
+
38
+ /**
39
+ * Timestamp range search options
40
+ */
41
+ export interface TsRangeOptions {
42
+ /** Number of results (default: 10) */
43
+ topK?: number;
44
+ /** Start timestamp (inclusive) */
45
+ tsFrom?: string;
46
+ /** End timestamp (inclusive) */
47
+ tsTo?: string;
48
+ /** Internal candidate multiplier */
49
+ candidateK?: number;
50
+ }
51
+
52
+ /**
53
+ * LogosDB database instance
54
+ */
55
+ export class DB {
56
+ /**
57
+ * Create a new LogosDB instance
58
+ * @param path - Database directory path
59
+ * @param options - Database options
60
+ */
61
+ constructor(path: string, options?: DBOptions);
62
+
63
+ /**
64
+ * Insert a vector with optional text and timestamp
65
+ * @param embedding - Float vector
66
+ * @param text - Optional text metadata
67
+ * @param timestamp - Optional ISO 8601 timestamp
68
+ * @returns Assigned row ID
69
+ */
70
+ put(embedding: number[], text?: string, timestamp?: string): number;
71
+
72
+ /**
73
+ * Search for similar vectors
74
+ * @param queryEmbedding - Query vector
75
+ * @param topK - Number of results (default: 10)
76
+ * @returns Search results
77
+ */
78
+ search(queryEmbedding: number[], topK?: number): SearchHit[];
79
+
80
+ /**
81
+ * Search with timestamp range filter
82
+ * @param queryEmbedding - Query vector
83
+ * @param options - Search options
84
+ * @returns Search results
85
+ */
86
+ searchTsRange(queryEmbedding: number[], options?: TsRangeOptions): SearchHit[];
87
+
88
+ /**
89
+ * Update an existing row (marks old as deleted, creates new)
90
+ * @param id - Row ID to update
91
+ * @param embedding - New embedding
92
+ * @param text - New text
93
+ * @param timestamp - New timestamp
94
+ * @returns New row ID
95
+ */
96
+ update(id: number, embedding: number[], text?: string, timestamp?: string): number;
97
+
98
+ /**
99
+ * Delete a row by ID
100
+ * @param id - Row ID to delete
101
+ */
102
+ delete(id: number): void;
103
+
104
+ /**
105
+ * Get total row count (including deleted)
106
+ * @returns Total count
107
+ */
108
+ count(): number;
109
+
110
+ /**
111
+ * Get live row count (excluding deleted)
112
+ * @returns Live count
113
+ */
114
+ countLive(): number;
115
+
116
+ /**
117
+ * Get vector dimension
118
+ * @returns Dimension
119
+ */
120
+ dim(): number;
121
+
122
+ /**
123
+ * Close the database
124
+ */
125
+ close(): void;
126
+ }
127
+
128
+ /** Inner product distance (default, requires L2-normalized vectors) */
129
+ export const DIST_IP: number;
130
+
131
+ /** Cosine similarity (auto-normalizes vectors) */
132
+ export const DIST_COSINE: number;
133
+
134
+ /** Euclidean distance (L2 space) */
135
+ export const DIST_L2: number;
136
+
137
+ /** Library version string */
138
+ export const VERSION: string;