• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25626413047

10 May 2026 10:31AM UTC coverage: 66.053% (+0.2%) from 65.897%
25626413047

push

github

web-flow
feat(engine): Phase 11.1 multi-connection foundation (SQLR-22) (#122)

First slice of Phase 11 (concurrent writes via MVCC + BEGIN CONCURRENT).
Refactors Connection from owning Database by value to holding
Arc<Mutex<Database>>, so multiple Connection handles can address the
same engine state from inside one process.

Connection::connect() mints a sibling handle that shares the backing
Database. Connection is now Send + Sync and can be moved across
threads without an outer Mutex<Connection>. The pager's existing
process-level flock and per-database mutex still serialize commits;
true multi-writer throughput on disjoint rows lands with
BEGIN CONCURRENT in 11.4.

Connection::database() / database_mut() return MutexGuard<'_, Database>;
internal call sites in src/ask, sqlrite-mcp tools, and the
Python/Node/WASM SDK shims bind the guard to a local first.

Six new tests in src/connection.rs:
- connect_shares_underlying_database
- connect_shares_file_backed_database
- handle_count_reflects_live_handles
- threaded_writers_serialize_cleanly (8 threads × 25 INSERTs)
- prep_cache_is_per_handle
- connection_is_send_and_sync (compile-time Send+Sync check)

Docs sweep: roadmap gets a Phase 11 section linking to
concurrent-writes-plan.md (the plan-doc internally numbers
sub-phases as "Phase 10.x" — that's its working title from before
Phase 10 = benchmarks shipped). New design-decisions entry 12a.
embedding.md gains a "Sharing one database across threads" section.

No file-format change. 579/579 workspace tests pass. No new clippy
warnings. fmt clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

91 of 110 new or added lines in 6 files covered. (82.73%)

1 existing line in 1 file now uncovered.

9468 of 14334 relevant lines covered (66.05%)

1.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/sdk/nodejs/src/lib.rs
1
//! Node.js bindings for SQLRite (Phase 5d).
2
//!
3
//! Shipped as the `@joaoh82/sqlrite` npm package — scoped because
4
//! the unscoped `sqlrite` name was rejected by npm's similarity
5
//! check against `sqlite` / `sqlite3`. Shape inspired by
6
//! [`better-sqlite3`](https://github.com/WiseLibs/better-sqlite3)
7
//! (sync API, row-as-object), so JavaScript callers familiar with
8
//! that library can pick this up immediately:
9
//!
10
//! ```js
11
//! import { Database } from '@joaoh82/sqlrite';
12
//!
13
//! const db = new Database('foo.sqlrite');
14
//! db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)');
15
//! db.prepare("INSERT INTO users (name) VALUES ('alice')").run();
16
//!
17
//! for (const row of db.prepare('SELECT id, name FROM users').iterate()) {
18
//!   console.log(row); // { id: 1, name: 'alice' }
19
//! }
20
//!
21
//! db.close();
22
//! ```
23
//!
24
//! ## Implementation
25
//!
26
//! - Wraps the Rust `sqlrite::Connection` directly. Like the Python
27
//!   binding, we skip the C FFI hop — napi-rs hands us typed JS
28
//!   values directly.
29
//! - Sync API, not async — the engine is in-process and most
30
//!   operations finish in microseconds. Promises would add overhead
31
//!   and make the API heavier.
32
//! - Rows come back as plain JS objects keyed by column name, which
33
//!   matches what Node devs expect from `better-sqlite3`.
34
//! - Errors surface as JS `Error` instances; the message matches the
35
//!   Rust `SQLRiteError` Display output.
36
//! - Parameter binding is deferred until Phase 5a.2 lands real
37
//!   binding in the engine. The wrapper accepts the positional-args
38
//!   shape for forward compat but throws on non-empty args.
39

40
use std::cell::RefCell;
41
use std::path::PathBuf;
42

43
use napi::bindgen_prelude::*;
44
use napi::{Env, JsObject, JsUnknown};
45
use napi_derive::napi;
46

47
use sqlrite::ask::{AskConfig as RustAskConfig, CacheTtl, ProviderKind, ask_with_database};
48
use sqlrite::{Connection as RustConnection, OwnedRow, Rows, Value};
49

50
// ---------------------------------------------------------------------------
51
// Helpers
52

53
fn map_err<E: std::fmt::Display>(e: E) -> napi::Error {
54
    napi::Error::from_reason(e.to_string())
55
}
56

57
/// Converts a `sqlrite::Value` into a napi-compatible JS value using the
58
/// env to allocate. Used both for row values and for error contexts.
59
fn value_to_js(env: &Env, v: &Value) -> Result<JsUnknown> {
60
    match v {
61
        Value::Integer(n) => Ok(env.create_int64(*n)?.into_unknown()),
62
        Value::Real(f) => Ok(env.create_double(*f)?.into_unknown()),
63
        Value::Text(s) => Ok(env.create_string(s)?.into_unknown()),
64
        Value::Bool(b) => Ok(env.get_boolean(*b)?.into_unknown()),
65
        // Phase 7a — `VECTOR(N)` columns surface to JS as `Array<number>`.
66
        // Widening f32→f64 since JS Number is f64-backed; no precision lost.
67
        // Future polish: optionally hand back a Float32Array (typed array)
68
        // for memory-efficient transfer of high-dim vectors.
69
        Value::Vector(elements) => {
70
            let mut arr = env.create_array_with_length(elements.len())?;
71
            for (i, x) in elements.iter().enumerate() {
72
                arr.set_element(i as u32, env.create_double(*x as f64)?)?;
73
            }
74
            Ok(arr.into_unknown())
75
        }
76
        Value::Null => Ok(env.get_null()?.into_unknown()),
77
    }
78
}
79

80
fn row_to_js_object(env: &Env, columns: &[String], row: &OwnedRow) -> Result<JsObject> {
81
    let mut obj = env.create_object()?;
82
    for (i, col) in columns.iter().enumerate() {
83
        let v = row.values.get(i).cloned().unwrap_or(Value::Null);
84
        let js = value_to_js(env, &v)?;
85
        obj.set_named_property(col, js)?;
86
    }
87
    Ok(obj)
88
}
89

90
/// Throws on any non-empty positional-args value. Placeholder until
91
/// Phase 5a.2 lands real parameter binding across the stack.
92
///
93
/// napi-rs auto-coerces `undefined` and `null` on the JS side to
94
/// `None` in Rust, and arrays land here as `Some(Vec<_>)`. Anything
95
/// else that isn't an array (a plain object, a string, etc.) never
96
/// makes it past napi's type check, so we only have to handle the
97
/// three cases.
98
fn reject_params_for_now(params: &Option<Vec<JsUnknown>>) -> Result<()> {
99
    match params {
100
        None => Ok(()),
101
        Some(v) if v.is_empty() => Ok(()),
102
        Some(_) => Err(napi::Error::from_reason(
103
            "parameter binding is not yet supported — inline values into the SQL \
104
             (a future Phase 5a.2 release will add real binding)",
105
        )),
106
    }
107
}
108

109
// ---------------------------------------------------------------------------
110
// Database
111
//
112
// Wraps `RustConnection` + a detach-from-borrow-via-OwnedRow Rows
113
// handle stored per-Statement, mirroring the Python SDK's shape.
114

115
#[napi]
116
pub struct Database {
117
    // RefCell because napi #[napi] methods receive `&mut self` but
118
    // inner shared state across Statement children reads the same
119
    // connection — the engine is single-threaded, so a RefCell is
120
    // sufficient. For cross-thread sharing Node users would call
121
    // `worker_threads`, which gives each worker its own `.node`
122
    // import + its own Database instance.
123
    inner: RefCell<Option<RustConnection>>,
124
    // Phase 7g.5 — per-connection ask() config. Set via
125
    // `setAskConfig()` or passed per-call to `ask()` / `askRun()`.
126
    // When None, `ask()` falls back to `AskConfig.fromEnv()` so
127
    // env-only consumers get the zero-config experience matching the
128
    // REPL, Desktop, and Python SDK surfaces.
129
    ask_config: RefCell<Option<RustAskConfig>>,
130
}
131

132
#[napi]
133
impl Database {
134
    /// Opens (or creates) a database file. Pass `":memory:"` for an
135
    /// in-memory DB (matching better-sqlite3 convention).
136
    #[napi(constructor)]
137
    pub fn new(database: String) -> Result<Self> {
×
138
        let conn = if database == ":memory:" {
×
139
            RustConnection::open_in_memory().map_err(map_err)?
×
140
        } else {
141
            RustConnection::open(PathBuf::from(database)).map_err(map_err)?
×
142
        };
143
        Ok(Self {
×
144
            inner: RefCell::new(Some(conn)),
×
145
            ask_config: RefCell::new(None),
×
146
        })
147
    }
148

149
    /// Opens an existing file read-only — shared OS lock, multi-reader
150
    /// safe, any write throws.
151
    #[napi(factory)]
152
    pub fn open_read_only(database: String) -> Result<Self> {
×
153
        let conn = RustConnection::open_read_only(PathBuf::from(database)).map_err(map_err)?;
×
154
        Ok(Self {
×
155
            inner: RefCell::new(Some(conn)),
×
156
            ask_config: RefCell::new(None),
×
157
        })
158
    }
159

160
    /// Runs one or more SQL statements. Use for DDL / DML /
161
    /// transactions — there's no return value, just a throw on error.
162
    #[napi]
163
    pub fn exec(&self, sql: String) -> Result<()> {
×
164
        let mut borrow = self.inner.borrow_mut();
×
165
        let conn = borrow
×
166
            .as_mut()
×
167
            .ok_or_else(|| napi::Error::from_reason("cannot exec: database is closed"))?;
×
168
        conn.execute(&sql).map_err(map_err)?;
×
169
        Ok(())
×
170
    }
171

172
    /// Prepares a SQL statement. Returned `Statement` runs in the
173
    /// context of this Database — once the Database is closed, its
174
    /// Statements throw on any operation.
175
    #[napi]
176
    pub fn prepare(&self, sql: String) -> Result<Statement> {
×
177
        // We verify the SQL parses at prepare time so syntax errors
178
        // surface early, matching better-sqlite3's behavior.
179
        let mut borrow = self.inner.borrow_mut();
×
180
        let conn = borrow
×
181
            .as_mut()
×
182
            .ok_or_else(|| napi::Error::from_reason("cannot prepare: database is closed"))?;
×
183
        let _ = conn.prepare(&sql).map_err(map_err)?;
×
184
        Ok(Statement {
×
185
            db_raw: self as *const Database,
×
186
            sql,
×
187
        })
188
    }
189

190
    /// Closes the connection and releases the OS file lock. Safe to
191
    /// call multiple times.
192
    #[napi]
193
    pub fn close(&self) -> Result<()> {
×
194
        *self.inner.borrow_mut() = None;
×
195
        Ok(())
×
196
    }
197

198
    #[napi(getter)]
199
    pub fn in_transaction(&self) -> Result<bool> {
×
200
        let borrow = self.inner.borrow();
×
201
        let conn = borrow
×
202
            .as_ref()
×
203
            .ok_or_else(|| napi::Error::from_reason("database is closed"))?;
×
204
        Ok(conn.in_transaction())
×
205
    }
206

207
    #[napi(getter)]
208
    pub fn readonly(&self) -> Result<bool> {
×
209
        let borrow = self.inner.borrow();
×
210
        let conn = borrow
×
211
            .as_ref()
×
212
            .ok_or_else(|| napi::Error::from_reason("database is closed"))?;
×
213
        Ok(conn.is_read_only())
×
214
    }
215

216
    // -----------------------------------------------------------------
217
    // Phase 7g.5 — natural-language → SQL.
218
    //
219
    // Three entry points, mirroring the Python SDK shape:
220
    //   * `setAskConfig(cfg)` stores a config on the DB so subsequent
221
    //     `ask()` calls reuse it without reconfiguring. Pass `null` to
222
    //     clear and fall back to env/defaults.
223
    //   * `ask(question, config?)` generates SQL — does NOT execute.
224
    //     Returns an `AskResponse` with `.sql` / `.explanation` /
225
    //     `.usage`.
226
    //   * `askRun(question, config?)` is the convenience that calls
227
    //     `ask()` then `prepare(resp.sql).all()` — returns the result
228
    //     rows directly. Empty SQL response (model declined) throws
229
    //     with the model's explanation rather than executing the
230
    //     empty string.
231
    //
232
    // Config resolution (when `config` arg omitted / null):
233
    //   1. Per-connection config from setAskConfig() if set.
234
    //   2. AskConfig.fromEnv() — reads SQLRITE_LLM_API_KEY etc.
235
    //   3. Built-in defaults (Sonnet 4.6, max_tokens 1024, 5-min cache).
236
    //
237
    // GIL note: napi-rs methods run synchronously on Node's main
238
    // event loop. The HTTP call inside ask_with_database() uses
239
    // ureq's blocking POST — Node's event loop is busy for the
240
    // round-trip duration (~hundreds of ms typical, capped at 90s
241
    // by ureq). A pure-Node HTTP mock listening on the same event
242
    // loop would deadlock (matches the Python GIL constraint we
243
    // hit in 7g.4); the test suite spins the mock in a
244
    // worker_thread to bypass this.
245

246
    /// Stash an `AskConfig` on the database. Subsequent `ask()` and
247
    /// `askRun()` calls without an explicit config use this.
248
    #[napi]
249
    pub fn set_ask_config(&self, config: Option<&AskConfig>) {
×
250
        *self.ask_config.borrow_mut() = config.map(|c| c.inner.clone());
×
251
    }
252

253
    /// Generate SQL from a natural-language question. Does **not**
254
    /// execute — call `db.prepare(resp.sql).all()` (or use `askRun()`
255
    /// for one-shot). Returns an `AskResponse` carrying `.sql`,
256
    /// `.explanation`, and `.usage`.
257
    #[napi]
258
    pub fn ask(&self, question: String, config: Option<&AskConfig>) -> Result<AskResponse> {
×
259
        let resolved = self.resolve_ask_config(config)?;
×
260
        let borrow = self.inner.borrow();
×
261
        let conn = borrow
×
262
            .as_ref()
×
263
            .ok_or_else(|| napi::Error::from_reason("cannot ask: database is closed"))?;
×
NEW
264
        let db = conn.database();
×
NEW
265
        let resp = ask_with_database(&db, &question, &resolved).map_err(map_err)?;
×
266
        Ok(AskResponse {
×
267
            sql: resp.sql,
×
268
            explanation: resp.explanation,
×
269
            usage: AskUsage {
×
270
                input_tokens: resp.usage.input_tokens as i64,
×
271
                output_tokens: resp.usage.output_tokens as i64,
×
272
                cache_creation_input_tokens: resp.usage.cache_creation_input_tokens as i64,
×
273
                cache_read_input_tokens: resp.usage.cache_read_input_tokens as i64,
×
274
            },
275
        })
276
    }
277

278
    /// Generate SQL **and execute it as a SELECT**. Returns rows as
279
    /// `Array<Object>` (same shape as `prepare(sql).all()`). Errors
280
    /// the same way `ask()` does on generation failure, and the same
281
    /// way `prepare().all()` does on bad-SQL execution failure.
282
    ///
283
    /// **Throws on empty SQL.** When the model declines to generate
284
    /// SQL (returns an empty `sql` string with an explanation), this
285
    /// throws rather than executing the empty string — the
286
    /// explanation is in the error message.
287
    ///
288
    /// Convenience for one-shot scripts. For interactive use, prefer
289
    /// `ask()` + manual review (the model can be wrong; auto-execute
290
    /// hides that).
291
    #[napi]
292
    pub fn ask_run(
×
293
        &self,
294
        env: Env,
295
        question: String,
296
        config: Option<&AskConfig>,
297
    ) -> Result<Vec<JsUnknown>> {
298
        let resp = self.ask(question, config)?;
×
299
        let trimmed = resp.sql.trim();
×
300
        if trimmed.is_empty() {
×
301
            return Err(napi::Error::from_reason(format!(
×
302
                "model declined to generate SQL: {}",
×
303
                if resp.explanation.is_empty() {
×
304
                    "(no explanation)"
×
305
                } else {
306
                    resp.explanation.as_str()
×
307
                }
308
            )));
309
        }
310
        // Re-borrow for the execution. This is intentionally a fresh
311
        // borrow — the borrow guard from `ask()` already released
312
        // before we got here.
313
        let mut borrow = self.inner.borrow_mut();
×
314
        let conn = borrow
×
315
            .as_mut()
×
316
            .ok_or_else(|| napi::Error::from_reason("cannot askRun: database is closed"))?;
×
317
        let stmt = conn.prepare(trimmed).map_err(map_err)?;
×
318
        let mut rows: Rows = stmt.query().map_err(map_err)?;
×
319
        let columns = rows.columns().to_vec();
×
320
        let mut out: Vec<JsUnknown> = Vec::new();
×
321
        while let Some(row) = rows.next().map_err(map_err)? {
×
322
            let owned = row.to_owned_row();
×
323
            out.push(row_to_js_object(&env, &columns, &owned)?.into_unknown());
×
324
        }
325
        Ok(out)
×
326
    }
327
}
328

329
// Free helper hung off Database (not in the #[napi] block) so it
330
// stays implementation-private — JS can't call it directly.
331
impl Database {
332
    fn resolve_ask_config(&self, per_call: Option<&AskConfig>) -> Result<RustAskConfig> {
×
333
        if let Some(cfg) = per_call {
×
334
            return Ok(cfg.inner.clone());
×
335
        }
336
        if let Some(cfg) = self.ask_config.borrow().as_ref() {
×
337
            return Ok(cfg.clone());
×
338
        }
339
        RustAskConfig::from_env().map_err(map_err)
×
340
    }
341
}
342

343
// ---------------------------------------------------------------------------
344
// AskConfig (Phase 7g.5)
345
//
346
// Constructed from a JS option object (idiomatic Node) instead of
347
// kwargs. Same field names as the Python SDK but camelCase per JS
348
// convention (apiKey vs api_key, maxTokens vs max_tokens, etc.).
349
//
350
// Three precedence layers when calling `db.ask(q, cfg?)`:
351
//   1. per-call cfg   (highest)
352
//   2. setAskConfig() stored on db
353
//   3. AskConfig.fromEnv() — SQLRITE_LLM_* env vars
354
//   4. AskConfig() defaults — anthropic / claude-sonnet-4-6 / 1024 / 5m
355

356
/// Options accepted by the AskConfig constructor.
357
///
358
/// All fields are optional; unset fields take the same defaults as
359
/// the Rust side (provider=anthropic, model=`claude-sonnet-4-6`,
360
/// maxTokens=1024, cacheTtl="5m").
361
#[napi(object)]
362
pub struct AskConfigOptions {
363
    /// `"anthropic"` (only currently supported).
364
    pub provider: Option<String>,
365
    /// API key for the LLM provider. Read from SQLRITE_LLM_API_KEY by
366
    /// `AskConfig.fromEnv()`. Treat as a secret — `AskConfig.toString()`
367
    /// deliberately omits the key value.
368
    pub api_key: Option<String>,
369
    /// Model ID (e.g. `"claude-sonnet-4-6"`, `"claude-haiku-4-5"`).
370
    pub model: Option<String>,
371
    /// Per-call max output tokens. Default 1024.
372
    pub max_tokens: Option<u32>,
373
    /// Anthropic prompt-cache TTL: `"5m"` (default), `"1h"`, or `"off"`.
374
    pub cache_ttl: Option<String>,
375
    /// Override the API base URL — production callers leave undefined;
376
    /// tests point it at a localhost mock.
377
    pub base_url: Option<String>,
378
}
379

380
/// Configuration for `db.ask()` / `db.askRun()` calls.
381
///
382
/// ```js
383
/// const cfg = new AskConfig({
384
///   apiKey: 'sk-ant-...',
385
///   model: 'claude-haiku-4-5',
386
///   cacheTtl: '1h',
387
/// });
388
/// db.setAskConfig(cfg);
389
/// const resp = db.ask('How many users?');
390
/// ```
391
///
392
/// Or build from env (SQLRITE_LLM_API_KEY etc.):
393
///
394
/// ```js
395
/// const cfg = AskConfig.fromEnv();
396
/// ```
397
#[napi]
398
pub struct AskConfig {
399
    inner: RustAskConfig,
400
}
401

402
#[napi]
403
impl AskConfig {
404
    /// Build from an options object. Any field left undefined uses
405
    /// the matching default.
406
    #[napi(constructor)]
407
    pub fn new(options: Option<AskConfigOptions>) -> Result<Self> {
×
408
        let mut inner = RustAskConfig::default();
×
409
        let Some(opts) = options else {
×
410
            return Ok(AskConfig { inner });
×
411
        };
412
        if let Some(p) = opts.provider {
×
413
            inner.provider = match p.to_ascii_lowercase().as_str() {
×
414
                "anthropic" => ProviderKind::Anthropic,
×
415
                other => {
×
416
                    return Err(napi::Error::from_reason(format!(
×
417
                        "unknown provider: {other} (supported: anthropic)"
×
418
                    )));
419
                }
420
            };
421
        }
422
        if let Some(k) = opts.api_key {
×
423
            if !k.is_empty() {
×
424
                inner.api_key = Some(k);
×
425
            }
426
        }
427
        if let Some(m) = opts.model {
×
428
            if !m.is_empty() {
×
429
                inner.model = m;
×
430
            }
431
        }
432
        if let Some(t) = opts.max_tokens {
×
433
            inner.max_tokens = t;
×
434
        }
435
        if let Some(c) = opts.cache_ttl {
×
436
            inner.cache_ttl = match c.to_ascii_lowercase().as_str() {
×
437
                "5m" | "5min" | "5minutes" => CacheTtl::FiveMinutes,
×
438
                "1h" | "1hr" | "1hour" => CacheTtl::OneHour,
×
439
                "off" | "none" | "disabled" => CacheTtl::Off,
×
440
                other => {
×
441
                    return Err(napi::Error::from_reason(format!(
×
442
                        "unknown cacheTtl: {other} (expected 5m, 1h, or off)"
×
443
                    )));
444
                }
445
            };
446
        }
447
        if let Some(u) = opts.base_url {
×
448
            if !u.is_empty() {
×
449
                inner.base_url = Some(u);
×
450
            }
451
        }
452
        Ok(AskConfig { inner })
×
453
    }
454

455
    /// Build from environment variables. Reads:
456
    ///   * SQLRITE_LLM_PROVIDER (default: anthropic)
457
    ///   * SQLRITE_LLM_API_KEY
458
    ///   * SQLRITE_LLM_MODEL (default: claude-sonnet-4-6)
459
    ///   * SQLRITE_LLM_MAX_TOKENS (default: 1024)
460
    ///   * SQLRITE_LLM_CACHE_TTL (default: 5m)
461
    ///
462
    /// A missing API key is NOT an error here — `db.ask()` raises the
463
    /// friendlier "missing API key" message later.
464
    #[napi(factory)]
465
    pub fn from_env() -> Result<Self> {
×
466
        Ok(AskConfig {
×
467
            inner: RustAskConfig::from_env().map_err(map_err)?,
×
468
        })
469
    }
470

471
    /// `true` when an API key has been set (either explicitly or via
472
    /// env). Doesn't expose the key value.
473
    #[napi(getter)]
474
    pub fn has_api_key(&self) -> bool {
×
475
        self.inner.api_key.is_some()
×
476
    }
477

478
    #[napi(getter)]
479
    pub fn model(&self) -> String {
×
480
        self.inner.model.clone()
×
481
    }
482

483
    #[napi(getter)]
484
    pub fn max_tokens(&self) -> u32 {
×
485
        self.inner.max_tokens
×
486
    }
487

488
    #[napi(getter)]
489
    pub fn cache_ttl(&self) -> &'static str {
×
490
        match self.inner.cache_ttl {
×
491
            CacheTtl::FiveMinutes => "5m",
×
492
            CacheTtl::OneHour => "1h",
×
493
            CacheTtl::Off => "off",
×
494
        }
495
    }
496

497
    #[napi(getter)]
498
    pub fn provider(&self) -> &'static str {
×
499
        match self.inner.provider {
×
500
            ProviderKind::Anthropic => "anthropic",
×
501
        }
502
    }
