mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
Compare commits
3 Commits
claude/fix
...
feat/bun-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4da9d26ffd | ||
|
|
2bd4d2aaca | ||
|
|
8205cb5d83 |
40
packages/bun-types/sqlite.d.ts
vendored
40
packages/bun-types/sqlite.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
344
test/js/bun/sqlite/backup.test.ts
Normal file
344
test/js/bun/sqlite/backup.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user