Merge 'Import JavaScript bindings test suite from libSQL' from Mikaël Francoeur

This PR imports the `sync` test suite from libSQL, and modifies the
export structure match `better-sqlite3`, so that at least a few tests
from the new test suite are passing.
I also changed the `package.json` to expose `wrapper.js` as an
entrypoint. I think the plain `index.js` was probably never meant to be
exposed directly to clients, because the wrapper does some important
transformation. It's also how libSQL-js is
[structured](https://github.com/tursodatabase/libsql-
js/blob/main/package.json#L20).
## DualTest test runner
The test suite that I imported was previously run twice, with different
environment variables: one run for libSQL-js, one run for better-
sqlite3. This worked well with libSQL, because it's compatible with
better-sqlite3. But Turso isn't there yet, so even though all tests need
to run on better-sqlite3, not all tests are passing on Turso.
To make it possible to run the test suite on both implementation, and to
make it possible to track the progress of Turso, I've added a `DualTest`
test runner that emulates the API of the Ava test runner, but instead of
a single `test()` function, it exposes two methods: `both()`, and
`onlySqlitePasses()`. The first one runs the test on both
implementations, and the second one also runs it on both, but expects
that Turso will fail.
When Turso is completely compatible with better-sqlite3, it will be easy
to remove the dual-test runner, since it has the same API as Ava.
## Future development
### Existing tests
The existing tests were divided in two files: `better-sqlite3.spec.mjs`,
and `dual-test.mjs` that are kept in sync manually. The first one is
mostly a superset of the second one, with additional tests indicating
behaviour that isn't implemented in Turso yet. I want to merge these
test suites to also use the dual-test test runner. This will make it
easier to evolve test suites and to track the progress of Turso.
### `SqliteError`
Modifying the test called `errors` to `both()` will show that Turso is
still missing an `SqliteError` type, to be compatible with better-
sqlite3. I have the required changes in a stash and will open a PR
shortly.
-----
as part of https://github.com/tursodatabase/turso/issues/1900

Closes #1941
This commit is contained in:
Pekka Enberg
2025-07-04 10:26:58 +03:00
6 changed files with 544 additions and 6 deletions

View File

@@ -23,7 +23,7 @@ npm install @tursodatabase/turso
### In-Memory Database
```javascript
import { Database } from '@tursodatabase/turso';
import Database from '@tursodatabase/turso';
// Create an in-memory database
const db = new Database(':memory:');
@@ -48,7 +48,7 @@ console.log(users);
### File-Based Database
```javascript
import { Database } from '@tursodatabase/turso';
import Database from '@tursodatabase/turso';
// Create or open a database file
const db = new Database('my-database.db');

View File

@@ -0,0 +1,82 @@
import avaTest from "ava";
import turso from "../wrapper.js";
import sqlite from "better-sqlite3";
class DualTest {
#libs = { turso, sqlite };
#beforeEaches = [];
#pathFn;
#options;
constructor(path_opt, options = {}) {
if (typeof path_opt === 'function') {
this.#pathFn = path_opt;
} else {
this.#pathFn = () => path_opt ?? "hello.db";
}
this.#options = options;
}
beforeEach(fn) {
this.#beforeEaches.push(fn);
}
only(name, impl, ...rest) {
avaTest.serial.only('[TESTING TURSO] ' + name, this.#wrap('turso', impl), ...rest);
avaTest.serial.only('[TESTING BETTER-SQLITE3] ' + name, this.#wrap('sqlite', impl), ...rest);
}
onlySqlitePasses(name, impl, ...rest) {
avaTest.serial.failing('[TESTING TURSO] ' + name, this.#wrap('turso', impl), ...rest);
avaTest.serial('[TESTING BETTER-SQLITE3] ' + name, this.#wrap('sqlite', impl), ...rest);
}
both(name, impl, ...rest) {
avaTest.serial('[TESTING TURSO] ' + name, this.#wrap('turso', impl), ...rest);
avaTest.serial('[TESTING BETTER-SQLITE3] ' + name, this.#wrap('sqlite', impl), ...rest);
}
skip(name, impl, ...rest) {
avaTest.serial.skip('[TESTING TURSO] ' + name, this.#wrap('turso', impl), ...rest);
avaTest.serial.skip('[TESTING BETTER-SQLITE3] ' + name, this.#wrap('sqlite', impl), ...rest);
}
async #runBeforeEach(t) {
for (const beforeEach of this.#beforeEaches) {
await beforeEach(t);
}
}
#wrap(provider, fn) {
return async (t, ...rest) => {
const path = this.#pathFn();
const Lib = this.#libs[provider];
const db = this.#connect(Lib, path, this.#options)
t.context = {
...t,
connect: this.#curry(this.#connect)(Lib),
db,
errorType: Lib.SqliteError,
path,
provider,
};
t.teardown(() => db.close());
await this.#runBeforeEach(t);
await fn(t, ...rest);
};
}
#connect(constructor, path, options) {
return new constructor(path, options);
}
#curry(fn) {
return (first) => (...rest) => fn(first, ...rest);
}
}
export default DualTest;

View File

@@ -3,7 +3,7 @@ import fs from "node:fs";
import { fileURLToPath } from "url";
import path from "node:path";
import { Database } from "../wrapper.js";
import Database from "../wrapper.js";
test("Open in-memory database", async (t) => {
const [db] = await connect(":memory:");

View File

@@ -0,0 +1,456 @@
import crypto from 'crypto';
import fs from 'fs';
import DualTest from "./dual-test.mjs";
const dualTest = new DualTest();
new DualTest(":memory:").both("Open in-memory database", async (t) => {
const db = t.context.db;
t.is(db.memory, true);
});
dualTest.beforeEach(async (t) => {
const db = t.context.db;
db.exec(`
DROP TABLE IF EXISTS users;
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)
`);
db.exec(
"INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.org')"
);
db.exec(
"INSERT INTO users (id, name, email) VALUES (2, 'Bob', 'bob@example.com')"
);
});
dualTest.onlySqlitePasses("Statement.prepare() error", async (t) => {
const db = t.context.db;
t.throws(() => {
return db.prepare("SYNTAX ERROR");
}, {
any: true,
instanceOf: t.context.errorType,
message: 'near "SYNTAX": syntax error'
});
});
dualTest.onlySqlitePasses("Statement.run() returning rows", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT 1");
const info = stmt.run();
t.is(info.changes, 0);
});
dualTest.onlySqlitePasses("Statement.run() [positional]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("INSERT INTO users(name, email) VALUES (?, ?)");
const info = stmt.run(["Carol", "carol@example.net"]);
t.is(info.changes, 1);
t.is(info.lastInsertRowid, 3);
// Verify that the data is inserted
const stmt2 = db.prepare("SELECT * FROM users WHERE id = 3");
t.is(stmt2.get().name, "Carol");
t.is(stmt2.get().email, "carol@example.net");
});
dualTest.onlySqlitePasses("Statement.run() [named]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("INSERT INTO users(name, email) VALUES (@name, @email);");
const info = stmt.run({ "name": "Carol", "email": "carol@example.net" });
t.is(info.changes, 1);
t.is(info.lastInsertRowid, 3);
});
dualTest.both("Statement.get() returns no rows", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users WHERE id = 0");
t.is(stmt.get(), undefined);
});
dualTest.both("Statement.get() [no parameters]", async (t) => {
const db = t.context.db;
var stmt = 0;
stmt = db.prepare("SELECT * FROM users");
t.is(stmt.get().name, "Alice");
t.deepEqual(stmt.raw().get(), [1, 'Alice', 'alice@example.org']);
});
dualTest.onlySqlitePasses("Statement.get() [positional]", async (t) => {
const db = t.context.db;
var stmt = 0;
stmt = db.prepare("SELECT * FROM users WHERE id = ?");
t.is(stmt.get(0), undefined);
t.is(stmt.get([0]), undefined);
t.is(stmt.get(1).name, "Alice");
t.is(stmt.get(2).name, "Bob");
stmt = db.prepare("SELECT * FROM users WHERE id = ?1");
t.is(stmt.get({ 1: 0 }), undefined);
t.is(stmt.get({ 1: 1 }).name, "Alice");
t.is(stmt.get({ 1: 2 }).name, "Bob");
});
dualTest.onlySqlitePasses("Statement.get() [named]", async (t) => {
const db = t.context.db;
var stmt = undefined;
stmt = db.prepare("SELECT :b, :a");
t.deepEqual(stmt.raw().get({ a: 'a', b: 'b' }), ['b', 'a']);
stmt = db.prepare("SELECT * FROM users WHERE id = :id");
t.is(stmt.get({ id: 0 }), undefined);
t.is(stmt.get({ id: 1 }).name, "Alice");
t.is(stmt.get({ id: 2 }).name, "Bob");
stmt = db.prepare("SELECT * FROM users WHERE id = @id");
t.is(stmt.get({ id: 0 }), undefined);
t.is(stmt.get({ id: 1 }).name, "Alice");
t.is(stmt.get({ id: 2 }).name, "Bob");
stmt = db.prepare("SELECT * FROM users WHERE id = $id");
t.is(stmt.get({ id: 0 }), undefined);
t.is(stmt.get({ id: 1 }).name, "Alice");
t.is(stmt.get({ id: 2 }).name, "Bob");
});
dualTest.both("Statement.get() [raw]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
t.deepEqual(stmt.raw().get(1), [1, "Alice", "alice@example.org"]);
});
dualTest.onlySqlitePasses("Statement.iterate() [empty]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users WHERE id = 0");
t.is(stmt.iterate().next().done, true);
t.is(stmt.iterate([]).next().done, true);
t.is(stmt.iterate({}).next().done, true);
});
dualTest.both("Statement.iterate()", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users");
const expected = [1, 2];
var idx = 0;
for (const row of stmt.iterate()) {
t.is(row.id, expected[idx++]);
}
});
dualTest.both("Statement.all()", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users");
const expected = [
{ id: 1, name: "Alice", email: "alice@example.org" },
{ id: 2, name: "Bob", email: "bob@example.com" },
];
t.deepEqual(stmt.all(), expected);
});
dualTest.both("Statement.all() [raw]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users");
const expected = [
[1, "Alice", "alice@example.org"],
[2, "Bob", "bob@example.com"],
];
t.deepEqual(stmt.raw().all(), expected);
});
dualTest.both("Statement.all() [pluck]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users");
const expected = [
1,
2,
];
t.deepEqual(stmt.pluck().all(), expected);
});
dualTest.onlySqlitePasses("Statement.all() [default safe integers]", async (t) => {
const db = t.context.db;
db.defaultSafeIntegers();
const stmt = db.prepare("SELECT * FROM users");
const expected = [
[1n, "Alice", "alice@example.org"],
[2n, "Bob", "bob@example.com"],
];
t.deepEqual(stmt.raw().all(), expected);
});
dualTest.onlySqlitePasses("Statement.all() [statement safe integers]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT * FROM users");
stmt.safeIntegers();
const expected = [
[1n, "Alice", "alice@example.org"],
[2n, "Bob", "bob@example.com"],
];
t.deepEqual(stmt.raw().all(), expected);
});
dualTest.onlySqlitePasses("Statement.raw() [failure]", async (t) => {
const db = t.context.db;
const stmt = db.prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)");
await t.throws(() => {
stmt.raw()
}, {
message: 'The raw() method is only for statements that return data'
});
});
dualTest.onlySqlitePasses("Statement.run() with array bind parameter", async (t) => {
const db = t.context.db;
db.exec(`
DROP TABLE IF EXISTS t;
CREATE TABLE t (value BLOB);
`);
const array = [1, 2, 3];
const insertStmt = db.prepare("INSERT INTO t (value) VALUES (?)");
await t.throws(() => {
insertStmt.run([array]);
}, {
message: 'SQLite3 can only bind numbers, strings, bigints, buffers, and null'
});
});
dualTest.onlySqlitePasses("Statement.run() with Float32Array bind parameter", async (t) => {
const db = t.context.db;
db.exec(`
DROP TABLE IF EXISTS t;
CREATE TABLE t (value BLOB);
`);
const array = new Float32Array([1, 2, 3]);
const insertStmt = db.prepare("INSERT INTO t (value) VALUES (?)");
insertStmt.run([array]);
const selectStmt = db.prepare("SELECT value FROM t");
t.deepEqual(selectStmt.raw().get()[0], Buffer.from(array.buffer));
});
/// This test is not supported by better-sqlite3, but is supported by libsql.
/// Therefore, when implementing it in Turso, only enable the test for Turso.
dualTest.skip("Statement.run() for vector feature with Float32Array bind parameter", async (t) => {
const db = t.context.db;
db.exec(`
DROP TABLE IF EXISTS t;
CREATE TABLE t (embedding FLOAT32(8));
CREATE INDEX t_idx ON t ( libsql_vector_idx(embedding) );
`);
const insertStmt = db.prepare("INSERT INTO t VALUES (?)");
insertStmt.run([new Float32Array([1, 1, 1, 1, 1, 1, 1, 1])]);
insertStmt.run([new Float32Array([-1, -1, -1, -1, -1, -1, -1, -1])]);
const selectStmt = db.prepare("SELECT embedding FROM vector_top_k('t_idx', vector('[2,2,2,2,2,2,2,2]'), 1) n JOIN t ON n.rowid = t.rowid");
t.deepEqual(selectStmt.raw().get()[0], Buffer.from(new Float32Array([1, 1, 1, 1, 1, 1, 1, 1]).buffer));
// we need to explicitly delete this table because later when sqlite-based (not LibSQL) tests will delete table 't' they will leave 't_idx_shadow' table untouched
db.exec(`DROP TABLE t`);
});
dualTest.onlySqlitePasses("Statement.columns()", async (t) => {
const db = t.context.db;
var stmt = undefined;
stmt = db.prepare("SELECT 1");
t.deepEqual(stmt.columns(), [
{
column: null,
database: null,
name: '1',
table: null,
type: null,
},
]);
stmt = db.prepare("SELECT * FROM users WHERE id = ?");
t.deepEqual(stmt.columns(), [
{
column: "id",
database: "main",
name: "id",
table: "users",
type: "INTEGER",
},
{
column: "name",
database: "main",
name: "name",
table: "users",
type: "TEXT",
},
{
column: "email",
database: "main",
name: "email",
table: "users",
type: "TEXT",
},
]);
});
dualTest.onlySqlitePasses("Database.transaction()", async (t) => {
const db = t.context.db;
const insert = db.prepare(
"INSERT INTO users(name, email) VALUES (:name, :email)"
);
const insertMany = db.transaction((users) => {
t.is(db.inTransaction, true);
for (const user of users) insert.run(user);
});
t.is(db.inTransaction, false);
insertMany([
{ name: "Joey", email: "joey@example.org" },
{ name: "Sally", email: "sally@example.org" },
{ name: "Junior", email: "junior@example.org" },
]);
t.is(db.inTransaction, false);
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
t.is(stmt.get(3).name, "Joey");
t.is(stmt.get(4).name, "Sally");
t.is(stmt.get(5).name, "Junior");
});
dualTest.onlySqlitePasses("Database.transaction().immediate()", async (t) => {
const db = t.context.db;
const insert = db.prepare(
"INSERT INTO users(name, email) VALUES (:name, :email)"
);
const insertMany = db.transaction((users) => {
t.is(db.inTransaction, true);
for (const user of users) insert.run(user);
});
t.is(db.inTransaction, false);
insertMany.immediate([
{ name: "Joey", email: "joey@example.org" },
{ name: "Sally", email: "sally@example.org" },
{ name: "Junior", email: "junior@example.org" },
]);
t.is(db.inTransaction, false);
});
dualTest.onlySqlitePasses("values", async (t) => {
const db = t.context.db;
const stmt = db.prepare("SELECT ?").raw();
t.deepEqual(stmt.get(1), [1]);
t.deepEqual(stmt.get(Number.MIN_VALUE), [Number.MIN_VALUE]);
t.deepEqual(stmt.get(Number.MAX_VALUE), [Number.MAX_VALUE]);
t.deepEqual(stmt.get(Number.MAX_SAFE_INTEGER), [Number.MAX_SAFE_INTEGER]);
t.deepEqual(stmt.get(9007199254740991n), [9007199254740991]);
});
dualTest.both("Database.pragma()", async (t) => {
const db = t.context.db;
db.pragma("cache_size = 2000");
t.deepEqual(db.pragma("cache_size"), [{ "cache_size": 2000 }]);
});
dualTest.onlySqlitePasses("errors", async (t) => {
const db = t.context.db;
const syntaxError = await t.throws(() => {
db.exec("SYNTAX ERROR");
}, {
any: true,
instanceOf: t.context.errorType,
message: 'near "SYNTAX": syntax error',
code: 'SQLITE_ERROR'
});
const noTableError = await t.throws(() => {
db.exec("SELECT * FROM missing_table");
}, {
any: true,
instanceOf: t.context.errorType,
message: "no such table: missing_table",
code: 'SQLITE_ERROR'
});
if (t.context.provider === 'libsql') {
t.is(noTableError.rawCode, 1)
t.is(syntaxError.rawCode, 1)
}
});
dualTest.onlySqlitePasses("Database.prepare() after close()", async (t) => {
const db = t.context.db;
db.close();
t.throws(() => {
db.prepare("SELECT 1");
}, {
instanceOf: TypeError,
message: "The database connection is not open"
});
});
dualTest.onlySqlitePasses("Database.exec() after close()", async (t) => {
const db = t.context.db;
db.close();
t.throws(() => {
db.exec("SELECT 1");
}, {
instanceOf: TypeError,
message: "The database connection is not open"
});
});
/// Generate a unique database filename
const genDatabaseFilename = () => {
return `test-${crypto.randomBytes(8).toString('hex')}.db`;
};
new DualTest(genDatabaseFilename).onlySqlitePasses("Timeout option", async (t) => {
t.teardown(() => fs.unlinkSync(t.context.path));
const timeout = 1000;
const { db: conn1 } = t.context;
conn1.exec("CREATE TABLE t(x)");
conn1.exec("BEGIN IMMEDIATE");
conn1.exec("INSERT INTO t VALUES (1)")
const options = { timeout };
const conn2 = t.context.connect(t.context.path, options);
const start = Date.now();
try {
conn2.exec("INSERT INTO t VALUES (1)")
} catch (e) {
t.is(e.code, "SQLITE_BUSY");
const end = Date.now();
const elapsed = end - start;
// Allow some tolerance for the timeout.
t.is(elapsed > timeout / 2, true);
}
conn1.close();
conn2.close();
});

View File

@@ -6,7 +6,7 @@
"url": "https://github.com/tursodatabase/turso"
},
"description": "The Turso database library",
"main": "index.js",
"main": "wrapper.js",
"types": "index.d.ts",
"napi": {
"name": "turso",
@@ -42,4 +42,4 @@
"version": "napi version"
},
"packageManager": "yarn@4.6.0"
}
}

View File

@@ -268,4 +268,4 @@ class Statement {
}
}
module.exports.Database = Database;
module.exports = Database;