503

504
    /// String form. **Deliberately does not include the API key
505
    /// value** — printing the config in a log line / debugger /
506
    /// console.log won't leak the secret. Shows `apiKey=<set>` or
507
    /// `apiKey=null` so callers can tell whether a key is configured.
508
    #[napi]
509
    pub fn to_string(&self) -> String {
×
510
        format!(
×
511
            "AskConfig(provider={:?}, model={:?}, maxTokens={}, cacheTtl={:?}, apiKey={})",
512
            self.provider(),
×
513
            self.model(),
×
514
            self.max_tokens(),
×
515
            self.cache_ttl(),
×
516
            if self.inner.api_key.is_some() {
×
517
                "<set>"
×
518
            } else {
519
                "null"
×
520
            },
521
        )
522
    }
523
}
524

525
// ---------------------------------------------------------------------------
526
// AskResponse (Phase 7g.5)
527

528
/// Returned by `db.ask()`. Carries the generated SQL, the model's
529
/// one-sentence rationale, and token usage. The API key is **not**
530
/// in here — by design.
531
#[napi(object)]
532
pub struct AskResponse {
533
    pub sql: String,
534
    pub explanation: String,
535
    pub usage: AskUsage,
536
}
537

538
/// Token usage breakdown from an `ask()` call. Inspect to verify
539
/// prompt-caching is actually working — if `cacheReadInputTokens`
540
/// stays zero across repeated calls with the same schema, something
541
/// in the prefix is invalidating the cache.
542
#[napi(object)]
543
pub struct AskUsage {
544
    pub input_tokens: i64,
545
    pub output_tokens: i64,
546
    pub cache_creation_input_tokens: i64,
547
    pub cache_read_input_tokens: i64,
548
}
549

