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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ docs/
.tmp/
.eslintcache
todo.md
.vscode
27 changes: 27 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ const addon = bindings<{
fn: (...args: ReadonlyArray<unknown>) => void,
bigint: boolean,
): void;
databaseSetWalHook(
db: NativeDatabase,
fn: (dbName: string, pageCount: number) => void,
): void;

signalTokenize(value: string): Array<string>;

Expand Down Expand Up @@ -409,6 +413,13 @@ export type DatabaseOptions = Readonly<{
cacheStatements?: boolean;
}>;

/**
* @param dbName - The name of the database that was written to.
* @param pageCount - The number of pages currently in the write-ahead log file,
* including those that were just committed.
*/
export type WalHook = (dbName: string, pageCount: number) => void;

/**
* A sqlite database class.
*/
Expand Down Expand Up @@ -495,6 +506,22 @@ export default class Database {
);
}

/**
* Register a callback to be invoked each time data is commited to a database
* in WAL mode.
*
* @param fn - function implementation
*/
public setWalHook(fn: WalHook): void {
if (this.#native === undefined) {
throw new Error('Database closed');
}
if (typeof fn !== 'function') {
throw new TypeError('Invalid fn argument');
}
addon.databaseSetWalHook(this.#native, fn);
}

/**
* Compile a single SQL statement.
*
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@
},
"scripts": {
"watch": "tsc --watch",
"build": "run-p --print-label build:ts build:esm build:cjs",
"build": "run-p --print-label build:ts build:esm build:cjs build:addon",
"build:ts": "tsc",
"build:esm": "esbuild --target=node20 --define:__dirname=undefined lib/index.ts --outfile=dist/index.mjs",
"build:cjs": "esbuild --target=node20 --define:import.meta.url=undefined lib/index.ts --format=cjs --outfile=dist/index.cjs",
"build:docs": "typedoc lib/index.ts --includeVersion",
"build:addon": "node-gyp build",
"install": "node-gyp-build",
"prebuildify": "prebuildify --strip --napi",
"test": "vitest --coverage --pool threads",
Expand Down
66 changes: 66 additions & 0 deletions src/addon.cc
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,38 @@ class FunctionWrap {
bool is_bigint_;
};

// WAL Hook

class WalHookWrap {
public:
explicit WalHookWrap(Napi::Function fn) { fn_.Reset(fn, 1); }

static int Run(void* p_app, sqlite3* _db, const char* db_name, int n_pages) {
auto wrap = static_cast<WalHookWrap*>(p_app);
wrap->Call(db_name, n_pages);
return SQLITE_OK;
}

protected:
void Call(const char* db_name, int n_pages) {
auto env = fn_.Env();
Napi::HandleScope scope(env);

auto result = fn_.Value().Call({
Napi::String::New(env, db_name),
Napi::Number::New(env, n_pages),
});

// Ignore exceptions
if (result.IsEmpty()) {
env.GetAndClearPendingException();
}
}

private:
Napi::Reference<Napi::Function> fn_;
};

// Global Settings

thread_local Napi::Reference<Napi::Function> logger_fn_;
Expand Down Expand Up @@ -236,6 +268,8 @@ Napi::Object Database::Init(Napi::Env env, Napi::Object exports) {
exports["databaseExec"] = Napi::Function::New(env, &Database::Exec);
exports["databaseCreateFunction"] =
Napi::Function::New(env, &Database::CreateFunction);
exports["databaseSetWalHook"] =
Napi::Function::New(env, &Database::SetWalHook);
return exports;
}

Expand All @@ -251,6 +285,9 @@ Database::~Database() {
return;
}

delete wal_hook_wrap_;
wal_hook_wrap_ = nullptr;

int r = sqlite3_close(handle_);
if (r != SQLITE_OK) {
fprintf(stderr, "Cleanup: sqlite3_close failure\n");
Expand Down Expand Up @@ -342,6 +379,9 @@ Napi::Value Database::Close(const Napi::CallbackInfo& info) {
}
db->statements_.clear();

delete db->wal_hook_wrap_;
db->wal_hook_wrap_ = nullptr;

int r = sqlite3_close(db->handle_);
if (r != SQLITE_OK) {
return db->ThrowSqliteError(env, r);
Expand Down Expand Up @@ -411,6 +451,32 @@ Napi::Value Database::CreateFunction(const Napi::CallbackInfo& info) {
return Napi::Value();
}

Napi::Value Database::SetWalHook(const Napi::CallbackInfo& info) {
auto env = info.Env();

auto db = FromExternal(info[0]);
auto fn = info[1].As<Napi::Function>();

assert(fn.IsFunction());

if (db == nullptr) {
return Napi::Value();
}

if (db->handle_ == nullptr) {
NAPI_THROW(Napi::Error::New(env, "Database closed"), Napi::Value());
}

auto wal_wrap = new WalHookWrap(fn);

delete db->wal_hook_wrap_;
db->wal_hook_wrap_ = wal_wrap;

sqlite3_wal_hook(db->handle_, WalHookWrap::Run, wal_wrap);

return Napi::Value();
}

Napi::Value Database::ThrowSqliteError(Napi::Env env, int error) {
assert(handle_ != nullptr);
const char* msg = sqlite3_errmsg(handle_);
Expand Down
4 changes: 4 additions & 0 deletions src/addon.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "sqlite3.h"

class Statement;
class WalHookWrap;

class Database {
public:
Expand All @@ -31,6 +32,7 @@ class Database {
static Napi::Value Close(const Napi::CallbackInfo& info);
static Napi::Value Exec(const Napi::CallbackInfo& info);
static Napi::Value CreateFunction(const Napi::CallbackInfo& info);
static Napi::Value SetWalHook(const Napi::CallbackInfo& info);

fts5_api* GetFTS5API(Napi::Env env);

Expand All @@ -45,6 +47,8 @@ class Database {
// All currently open statements for this database. Used to close all open
// statements when closing the database.
std::list<Statement*> statements_;

WalHookWrap* wal_hook_wrap_ = nullptr;
};

class AutoResetStatement {
Expand Down
79 changes: 78 additions & 1 deletion test/disk.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, test, beforeEach, afterEach } from 'vitest';
import { expect, test, describe, beforeEach, afterEach, vi } from 'vitest';

import Database from '../lib/index.js';

Expand Down Expand Up @@ -65,3 +65,80 @@ test.each([[false], [true]])('ciphertext=%j', (ciphertext) => {

expect(row).toEqual({ name: 'Adam', value: 'Sandler' });
});

describe('setWalHook', () => {
beforeEach(() => {
db.pragma('journal_mode = WAL');
db.exec('CREATE TABLE t (a INTEGER)');
});

test('calls hook after WAL commit', () => {
const hook = vi.fn();
db.setWalHook(hook);

db.prepare('INSERT INTO t (a) VALUES (1)').run();

expect(hook).toHaveBeenCalledOnce();
expect(hook).toHaveBeenCalledWith('main', expect.any(Number));
});

test('hook receives page count > 0', () => {
let pageCount: number | null = null;
db.setWalHook((_dbName, n) => {
pageCount = n;
});

db.prepare('INSERT INTO t (a) VALUES (1)').run();

expect(pageCount).toBeGreaterThan(0);
});

test('hook fires once per commit', () => {
const hook = vi.fn();
db.setWalHook(hook);

db.transaction(() => {
db.prepare('INSERT INTO t (a) VALUES (1)').run();
db.prepare('INSERT INTO t (a) VALUES (2)').run();
})();

expect(hook).toHaveBeenCalledOnce();
});

test('replaces previous hook', () => {
const first = vi.fn();
const second = vi.fn();

db.setWalHook(first);
db.setWalHook(second);

db.prepare('INSERT INTO t (a) VALUES (1)').run();

expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledOnce();
});

test('silently ignores exceptions thrown by hook', () => {
let called = false;
db.setWalHook(() => {
called = true;
throw new Error('hook error');
});

expect(() =>
db.prepare('INSERT INTO t (a) VALUES (1)').run(),
).not.toThrow();
expect(called).toBe(true);
});

test('throws when database is closed', () => {
db.close();
expect(() => db.setWalHook(vi.fn())).toThrowError('Database closed');
db = new Database(join(dir, 'db2.sqlite'));
});

test('throws for invalid argument', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(() => db.setWalHook(123 as any)).toThrowError('Invalid fn argument');
});
});
Loading