Add db.setWalHook()
Co-authored-by: Fedor Indutny <indutny@signal.org>
This commit is contained in:
parent
341ba5387b
commit
f6c6098522
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@ docs/
|
||||
.tmp/
|
||||
.eslintcache
|
||||
todo.md
|
||||
.vscode
|
||||
|
||||
27
lib/index.ts
27
lib/index.ts
@ -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>;
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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",
|
||||
|
||||
66
src/addon.cc
66
src/addon.cc
@ -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_;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
@ -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);
|
||||
@ -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_);
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
#include "sqlite3.h"
|
||||
|
||||
class Statement;
|
||||
class WalHookWrap;
|
||||
|
||||
class Database {
|
||||
public:
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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…
Reference in New Issue
Block a user