Compare commits

...

3 Commits

Author SHA1 Message Date
Claude
4da9d26ffd fix(sqlite): remove unused SQLITE_BUSY/SQLITE_LOCKED from JS constants
These error codes are only checked in the C++ backup error path and
were never referenced from JS. No reason to expose them to users.

https://claude.ai/code/session_015ciFGBR1pEr7H12jQF8Fpw
2026-02-11 18:21:39 +00:00
Claude
2bd4d2aaca fix(sqlite): fix use-after-free in backup error path, clean up CppSQL interface
- In jsSQLStatementBackupToFunction, destSqlite was closed before the
  error-handling block that tried to dereference it via
  createSQLiteError(globalObject, destSqlite). Move the close after the
  error check and always use the rc-based overload for error messages.
- Replace stale backupInit/Step/Finish/Dispose declarations in CppSQL
  interface with the actual backupTo binding.

https://claude.ai/code/session_015ciFGBR1pEr7H12jQF8Fpw
2026-02-11 18:10:09 +00:00
Jacob D
8205cb5d83 feat(sqlite): add Database.backupTo() using SQLite Online Backup API
Closes #22954. Adds Database.backupTo(dest) for one-shot backup to a
file path or another Database instance. Returns a result object with
pageCount and remaining properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:57:37 -05:00
6 changed files with 622 additions and 1 deletions

View File

