Implement JavaScript bindings with minimal Rust core

This rewrites the JavaScript bindings completely by exposing only
primitive operations from Rust NAPI-RS code. For example, there is
prepare(), bind(), and step(), but high level interfaces like all() and
get() are implemented in JavaScript.

We're doing this so that we can implement async interfaces in the
JavaScript layer instead of having to bring in Tokio.
This commit is contained in:
Pekka Enberg
2025-07-31 14:56:04 +03:00
parent fedd70f60e
commit 02db72cc2c
7 changed files with 614 additions and 663 deletions

View File

@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
[dependencies]
turso_core = { workspace = true }
napi = { version = "3.1.3", default-features = false }
napi = { version = "3.1.3", default-features = false, features = ["napi6"] }
napi-derive = { version = "3.1.1", default-features = true }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -0,0 +1,70 @@
// Bind parameters to a statement.
//
// This function is used to bind parameters to a statement. It supports both
// named and positional parameters, and nested arrays.
//
// The `stmt` parameter is a statement object.
// The `params` parameter is an array of parameters.
//
// The function returns void.
function bindParams(stmt, params) {
const len = params?.length;
if (len === 0) {
return;
}
if (len === 1) {
const param = params[0];
if (isPlainObject(param)) {
bindNamedParams(stmt, param);
return;
}
bindValue(stmt, 1, param);
return;
}
bindPositionalParams(stmt, params);
}
// Check if object is plain (no prototype chain)
function isPlainObject(obj) {
if (!obj || typeof obj !== 'object') return false;
const proto = Object.getPrototypeOf(obj);
return proto === Object.prototype || proto === null;
}
// Handle named parameters
function bindNamedParams(stmt, paramObj) {
const paramCount = stmt.parameterCount();
for (let i = 1; i <= paramCount; i++) {
const paramName = stmt.parameterName(i);
if (paramName) {
const key = paramName.substring(1); // Remove ':' or '$' prefix
const value = paramObj[key];
if (value !== undefined) {
bindValue(stmt, i, value);
}
}
}
}
// Handle positional parameters (including nested arrays)
function bindPositionalParams(stmt, params) {
let bindIndex = 1;
for (let i = 0; i < params.length; i++) {
const param = params[i];
if (Array.isArray(param)) {
for (let j = 0; j < param.length; j++) {
bindValue(stmt, bindIndex++, param[j]);
}
} else {
bindValue(stmt, bindIndex++, param);
}
}
}
function bindValue(stmt, index, value) {
stmt.bindAt(index, value);
}
module.exports = { bindParams };

View File

@@ -1,46 +1,101 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
/** A database connection. */
export declare class Database {
memory: boolean
readonly: boolean
open: boolean
name: string
constructor(path: string, options?: OpenDatabaseOptions | undefined | null)
/**
* Creates a new database instance.
*
* # Arguments
* * `path` - The path to the database file.
*/
constructor(path: string)
/** Returns whether the database is in memory-only mode. */
get memory(): boolean
/**
* Executes a batch of SQL statements.
*
* # Arguments
*
* * `sql` - The SQL statements to execute.
*
* # Returns
*/
batch(sql: string): void
/**
* Prepares a statement for execution.
*
* # Arguments
*
* * `sql` - The SQL statement to prepare.
*
* # Returns
*
* A `Statement` instance.
*/
prepare(sql: string): Statement
pragma(pragmaName: string, options?: PragmaOptions | undefined | null): unknown
backup(): void
serialize(): void
function(): void
aggregate(): void
table(): void
loadExtension(path: string): void
exec(sql: string): void
/**
* Returns the rowid of the last row inserted.
*
* # Returns
*
* The rowid of the last row inserted.
*/
lastInsertRowid(): number
/**
* Returns the number of changes made by the last statement.
*
* # Returns
*
* The number of changes made by the last statement.
*/
changes(): number
/**
* Returns the total number of changes made by all statements.
*
* # Returns
*
* The total number of changes made by all statements.
*/
totalChanges(): number
/**
* Closes the database connection.
*
* # Returns
*
* `Ok(())` if the database is closed successfully.
*/
close(): void
/** Runs the I/O loop synchronously. */
ioLoopSync(): void
/** Runs the I/O loop asynchronously, returning a Promise. */
ioLoopAsync(): Promise<void>
}
/** A prepared statement. */
export declare class Statement {
source: string
get(args?: Array<unknown> | undefined | null): unknown
run(args?: Array<unknown> | undefined | null): RunResult
all(args?: Array<unknown> | undefined | null): unknown
pluck(pluck?: boolean | undefined | null): void
static expand(): void
reset(): void
/** Returns the number of parameters in the statement. */
parameterCount(): number
/**
* Returns the name of a parameter at a specific 1-based index.
*
* # Arguments
*
* * `index` - The 1-based parameter index.
*/
parameterName(index: number): string | null
/**
* Binds a parameter at a specific 1-based index with explicit type.
*
* # Arguments
*
* * `index` - The 1-based parameter index.
* * `value_type` - The type constant (0=null, 1=int, 2=float, 3=text, 4=blob).
* * `value` - The value to bind.
*/
bindAt(index: number, value: unknown): void
step(): unknown
raw(raw?: boolean | undefined | null): void
static columns(): void
bind(args?: Array<unknown> | undefined | null): Statement
}
export interface OpenDatabaseOptions {
readonly?: boolean
fileMustExist?: boolean
timeout?: number
}
export interface PragmaOptions {
simple: boolean
}
export interface RunResult {
changes: number
lastInsertRowid: number
pluck(pluck?: boolean | undefined | null): void
finalize(): void
}

View File

