Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
68 changes: 68 additions & 0 deletions src/addon.cc
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,40 @@ 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 +270,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 +287,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 +381,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 +453,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