@@ -563,6 +563,46 @@ declare module "bun:sqlite" {
* @link https://www.sqlite.org/c3ref/file_control.html
*/
fileControl(zDbName: string, op: number, arg?: ArrayBufferView | number): number;
/**
* Back up this database to another location.
*
* Uses the SQLite Online Backup API to safely copy this database.
* The backup completes synchronously before returning.
*
* @param destination File path or Database instance to back up to
* @returns A `DatabaseBackup` handle
* @throws {TypeError} If destination is not a string or Database instance
* @throws {Error} If the source or destination database is closed
*
* @example
* ```ts
* using backup = db.backupTo("backup.db");
* // backup is already complete
* ```
*
* @link https://www.sqlite.org/backup.html
*/
backupTo(destination: string | Database): DatabaseBackup;
}
/**
* Result of a completed backup operation.
*
* Returned by {@link Database.backupTo}. The backup is already complete
* when this object is returned.
*
* @link https://www.sqlite.org/backup.html
*/
export interface DatabaseBackup {
/** Always `0` for a completed one-shot backup. */
readonly pageCount: number;
/** Always `0` for a completed one-shot backup. */
readonly remaining: number;
/** Returns a JSON-friendly representation of the backup state. */
toJSON(): { finished: boolean; success: boolean; pageCount: number; remaining: number };
/** Returns a string representation of the backup state. */
toString(): string;
}
/**

View File

@@ -287,6 +287,8 @@ JSC_DECLARE_CUSTOM_GETTER(jsSqlStatementGetColumnCount);
JSC_DECLARE_HOST_FUNCTION(jsSQLStatementSerialize);
JSC_DECLARE_HOST_FUNCTION(jsSQLStatementDeserialize);
JSC_DECLARE_HOST_FUNCTION(jsSQLStatementBackupToFunction);
JSC_DECLARE_HOST_FUNCTION(jsSQLStatementSetPrototypeFunction);
JSC_DECLARE_HOST_FUNCTION(jsSQLStatementFunctionFinalize);
JSC_DECLARE_HOST_FUNCTION(jsSQLStatementToStringFunction);
@@ -334,6 +336,38 @@ static JSValue createSQLiteError(JSC::JSGlobalObject* globalObject, sqlite3* db)
return object;
}
// Overload for when the error code is known (e.g. from sqlite3_backup_step return value)
// but NOT set on the connection — SQLite doesn't set errmsg for transient BUSY/LOCKED.
static JSValue createSQLiteError(JSC::JSGlobalObject* globalObject, int rc)
{
auto& vm = JSC::getVM(globalObject);
const char* msg = sqlite3_errstr(rc);
WTF::String str = WTF::String::fromUTF8(msg);
JSC::JSObject* object = JSC::createError(globalObject, str);
auto& builtinNames = WebCore::builtinNames(vm);
object->putDirect(vm, vm.propertyNames->name, jsString(vm, String("SQLiteError"_s)), JSC::PropertyAttribute::DontEnum | 0);
String codeStr;
switch (rc) {
#define MACRO(SQLITE_DEF) \
case SQLITE_DEF: { \
codeStr = #SQLITE_DEF##_s; \
break; \
}
FOR_EACH_SQLITE_ERROR(MACRO)
#undef MACRO
}
if (!codeStr.isEmpty())
object->putDirect(vm, builtinNames.codePublicName(), jsString(vm, codeStr), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | 0);
object->putDirect(vm, builtinNames.errnoPublicName(), jsNumber(rc), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | 0);
object->putDirect(vm, vm.propertyNames->byteOffset, jsNumber(-1), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | 0);
return object;
}
class SQLiteBindingsMap {
public:
SQLiteBindingsMap() = default;
@@ -1843,6 +1877,113 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementFcntlFunction, (JSC::JSGlobalObject * lex
return JSValue::encode(jsNumber(statusCode));
}
/* ******************************************************************************** */
// SQLite Backup API
/* ******************************************************************************** */
// backupTo(sourceDbIndex, destination)
// One-shot backup: init + step(-1) + finish. Throws on error.
JSC_DEFINE_HOST_FUNCTION(jsSQLStatementBackupToFunction, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
{
auto& vm = JSC::getVM(lexicalGlobalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
if (!jsDynamicCast<JSSQLStatementConstructor*>(callFrame->thisValue().getObject())) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected SQLStatement"_s));
return {};
}
#if LAZY_LOAD_SQLITE
if (!sqlite3_backup_init || !sqlite3_backup_step || !sqlite3_backup_finish) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLite backup API is unavailable in this build"_s));
return {};
}
#endif
int32_t srcIndex = callFrame->argument(0).toInt32(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
if (srcIndex < 0 || static_cast<size_t>(srcIndex) >= databases().size()) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid source database handle"_s));
return {};
}
VersionSqlite3* sourceDb = databases()[srcIndex];
if (!sourceDb->db) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Can't backup a closed database"_s));
return {};
}
// Get destination — string path opens a new connection, number reuses existing handle
JSValue destValue = callFrame->argument(1);
sqlite3* destSqlite = nullptr;
bool ownsDest = false;
if (destValue.isString()) {
WTF::String destPath = destValue.toWTFString(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
int rc = sqlite3_open_v2(destPath.utf8().data(), &destSqlite, DEFAULT_SQLITE_FLAGS, nullptr);
if (rc != SQLITE_OK) [[unlikely]] {
if (destSqlite) {
JSValue err = createSQLiteError(lexicalGlobalObject, destSqlite);
sqlite3_close_v2(destSqlite);
throwException(lexicalGlobalObject, scope, err);
} else {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Failed to open destination database"_s));
}
return {};
}
ownsDest = true;
} else if (destValue.isNumber()) {
int32_t destIndex = destValue.toInt32(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
if (destIndex < 0 || static_cast<size_t>(destIndex) >= databases().size()) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid destination database handle"_s));
return {};
}
VersionSqlite3* destDb = databases()[destIndex];
if (!destDb->db) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Destination database is closed"_s));
return {};
}
if (destDb == sourceDb) [[unlikely]] {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Cannot backup a database to itself"_s));
return {};
}
destSqlite = destDb->db;
} else {
throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Expected destination to be a string path or database handle"_s));
return {};
}
sqlite3_backup* backup = sqlite3_backup_init(destSqlite, "main", sourceDb->db, "main");
if (!backup) [[unlikely]] {
JSValue err = createSQLiteError(lexicalGlobalObject, destSqlite);
if (ownsDest) sqlite3_close_v2(destSqlite);
throwException(lexicalGlobalObject, scope, err);
return {};
}
int rc = sqlite3_backup_step(backup, -1);
int totalPages = sqlite3_backup_pagecount(backup);
sqlite3_backup_finish(backup);
if (rc != SQLITE_DONE) [[unlikely]] {
// Use the rc-based overload for all error cases: destSqlite may
// already be closed (ownsDest), so we cannot safely dereference it.
if (ownsDest) sqlite3_close_v2(destSqlite);
throwException(lexicalGlobalObject, scope, createSQLiteError(lexicalGlobalObject, rc));
return {};
}
if (ownsDest)
sqlite3_close_v2(destSqlite);
RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(totalPages)));
}
/* Hash table for constructor */
static const HashTableValue JSSQLStatementConstructorTableValues[] = {
{ "open"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementOpenStatementFunction, 2 } },
@@ -1855,6 +1996,7 @@ static const HashTableValue JSSQLStatementConstructorTableValues[] = {
{ "serialize"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementSerialize, 1 } },
{ "deserialize"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementDeserialize, 2 } },
{ "fcntl"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementFcntlFunction, 2 } },
{ "backupTo"_s, static_cast<unsigned>(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsSQLStatementBackupToFunction, 2 } },
};
const ClassInfo JSSQLStatementConstructor::s_info = { "SQLStatement"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSSQLStatementConstructor) };