550
// ---------------------------------------------------------------------------
551
// Statement
552
//
553
// Unlike better-sqlite3, our Statement does NOT own a compiled plan
554
// (the engine doesn't cache plans yet). It stores the SQL and the
555
// parent Database pointer; each run()/get()/all()/iterate() call
556
// re-prepares and executes. That's fine for the Phase 5d MVP and
557
// will get cheaper once 5a.2 lands prepared-statement caching.
558

559
#[napi]
560
pub struct Statement {
561
    /// Raw pointer to the parent `Database`. napi-rs handles lifetime
562
    /// management across JS/Rust via its own ObjectRef system; we
563
    /// don't hand it a Rust reference because Statement isn't a
564
    /// `#[napi(constructor)]` entry point — it's returned from
565
    /// `prepare()` and its lifetime is tied to the JS-side
566
    /// reachability of the Database object that created it.
567
    db_raw: *const Database,
568
    sql: String,
569
}
570

571
// Both fields are trivially Send; the RefCell inside Database
572
// prevents concurrent access on the Rust side.
573
unsafe impl Send for Statement {}
574

575
impl Statement {
576
    fn with_db<F, T>(&self, op: &str, f: F) -> Result<T>
577
    where
578
        F: FnOnce(&Database) -> Result<T>,
579
    {
580
        // Safety: Statement's JS wrapper keeps a reference to the
581
        // parent Database object, so `db_raw` stays valid as long
582
        // as the Statement handle exists on the JS side.
583
        let db = unsafe { self.db_raw.as_ref() }.ok_or_else(|| {
×
584
            napi::Error::from_reason(format!("cannot {op}: parent database dropped"))
×
585
        })?;
586
        f(db)
×
587
    }
588

589
    fn run_query(&self, env: &Env) -> Result<(Vec<String>, Vec<OwnedRow>)> {
×
590
        self.with_db("query", |db| {
×
591
            let mut borrow = db.inner.borrow_mut();
×
592
            let conn = borrow
×
593
                .as_mut()
×
594
                .ok_or_else(|| napi::Error::from_reason("cannot query: database is closed"))?;
×
595
            let stmt = conn.prepare(&self.sql).map_err(map_err)?;
×
596
            let mut rows: Rows = stmt.query().map_err(map_err)?;
×
597
            let columns = rows.columns().to_vec();
×
598
            let mut out: Vec<OwnedRow> = Vec::new();
×
599
            while let Some(row) = rows.next().map_err(map_err)? {
×
600
                out.push(row.to_owned_row());
×
601
            }
602
            let _ = env; // env used by caller for row_to_js_object
603
            Ok((columns, out))
×
604
        })
605
    }
606
}
607