@@ -12,6 +12,7 @@
"./sync": "./sync.js"
},
"files": [
"bindjs",
"browser.js",
"index.js",
"promise.js",

View File

@@ -1,6 +1,7 @@
"use strict";
const { Database: NativeDB } = require("./index.js");
const { bindParams } = require("./bind.js");
const SqliteError = require("./sqlite-error.js");
@@ -138,12 +139,12 @@ class Database {
if (typeof options !== "object")
throw new TypeError("Expected second argument to be an options object");
const simple = options["simple"];
const pragma = `PRAGMA ${source}`;
return simple
? this.db.pragma(source, { simple: true })
: this.db.pragma(source);
const stmt = this.prepare(pragma);
const results = stmt.all();
return results;
}
backup(filename, options) {
@@ -181,7 +182,7 @@ class Database {
*/
exec(sql) {
try {
this.db.exec(sql);
this.db.batch(sql);
} catch (err) {
throw convertError(err);
}
@@ -250,8 +251,27 @@ class Statement {
/**
* Executes the SQL statement and returns an info object.
*/
run(...bindParameters) {
return this.stmt.run(bindParameters.flat());
async run(...bindParameters) {
const totalChangesBefore = this.db.db.totalChanges();
this.stmt.reset();
bindParams(this.stmt, bindParameters);
while (true) {
const result = this.stmt.step();
if (result.io) {
await this.db.db.ioLoopAsync();
continue;
}
if (result.done) {
break;
}
}
const lastInsertRowid = this.db.db.lastInsertRowid();
const changes = this.db.db.totalChanges() === totalChangesBefore ? 0 : this.db.db.changes();
return { changes, lastInsertRowid };
}
/**
@@ -259,8 +279,21 @@ class Statement {
*
* @param bindParameters - The bind parameters for executing the statement.
*/
get(...bindParameters) {
return this.stmt.get(bindParameters.flat());
async get(...bindParameters) {
this.stmt.reset();
bindParams(this.stmt, bindParameters);
while (true) {
const result = this.stmt.step();
if (result.io) {
await this.db.db.ioLoopAsync();
continue;
}
if (result.done) {
return undefined;
}
return result.value;
}
}
/**
@@ -277,8 +310,23 @@ class Statement {
*
* @param bindParameters - The bind parameters for executing the statement.
*/
all(...bindParameters) {
return this.stmt.all(bindParameters.flat());
async all(...bindParameters) {
this.stmt.reset();
bindParams(this.stmt, bindParameters);
const rows = [];
while (true) {
const result = this.stmt.step();
if (result.io) {
await this.db.db.ioLoopAsync();
continue;
}
if (result.done) {
break;
}
rows.push(result.value);
}
return rows;
}
/**
@@ -304,7 +352,8 @@ class Statement {
*/
bind(...bindParameters) {
try {
return new Statement(this.stmt.bind(bindParameters.flat()), this.db);
bindParams(this.stmt, bindParameters);
return this;
} catch (err) {
throw convertError(err);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
"use strict";
const { Database: NativeDB } = require("./index.js");
const { bindParams } = require("./bind.js");
const SqliteError = require("./sqlite-error.js");
@@ -138,12 +139,12 @@ class Database {
if (typeof options !== "object")
throw new TypeError("Expected second argument to be an options object");
const simple = options["simple"];
const pragma = `PRAGMA ${source}`;
return simple
? this.db.pragma(source, { simple: true })
: this.db.pragma(source);
const stmt = this.prepare(pragma);
const results = stmt.all();
return results;
}
backup(filename, options) {
@@ -181,7 +182,7 @@ class Database {
*/
exec(sql) {
try {
this.db.exec(sql);
this.db.batch(sql);
} catch (err) {
throw convertError(err);
}
@@ -251,7 +252,25 @@ class Statement {
* Executes the SQL statement and returns an info object.
*/
run(...bindParameters) {
return this.stmt.run(bindParameters.flat());
const totalChangesBefore = this.db.db.totalChanges();
this.stmt.reset();
bindParams(this.stmt, bindParameters);
for (;;) {
const result = this.stmt.step();
if (result.io) {
this.db.db.ioLoopSync();
continue;
}
if (result.done) {
break;
}
}
const lastInsertRowid = this.db.db.lastInsertRowid();
const changes = this.db.db.totalChanges() === totalChangesBefore ? 0 : this.db.db.changes();
return { changes, lastInsertRowid };
}
/**
@@ -260,7 +279,19 @@ class Statement {
* @param bindParameters - The bind parameters for executing the statement.
*/
get(...bindParameters) {
return this.stmt.get(bindParameters.flat());
this.stmt.reset();
bindParams(this.stmt, bindParameters);
for (;;) {
const result = this.stmt.step();
if (result.io) {
this.db.db.ioLoopSync();
continue;
}
if (result.done) {
return undefined;
}
return result.value;
}
}
/**
@@ -278,7 +309,21 @@ class Statement {
* @param bindParameters - The bind parameters for executing the statement.
*/
all(...bindParameters) {
return this.stmt.all(bindParameters.flat());
this.stmt.reset();
bindParams(this.stmt, bindParameters);
const rows = [];
for (;;) {
const result = this.stmt.step();
if (result.io) {
this.db.db.ioLoopSync();
continue;
}
if (result.done) {
break;
}
rows.push(result.value);
}
return rows;
}
/**
@@ -304,7 +349,8 @@ class Statement {
*/
bind(...bindParameters) {
try {
return new Statement(this.stmt.bind(bindParameters.flat()), this.db);
bindParams(this.stmt, bindParameters);
return this;
} catch (err) {
throw convertError(err);
}