View File

@@ -95,6 +95,12 @@ typedef int (*lazy_sqlite3_stmt_busy_type)(sqlite3_stmt* pStmt);
typedef int (*lazy_sqlite3_compileoption_used_type)(const char* zOptName);
typedef int64_t (*lazy_sqlite3_last_insert_rowid_type)(sqlite3* db);
typedef sqlite3_backup* (*lazy_sqlite3_backup_init_type)(sqlite3* pDest, const char* zDestName, sqlite3* pSource, const char* zSourceName);
typedef int (*lazy_sqlite3_backup_step_type)(sqlite3_backup* p, int nPage);
typedef int (*lazy_sqlite3_backup_finish_type)(sqlite3_backup* p);
typedef int (*lazy_sqlite3_backup_remaining_type)(sqlite3_backup* p);
typedef int (*lazy_sqlite3_backup_pagecount_type)(sqlite3_backup* p);
static lazy_sqlite3_bind_blob_type lazy_sqlite3_bind_blob;
static lazy_sqlite3_bind_double_type lazy_sqlite3_bind_double;
static lazy_sqlite3_bind_int_type lazy_sqlite3_bind_int;
@@ -147,6 +153,11 @@ static lazy_sqlite3_memory_used_type lazy_sqlite3_memory_used;
static lazy_sqlite3_bind_parameter_name_type lazy_sqlite3_bind_parameter_name;
static lazy_sqlite3_total_changes_type lazy_sqlite3_total_changes;
static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid;
static lazy_sqlite3_backup_init_type lazy_sqlite3_backup_init;
static lazy_sqlite3_backup_step_type lazy_sqlite3_backup_step;
static lazy_sqlite3_backup_finish_type lazy_sqlite3_backup_finish;
static lazy_sqlite3_backup_remaining_type lazy_sqlite3_backup_remaining;
static lazy_sqlite3_backup_pagecount_type lazy_sqlite3_backup_pagecount;
#define sqlite3_bind_blob lazy_sqlite3_bind_blob
#define sqlite3_bind_double lazy_sqlite3_bind_double
@@ -199,6 +210,11 @@ static lazy_sqlite3_last_insert_rowid_type lazy_sqlite3_last_insert_rowid;
#define sqlite3_bind_parameter_name lazy_sqlite3_bind_parameter_name
#define sqlite3_total_changes lazy_sqlite3_total_changes
#define sqlite3_last_insert_rowid lazy_sqlite3_last_insert_rowid
#define sqlite3_backup_init lazy_sqlite3_backup_init
#define sqlite3_backup_step lazy_sqlite3_backup_step
#define sqlite3_backup_finish lazy_sqlite3_backup_finish
#define sqlite3_backup_remaining lazy_sqlite3_backup_remaining
#define sqlite3_backup_pagecount lazy_sqlite3_backup_pagecount
#if !OS(WINDOWS)
#define HMODULE void*
@@ -285,6 +301,11 @@ static int lazyLoadSQLite()
lazy_sqlite3_bind_parameter_name = (lazy_sqlite3_bind_parameter_name_type)dlsym(sqlite3_handle, "sqlite3_bind_parameter_name");
lazy_sqlite3_total_changes = (lazy_sqlite3_total_changes_type)dlsym(sqlite3_handle, "sqlite3_total_changes");
lazy_sqlite3_last_insert_rowid = (lazy_sqlite3_last_insert_rowid_type)dlsym(sqlite3_handle, "sqlite3_last_insert_rowid");
lazy_sqlite3_backup_init = (lazy_sqlite3_backup_init_type)dlsym(sqlite3_handle, "sqlite3_backup_init");
lazy_sqlite3_backup_step = (lazy_sqlite3_backup_step_type)dlsym(sqlite3_handle, "sqlite3_backup_step");
lazy_sqlite3_backup_finish = (lazy_sqlite3_backup_finish_type)dlsym(sqlite3_handle, "sqlite3_backup_finish");
lazy_sqlite3_backup_remaining = (lazy_sqlite3_backup_remaining_type)dlsym(sqlite3_handle, "sqlite3_backup_remaining");
lazy_sqlite3_backup_pagecount = (lazy_sqlite3_backup_pagecount_type)dlsym(sqlite3_handle, "sqlite3_backup_pagecount");
if (!lazy_sqlite3_extended_result_codes) {
lazy_sqlite3_extended_result_codes = [](sqlite3*, int) -> int {

View File

@@ -122,6 +122,7 @@ interface CppSQL {
fcntl(handle: TODO, ...args: TODO[]): TODO;
close(handle: TODO, throwOnError: boolean): void;
setCustomSQLite(path: string): void;
backupTo(sourceHandle: TODO, dest: string | TODO): number;
}
let SQL: CppSQL;
@@ -340,6 +341,20 @@ class Statement {
}
}
// Prototype for the result object returned by backupTo().
// #26884 replaces this with the full DatabaseBackup class (step/finish/abort).
const DatabaseBackupProto = {
get [toStringTag]() {
return "DatabaseBackup";
},
toJSON(this: { pageCount: number; remaining: number }) {
return { finished: true, success: true, pageCount: this.pageCount, remaining: 0 };
},
toString() {
return "[DatabaseBackup finished=true success=true]";
},
};
const cachedCount = Symbol.for("Bun.Database.cache.count");
class Database implements SqliteTypes.Database {
@@ -362,6 +377,7 @@ class Database implements SqliteTypes.Database {
if (options.readonly) {
deserializeFlags |= constants.SQLITE_DESERIALIZE_READONLY;
this.#isReadonly = true;
}
}
@@ -411,6 +427,8 @@ class Database implements SqliteTypes.Database {
flags = options;
}
this.#isReadonly = (flags & constants.SQLITE_OPEN_READONLY) !== 0;
const anonymous = filename === "" || filename === ":memory:";
if (anonymous && (flags & constants.SQLITE_OPEN_READONLY) !== 0) {
throw new Error("Cannot open an anonymous database in read-only mode.");
@@ -431,6 +449,7 @@ class Database implements SqliteTypes.Database {
#cachedQueriesValues: Statement[] = [];
filename;
#hasClosed = false;
#isReadonly = false;
get handle() {
return this.#handle;
}
@@ -451,6 +470,37 @@ class Database implements SqliteTypes.Database {
return SQL.serialize(this.#handle, optionalName || "main");
}
backupTo(destination: string | Database) {
if (this.#hasClosed) {
throw new Error("Cannot backup a closed database");
}
let dest;
if (destination instanceof Database) {
if (destination === this) {
throw new Error("Cannot backup a database to itself");
}
if (destination.#hasClosed) {
throw new Error("Cannot backup to a closed database");
}
if (destination.#isReadonly) {
throw new Error("Cannot backup to a readonly database");
}
dest = destination.#handle;
} else if (typeof destination === "string") {
dest = destination;
} else {
throw new TypeError(`Expected 'destination' to be a string or Database, got '${typeof destination}'`);
}
if (!SQL) initializeSQL();
const pageCount = SQL.backupTo(this.#handle, dest);
const result = Object.create(DatabaseBackupProto);
result.pageCount = pageCount;
result.remaining = 0;
return result;
}
static #deserialize(serialized: NodeJS.TypedArray | ArrayBufferLike, openFlags: number, deserializeFlags: number) {
if (!SQL) {
initializeSQL();
@@ -681,6 +731,7 @@ class SQLiteError extends Error {
export default {
__esModule: true,
Database,
DatabaseBackup: DatabaseBackupProto,
Statement,
constants,
default: Database,

View File

@@ -1,4 +1,4 @@
import { type Changes, Database, constants } from "bun:sqlite";
import { type Changes, Database, type DatabaseBackup, constants } from "bun:sqlite";
import { expectType } from "./utilities";
expectType(constants.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE).is<number>();
@@ -50,3 +50,26 @@ insertManyCats([
// @ts-expect-error - Should fail
{ fail: true },
]);
// DatabaseBackup API
const backupToFile = db.backupTo("backup.db");
expectType<DatabaseBackup>(backupToFile);
const dest = new Database(":memory:");
const backupToDb = db.backupTo(dest);
expectType<DatabaseBackup>(backupToDb);
// @ts-expect-error - Should fail: number is not a valid destination
db.backupTo(123);
// pageCount and remaining getters
expectType<number>(backupToDb.pageCount);
expectType<number>(backupToDb.remaining);
// toJSON
const jsonResult = backupToDb.toJSON();
expectType<number>(jsonResult.pageCount);
expectType<number>(jsonResult.remaining);
const strResult = backupToDb.toString();
expectType<string>(strResult);

View File

@@ -0,0 +1,344 @@
import { Database, constants } from "bun:sqlite";
import { describe, expect, it } from "bun:test";
import { gcTick, tempDir } from "harness";
import { readFileSync } from "node:fs";
import path from "node:path";
describe("Database.backupTo", () => {
it("backs up in-memory database to file", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)");
source.run("INSERT INTO test VALUES (1, 'foo')");
source.run("INSERT INTO test VALUES (2, 'bar')");
using dir = tempDir("sqlite-backup", {});
const destPath = path.join(String(dir), "backup.db");
source.backupTo(destPath);
using restored = new Database(destPath);
expect(restored.query("SELECT * FROM test ORDER BY id").all()).toEqual([
{ id: 1, name: "foo" },
{ id: 2, name: "bar" },
]);
});
it("backs up file database to another file", () => {
using dir = tempDir("sqlite-backup-file", {
"source.db": "",
});
const sourcePath = path.join(String(dir), "source.db");
const destPath = path.join(String(dir), "dest.db");
using source = new Database(sourcePath);
source.run("CREATE TABLE data (val INTEGER)");
source.run("INSERT INTO data VALUES (42)");
source.run("INSERT INTO data VALUES (100)");
source.backupTo(destPath);
using dest = new Database(destPath);
expect(dest.query("SELECT * FROM data ORDER BY val").all()).toEqual([{ val: 42 }, { val: 100 }]);
});
it("backs up to another Database instance", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE data (val INTEGER)");
source.run("INSERT INTO data VALUES (42)");
using dest = new Database(":memory:");
source.backupTo(dest);
expect(dest.query("SELECT * FROM data").all()).toEqual([{ val: 42 }]);
});
it("preserves multiple tables and data types", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE nums (i INTEGER, f REAL, b BLOB)");
source.run("CREATE TABLE texts (t TEXT)");
source.run("INSERT INTO nums VALUES (1, 3.14, X'DEADBEEF')");
source.run("INSERT INTO texts VALUES ('hello')");
using dest = new Database(":memory:");
source.backupTo(dest);
expect(dest.query("SELECT i, f FROM nums").get()).toEqual({ i: 1, f: 3.14 });
expect(dest.query("SELECT t FROM texts").get()).toEqual({ t: "hello" });
// Verify BLOB
const blob = dest.query("SELECT b FROM nums").get() as { b: Uint8Array };
expect(Buffer.from(blob.b).toString("hex")).toBe("deadbeef");
});
it("backs up empty database (no tables)", () => {
using source = new Database(":memory:");
using dest = new Database(":memory:");
source.backupTo(dest);
const tables = dest.query("SELECT name FROM sqlite_master WHERE type='table'").all();
expect(tables).toEqual([]);
});
it("backs up to non-empty destination (overwrites)", () => {
using dest = new Database(":memory:");
dest.run("CREATE TABLE old (x INTEGER)");
dest.run("INSERT INTO old VALUES (999)");
using source = new Database(":memory:");
source.run("CREATE TABLE new_table (y TEXT)");
source.run("INSERT INTO new_table VALUES ('fresh')");
source.backupTo(dest);
expect(dest.query("SELECT * FROM new_table").all()).toEqual([{ y: "fresh" }]);
// Old table should be gone
expect(() => dest.query("SELECT * FROM old").all()).toThrow("no such table: old");
});
it("throws with no arguments", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
expect(() => (source as any).backupTo()).toThrow("Expected 'destination' to be a string or Database");
});
it("backs up from a readonly database", () => {
using dir = tempDir("sqlite-backup-readonly", {});
const dbPath = path.join(String(dir), "source.db");
{
using db = new Database(dbPath);
db.run("CREATE TABLE t (id INTEGER)");
db.run("INSERT INTO t VALUES (1)");
}
using source = new Database(dbPath, { readonly: true });
using dest = new Database(":memory:");
source.backupTo(dest);
expect(dest.query("SELECT * FROM t").all()).toEqual([{ id: 1 }]);
});
});
describe("returned result object", () => {
it("has pageCount, remaining, toJSON, toString", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.run("INSERT INTO t VALUES (1)");
using dest = new Database(":memory:");
const result = source.backupTo(dest);
expect(typeof result.pageCount).toBe("number");
expect(result.pageCount).toBeGreaterThan(0);
expect(result.remaining).toBe(0);
expect(result.toJSON()).toEqual({
finished: true,
success: true,
pageCount: result.pageCount,
remaining: 0,
});
expect(result.toString()).toBe("[DatabaseBackup finished=true success=true]");
});
});
describe("error handling", () => {
it("throws when source database is closed", () => {
const source = new Database(":memory:");
source.close();
expect(() => source.backupTo(":memory:")).toThrow("Cannot backup a closed database");
});
it("throws when destination Database instance is closed", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
const dest = new Database(":memory:");
dest.close();
expect(() => source.backupTo(dest)).toThrow("Cannot backup to a closed database");
});
it("throws when backing up a database to itself", () => {
using db = new Database(":memory:");
db.run("CREATE TABLE t (id INTEGER)");
expect(() => db.backupTo(db)).toThrow("Cannot backup a database to itself");
});
it("throws when destination opened with numeric SQLITE_OPEN_READONLY flag", () => {
using dir = tempDir("sqlite-backup-numeric-readonly", {});
const destPath = path.join(String(dir), "dest.db");
{
using db = new Database(destPath);
db.run("CREATE TABLE t (id INTEGER)");
}
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
using dest = new Database(destPath, constants.SQLITE_OPEN_READONLY);
expect(() => source.backupTo(dest)).toThrow("Cannot backup to a readonly database");
});
it("throws with invalid destination type", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
expect(() => (source as any).backupTo(123)).toThrow("Expected 'destination' to be a string or Database");
expect(() => (source as any).backupTo(null)).toThrow("Expected 'destination' to be a string or Database");
expect(() => (source as any).backupTo(undefined)).toThrow("Expected 'destination' to be a string or Database");
});
it("backup to invalid path throws", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
using dir = tempDir("sqlite-backup-badpath", {});
const badPath = path.join(String(dir), "deeply", "nested", "missing", "backup.db");
expect(() => source.backupTo(badPath)).toThrow("unable to open database file");
});
it("backup to readonly Database destination throws", () => {
using dir = tempDir("sqlite-backup-readonly-dest", {});
const destPath = path.join(String(dir), "dest.db");
{
using db = new Database(destPath);
db.run("CREATE TABLE t (id INTEGER)");
}
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.run("INSERT INTO t VALUES (1)");
using dest = new Database(destPath, { readonly: true });
expect(() => source.backupTo(dest)).toThrow("Cannot backup to a readonly database");
});
});
describe("large data", () => {
it("backs up database with many rows", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE large (id INTEGER, data TEXT)");
using insert = source.prepare("INSERT INTO large VALUES (?, ?)");
const data = Buffer.alloc(100, "x").toString();
for (let i = 0; i < 500; i++) {
insert.run(i, data);
}
using dest = new Database(":memory:");
source.backupTo(dest);
const count = dest.query("SELECT COUNT(*) as count FROM large").get() as { count: number };
expect(count.count).toBe(500);
const first = dest.query("SELECT * FROM large WHERE id = 0").get() as { id: number; data: string };
expect(first.id).toBe(0);
expect(first.data).toBe(data);
});
});
describe("file management", () => {
it("backup overwrites an existing destination file", () => {
using dir = tempDir("sqlite-backup-overwrite", {});
const destPath = path.join(String(dir), "dest.db");
{
using old = new Database(destPath);
old.run("CREATE TABLE old_table (x INTEGER)");
old.run("INSERT INTO old_table VALUES (999)");
}
using source = new Database(":memory:");
source.run("CREATE TABLE new_table (y TEXT)");
source.run("INSERT INTO new_table VALUES ('fresh')");
source.backupTo(destPath);
using dest = new Database(destPath, { readonly: true });
expect(dest.query("SELECT * FROM new_table").all()).toEqual([{ y: "fresh" }]);
expect(() => dest.query("SELECT * FROM old_table").all()).toThrow("no such table: old_table");
});
it("backup destination is a valid SQLite file", () => {
using dir = tempDir("sqlite-backup-header", {});
const destPath = path.join(String(dir), "backup.db");
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.backupTo(destPath);
const header = readFileSync(destPath);
expect(header.subarray(0, 15).toString("ascii")).toBe("SQLite format 3");
});
});
describe("resource lifecycle", () => {
it("source database remains fully usable after backup", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.run("INSERT INTO t VALUES (1)");
using dest = new Database(":memory:");
source.backupTo(dest);
expect(source.query("SELECT * FROM t").all()).toEqual([{ id: 1 }]);
source.run("INSERT INTO t VALUES (2)");
expect(source.query("SELECT COUNT(*) as c FROM t").get()).toEqual({ c: 2 });
});
it("destination Database instance remains usable after backup", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.run("INSERT INTO t VALUES (1)");
using dest = new Database(":memory:");
source.backupTo(dest);
dest.run("INSERT INTO t VALUES (2)");
expect(dest.query("SELECT * FROM t ORDER BY id").all()).toEqual([{ id: 1 }, { id: 2 }]);
});
it("multiple sequential backups from same source", () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.run("INSERT INTO t VALUES (1)");
using dest1 = new Database(":memory:");
source.backupTo(dest1);
source.run("INSERT INTO t VALUES (2)");
using dest2 = new Database(":memory:");
source.backupTo(dest2);
expect(dest1.query("SELECT COUNT(*) as c FROM t").get()).toEqual({ c: 1 });
expect(dest2.query("SELECT COUNT(*) as c FROM t").get()).toEqual({ c: 2 });
});
it("GC cleans up abandoned backup without crash", async () => {
using source = new Database(":memory:");
source.run("CREATE TABLE t (id INTEGER)");
source.run("INSERT INTO t VALUES (1)");
(() => {
using dest = new Database(":memory:");
source.backupTo(dest);
})();
Bun.gc(true);
await gcTick();
Bun.gc(true);
expect(source.query("SELECT * FROM t").all()).toEqual([{ id: 1 }]);
});
});