608
#[napi]
609
impl Statement {
610
    /// Executes a non-query statement (INSERT / UPDATE / DELETE / etc.)
611
    /// `params` must be `undefined`, `null`, or an empty array until
612
    /// Phase 5a.2 lands parameter binding — anything else throws.
613
    #[napi]
614
    pub fn run(&self, params: Option<Vec<JsUnknown>>) -> Result<RunResult> {
×
615
        reject_params_for_now(&params)?;
×
616
        self.with_db("run", |db| {
×
617
            let mut borrow = db.inner.borrow_mut();
×
618
            let conn = borrow
×
619
                .as_mut()
×
620
                .ok_or_else(|| napi::Error::from_reason("cannot run: database is closed"))?;
×
621
            conn.execute(&self.sql).map_err(map_err)?;
×
622
            Ok(RunResult {
×
623
                // `changes` and `lastInsertRowid` aren't tracked by
624
                // the engine yet; better-sqlite3 returns them here,
625
                // so we mirror the shape with zeros.
626
                changes: 0,
×
627
                last_insert_rowid: 0,
×
628
            })
629
        })
630
    }
631

632
    /// Runs a SELECT and returns the first row as an object (or null
633
    /// if empty).
634
    #[napi]
635
    pub fn get(&self, env: Env, params: Option<Vec<JsUnknown>>) -> Result<JsUnknown> {
×
636
        reject_params_for_now(&params)?;
×
637
        let (columns, mut rows) = self.run_query(&env)?;
×
638
        if rows.is_empty() {
×
639
            return Ok(env.get_null()?.into_unknown());
×
640
        }
641
        let first = rows.remove(0);
×
642
        Ok(row_to_js_object(&env, &columns, &first)?.into_unknown())
×
643
    }
644

645
    /// Runs a SELECT and returns every row as an array of objects.
646
    #[napi]
647
    pub fn all(&self, env: Env, params: Option<Vec<JsUnknown>>) -> Result<Vec<JsUnknown>> {
×
648
        reject_params_for_now(&params)?;
×
649
        let (columns, rows) = self.run_query(&env)?;
×
650
        let mut out: Vec<JsUnknown> = Vec::with_capacity(rows.len());
×
651
        for row in &rows {
×
652
            out.push(row_to_js_object(&env, &columns, row)?.into_unknown());
×
653
        }
654
        Ok(out)
×
655
    }
656

657
    /// Eager iterator — returns an array (better-sqlite3 uses a real
658
    /// JS iterator for memory efficiency; the Phase 5a.2 cursor work
659
    /// will let us do the same. For now, `iterate()` behaves like
660
    /// `all()` so callers write `for (const row of stmt.iterate())`
661
    /// ergonomically).
662
    #[napi]
663
    pub fn iterate(&self, env: Env, params: Option<Vec<JsUnknown>>) -> Result<Vec<JsUnknown>> {
×
664
        self.all(env, params)
×
665
    }
666

667
    /// Column names the statement will produce, in projection order.
668
    /// Runs the query once to discover them (the engine doesn't yet
669
    /// have a plan-inspection API separate from execution).
670
    #[napi]
671
    pub fn columns(&self, env: Env) -> Result<Vec<String>> {
×
672
        let (columns, _) = self.run_query(&env)?;
×
673
        Ok(columns)
×
674
    }
675
}
676

677
/// Matches better-sqlite3's `RunResult` shape. Both fields are 0 for
678
/// now — the engine doesn't track affected-row counts or
679
/// last-insert-rowid at the public API layer yet. Kept so upgrading
680
/// to real tracking doesn't break the JS surface.
681
#[napi(object)]
682
pub struct RunResult {
683
    pub changes: i64,
684
    pub last_insert_rowid: i64,
685
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc