Compare commits

...

63 Commits
v1.0.0 ... main

Author SHA1 Message Date
Fedor Indutny
f90dd065e9 3.3.5
Some checks failed
Test / test (clang, clang++, macos-latest) (push) Has been cancelled
Test / test (gcc, g++, ubuntu-latest) (push) Has been cancelled
Test / test (windows-latest) (push) Has been cancelled
2026-05-26 12:32:59 -07:00
Fedor Indutny
9e947e02ae fix: pnpm build with prebuilds 2026-05-26 12:32:50 -07:00
Fedor Indutny
ef6443f848 fix: linux ci 2026-05-26 12:24:23 -07:00
Fedor Indutny
84a54d03d5 3.3.4 2026-05-26 12:08:35 -07:00
Fedor Indutny
d27e1d9a27 fix: install pnpm in Dockerfile 2026-05-26 12:08:25 -07:00
Fedor Indutny
19eb15285d 3.3.3 2026-05-26 11:58:19 -07:00
Fedor Indutny
c5c58ee0c4 fix: specify pnpm version in package.json 2026-05-26 11:58:08 -07:00
Fedor Indutny
d755f4997e 3.3.2 2026-05-26 11:31:32 -07:00
Fedor Indutny
3df7bb3fda chore: update pnpm to 10.18.1 2026-05-26 11:31:18 -07:00
Fedor Indutny
14ea17da26 3.3.1 2026-05-26 11:02:26 -07:00
Fedor Indutny
c2ba9fcc16
chore: update docker image to fix linux builds 2026-05-26 11:02:06 -07:00
Fedor Indutny
788376d0cf 3.3.0 2026-05-26 09:49:16 -07:00
Jamie
f6c6098522
Add db.setWalHook()
Co-authored-by: Fedor Indutny <indutny@signal.org>
2026-05-26 09:49:06 -07:00
Fedor Indutny
341ba5387b 3.2.1 2026-03-17 09:43:47 -07:00
Fedor Indutny
0180ac2982 chore: update rust-toolchain for CI 2026-03-17 09:43:29 -07:00
Fedor Indutny
059c56e5e1 3.2.0 2026-03-17 09:10:26 -07:00
Jamie
8ca829224f
Update SqliteValue to expect a Uint8Array<ArrayBuffer> 2026-03-17 09:10:11 -07:00
Fedor Indutny
e303f7f333 3.1.0 2025-12-31 00:04:35 +01:00
Fedor Indutny
8f625a6628
feat: custom functions 2025-12-31 00:04:26 +01:00
Fedor Indutny
e7dad7dc6e 3.0.1 2025-11-25 13:29:20 -08:00
Fedor Indutny
4ef618cee4 3.0.1-rc.2 2025-11-25 13:20:31 -08:00
Fedor Indutny
e7aa532457 chore: update npm in publish action 2025-11-25 13:20:21 -08:00
Fedor Indutny
a7bf1b5a41 3.0.1-rc.1 2025-11-25 13:09:05 -08:00
Fedor Indutny
dcc79168bf chore: use OIDC for workflow 2025-11-25 13:08:43 -08:00
Fedor Indutny
808689b6ef fix: clear pending exception in log fn 2025-11-25 13:06:38 -08:00
Fedor Indutny
c50acaa2ed chore: put back INCRBLOB 2025-11-19 12:13:44 -08:00
Fedor Indutny
db0e2cdde6 chore: use alloca 2025-11-19 12:08:32 -08:00
Fedor Indutny
d5a55f168b chore: use recommended defines for sqlcipher 2025-11-19 12:02:01 -08:00
Fedor Indutny
e9110912fb fix: gyp file syntax error 2025-11-19 11:09:32 -08:00
Fedor Indutny
102d205512 chore: enable LTO on sqlcipher 2025-11-19 11:08:00 -08:00
Fedor Indutny
c41fadeb7a 3.0.0 2025-09-05 10:01:54 -07:00
Fedor Indutny
ac72ab5354
feat: cache parameter bindings 2025-09-05 10:00:29 -07:00
Fedor Indutny
e528fa8aaa 2.4.4 2025-08-15 10:36:27 -07:00
Fedor Indutny
ee6c652c6b fix: update extension to 0.2.2 2025-08-15 10:36:07 -07:00
Fedor Indutny
9c1859e99b 2.4.3 2025-08-13 16:46:40 -07:00
Fedor Indutny
5eeadbe38e chore: fix test in workflow 2025-08-13 16:46:33 -07:00
Fedor Indutny
5b8c8d9ecd 2.4.2 2025-08-13 16:28:21 -07:00
Fedor Indutny
d06c1718c7 chore: fix binaries for profiling release 2025-08-13 16:28:20 -07:00
Fedor Indutny
026e7205a3 2.4.1 2025-08-13 16:19:34 -07:00
Fedor Indutny
e5aa3aefda chore: fix publish script 2025-08-13 16:19:25 -07:00
Fedor Indutny
d5578e2f47 2.4.0 2025-08-13 16:07:46 -07:00
Fedor Indutny
92ca1f8017
Update sqlcipher to v4.10.0 2025-08-13 16:07:35 -07:00
Fedor Indutny
2de04bedae 2.3.0 2025-08-13 13:15:47 -07:00
Fedor Indutny
31a01e846b
feat: introduce setLogger method 2025-08-13 13:15:27 -07:00
Fedor Indutny
67c25d34cf 2.2.2 2025-08-07 10:32:16 -07:00
Fedor Indutny
b788cec79b
fix: include original error in ROLLBACK failure 2025-08-07 10:32:09 -07:00
Fedor Indutny
26e1566203 2.2.1 2025-08-04 14:50:11 -07:00
Fedor Indutny
8b7d2f7433 fix: profiling build 2025-08-04 14:50:02 -07:00
Fedor Indutny
0251c9027d 2.2.0 2025-08-04 14:29:26 -07:00
Fedor Indutny
0ec7a54258 chore: build profiling and production versions 2025-08-04 14:01:37 -07:00
Fedor Indutny
9e85ee2e91
feat: stmt.scanStats() 2025-08-04 13:40:00 -07:00
Fedor Indutny
d1646af2f3 2.1.0 2025-06-30 10:34:29 -07:00
Fedor Indutny
9d1ddd2e24
feat: include string version of error in err.code 2025-06-30 10:33:30 -07:00
Fedor Indutny
8b6365b761 2.0.3 2025-05-12 11:59:14 -07:00
Fedor Indutny
8f3d534645 fix: always reset statement on error 2025-05-12 11:49:05 -07:00
Fedor Indutny
24a198971b 2.0.2 2025-05-08 09:38:34 -07:00
Willow
2cf0ed0e59
fix: allow importing with exports-aware Typescript 2025-05-08 09:38:15 -07:00
Fedor Indutny
fefc9efbe3 2.0.1 2025-04-28 14:08:19 -07:00
Fedor Indutny
b4d9e35023
fix: crash after closing the database 2025-04-28 14:06:06 -07:00
Fedor Indutny
d2364faa35 2.0.0 2025-04-03 12:33:08 -07:00
Fedor Indutny
76b0627059
breaking: add initTokenizer() API method 2025-04-03 12:32:39 -07:00
Jamie Kyle
8560210b73 1.1.0 2025-04-03 11:26:48 -07:00
Jamie Kyle
3223f79e92
Update sqlcipher to 4.7.0 2025-04-03 11:26:32 -07:00
29 changed files with 14620 additions and 7461 deletions

View File

