diff --git a/.gitignore b/.gitignore index d6ae9cd..5b3683a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ docs/ .tmp/ .eslintcache todo.md +.vscode diff --git a/lib/index.ts b/lib/index.ts index cbae172..ece6660 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -53,6 +53,10 @@ const addon = bindings<{ fn: (...args: ReadonlyArray) => void, bigint: boolean, ): void; + databaseSetWalHook( + db: NativeDatabase, + fn: (dbName: string, pageCount: number) => void, + ): void; signalTokenize(value: string): Array; @@ -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. * diff --git a/package.json b/package.json index 80739e1..bf5b9cf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/addon.cc b/src/addon.cc index 1f5353e..d701ac7 100644 --- a/src/addon.cc +++ b/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(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 fn_; +}; + // Global Settings thread_local Napi::Reference 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(); + + 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_); diff --git a/src/addon.h b/src/addon.h index 800aff5..ae9cf81 100644 --- a/src/addon.h +++ b/src/addon.h @@ -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 statements_; + + WalHookWrap* wal_hook_wrap_ = nullptr; }; class AutoResetStatement { diff --git a/test/disk.test.ts b/test/disk.test.ts index 3b93d80..602d0e3 100644 --- a/test/disk.test.ts +++ b/test/disk.test.ts @@ -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'); + }); +});