Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: ${{ matrix.node }}
# Node 24.19.0+ (through at least 24.21.0) aborts when it garbage
# collects a node::ObjectWrap compiled against its own headers: the
# header's destructor calls RemoveEnvironmentCleanupHook, which the
# 24.x runtime can't handle without a current Environment
# (https://github.com/nodejs/node/issues/65446). The release
# prebuilds compile against 24.0.0 headers and are unaffected.
# Unpin once a 24.x release ships the fix.
node-version: ${{ matrix.node == 24 && '24.18.1' || matrix.node }}
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ startsWith(matrix.os, 'macos') }}
Expand Down
17 changes: 17 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- [Database#loadExtension()](#loadextensionpath-entrypoint---this)
- [Database#exec()](#execstring---this)
- [Database#close()](#close---this)
- [Database#persistWal()](#persistwaltogglestate---this)
- [Properties](#properties)

### new Database(*path*, [*options*])
Expand Down Expand Up @@ -394,6 +395,22 @@ process.on('SIGINT', () => process.exit(128 + 2));
process.on('SIGTERM', () => process.exit(128 + 15));
```

### .persistWal([*toggleState*]) -> *this*

Toggles whether the connection keeps the [write-ahead log](https://www.sqlite.org/wal.html) when it is the last connection to close the database (see [`SQLITE_FCNTL_PERSIST_WAL`](https://www.sqlite.org/c3ref/c_fcntl_begin_atomic_write.html#sqlitefcntlpersistwal)). The last connection still checkpoints the WAL into the database, but leaves the `-wal` and `-shm` files in place instead of deleting them, so the next connection continues the same WAL. It is off by default. Invoking it without an argument turns it on.

The setting applies to the `main` database of this connection only, and has no effect on in-memory or temporary databases.

Turn it off before switching the database out of WAL mode (e.g. `PRAGMA journal_mode = DELETE` or `OFF`). Otherwise the `-wal` file is left in place and SQLite reopens it on the next transaction, so the database effectively stays in WAL mode.

```js
db.pragma('journal_mode = WAL');
db.persistWal(); // Keep the WAL when this connection closes.

db.persistWal(false);
db.pragma('journal_mode = DELETE'); // Folds in and deletes the WAL.
```

## Properties

**.open -> _boolean_** - Whether the database connection is currently open.
Expand Down
1 change: 1 addition & 0 deletions lib/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Database.prototype.exec = wrappers.exec;
Database.prototype.close = wrappers.close;
Database.prototype.defaultSafeIntegers = wrappers.defaultSafeIntegers;
Database.prototype.unsafeMode = wrappers.unsafeMode;
Database.prototype.persistWal = wrappers.persistWal;
Database.prototype.loadExtension = wrappers.loadExtension;
Database.prototype[util.inspect] = require('./methods/inspect');

Expand Down
1 change: 1 addition & 0 deletions lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ declare namespace BetterSqlite3 {
backup(destinationFile: string, options?: Database.BackupOptions): Promise<Database.BackupMetadata>;
table(name: string, options: VirtualTableOptions): this;
unsafeMode(unsafe?: boolean): this;
persistWal(persist?: boolean): this;
serialize(options?: Database.SerializeOptions): Buffer;
}

Expand Down
5 changes: 5 additions & 0 deletions lib/methods/wrappers.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ exports.unsafeMode = function unsafeMode(...args) {
return this;
};

exports.persistWal = function persistWal(...args) {
this[cppdb].persistWal(...args);
return this;
};

exports.loadExtension = function loadExtension(...args) {
this[cppdb].loadExtension(...args);
return this;
Expand Down
15 changes: 15 additions & 0 deletions src/objects/database.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ INIT(Database::Init) {
SetPrototypeMethod(isolate, data, t, "close", JS_close);
SetPrototypeMethod(isolate, data, t, "defaultSafeIntegers", JS_defaultSafeIntegers);
SetPrototypeMethod(isolate, data, t, "unsafeMode", JS_unsafeMode);
SetPrototypeMethod(isolate, data, t, "persistWal", JS_persistWal);
SetPrototypeGetter(isolate, data, t, "open", JS_open);
SetPrototypeGetter(isolate, data, t, "inTransaction", JS_inTransaction);
return t->GetFunction(OnlyContext).ToLocalChecked();
Expand Down Expand Up @@ -407,6 +408,20 @@ NODE_METHOD(Database::JS_unsafeMode) {
sqlite3_db_config(db->db_handle, SQLITE_DBCONFIG_DEFENSIVE, static_cast<int>(!db->unsafe_mode), NULL);
}

NODE_METHOD(Database::JS_persistWal) {
Database* db = Unwrap<Database>(info.This());
REQUIRE_DATABASE_OPEN(db);
bool persist_wal = true;
if (info.Length() != 0) { REQUIRE_ARGUMENT_BOOLEAN(first, persist_wal); }
int arg = persist_wal ? 1 : 0;
int status = sqlite3_file_control(db->db_handle, "main", SQLITE_FCNTL_PERSIST_WAL, &arg);
// The VFS of an in-memory or temporary database has no WAL to persist and
// reports SQLITE_NOTFOUND, which is not an error.
if (status != SQLITE_OK && status != SQLITE_NOTFOUND) {
ThrowSqliteError(db->addon, sqlite3_errstr(status), status);
}
}

NODE_GETTER(Database::JS_open) {
info.GetReturnValue().Set(Unwrap<Database>(PROPERTY_HOLDER(info))->open);
}
Expand Down
1 change: 1 addition & 0 deletions src/objects/database.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class Database : public node::ObjectWrap {
static NODE_METHOD(JS_close);
static NODE_METHOD(JS_defaultSafeIntegers);
static NODE_METHOD(JS_unsafeMode);
static NODE_METHOD(JS_persistWal);
static NODE_GETTER(JS_open);
static NODE_GETTER(JS_inTransaction);

Expand Down
97 changes: 97 additions & 0 deletions test/53.persist-wal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use strict';
const fs = require('fs');
const Database = require('../.');

describe('Database#persistWal()', function () {
beforeEach(function () {
this.db = new Database(util.next());
this.wal = `${this.db.name}-wal`;
this.db.pragma('journal_mode = WAL');
this.db.exec('CREATE TABLE t (x INTEGER)');
this.db.prepare('INSERT INTO t VALUES (?)').run(1);
});
afterEach(function () {
if (this.db.open) this.db.close();
});

// The checkpoint sequence number and the salts of the WAL header.
const walHeader = wal => fs.readFileSync(wal).subarray(12, 24).toString('hex');

it('should return the database object', function () {
expect(this.db.persistWal()).to.equal(this.db);
expect(this.db.persistWal(false)).to.equal(this.db);
expect(this.db.persistWal(true)).to.equal(this.db);
});
it('should throw if the argument is not a boolean', function () {
expect(() => this.db.persistWal(1)).to.throw(TypeError);
expect(() => this.db.persistWal('true')).to.throw(TypeError);
expect(() => this.db.persistWal(null)).to.throw(TypeError);
});
it('should throw if the database is closed', function () {
this.db.close();
expect(() => this.db.persistWal()).to.throw(TypeError);
});
it('should be a no-op for in-memory databases', function () {
const db = new Database(':memory:');
expect(db.persistWal()).to.equal(db);
expect(db.persistWal(false)).to.equal(db);
db.close();
});
it('should delete the WAL on the last close by default', function () {
expect(fs.existsSync(this.wal)).to.be.true;
this.db.close();
expect(fs.existsSync(this.wal)).to.be.false;
});
it('should keep the WAL on the last close when toggled on', function () {
this.db.persistWal();
this.db.close();
expect(fs.statSync(this.wal).size).to.be.above(32);
const header = walHeader(this.wal);

// Reopening and writing appends to the same WAL, rather than restarting it.
const size = fs.statSync(this.wal).size;
const db = new Database(this.db.name);
db.persistWal();
db.prepare('INSERT INTO t VALUES (?)').run(2);
expect(fs.statSync(this.wal).size).to.be.above(size);
expect(walHeader(this.wal)).to.equal(header);
db.close();
expect(walHeader(this.wal)).to.equal(header);

const reader = new Database(this.db.name, { readonly: true });
expect(reader.prepare('SELECT x FROM t ORDER BY x').pluck().all()).to.deep.equal([1, 2]);
reader.close();
});
it('should delete the WAL on the last close when toggled off', function () {
this.db.persistWal();
this.db.persistWal(false);
this.db.close();
expect(fs.existsSync(this.wal)).to.be.false;
});
it('should keep the WAL when leaving WAL mode while toggled on', function () {
// This is why persistence must be toggled off before leaving WAL mode.
this.db.persistWal();
expect(this.db.pragma('journal_mode = DELETE', { simple: true })).to.equal('delete');
expect(fs.existsSync(this.wal)).to.be.true;
});
it('should leave WAL mode when toggled off first', function () {
this.db.persistWal();
this.db.unsafeMode();
this.db.persistWal(false);
expect(this.db.pragma('journal_mode = OFF', { simple: true })).to.equal('off');
expect(fs.existsSync(this.wal)).to.be.false;
this.db.exec('VACUUM');
this.db.prepare('INSERT INTO t VALUES (?)').run(2);
expect(fs.existsSync(this.wal)).to.be.false;
this.db.unsafeMode(false);
this.db.persistWal();
this.db.close();

const db = new Database(this.db.name);
expect(db.pragma('journal_mode', { simple: true })).to.equal('delete');
expect(db.prepare('SELECT x FROM t ORDER BY x').pluck().all()).to.deep.equal([1, 2]);
expect(db.pragma('integrity_check', { simple: true })).to.equal('ok');
db.close();
expect(fs.existsSync(this.wal)).to.be.false;
});
});
Loading