@ -20,6 +20,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, macos-latest]
buildType: ['production', 'profiling']
include:
- os: macos-latest
target: arm64
@ -39,7 +40,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
version: 10.3.0
version: 10.18.1
- name: Get Node version from .nvmrc
id: get-nvm-version
@ -51,6 +52,10 @@ jobs:
with:
node-version-file: '.nvmrc'
- name: Update package.json
if: ${{ matrix.buildType == 'profiling' }}
run: pnpm version prerelease --no-git-tag-version --preid profiling
- name: Install node_modules
run: pnpm install --frozen-lockfile
@ -68,28 +73,54 @@ jobs:
- name: Upload artifacts
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: sqlcipher-${{matrix.os}}
name: sqlcipher-${{matrix.buildType}}-${{matrix.os}}
path: prebuilds/*
prebuild_linux:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
buildType: ['production', 'profiling']
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
version: 10.18.1
- name: Get Node version from .nvmrc
id: get-nvm-version
shell: bash
run: echo "node-version=$(cat .nvmrc)" >> $GITHUB_OUTPUT
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version-file: '.nvmrc'
- name: Update package.json
if: ${{ matrix.buildType == 'profiling' }}
run: pnpm version prerelease --no-git-tag-version --preid profiling
- name: Build in docker container
run: ./docker-prebuildify.sh
- name: Upload artifacts
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: sqlcipher-linux-latest
name: sqlcipher-${{matrix.buildType}}-linux-latest
path: prebuilds/*
publish:
name: Publish
permissions:
# Required for OIDC
id-token: 'write'
# Needed for ncipollo/release-action.
contents: 'write'
@ -106,17 +137,20 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
version: 10.3.0
version: 10.18.1
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org/'
- name: Download built libraries
- name: Update npm
run: npm install -g npm@latest
- name: Download built production libraries
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.19.1
with:
pattern: sqlcipher-*
pattern: sqlcipher-production-*
path: prebuilds
merge-multiple: true
@ -129,14 +163,13 @@ jobs:
- name: Lint
run: pnpm lint
- run: pnpm test
- name: Production Tests
run: pnpm test
env:
PREBUILDS_ONLY: 1
- name: Publish
- name: Publish production
run: pnpm publish --tag '${{ github.event.inputs.npm_tag }}' --access public --no-git-checks ${{ inputs.dry_run && '--dry-run' || ''}}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Rename symbols
run: |
@ -150,8 +183,28 @@ jobs:
# This step is expected to fail if not run on a tag.
- name: Upload debug info to release
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
if: ${{ !inputs.dry_run }}
with:
allowUpdates: true
artifactErrorsFailBuild: true
artifacts: prebuilds/node_sqlcipher_*.sym
- name: Update package.json version to profiling
run: pnpm version prerelease --no-git-tag-version --preid profiling
- name: Remove production prebuilds
run: rm -rf prebuilds
- name: Download built profiling libraries
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.19.1
with:
pattern: sqlcipher-profiling-*
path: prebuilds
merge-multiple: true
- name: Profiling build tests
run: pnpm test
env:
PREBUILDS_ONLY: 1
- name: Publish profiling
run: pnpm publish --tag 'profiling' --access public --no-git-checks ${{ inputs.dry_run && '--dry-run' || ''}}

View File

@ -30,7 +30,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
with:
version: 10.3.0
version: 10.18.1
- name: Setup node.js
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ docs/
.tmp/
.eslintcache
todo.md
.vscode

2
.nvmrc
View File

@ -1 +1 @@
20.18.2
22.18.0

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
#
FROM ubuntu:focal-20240530@sha256:fa17826afb526a9fc7250e0fbcbfd18d03fe7a54849472f86879d8bf562c629e
FROM ubuntu:jammy-20250714@sha256:1ec65b2719518e27d4d25f104d93f9fac60dc437f81452302406825c46fcc9cb
# Avoid getting prompted to configure things during installation.
ENV DEBIAN_FRONTEND=noninteractive
@ -15,13 +15,15 @@ COPY docker/apt.conf docker/sources.list /etc/apt/
# But we can't install it because it doesn't trust our mirror!
# Temporarily disables APT's certificate signature checking
# to download the certificates.
RUN apt-get update -oAcquire::https::Verify-Peer=false \
&& apt-get install -oAcquire::https::Verify-Peer=false -y ca-certificates
RUN apt update -oAcquire::https::Verify-Peer=false
RUN apt install -oAcquire::https::Verify-Peer=false -y ca-certificates
# Back to normal, verification back on
# Install only what's needed to set up Rust and Node.
# We'll install additional tools at the end to take advantage of Docker's caching of earlier steps.
RUN apt-get update && apt-get install -y apt-transport-https xz-utils unzip
RUN apt update
RUN apt install -y apt-transport-https xz-utils unzip
# User-specific setup!
@ -65,6 +67,9 @@ RUN tar -xf node.tar.xz \
ENV PATH="/home/sqlcipher/node/bin:${PATH}"
# Install pnpm
RUN npm install -g pnpm@10.18.1
# And finally any bonus packages we're going to need
# Note that we jump back to root for this.
USER root

View File

@ -37,7 +37,7 @@ cd deps/sqlcipher
export OPENSSL_PREFIX=`brew --prefix openssl`
export CFLAGS="-I $OPENSSL_PREFIX/include"
export LIBRARY_PATH="$LIBRARY_PATH:$OPENSSL_PREFIX/lib"
./update.sh v4.6.1
./update.sh v4.10.0
cd -
```

View File

@ -2,7 +2,8 @@ import { Buffer } from 'node:buffer';
import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3';
import Database from '../lib/index.js';
import { DatabaseSync as NDatabase } from 'node:sqlite';
import Database from '../dist/index.mjs';
const PREPARE = `
CREATE TABLE t (
@ -21,12 +22,15 @@ const DELETE = 'DELETE FROM t';
describe('INSERT INTO t', () => {
const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE);
bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
bench(
'@signalapp/sqlcipher',
@ -51,4 +55,16 @@ describe('INSERT INTO t', () => {
},
},
);
bench(
'node:sqlite',
() => {
ninsert.run({ b: BLOB });
},
{
teardown: () => {
ndb.exec(DELETE);
},
},
);
});

View File

@ -1,7 +1,8 @@
import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3';
import Database from '../lib/index.js';
import { DatabaseSync as NDatabase } from 'node:sqlite';
import Database from '../dist/index.mjs';
const PREPARE = `
CREATE TABLE t (
@ -24,12 +25,15 @@ const DELETE = 'DELETE FROM t';
describe('INSERT INTO t', () => {
const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE);
bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
bench(
'@signalapp/sqlcipher',
@ -54,4 +58,16 @@ describe('INSERT INTO t', () => {
},
},
);
bench(
'node:sqlite',
() => {
ninsert.run({ a1: 1, a2: 2, a3: 3, b1: 'b1', b2: 'b2', b3: 'b3' });
},
{
teardown: () => {
ndb.exec(DELETE);
},
},
);
});

View File

@ -1,7 +1,8 @@
import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3';
import Database from '../lib/index.js';
import { DatabaseSync as NDatabase } from 'node:sqlite';
import Database from '../dist/index.mjs';
const PREPARE = `
CREATE TABLE t (
@ -36,12 +37,15 @@ const SELECT = 'SELECT * FROM t LIMIT 1000';
describe('SELECT * FROM t', () => {
const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE);
bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
sdb.transaction(() => {
for (const value of VALUES) {
@ -55,6 +59,12 @@ describe('SELECT * FROM t', () => {
}
})();
ndb.exec('BEGIN');
for (const value of VALUES) {
ninsert.run(value);
}
ndb.exec('COMMIT');
const sselect = sdb.prepare(SELECT);
const bselect = bdb.prepare(SELECT);
@ -65,4 +75,10 @@ describe('SELECT * FROM t', () => {
bench('@signalapp/better-sqlite', () => {
bselect.all();
});
bench('node:sqlite', () => {
// Node.js seems to finalize the statement after `.all()`
const nselect = ndb.prepare(SELECT);
nselect.all();
});
});

14
deps/extension/Cargo.lock generated vendored
View File

@ -425,6 +425,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
@ -454,7 +465,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-sqlcipher-extension"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"aes",
"cbc",
@ -462,6 +473,7 @@ dependencies = [
"hmac",
"pbkdf2",
"rand_core",
"sha1",
"sha2",
"signal-tokenizer",
]

View File

@ -5,7 +5,7 @@
[package]
name = "signal-sqlcipher-extension"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
license = "AGPL-3.0-only"
@ -23,6 +23,7 @@ cbc = "0.1.2"
hmac = "0.12.1"
pbkdf2 = "0.12.2"
rand_core = { version = "0.6.4", "default-features" = false, features = ["getrandom"] }
sha1 = { version = "0.10.6", "default-features" = false }
sha2 = { version = "0.10.8", "default-features" = false }
signal-tokenizer = { git = "https://github.com/signalapp/Signal-FTS5-Extension" }

View File

@ -10,6 +10,7 @@ use core::ffi::{c_char, c_int, c_uchar, c_void};
use hmac::{Hmac, Mac};
use pbkdf2::pbkdf2_hmac;
use rand_core::{OsRng, RngCore};
use sha1::Sha1;
use sha2::Sha512;
pub use signal_tokenizer;
@ -70,6 +71,7 @@ extern "C" fn random(_ctx: *mut c_void, buf: *mut c_void, length: c_int) -> c_in
extern "C" fn get_hmac_sz(_ctx: *mut c_void, algorithm: c_int) -> c_int {
match algorithm {
SQLCIPHER_HMAC_SHA512 => 64,
SQLCIPHER_HMAC_SHA1 => 20,
_ => 0,
}
}
@ -85,9 +87,6 @@ extern "C" fn hmac(
in2_sz: c_int,
out: *mut c_uchar,
) -> c_int {
if algorithm != SQLCIPHER_HMAC_SHA512 {
return SQLITE_ERROR;
}
if hmac_key.is_null() || in1.is_null() || out.is_null() {
return SQLITE_ERROR;
}
@ -99,16 +98,34 @@ extern "C" fn hmac(
Some(unsafe { core::slice::from_raw_parts(in2 as *mut c_uchar, in2_sz as usize) })
};
let Ok(mut mac) = Hmac::<Sha512>::new_from_slice(key) else {
return SQLITE_ERROR;
};
mac.update(in1);
if let Some(in2) = in2 {
mac.update(in2);
}
let digest = mac.finalize().into_bytes();
unsafe {
out.copy_from(digest.as_ptr(), digest.len());
match algorithm {
SQLCIPHER_HMAC_SHA512 => {
let Ok(mut mac) = Hmac::<Sha512>::new_from_slice(key) else {
return SQLITE_ERROR;
};
mac.update(in1);
if let Some(in2) = in2 {
mac.update(in2);
}
let digest = mac.finalize().into_bytes();
unsafe {
out.copy_from(digest.as_ptr(), digest.len());
};
}
SQLCIPHER_HMAC_SHA1 => {
let Ok(mut mac) = Hmac::<Sha1>::new_from_slice(key) else {
return SQLITE_ERROR;
};
mac.update(in1);
if let Some(in2) = in2 {
mac.update(in2);
}
let digest = mac.finalize().into_bytes();
unsafe {
out.copy_from(digest.as_ptr(), digest.len());
};
}
_ => return SQLITE_ERROR,
};
SQLITE_OK
}
@ -124,16 +141,21 @@ extern "C" fn pbkdf(
key_sz: c_int,
key: *mut c_uchar,
) -> c_int {
if algorithm != SQLCIPHER_PBKDF2_HMAC_SHA512 {
return SQLITE_ERROR;
}
if pass.is_null() || salt.is_null() || key.is_null() {
return SQLITE_ERROR;
}
let password = unsafe { core::slice::from_raw_parts(pass as *const c_uchar, pass_sz as usize) };
let salt = unsafe { core::slice::from_raw_parts(salt as *const c_uchar, salt_sz as usize) };
let buf = unsafe { core::slice::from_raw_parts_mut(key as *mut c_uchar, key_sz as usize) };
pbkdf2_hmac::<Sha512>(password, salt, workfactor as u32, buf);
match algorithm {
SQLCIPHER_PBKDF2_HMAC_SHA512 => {
pbkdf2_hmac::<Sha512>(password, salt, workfactor as u32, buf);
}
SQLCIPHER_PBKDF2_HMAC_SHA1 => {
pbkdf2_hmac::<Sha1>(password, salt, workfactor as u32, buf);
}
_ => return SQLITE_ERROR,
};
SQLITE_OK
}

View File

@ -10,6 +10,10 @@ use core::ffi::{c_char, c_int, c_uchar, c_void};
pub const SQLCIPHER_HMAC_SHA512: c_int = 2;
pub const SQLCIPHER_PBKDF2_HMAC_SHA512: c_int = 2;
// Legacy encryption primitives
pub const SQLCIPHER_HMAC_SHA1: c_int = 0;
pub const SQLCIPHER_PBKDF2_HMAC_SHA1: c_int = 0;
pub const CIPHER_ENCRYPT: c_int = 1;
#[repr(C)]

View File

@ -1,4 +1,4 @@
Copyright (c) 2008-2023, ZETETIC LLC
Copyright (c) 2025, ZETETIC LLC
All rights reserved.
Redistribution and use in source and binary forms, with or without

View File

@ -1,24 +0,0 @@
diff --git a/src/sqlcipher.c b/src/sqlcipher.c
index 8be4bc92..9cafc366 100644
--- a/src/sqlcipher.c
+++ b/src/sqlcipher.c
@@ -76,7 +76,8 @@ void sqlite3pager_reset(Pager *pPager);
#if !defined (SQLCIPHER_CRYPTO_CC) \
&& !defined (SQLCIPHER_CRYPTO_LIBTOMCRYPT) \
&& !defined (SQLCIPHER_CRYPTO_NSS) \
- && !defined (SQLCIPHER_CRYPTO_OPENSSL)
+ && !defined (SQLCIPHER_CRYPTO_OPENSSL) \
+ && !defined (SQLCIPHER_CRYPTO_CUSTOM)
#define SQLCIPHER_CRYPTO_OPENSSL
#endif
@@ -540,6 +541,9 @@ static void sqlcipher_activate() {
#elif defined (SQLCIPHER_CRYPTO_OSSL3)
extern int sqlcipher_ossl3_setup(sqlcipher_provider *p);
sqlcipher_ossl3_setup(p);
+#elif defined (SQLCIPHER_CRYPTO_CUSTOM)
+ extern int SQLCIPHER_CRYPTO_CUSTOM(sqlcipher_provider *p);
+ SQLCIPHER_CRYPTO_CUSTOM(p);
#else
#error "NO DEFAULT SQLCIPHER CRYPTO PROVIDER DEFINED"
#endif

View File

@ -1,15 +0,0 @@
diff --git a/src/date.c b/src/date.c
index d74cecb..8a609ae 100644
--- a/src/date.c
+++ b/src/date.c
@@ -667,8 +667,8 @@ static const struct {
/* 1 */ { 6, "minute", 7.7379e+12, 60.0 },
/* 2 */ { 4, "hour", 1.2897e+11, 3600.0 },
/* 3 */ { 3, "day", 5373485.0, 86400.0 },
- /* 4 */ { 5, "month", 176546.0, 30.0*86400.0 },
- /* 5 */ { 4, "year", 14713.0, 365.0*86400.0 },
+ /* 4 */ { 5, "month", 176546.0, 2592000.0 },
+ /* 5 */ { 4, "year", 14713.0, 31536000.0 },
};
/*

View File

@ -21,13 +21,16 @@
'xcode_settings': {
'OTHER_CFLAGS': ['-std=c99'],
'WARNING_CFLAGS': ['-w'],
'DEAD_CODE_STRIPPING': 'YES',
'LLVM_LTO': 'YES',
},
'defines': [
'SQLITE_LIKE_DOESNT_MATCH_BLOBS',
'SQLITE_THREADSAFE=2',
'SQLITE_USE_URI=0',
'SQLITE_DEFAULT_MEMSTATUS=0',
'SQLITE_USE_ALLOCA',
'SQLITE_OMIT_AUTOINIT',
'SQLITE_OMIT_DECLTYPE',
'SQLITE_OMIT_DEPRECATED',
'SQLITE_OMIT_DESERIALIZE',
'SQLITE_OMIT_GET_TABLE',
@ -36,22 +39,22 @@
'SQLITE_OMIT_SHARED_CACHE',
'SQLITE_OMIT_UTF16',
'SQLITE_OMIT_COMPLETE',
'SQLITE_OMIT_GET_TABLE',
'SQLITE_OMIT_AUTHORIZATION',
'SQLITE_OMIT_LOAD_EXTENSION',
'SQLITE_TRACE_SIZE_LIMIT=32',
'SQLITE_OMIT_INTROSPECTION_PRAGMAS',
'SQLITE_OMIT_TRACE',
'SQLITE_DEFAULT_CACHE_SIZE=-16000',
'SQLITE_DEFAULT_FOREIGN_KEYS=1',
'SQLITE_DEFAULT_WAL_SYNCHRONOUS=1',
'SQLITE_DEFAULT_MEMSTATUS=0',
'SQLITE_LIKE_DOESNT_MATCH_BLOBS',
'SQLITE_MAX_EXPR_DEPTH=0',
'SQLITE_DQS=0',
'SQLITE_ENABLE_MATH_FUNCTIONS',
'SQLITE_ENABLE_DESERIALIZE',
'SQLITE_ENABLE_COLUMN_METADATA',
'SQLITE_ENABLE_UPDATE_DELETE_LIMIT',
'SQLITE_ENABLE_STAT4',
'SQLITE_ENABLE_FTS5',
'SQLITE_ENABLE_JSON1',
'SQLITE_INTROSPECTION_PRAGMAS',
'SQLCIPHER_CRYPTO_CUSTOM=signal_crypto_provider_setup',
@ -69,8 +72,11 @@
'SQLITE_HAS_CODEC',
'SQLITE_TEMP_STORE=2',
'SQLITE_SECURE_DELETE',
'SQLITE_EXTRA_INIT=sqlcipher_extra_init',
'SQLITE_EXTRA_SHUTDOWN=sqlcipher_extra_shutdown',
],
'conditions': [
# Link with extension
['OS == "win"', {
'defines': [
'WIN32'
@ -94,6 +100,25 @@
]
},
}],
# LTO on Linux
['OS == "linux"', {
# GCC only for now
'cflags': ['-flto=4', '-fuse-linker-plugin', '-ffat-lto-objects'],
'ldflags': ['-flto=4', '-fuse-linker-plugin', '-ffat-lto-objects'],
}],
# Profiling
["\"-profiling.\" in \"<!(node -p \"require('../../package.json').version\")\"", {
'defines': [
'SQLITE_ENABLE_STMT_SCANSTATUS'
],
'direct_dependent_settings': {
'defines': [
'SQLITE_ENABLE_STMT_SCANSTATUS'
],
},
}],
],
'configurations': {
'Debug': {

20063
deps/sqlcipher/sqlite3.c vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -7,8 +7,6 @@ tag=${1?Pass a valid sqlcipher version as an argument}
rm -rf .tmp/sqlcipher
git clone --branch $tag --filter=blob:none git@github.com:sqlcipher/sqlcipher.git .tmp/sqlcipher
cd .tmp/sqlcipher
git apply ../../patches/sqlcipher/custom-crypto-provider.diff
git apply ../../patches/sqlcipher/fix-constant-expression-for-msvc-arm64-6c103aee6f146869.diff
./configure --enable-update-limit
make sqlite3.h sqlite3.c sqlite3ext.h shell.c
cd -

View File

@ -1,3 +1,3 @@
deb [snapshot=20240829T060900Z] http://archive.ubuntu.com/ubuntu/ focal main universe
deb [snapshot=20240829T060900Z] http://archive.ubuntu.com/ubuntu/ focal-updates main universe
deb [snapshot=20240829T060900Z] http://security.ubuntu.com/ubuntu focal-security main universe
deb [snapshot=20250811T060900Z] http://archive.ubuntu.com/ubuntu/ jammy main universe
deb [snapshot=20250811T060900Z] http://archive.ubuntu.com/ubuntu/ jammy-updates main universe
deb [snapshot=20250811T060900Z] http://security.ubuntu.com/ubuntu jammy-security main universe

View File

@ -27,25 +27,40 @@ const addon = bindings<{
persistent: boolean,
pluck: boolean,
bigint: boolean,
paramNames: Array<string | null>,
): NativeStatement;
statementRun<Options extends StatementOptions>(
stmt: NativeStatement,
params: StatementParameters<Options> | undefined,
params: NativeParameters<Options> | undefined,
result: [number, number],
): void;
statementStep<Options extends StatementOptions>(
stmt: NativeStatement,
params: StatementParameters<Options> | null | undefined,
params: NativeParameters<Options> | null | undefined,
cache: Array<SqliteValue<Options>> | undefined,
isGet: boolean,
): Array<SqliteValue<Options>>;
statementScanStats(stmt: NativeStatement): Array<ScanStats>;
statementClose(stmt: NativeStatement): void;
databaseOpen(path: string): NativeDatabase;
databaseInitTokenizer(db: NativeDatabase): void;
databaseExec(db: NativeDatabase, query: string): void;
databaseClose(db: NativeDatabase): void;
databaseCreateFunction(
db: NativeDatabase,
name: string,
fn: (...args: ReadonlyArray<unknown>) => void,
bigint: boolean,
): void;
databaseSetWalHook(
db: NativeDatabase,
fn: (dbName: string, pageCount: number) => void,
): void;
signalTokenize(value: string): Array<string>;
setLogger(fn: (code: string, message: string) => void): void;
}>(ROOT_DIR);
export type RunResult = {
@ -81,11 +96,15 @@ export type StatementOptions = Readonly<{
bigint?: true;
}>;
export type NativeParameters<Options extends StatementOptions> = ReadonlyArray<
SqliteValue<Options>
>;
/**
* Parameters accepted by `.run()`/`.get()`/`.all()` methods of the statement.
*/
export type StatementParameters<Options extends StatementOptions> =
| ReadonlyArray<SqliteValue<Options>>
| NativeParameters<Options>
| Readonly<Record<string, SqliteValue<Options>>>;
/**
@ -93,7 +112,7 @@ export type StatementParameters<Options extends StatementOptions> =
*/
export type SqliteValue<Options extends StatementOptions> =
| string
| Uint8Array
| Uint8Array<ArrayBuffer>
| number
| null
| (Options extends { bigint: true } ? bigint : never);
@ -107,6 +126,14 @@ export type RowType<Options extends StatementOptions> = Options extends {
? SqliteValue<Options>
: Record<string, SqliteValue<Options>>;
export type FunctionOptions = Readonly<{
/**
* If `true` - all integers passed to the fucntion will be big
* integers instead of regular (floating-point) numbers.
*/
bigint?: boolean;
}>;
/**
* A compiled SQL statement class.
*/
@ -115,6 +142,9 @@ class Statement<Options extends StatementOptions = object> {
#cache: Array<SqliteValue<Options>> | undefined;
#createRow: undefined | ((result: unknown) => RowType<Options>);
#translateParams: (
params: StatementParameters<Options>,
) => NativeParameters<Options>;
#native: NativeStatement | undefined;
#onClose: (() => void) | undefined;
@ -127,14 +157,47 @@ class Statement<Options extends StatementOptions = object> {
) {
this.#needsTranslation = persistent === true && !pluck;
const paramNames = new Array<string | null>();
this.#native = addon.statementNew(
db,
query,
persistent === true,
pluck === true,
bigint === true,
paramNames,
);
const isArrayParams = paramNames.every((name) => name === null);
const isObjectParams =
!isArrayParams && paramNames.every((name) => typeof name === 'string');
if (!isArrayParams && !isObjectParams) {
throw new TypeError('Cannot mix named and anonymous params in query');
}
if (isArrayParams) {
this.#translateParams = (params) => {
if (!Array.isArray(params)) {
throw new TypeError('Query requires an array of anonymous params');
}
return params;
};
} else {
this.#translateParams = runInThisContext(`
(function translateParams(params) {
if (Array.isArray(params)) {
throw new TypeError('Query requires an object of named params');
}
return [
${paramNames
.map((name) => `params[${JSON.stringify(name)}]`)
.join(',\n')}
];
})
`);
}
this.#onClose = onClose;
}
@ -150,8 +213,8 @@ class Statement<Options extends StatementOptions = object> {
throw new Error('Statement closed');
}
const result: [number, number] = [0, 0];
this.#checkParams(params);
addon.statementRun(this.#native, params, result);
const nativeParams = this.#checkParams(params);
addon.statementRun(this.#native, nativeParams, result);
return { changes: result[0], lastInsertRowid: result[1] };
}
@ -170,8 +233,13 @@ class Statement<Options extends StatementOptions = object> {
if (this.#native === undefined) {
throw new Error('Statement closed');
}
this.#checkParams(params);
const result = addon.statementStep(this.#native, params, this.#cache, true);
const nativeParams = this.#checkParams(params);
const result = addon.statementStep(
this.#native,
nativeParams,
this.#cache,
true,
);
if (result === undefined) {
return undefined;
}
@ -198,9 +266,8 @@ class Statement<Options extends StatementOptions = object> {
throw new Error('Statement closed');
}
const result = [];
this.#checkParams(params);
let singleUseParams: StatementParameters<Options> | undefined | null =
params;
const nativeParams = this.#checkParams(params);
let singleUseParams: typeof nativeParams | undefined | null = nativeParams;
while (true) {
const single = addon.statementStep(
this.#native,
@ -224,6 +291,20 @@ class Statement<Options extends StatementOptions = object> {
return result as unknown as Array<Row>;
}
/**
* Report collected performance statics for the statement.
*
* @returns A list of objects describing the performance of the query.
*
* @see {@link https://www.sqlite.org/profile.html}
*/
public scanStats(): Array<ScanStats> {
if (this.#native === undefined) {
throw new Error('Statement closed');
}
return addon.statementScanStats(this.#native);
}
/**
* Close the statement and release the used memory.
*/
@ -264,9 +345,11 @@ class Statement<Options extends StatementOptions = object> {
}
/** @internal */
#checkParams(params: StatementParameters<Options> | undefined): void {
#checkParams(
params: StatementParameters<Options> | undefined,
): NativeParameters<Options> | undefined {
if (params === undefined) {
return;
return undefined;
}
if (typeof params !== 'object') {
throw new TypeError('Params must be either object or array');
@ -274,6 +357,7 @@ class Statement<Options extends StatementOptions = object> {
if (params === null) {
throw new TypeError('Params cannot be null');
}
return this.#translateParams(params);
}
}
@ -301,6 +385,20 @@ export type PragmaResult<Options extends PragmaOptions> = Options extends {
? RowType<{ pluck: true }> | undefined
: Array<RowType<object>>;
/**
* An entry of result array of `stmt.scanStats()` method.
*
* Value of `-1` indicates that the field is not available for a given entry.
*/
export type ScanStats = Readonly<{
id: number;
parent: number;
cycles: number;
loops: number;
rows: number;
explain: string | null;
}>;
/** @internal */
type TransactionStatement = Statement<{ persistent: true; pluck: true }>;
@ -315,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.
*/
@ -350,6 +455,13 @@ export default class Database {
this.#isCacheEnabled = cacheStatements === true;
}
public initTokenizer(): void {
if (this.#native === undefined) {
throw new Error('Database closed');
}
addon.databaseInitTokenizer(this.#native);
}
/**
* Execute one or multiple SQL statements in a given `sql` string.
*
@ -365,6 +477,51 @@ export default class Database {
addon.databaseExec(this.#native, sql);
}
/**
* Create custom SQL function with a given `name`.
*
* @param name - name of the function
* @param fn - function implementation
* @param options - function options.
*/
public createFunction(
name: string,
fn: (...args: ReadonlyArray<unknown>) => void,
options: FunctionOptions = {},
): void {
if (this.#native === undefined) {
throw new Error('Database closed');
}
if (typeof name !== 'string') {
throw new TypeError('Invalid name argument');
}
if (typeof fn !== 'function') {
throw new TypeError('Invalid fn argument');
}
addon.databaseCreateFunction(
this.#native,
name,
fn,
options.bigint === true,
);
}
/**
* 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.
*
@ -518,7 +675,14 @@ export default class Database {
commit.run();
return result;
} catch (error) {
rollback.run();
try {
rollback.run();
} catch (rollbackError) {
if (rollbackError instanceof Error) {
rollbackError.cause = error;
}
throw rollbackError;
}
throw error;
} finally {
this.#transactionDepth -= 1;
@ -543,4 +707,12 @@ export default class Database {
}
}
export { Database };
function setLogger(fn: (code: string, message: string) => void): void {
if (typeof fn !== 'function') {
throw new TypeError('Invalid value');
}
return addon.setLogger(fn);
}
export { Database, setLogger };

View File

@ -1,6 +1,7 @@
{
"packageManager": "pnpm@10.18.1",
"name": "@signalapp/sqlcipher",
"version": "1.0.0",
"version": "3.3.5",
"description": "A fast N-API-based Node.js addon wrapping sqlcipher and FTS5 segmenting APIs",
"homepage": "http://github.com/signalapp/node-sqlcipher.git",
"license": "AGPL-3.0-only",
@ -16,6 +17,7 @@
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"exports": {
"types": "./dist/lib/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
@ -43,7 +45,7 @@
"prebuildify": "prebuildify --strip --napi",
"test": "vitest --coverage --pool threads",
"format": "run-p --print-label format:c format:js",
"format:c": "xcrun clang-format --style=chromium -Werror --verbose -i src/*.cc",
"format:c": "xcrun clang-format --style=chromium -Werror --verbose -i src/*.cc src/*.h",
"format:js": "prettier --cache --write .",
"lint": "run-p --print-label check:eslint check:format",
"check:eslint": "eslint --cache .",

View File

@ -1 +1 @@
nightly-2025-02-25
nightly-2025-09-24

View File

@ -5,6 +5,7 @@
#include <list>
#include "addon.h"
#include "errors.h"
#include "napi.h"
#include "signal-tokenizer.h"
@ -73,6 +74,169 @@ static Napi::Value SignalTokenize(const Napi::CallbackInfo& info) {
return result;
}
// Functions
class FunctionWrap {
public:
FunctionWrap(Napi::Function fn, bool is_bigint) : is_bigint_(is_bigint) {
fn_.Reset(fn, 1);
}
static void Run(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
auto wrap = static_cast<FunctionWrap*>(sqlite3_user_data(ctx));
wrap->Call(ctx, argc, argv);
}
static void Final(void* p_app) { delete static_cast<FunctionWrap*>(p_app); }
protected:
void Call(sqlite3_context* ctx, int argc, sqlite3_value** argv) {
auto env = fn_.Env();
Napi::HandleScope scope(env);
assert(argc >= 0);
auto args = std::vector<Napi::Value>(static_cast<size_t>(argc));
for (int i = 0; i < argc; i++) {
args[i] = TranslateValue(argv[i]);
}
auto result = fn_.Value().Call(args);
// Ignore exceptions
if (result.IsEmpty()) {
auto e = env.GetAndClearPendingException();
sqlite3_result_error(ctx, e.Message().c_str(), SQLITE_ERROR);
} else if (result.IsUndefined()) {
sqlite3_result_null(ctx);
} else {
sqlite3_result_error(ctx, "Function must not return a value",
SQLITE_ERROR);
}
}
Napi::Value TranslateValue(sqlite3_value* value) {
auto env = fn_.Env();
int type = sqlite3_value_type(value);
switch (type) {
case SQLITE_INTEGER: {
auto val = sqlite3_value_int64(value);
if (is_bigint_) {
return Napi::BigInt::New(env, static_cast<int64_t>(val));
}
if (static_cast<int64_t>(INT32_MIN) <= val &&
val <= static_cast<int64_t>(INT32_MAX)) {
napi_value n_value;
NAPI_THROW_IF_FAILED(
env, napi_create_int32(env, static_cast<int32_t>(val), &n_value),
Napi::Value());
return Napi::Value(env, n_value);
} else {
return Napi::Number::New(env, val);
}
}
case SQLITE_TEXT:
return Napi::String::New(
env, reinterpret_cast<const char*>(sqlite3_value_text(value)),
sqlite3_value_bytes(value));
case SQLITE_FLOAT:
return Napi::Number::New(env, sqlite3_value_double(value));
case SQLITE_BLOB:
return Napi::Buffer<uint8_t>::Copy(
env, reinterpret_cast<const uint8_t*>(sqlite3_value_blob(value)),
sqlite3_value_bytes(value));
case SQLITE_NULL:
return env.Null();
}
return Napi::Value();
}
private:
Napi::Reference<Napi::Function> fn_;
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_;
static void LoggerWrapper(void* _ctx, int code, const char* msg) {
if (logger_fn_.IsEmpty()) {
return;
}
auto env = logger_fn_.Env();
Napi::HandleScope scope(env);
#define CODE_STR(NAME) \
case NAME: \
code_name = #NAME; \
break;
const char* code_name;
switch (code) {
SQLITE_ERROR_ENUM(CODE_STR)
default:
code_name = "unknown";
break;
}
#undef CODE_STR
auto result = logger_fn_.Value().Call({
Napi::String::New(env, code_name),
Napi::String::New(env, msg),
});
// Ignore exceptions
if (result.IsEmpty()) {
env.GetAndClearPendingException();
}
}
static void SetLogger(const Napi::CallbackInfo& info) {
auto callback = info[0].As<Napi::Function>();
assert(callback.IsFunction());
logger_fn_.Reset(callback, 1);
sqlite3_config(SQLITE_CONFIG_LOG, LoggerWrapper);
}
// Utils
Napi::Error FormatError(Napi::Env env, const char* format, ...) {
@ -98,8 +262,14 @@ Napi::Error FormatError(Napi::Env env, const char* format, ...) {
Napi::Object Database::Init(Napi::Env env, Napi::Object exports) {
exports["databaseOpen"] = Napi::Function::New(env, &Database::Open);
exports["databaseInitTokenizer"] =
Napi::Function::New(env, &Database::InitTokenizer);
exports["databaseClose"] = Napi::Function::New(env, &Database::Close);
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;
}
@ -115,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");
@ -159,20 +332,32 @@ Napi::Value Database::Open(const Napi::CallbackInfo& info) {
return db->ThrowSqliteError(env, r);
}
return db->self_ref_.Value();
}
Napi::Value Database::InitTokenizer(const Napi::CallbackInfo& info) {
auto env = info.Env();
auto db = FromExternal(info[0]);
if (db == nullptr) {
return Napi::Value();
}
fts5_api* fts5 = db->GetFTS5API(env);
if (fts5 == nullptr) {
return Napi::Value();
}
SignalTokenizerModule* icu = new SignalTokenizerModule();
r = fts5->xCreateTokenizer(fts5, "signal_tokenizer", icu, &icu->api_object,
int r =
fts5->xCreateTokenizer(fts5, "signal_tokenizer", icu, &icu->api_object,
&SignalTokenizerModule::Destroy);
if (r != SQLITE_OK) {
delete icu;
return db->ThrowSqliteError(env, r);
}
return db->self_ref_.Value();
return Napi::Value();
}
Napi::Value Database::Close(const Napi::CallbackInfo& info) {
@ -194,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);
@ -227,19 +415,99 @@ Napi::Value Database::Exec(const Napi::CallbackInfo& info) {
return Napi::Value();
}
Napi::Value Database::CreateFunction(const Napi::CallbackInfo& info) {
auto env = info.Env();
auto db = FromExternal(info[0]);
auto name = info[1].As<Napi::String>();
auto fn = info[2].As<Napi::Function>();
auto is_bigint = info[3].As<Napi::Boolean>();
assert(name.IsString());
assert(fn.IsFunction());
assert(is_bigint.IsBoolean());
if (db == nullptr) {
return Napi::Value();
}
if (db->handle_ == nullptr) {
NAPI_THROW(Napi::Error::New(env, "Database closed"), Napi::Value());
}
auto name_utf8 = name.Utf8Value();
auto fn_wrap = new FunctionWrap(fn, is_bigint);
int r = sqlite3_create_function_v2(db->handle_, name_utf8.c_str(), -1,
SQLITE_UTF8, // TODO(indutny): or UTF16?
fn_wrap, FunctionWrap::Run, nullptr,
nullptr, FunctionWrap::Final);
if (r != SQLITE_OK) {
delete fn_wrap;
return db->ThrowSqliteError(env, r);
}
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_);
int offset = sqlite3_error_offset(handle_);
int extended = sqlite3_extended_errcode(handle_);
if (offset == -1) {
NAPI_THROW(FormatError(env, "sqlite error(%d): %s", extended, msg),
Napi::Value());
} else {
NAPI_THROW(FormatError(env, "sqlite error(%d): %s, offset: %d", extended,
msg, offset),
Napi::Value());
#define EXTENDED_STR(NAME) \
case NAME: \
extended_name = #NAME; \
break;
const char* extended_name;
switch (extended) {
SQLITE_ERROR_ENUM(EXTENDED_STR)
default:
extended_name = "unknown";
break;
}
#undef EXTENDED_STR
Napi::Error err;
if (offset == -1) {
err = FormatError(env, "sqlite error(%s): %s", extended_name, msg);
} else {
err = FormatError(env, "sqlite error(%s): %s, offset: %d", extended_name,
msg, offset);
}
err.Set("code", extended_name);
NAPI_THROW(err, Napi::Value());
}
fts5_api* Database::GetFTS5API(Napi::Env env) {
@ -288,6 +556,8 @@ Napi::Object Statement::Init(Napi::Env env, Napi::Object exports) {
exports["statementClose"] = Napi::Function::New(env, &Statement::Close);
exports["statementRun"] = Napi::Function::New(env, &Statement::Run);
exports["statementStep"] = Napi::Function::New(env, &Statement::Step);
exports["statementScanStats"] =
Napi::Function::New(env, &Statement::ScanStats);
return exports;
}
@ -329,12 +599,14 @@ Napi::Value Statement::New(const Napi::CallbackInfo& info) {
auto is_persistent = info[2].As<Napi::Boolean>();
auto is_pluck = info[3].As<Napi::Boolean>();
auto is_bigint = info[4].As<Napi::Boolean>();
auto param_names = info[5].As<Napi::Array>();
assert(db_external.IsExternal());
assert(query.IsString());
assert(is_persistent.IsBoolean());
assert(is_pluck.IsBoolean());
assert(is_bigint.IsBoolean());
assert(param_names.IsArray());
auto db = db_external.Data();
@ -363,6 +635,18 @@ Napi::Value Statement::New(const Napi::CallbackInfo& info) {
auto stmt = new Statement(db, db_external, handle, is_persistent, is_pluck,
is_bigint);
int key_count = sqlite3_bind_parameter_count(handle);
for (int i = 1; i <= key_count; i++) {
auto name = sqlite3_bind_parameter_name(handle, i);
if (name == nullptr) {
param_names[i - 1] = env.Null();
} else {
// Skip "$"
param_names[i - 1] = name + 1;
}
}
return Napi::External<Statement>::New(
env, stmt, [](Napi::Env env, Statement* stmt) { delete stmt; });
}
@ -384,6 +668,9 @@ Napi::Value Statement::Close(const Napi::CallbackInfo& info) {
auto env = info.Env();
auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
int r = sqlite3_finalize(stmt->handle_);
if (r != SQLITE_OK) {
@ -399,6 +686,10 @@ Napi::Value Statement::Run(const Napi::CallbackInfo& info) {
auto env = info.Env();
auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
auto params = info[1];
auto result = info[2].As<Napi::Array>();
@ -437,6 +728,10 @@ Napi::Value Statement::Step(const Napi::CallbackInfo& info) {
auto env = info.Env();
auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
auto params = info[1];
auto cache = info[2];
auto is_get = info[3].As<Napi::Boolean>();
@ -457,14 +752,16 @@ Napi::Value Statement::Step(const Napi::CallbackInfo& info) {
int r = sqlite3_step(stmt->handle_);
AutoResetStatement auto_reset(stmt, is_get.Value());
// No more rows
if (r == SQLITE_DONE) {
stmt->Reset();
auto_reset.Reset();
return Napi::Value();
}
AutoResetStatement _(stmt, is_get.Value());
if (r != SQLITE_ROW) {
auto_reset.Reset();
return stmt->db_->ThrowSqliteError(env, r);
}
@ -473,6 +770,7 @@ Napi::Value Statement::Step(const Napi::CallbackInfo& info) {
// In pluck mode - return the value of the first column
if (stmt->is_pluck_) {
if (column_count != 1) {
auto_reset.Reset();
NAPI_THROW(Napi::Error::New(env, "Invalid column count for pluck"),
Napi::Value());
}
@ -515,6 +813,108 @@ Napi::Value Statement::Step(const Napi::CallbackInfo& info) {
return result;
}
// Only enabled on `-profiling` npm package versions
#ifdef SQLITE_ENABLE_STMT_SCANSTATUS
Napi::Value Statement::ScanStats(const Napi::CallbackInfo& info) {
auto env = info.Env();
auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
sqlite3_int64 total_cycles = 0;
int r = sqlite3_stmt_scanstatus_v2(stmt->handle_, -1, SQLITE_SCANSTAT_NCYCLE,
SQLITE_SCANSTAT_COMPLEX, &total_cycles);
if (r != SQLITE_OK) {
return stmt->db_->ThrowSqliteError(env, r);
}
auto results = Napi::Array::New(env, 1);
auto root = Napi::Object::New(env);
root["id"] = 0;
root["parent"] = -1;
root["cycles"] = total_cycles;
root["loops"] = -1;
root["rows"] = -1;
root["explain"] = env.Null();
results[static_cast<uint32_t>(0)] = root;
for (int idx = 0; r == SQLITE_OK; idx++) {
int id = 0;
int parent = 0;
sqlite3_int64 cycles = 0;
sqlite3_int64 loops = 0;
sqlite3_int64 rows = 0;
const char* explain = nullptr;
r = sqlite3_stmt_scanstatus_v2(stmt->handle_, idx, SQLITE_SCANSTAT_SELECTID,
SQLITE_SCANSTAT_COMPLEX, &id);
if (r != SQLITE_OK) {
break;
}
r = sqlite3_stmt_scanstatus_v2(stmt->handle_, idx, SQLITE_SCANSTAT_PARENTID,
SQLITE_SCANSTAT_COMPLEX, &parent);
if (r != SQLITE_OK) {
break;
}
r = sqlite3_stmt_scanstatus_v2(stmt->handle_, idx, SQLITE_SCANSTAT_NCYCLE,
SQLITE_SCANSTAT_COMPLEX, &cycles);
if (r != SQLITE_OK) {
break;
}
r = sqlite3_stmt_scanstatus_v2(stmt->handle_, idx, SQLITE_SCANSTAT_NLOOP,
SQLITE_SCANSTAT_COMPLEX, &loops);
if (r != SQLITE_OK) {
break;
}
r = sqlite3_stmt_scanstatus_v2(stmt->handle_, idx, SQLITE_SCANSTAT_NVISIT,
SQLITE_SCANSTAT_COMPLEX, &rows);
if (r != SQLITE_OK) {
break;
}
r = sqlite3_stmt_scanstatus_v2(stmt->handle_, idx, SQLITE_SCANSTAT_EXPLAIN,
SQLITE_SCANSTAT_COMPLEX, &explain);
if (r != SQLITE_OK) {
break;
}
auto result = Napi::Object::New(env);
result["id"] = id;
result["parent"] = parent;
result["cycles"] = cycles;
result["loops"] = loops;
result["rows"] = rows;
if (explain == nullptr) {
result["explain"] = env.Null();
} else {
result["explain"] = explain;
}
results[static_cast<uint32_t>(idx + 1)] = result;
}
// SQLITE_ERROR is returned when `idx` is out of range
if (r != SQLITE_ERROR) {
return stmt->db_->ThrowSqliteError(env, r);
}
return results;
}
#else // !SQLITE_ENABLE_STMT_SCANSTATUS
Napi::Value Statement::ScanStats(const Napi::CallbackInfo& info) {
auto env = info.Env();
NAPI_THROW(Napi::Error::New(env, "Not available in production builds"),
Napi::Value());
}
#endif // !SQLITE_ENABLE_STMT_SCANSTATUS
bool Statement::BindParams(Napi::Env env, Napi::Value params) {
int key_count = sqlite3_bind_parameter_count(handle_);
@ -540,36 +940,18 @@ bool Statement::BindParams(Napi::Env env, Napi::Value params) {
for (int i = 1; i <= list_len; i++) {
auto name = sqlite3_bind_parameter_name(handle_, i);
if (name != nullptr) {
NAPI_THROW(FormatError(env, "Unexpected named param %s at %d", name, i),
false);
}
auto error = BindParam(env, i, list[i - 1]);
if (error != nullptr) {
NAPI_THROW(
FormatError(env, "Failed to bind param %d, error %s", i, error),
false);
}
}
} else {
auto obj = params.As<Napi::Object>();
for (int i = 1; i <= key_count; i++) {
auto name = sqlite3_bind_parameter_name(handle_, i);
if (name == nullptr) {
NAPI_THROW(FormatError(env, "Unexpected anonymous param at %d", i),
false);
}
// Skip "$"
name = name + 1;
auto value = obj[name];
auto error = BindParam(env, i, value);
if (error != nullptr) {
NAPI_THROW(
FormatError(env, "Failed to bind param %s, error %s", name, error),
false);
if (name == nullptr) {
NAPI_THROW(
FormatError(env, "Failed to bind param %d, error %s", i, error),
false);
} else {
NAPI_THROW(FormatError(env, "Failed to bind param %s, error %s",
name + 1, error),
false);
}
}
}
}
@ -689,6 +1071,11 @@ Napi::Value Statement::GetColumnValue(Napi::Env env, int column) {
return Napi::Value();
}
void AutoResetStatement::Reset() {
stmt_->Reset();
enabled_ = false;
}
AutoResetStatement::~AutoResetStatement() {
if (enabled_) {
stmt_->Reset();
@ -700,6 +1087,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
Database::Init(env, exports);
Statement::Init(env, exports);
exports["setLogger"] = Napi::Function::New(env, &SetLogger);
exports["signalTokenize"] = Napi::Function::New(env, &SignalTokenize);
return exports;
}

View File

@ -9,6 +9,7 @@
#include "sqlite3.h"
class Statement;
class WalHookWrap;
class Database {
public:
@ -27,8 +28,11 @@ class Database {
static Database* FromExternal(const Napi::Value value);
static Napi::Value Open(const Napi::CallbackInfo& info);
static Napi::Value InitTokenizer(const Napi::CallbackInfo& info);
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);
@ -43,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 {
@ -52,6 +58,9 @@ class AutoResetStatement {
~AutoResetStatement();
// Force reset statement now and clear `enabled_`
void Reset();
private:
Statement* stmt_;
bool enabled_;
@ -89,7 +98,7 @@ class Statement {
if (std::isspace(ch) || ch == ';') {
p = p.substr(1);
} else if (p.rfind("--", 0) == 0) {
} else if (p.rfind("--", 0) == 0) {
// Line comment: "--"
p = p.substr(2);
@ -127,6 +136,7 @@ class Statement {
static Napi::Value Close(const Napi::CallbackInfo& info);
static Napi::Value Run(const Napi::CallbackInfo& info);
static Napi::Value Step(const Napi::CallbackInfo& info);
static Napi::Value ScanStats(const Napi::CallbackInfo& info);
bool BindParams(Napi::Env env, Napi::Value params);

116
src/errors.h Normal file
View File

@ -0,0 +1,116 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
#ifndef SRC_ERRORS_H_
#define SRC_ERRORS_H_
#define SQLITE_ERROR_ENUM(V) \
V(SQLITE_ERROR) \
V(SQLITE_INTERNAL) \
V(SQLITE_PERM) \
V(SQLITE_ABORT) \
V(SQLITE_BUSY) \
V(SQLITE_LOCKED) \
V(SQLITE_NOMEM) \
V(SQLITE_READONLY) \
V(SQLITE_INTERRUPT) \
V(SQLITE_IOERR) \
V(SQLITE_CORRUPT) \
V(SQLITE_NOTFOUND) \
V(SQLITE_FULL) \
V(SQLITE_CANTOPEN) \
V(SQLITE_PROTOCOL) \
V(SQLITE_EMPTY) \
V(SQLITE_SCHEMA) \
V(SQLITE_TOOBIG) \
V(SQLITE_CONSTRAINT) \
V(SQLITE_MISMATCH) \
V(SQLITE_MISUSE) \
V(SQLITE_NOLFS) \
V(SQLITE_AUTH) \
V(SQLITE_FORMAT) \
V(SQLITE_RANGE) \
V(SQLITE_NOTADB) \
V(SQLITE_NOTICE) \
V(SQLITE_WARNING) \
V(SQLITE_ROW) \
V(SQLITE_DONE) \
V(SQLITE_ERROR_MISSING_COLLSEQ) \
V(SQLITE_ERROR_RETRY) \
V(SQLITE_ERROR_SNAPSHOT) \
V(SQLITE_IOERR_READ) \
V(SQLITE_IOERR_SHORT_READ) \
V(SQLITE_IOERR_WRITE) \
V(SQLITE_IOERR_FSYNC) \
V(SQLITE_IOERR_DIR_FSYNC) \
V(SQLITE_IOERR_TRUNCATE) \
V(SQLITE_IOERR_FSTAT) \
V(SQLITE_IOERR_UNLOCK) \
V(SQLITE_IOERR_RDLOCK) \
V(SQLITE_IOERR_DELETE) \
V(SQLITE_IOERR_BLOCKED) \
V(SQLITE_IOERR_NOMEM) \
V(SQLITE_IOERR_ACCESS) \
V(SQLITE_IOERR_CHECKRESERVEDLOCK) \
V(SQLITE_IOERR_LOCK) \
V(SQLITE_IOERR_CLOSE) \
V(SQLITE_IOERR_DIR_CLOSE) \
V(SQLITE_IOERR_SHMOPEN) \
V(SQLITE_IOERR_SHMSIZE) \
V(SQLITE_IOERR_SHMLOCK) \
V(SQLITE_IOERR_SHMMAP) \
V(SQLITE_IOERR_SEEK) \
V(SQLITE_IOERR_DELETE_NOENT) \
V(SQLITE_IOERR_MMAP) \
V(SQLITE_IOERR_GETTEMPPATH) \
V(SQLITE_IOERR_CONVPATH) \
V(SQLITE_IOERR_VNODE) \
V(SQLITE_IOERR_AUTH) \
V(SQLITE_IOERR_BEGIN_ATOMIC) \
V(SQLITE_IOERR_COMMIT_ATOMIC) \
V(SQLITE_IOERR_ROLLBACK_ATOMIC) \
V(SQLITE_IOERR_DATA) \
V(SQLITE_IOERR_CORRUPTFS) \
V(SQLITE_IOERR_IN_PAGE) \
V(SQLITE_LOCKED_SHAREDCACHE) \
V(SQLITE_LOCKED_VTAB) \
V(SQLITE_BUSY_RECOVERY) \
V(SQLITE_BUSY_SNAPSHOT) \
V(SQLITE_BUSY_TIMEOUT) \
V(SQLITE_CANTOPEN_NOTEMPDIR) \
V(SQLITE_CANTOPEN_ISDIR) \
V(SQLITE_CANTOPEN_FULLPATH) \
V(SQLITE_CANTOPEN_CONVPATH) \
V(SQLITE_CANTOPEN_DIRTYWAL) \
V(SQLITE_CANTOPEN_SYMLINK) \
V(SQLITE_CORRUPT_VTAB) \
V(SQLITE_CORRUPT_SEQUENCE) \
V(SQLITE_CORRUPT_INDEX) \
V(SQLITE_READONLY_RECOVERY) \
V(SQLITE_READONLY_CANTLOCK) \
V(SQLITE_READONLY_ROLLBACK) \
V(SQLITE_READONLY_DBMOVED) \
V(SQLITE_READONLY_CANTINIT) \
V(SQLITE_READONLY_DIRECTORY) \
V(SQLITE_ABORT_ROLLBACK) \
V(SQLITE_CONSTRAINT_CHECK) \
V(SQLITE_CONSTRAINT_COMMITHOOK) \
V(SQLITE_CONSTRAINT_FOREIGNKEY) \
V(SQLITE_CONSTRAINT_FUNCTION) \
V(SQLITE_CONSTRAINT_NOTNULL) \
V(SQLITE_CONSTRAINT_PRIMARYKEY) \
V(SQLITE_CONSTRAINT_TRIGGER) \
V(SQLITE_CONSTRAINT_UNIQUE) \
V(SQLITE_CONSTRAINT_VTAB) \
V(SQLITE_CONSTRAINT_ROWID) \
V(SQLITE_CONSTRAINT_PINNED) \
V(SQLITE_CONSTRAINT_DATATYPE) \
V(SQLITE_NOTICE_RECOVER_WAL) \
V(SQLITE_NOTICE_RECOVER_ROLLBACK) \
V(SQLITE_NOTICE_RBU) \
V(SQLITE_WARNING_AUTOINDEX) \
V(SQLITE_AUTH_USER) \
V(SQLITE_OK_LOAD_PERMANENTLY) \
V(SQLITE_OK_SYMLINK)
#endif // SRC_ERRORS_H_

View File

@ -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');
});
});

View File

@ -1,6 +1,6 @@
import { describe, expect, test, beforeEach, afterEach } from 'vitest';
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest';
import Database from '../lib/index.js';
import Database, { setLogger } from '../lib/index.js';
const rows = [
{
@ -49,6 +49,16 @@ test('db.close', () => {
db = new Database();
});
test('db.close with existing statement', () => {
const stmt = db.prepare('SELECT 1');
db.close();
expect(() => stmt.run()).toThrowError('Statement closed');
// Just to fix afterEach
db = new Database();
});
test('statement.close', () => {
const stmt = db.prepare('SELECT 1');
stmt.close();
@ -170,6 +180,21 @@ test('persistent statement recompilation', () => {
});
});
test('setLogger should log on statement recompilation', () => {
const messages = new Array<{ code: string; message: string }>();
setLogger((code, message) => messages.push({ code, message }));
const stmt = db.prepare('SELECT * FROM t', { persistent: true });
db.exec(`ALTER TABLE t ADD COLUMN d TEXT DEFAULT 'hello'`);
expect(stmt.get()).not.toBeUndefined();
expect(messages).toHaveLength(1);
expect(messages[0]?.code).toEqual('SQLITE_SCHEMA');
expect(messages[0]?.message).toMatch(/database schema has changed/);
});
describe('list parameters', () => {
test('correct count', () => {
expect(db.prepare('SELECT * FROM t WHERE a > ?').get([2])).toEqual(rows[2]);
@ -187,12 +212,16 @@ describe('list parameters', () => {
test('object parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > ?');
expect(() => stmt.get({})).toThrowError('Unexpected anonymous param at 1');
expect(() => stmt.get({})).toThrowError(
'Query requires an array of anonymous params',
);
});
test('against named parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > $a');
expect(() => stmt.get([2])).toThrowError('Unexpected named param $a at 1');
expect(() => stmt.get([2])).toThrowError(
'Query requires an object of named params',
);
});
});
@ -214,13 +243,6 @@ describe('object parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > $a');
expect(() => stmt.get()).toThrowError('Expected 1 parameters, got 0');
});
test('against anonymous parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > ?');
expect(() => stmt.get({ a: 1 })).toThrowError(
'Unexpected anonymous param at 1',
);
});
});
describe('tail', () => {
@ -380,6 +402,37 @@ test('bigint mode', () => {
).toEqual(n);
});
test('extended error codes', () => {
db.exec(`
CREATE TABLE parent (id INTEGER PRIMARY KEY);
CREATE TABLE refs (id INTEGER, FOREIGN KEY (id) REFERENCES parent(id));
`);
const stmt = db.prepare('INSERT INTO refs (id) VALUES (?)');
expect(() => stmt.run([4])).toThrowError(
expect.objectContaining({
message:
'sqlite error(SQLITE_CONSTRAINT_FOREIGNKEY): ' +
'FOREIGN KEY constraint failed',
code: 'SQLITE_CONSTRAINT_FOREIGNKEY',
}),
);
});
test('tokenizer setup', () => {
db.initTokenizer();
});
test('tokenizer setup after close', () => {
db.close();
expect(() => db.initTokenizer()).toThrowError('Database closed');
// Just to fix afterEach
db = new Database();
});
test('signalTokenize', () => {
expect(db.signalTokenize('a b c')).toEqual(['a', 'b', 'c']);
});
@ -444,3 +497,51 @@ describe('statement cache', () => {
);
});
});
describe('custom function', () => {
let fnDb: Database;
let fn: ReturnType<typeof vi.fn>;
let bigFn: ReturnType<typeof vi.fn>;
beforeEach(() => {
fnDb = new Database(':memory:');
fn = vi.fn();
fnDb.createFunction('fn', fn);
bigFn = vi.fn();
fnDb.createFunction('bigFn', bigFn, {
bigint: true,
});
});
afterEach(() => {
fnDb.close();
});
test('it calls the function without args', () => {
fnDb.exec(`SELECT fn()`);
expect(fn).toHaveBeenCalledWith();
});
test('it calls the function with multiple args', () => {
fnDb.exec(`SELECT fn(1, '123', NULL)`);
expect(fn).toHaveBeenCalledWith(1, '123', null);
});
test('it calls the function with blob', () => {
fnDb.exec(`SELECT fn(x'abba')`);
expect(fn).toHaveBeenCalledWith(Buffer.from('abba', 'hex'));
});
test('it uses bigints when configured', () => {
fnDb.exec(`SELECT bigFn(123456)`);
expect(bigFn).toHaveBeenCalledWith(123456n);
});
test('it throws when function returns a value', () => {
fnDb.createFunction('intFn', () => {
return 1;
});
expect(() => fnDb.exec(`SELECT intFn()`)).toThrowError('SQLITE_ERROR');
});
});