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

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ docs/
.tmp/ .tmp/
.eslintcache .eslintcache
todo.md 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 # 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. # Avoid getting prompted to configure things during installation.
ENV DEBIAN_FRONTEND=noninteractive 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! # But we can't install it because it doesn't trust our mirror!
# Temporarily disables APT's certificate signature checking # Temporarily disables APT's certificate signature checking
# to download the certificates. # to download the certificates.
RUN apt-get update -oAcquire::https::Verify-Peer=false \ RUN apt update -oAcquire::https::Verify-Peer=false
&& apt-get install -oAcquire::https::Verify-Peer=false -y ca-certificates RUN apt install -oAcquire::https::Verify-Peer=false -y ca-certificates
# Back to normal, verification back on # Back to normal, verification back on
# Install only what's needed to set up Rust and Node. # 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. # 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! # User-specific setup!
@ -65,6 +67,9 @@ RUN tar -xf node.tar.xz \
ENV PATH="/home/sqlcipher/node/bin:${PATH}" 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 # And finally any bonus packages we're going to need
# Note that we jump back to root for this. # Note that we jump back to root for this.
USER root USER root

View File

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

View File

@ -2,7 +2,8 @@ import { Buffer } from 'node:buffer';
import { bench, describe } from 'vitest'; import { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3'; 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 = ` const PREPARE = `
CREATE TABLE t ( CREATE TABLE t (
@ -21,12 +22,15 @@ const DELETE = 'DELETE FROM t';
describe('INSERT INTO t', () => { describe('INSERT INTO t', () => {
const sdb = new Database(':memory:', { cacheStatements: true }); const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:'); const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE); sdb.exec(PREPARE);
bdb.exec(PREPARE); bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT); const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT); const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
bench( bench(
'@signalapp/sqlcipher', '@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 { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3'; 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 = ` const PREPARE = `
CREATE TABLE t ( CREATE TABLE t (
@ -24,12 +25,15 @@ const DELETE = 'DELETE FROM t';
describe('INSERT INTO t', () => { describe('INSERT INTO t', () => {
const sdb = new Database(':memory:', { cacheStatements: true }); const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:'); const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE); sdb.exec(PREPARE);
bdb.exec(PREPARE); bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT); const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT); const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
bench( bench(
'@signalapp/sqlcipher', '@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 { bench, describe } from 'vitest';
import BDatabase from '@signalapp/better-sqlite3'; 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 = ` const PREPARE = `
CREATE TABLE t ( CREATE TABLE t (
@ -36,12 +37,15 @@ const SELECT = 'SELECT * FROM t LIMIT 1000';
describe('SELECT * FROM t', () => { describe('SELECT * FROM t', () => {
const sdb = new Database(':memory:', { cacheStatements: true }); const sdb = new Database(':memory:', { cacheStatements: true });
const bdb = new BDatabase(':memory:'); const bdb = new BDatabase(':memory:');
const ndb = new NDatabase(':memory:');
sdb.exec(PREPARE); sdb.exec(PREPARE);
bdb.exec(PREPARE); bdb.exec(PREPARE);
ndb.exec(PREPARE);
const sinsert = sdb.prepare(INSERT); const sinsert = sdb.prepare(INSERT);
const binsert = bdb.prepare(INSERT); const binsert = bdb.prepare(INSERT);
const ninsert = ndb.prepare(INSERT);
sdb.transaction(() => { sdb.transaction(() => {
for (const value of VALUES) { 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 sselect = sdb.prepare(SELECT);
const bselect = bdb.prepare(SELECT); const bselect = bdb.prepare(SELECT);
@ -65,4 +75,10 @@ describe('SELECT * FROM t', () => {
bench('@signalapp/better-sqlite', () => { bench('@signalapp/better-sqlite', () => {
bselect.all(); 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", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.8" version = "0.10.8"
@ -454,7 +465,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-sqlcipher-extension" name = "signal-sqlcipher-extension"
version = "0.2.1" version = "0.2.2"
dependencies = [ dependencies = [
"aes", "aes",
"cbc", "cbc",
@ -462,6 +473,7 @@ dependencies = [
"hmac", "hmac",
"pbkdf2", "pbkdf2",
"rand_core", "rand_core",
"sha1",
"sha2", "sha2",
"signal-tokenizer", "signal-tokenizer",
] ]

View File

@ -5,7 +5,7 @@
[package] [package]
name = "signal-sqlcipher-extension" name = "signal-sqlcipher-extension"
version = "0.2.1" version = "0.2.2"
edition = "2021" edition = "2021"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
@ -23,6 +23,7 @@ cbc = "0.1.2"
hmac = "0.12.1" hmac = "0.12.1"
pbkdf2 = "0.12.2" pbkdf2 = "0.12.2"
rand_core = { version = "0.6.4", "default-features" = false, features = ["getrandom"] } 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 } sha2 = { version = "0.10.8", "default-features" = false }
signal-tokenizer = { git = "https://github.com/signalapp/Signal-FTS5-Extension" } 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 hmac::{Hmac, Mac};
use pbkdf2::pbkdf2_hmac; use pbkdf2::pbkdf2_hmac;
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
use sha1::Sha1;
use sha2::Sha512; use sha2::Sha512;
pub use signal_tokenizer; 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 { extern "C" fn get_hmac_sz(_ctx: *mut c_void, algorithm: c_int) -> c_int {
match algorithm { match algorithm {
SQLCIPHER_HMAC_SHA512 => 64, SQLCIPHER_HMAC_SHA512 => 64,
SQLCIPHER_HMAC_SHA1 => 20,
_ => 0, _ => 0,
} }
} }
@ -85,9 +87,6 @@ extern "C" fn hmac(
in2_sz: c_int, in2_sz: c_int,
out: *mut c_uchar, out: *mut c_uchar,
) -> c_int { ) -> c_int {
if algorithm != SQLCIPHER_HMAC_SHA512 {
return SQLITE_ERROR;
}
if hmac_key.is_null() || in1.is_null() || out.is_null() { if hmac_key.is_null() || in1.is_null() || out.is_null() {
return SQLITE_ERROR; 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) }) 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 { match algorithm {
return SQLITE_ERROR; SQLCIPHER_HMAC_SHA512 => {
}; let Ok(mut mac) = Hmac::<Sha512>::new_from_slice(key) else {
mac.update(in1); return SQLITE_ERROR;
if let Some(in2) = in2 { };
mac.update(in2); mac.update(in1);
} if let Some(in2) = in2 {
let digest = mac.finalize().into_bytes(); mac.update(in2);
unsafe { }
out.copy_from(digest.as_ptr(), digest.len()); 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 SQLITE_OK
} }
@ -124,16 +141,21 @@ extern "C" fn pbkdf(
key_sz: c_int, key_sz: c_int,
key: *mut c_uchar, key: *mut c_uchar,
) -> c_int { ) -> c_int {
if algorithm != SQLCIPHER_PBKDF2_HMAC_SHA512 {
return SQLITE_ERROR;
}
if pass.is_null() || salt.is_null() || key.is_null() { if pass.is_null() || salt.is_null() || key.is_null() {
return SQLITE_ERROR; return SQLITE_ERROR;
} }
let password = unsafe { core::slice::from_raw_parts(pass as *const c_uchar, pass_sz as usize) }; 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 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) }; 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 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_HMAC_SHA512: c_int = 2;
pub const SQLCIPHER_PBKDF2_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; pub const CIPHER_ENCRYPT: c_int = 1;
#[repr(C)] #[repr(C)]

View File

@ -1,4 +1,4 @@
Copyright (c) 2008-2023, ZETETIC LLC Copyright (c) 2025, ZETETIC LLC
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without 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': { 'xcode_settings': {
'OTHER_CFLAGS': ['-std=c99'], 'OTHER_CFLAGS': ['-std=c99'],
'WARNING_CFLAGS': ['-w'], 'WARNING_CFLAGS': ['-w'],
'DEAD_CODE_STRIPPING': 'YES',
'LLVM_LTO': 'YES',
}, },
'defines': [ 'defines': [
'SQLITE_LIKE_DOESNT_MATCH_BLOBS', 'SQLITE_LIKE_DOESNT_MATCH_BLOBS',
'SQLITE_THREADSAFE=2', 'SQLITE_THREADSAFE=2',
'SQLITE_USE_URI=0', 'SQLITE_USE_URI=0',
'SQLITE_DEFAULT_MEMSTATUS=0', 'SQLITE_USE_ALLOCA',
'SQLITE_OMIT_AUTOINIT', 'SQLITE_OMIT_AUTOINIT',
'SQLITE_OMIT_DECLTYPE',
'SQLITE_OMIT_DEPRECATED', 'SQLITE_OMIT_DEPRECATED',
'SQLITE_OMIT_DESERIALIZE', 'SQLITE_OMIT_DESERIALIZE',
'SQLITE_OMIT_GET_TABLE', 'SQLITE_OMIT_GET_TABLE',
@ -36,22 +39,22 @@
'SQLITE_OMIT_SHARED_CACHE', 'SQLITE_OMIT_SHARED_CACHE',
'SQLITE_OMIT_UTF16', 'SQLITE_OMIT_UTF16',
'SQLITE_OMIT_COMPLETE', 'SQLITE_OMIT_COMPLETE',
'SQLITE_OMIT_GET_TABLE',
'SQLITE_OMIT_AUTHORIZATION', 'SQLITE_OMIT_AUTHORIZATION',
'SQLITE_OMIT_LOAD_EXTENSION', 'SQLITE_OMIT_LOAD_EXTENSION',
'SQLITE_TRACE_SIZE_LIMIT=32', 'SQLITE_OMIT_INTROSPECTION_PRAGMAS',
'SQLITE_OMIT_TRACE',
'SQLITE_DEFAULT_CACHE_SIZE=-16000', 'SQLITE_DEFAULT_CACHE_SIZE=-16000',
'SQLITE_DEFAULT_FOREIGN_KEYS=1', 'SQLITE_DEFAULT_FOREIGN_KEYS=1',
'SQLITE_DEFAULT_WAL_SYNCHRONOUS=1', 'SQLITE_DEFAULT_WAL_SYNCHRONOUS=1',
'SQLITE_DEFAULT_MEMSTATUS=0',
'SQLITE_LIKE_DOESNT_MATCH_BLOBS',
'SQLITE_MAX_EXPR_DEPTH=0',
'SQLITE_DQS=0', 'SQLITE_DQS=0',
'SQLITE_ENABLE_MATH_FUNCTIONS', 'SQLITE_ENABLE_MATH_FUNCTIONS',
'SQLITE_ENABLE_DESERIALIZE',
'SQLITE_ENABLE_COLUMN_METADATA',
'SQLITE_ENABLE_UPDATE_DELETE_LIMIT', 'SQLITE_ENABLE_UPDATE_DELETE_LIMIT',
'SQLITE_ENABLE_STAT4', 'SQLITE_ENABLE_STAT4',
'SQLITE_ENABLE_FTS5', 'SQLITE_ENABLE_FTS5',
'SQLITE_ENABLE_JSON1', 'SQLITE_ENABLE_JSON1',
'SQLITE_INTROSPECTION_PRAGMAS',
'SQLCIPHER_CRYPTO_CUSTOM=signal_crypto_provider_setup', 'SQLCIPHER_CRYPTO_CUSTOM=signal_crypto_provider_setup',
@ -69,8 +72,11 @@
'SQLITE_HAS_CODEC', 'SQLITE_HAS_CODEC',
'SQLITE_TEMP_STORE=2', 'SQLITE_TEMP_STORE=2',
'SQLITE_SECURE_DELETE', 'SQLITE_SECURE_DELETE',
'SQLITE_EXTRA_INIT=sqlcipher_extra_init',
'SQLITE_EXTRA_SHUTDOWN=sqlcipher_extra_shutdown',
], ],
'conditions': [ 'conditions': [
# Link with extension
['OS == "win"', { ['OS == "win"', {
'defines': [ 'defines': [
'WIN32' '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': { 'configurations': {
'Debug': { '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 rm -rf .tmp/sqlcipher
git clone --branch $tag --filter=blob:none git@github.com:sqlcipher/sqlcipher.git .tmp/sqlcipher git clone --branch $tag --filter=blob:none git@github.com:sqlcipher/sqlcipher.git .tmp/sqlcipher
cd .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 ./configure --enable-update-limit
make sqlite3.h sqlite3.c sqlite3ext.h shell.c make sqlite3.h sqlite3.c sqlite3ext.h shell.c
cd - cd -

View File

@ -1,3 +1,3 @@
deb [snapshot=20240829T060900Z] http://archive.ubuntu.com/ubuntu/ focal main universe deb [snapshot=20250811T060900Z] http://archive.ubuntu.com/ubuntu/ jammy main universe
deb [snapshot=20240829T060900Z] http://archive.ubuntu.com/ubuntu/ focal-updates main universe deb [snapshot=20250811T060900Z] http://archive.ubuntu.com/ubuntu/ jammy-updates main universe
deb [snapshot=20240829T060900Z] http://security.ubuntu.com/ubuntu focal-security 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, persistent: boolean,
pluck: boolean, pluck: boolean,
bigint: boolean, bigint: boolean,
paramNames: Array<string | null>,
): NativeStatement; ): NativeStatement;
statementRun<Options extends StatementOptions>( statementRun<Options extends StatementOptions>(
stmt: NativeStatement, stmt: NativeStatement,
params: StatementParameters<Options> | undefined, params: NativeParameters<Options> | undefined,
result: [number, number], result: [number, number],
): void; ): void;
statementStep<Options extends StatementOptions>( statementStep<Options extends StatementOptions>(
stmt: NativeStatement, stmt: NativeStatement,
params: StatementParameters<Options> | null | undefined, params: NativeParameters<Options> | null | undefined,
cache: Array<SqliteValue<Options>> | undefined, cache: Array<SqliteValue<Options>> | undefined,
isGet: boolean, isGet: boolean,
): Array<SqliteValue<Options>>; ): Array<SqliteValue<Options>>;
statementScanStats(stmt: NativeStatement): Array<ScanStats>;
statementClose(stmt: NativeStatement): void; statementClose(stmt: NativeStatement): void;
databaseOpen(path: string): NativeDatabase; databaseOpen(path: string): NativeDatabase;
databaseInitTokenizer(db: NativeDatabase): void;
databaseExec(db: NativeDatabase, query: string): void; databaseExec(db: NativeDatabase, query: string): void;
databaseClose(db: NativeDatabase): 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>; signalTokenize(value: string): Array<string>;
setLogger(fn: (code: string, message: string) => void): void;
}>(ROOT_DIR); }>(ROOT_DIR);
export type RunResult = { export type RunResult = {
@ -81,11 +96,15 @@ export type StatementOptions = Readonly<{
bigint?: true; bigint?: true;
}>; }>;
export type NativeParameters<Options extends StatementOptions> = ReadonlyArray<
SqliteValue<Options>
>;
/** /**
* Parameters accepted by `.run()`/`.get()`/`.all()` methods of the statement. * Parameters accepted by `.run()`/`.get()`/`.all()` methods of the statement.
*/ */
export type StatementParameters<Options extends StatementOptions> = export type StatementParameters<Options extends StatementOptions> =
| ReadonlyArray<SqliteValue<Options>> | NativeParameters<Options>
| Readonly<Record<string, SqliteValue<Options>>>; | Readonly<Record<string, SqliteValue<Options>>>;
/** /**
@ -93,7 +112,7 @@ export type StatementParameters<Options extends StatementOptions> =
*/ */
export type SqliteValue<Options extends StatementOptions> = export type SqliteValue<Options extends StatementOptions> =
| string | string
| Uint8Array | Uint8Array<ArrayBuffer>
| number | number
| null | null
| (Options extends { bigint: true } ? bigint : never); | (Options extends { bigint: true } ? bigint : never);
@ -107,6 +126,14 @@ export type RowType<Options extends StatementOptions> = Options extends {
? SqliteValue<Options> ? SqliteValue<Options>
: Record<string, 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. * A compiled SQL statement class.
*/ */
@ -115,6 +142,9 @@ class Statement<Options extends StatementOptions = object> {
#cache: Array<SqliteValue<Options>> | undefined; #cache: Array<SqliteValue<Options>> | undefined;
#createRow: undefined | ((result: unknown) => RowType<Options>); #createRow: undefined | ((result: unknown) => RowType<Options>);
#translateParams: (
params: StatementParameters<Options>,
) => NativeParameters<Options>;
#native: NativeStatement | undefined; #native: NativeStatement | undefined;
#onClose: (() => void) | undefined; #onClose: (() => void) | undefined;
@ -127,14 +157,47 @@ class Statement<Options extends StatementOptions = object> {
) { ) {
this.#needsTranslation = persistent === true && !pluck; this.#needsTranslation = persistent === true && !pluck;
const paramNames = new Array<string | null>();
this.#native = addon.statementNew( this.#native = addon.statementNew(
db, db,
query, query,
persistent === true, persistent === true,
pluck === true, pluck === true,
bigint === 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; this.#onClose = onClose;
} }
@ -150,8 +213,8 @@ class Statement<Options extends StatementOptions = object> {
throw new Error('Statement closed'); throw new Error('Statement closed');
} }
const result: [number, number] = [0, 0]; const result: [number, number] = [0, 0];
this.#checkParams(params); const nativeParams = this.#checkParams(params);
addon.statementRun(this.#native, params, result); addon.statementRun(this.#native, nativeParams, result);
return { changes: result[0], lastInsertRowid: result[1] }; return { changes: result[0], lastInsertRowid: result[1] };
} }
@ -170,8 +233,13 @@ class Statement<Options extends StatementOptions = object> {
if (this.#native === undefined) { if (this.#native === undefined) {
throw new Error('Statement closed'); throw new Error('Statement closed');
} }
this.#checkParams(params); const nativeParams = this.#checkParams(params);
const result = addon.statementStep(this.#native, params, this.#cache, true); const result = addon.statementStep(
this.#native,
nativeParams,
this.#cache,
true,
);
if (result === undefined) { if (result === undefined) {
return undefined; return undefined;
} }
@ -198,9 +266,8 @@ class Statement<Options extends StatementOptions = object> {
throw new Error('Statement closed'); throw new Error('Statement closed');
} }
const result = []; const result = [];
this.#checkParams(params); const nativeParams = this.#checkParams(params);
let singleUseParams: StatementParameters<Options> | undefined | null = let singleUseParams: typeof nativeParams | undefined | null = nativeParams;
params;
while (true) { while (true) {
const single = addon.statementStep( const single = addon.statementStep(
this.#native, this.#native,
@ -224,6 +291,20 @@ class Statement<Options extends StatementOptions = object> {
return result as unknown as Array<Row>; 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. * Close the statement and release the used memory.
*/ */
@ -264,9 +345,11 @@ class Statement<Options extends StatementOptions = object> {
} }
/** @internal */ /** @internal */
#checkParams(params: StatementParameters<Options> | undefined): void { #checkParams(
params: StatementParameters<Options> | undefined,
): NativeParameters<Options> | undefined {
if (params === undefined) { if (params === undefined) {
return; return undefined;
} }
if (typeof params !== 'object') { if (typeof params !== 'object') {
throw new TypeError('Params must be either object or array'); throw new TypeError('Params must be either object or array');
@ -274,6 +357,7 @@ class Statement<Options extends StatementOptions = object> {
if (params === null) { if (params === null) {
throw new TypeError('Params cannot be 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 ? RowType<{ pluck: true }> | undefined
: Array<RowType<object>>; : 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 */ /** @internal */
type TransactionStatement = Statement<{ persistent: true; pluck: true }>; type TransactionStatement = Statement<{ persistent: true; pluck: true }>;
@ -315,6 +413,13 @@ export type DatabaseOptions = Readonly<{
cacheStatements?: boolean; 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. * A sqlite database class.
*/ */
@ -350,6 +455,13 @@ export default class Database {
this.#isCacheEnabled = cacheStatements === true; 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. * Execute one or multiple SQL statements in a given `sql` string.
* *
@ -365,6 +477,51 @@ export default class Database {
addon.databaseExec(this.#native, sql); 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. * Compile a single SQL statement.
* *
@ -518,7 +675,14 @@ export default class Database {
commit.run(); commit.run();
return result; return result;
} catch (error) { } catch (error) {
rollback.run(); try {
rollback.run();
} catch (rollbackError) {
if (rollbackError instanceof Error) {
rollbackError.cause = error;
}
throw rollbackError;
}
throw error; throw error;
} finally { } finally {
this.#transactionDepth -= 1; 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", "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", "description": "A fast N-API-based Node.js addon wrapping sqlcipher and FTS5 segmenting APIs",
"homepage": "http://github.com/signalapp/node-sqlcipher.git", "homepage": "http://github.com/signalapp/node-sqlcipher.git",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@ -16,6 +17,7 @@
"main": "dist/index.cjs", "main": "dist/index.cjs",
"module": "dist/index.mjs", "module": "dist/index.mjs",
"exports": { "exports": {
"types": "./dist/lib/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.mjs",
"require": "./dist/index.cjs" "require": "./dist/index.cjs"
}, },
@ -43,7 +45,7 @@
"prebuildify": "prebuildify --strip --napi", "prebuildify": "prebuildify --strip --napi",
"test": "vitest --coverage --pool threads", "test": "vitest --coverage --pool threads",
"format": "run-p --print-label format:c format:js", "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 .", "format:js": "prettier --cache --write .",
"lint": "run-p --print-label check:eslint check:format", "lint": "run-p --print-label check:eslint check:format",
"check:eslint": "eslint --cache .", "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 <list>
#include "addon.h" #include "addon.h"
#include "errors.h"
#include "napi.h" #include "napi.h"
#include "signal-tokenizer.h" #include "signal-tokenizer.h"
@ -73,6 +74,169 @@ static Napi::Value SignalTokenize(const Napi::CallbackInfo& info) {
return result; 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 // Utils
Napi::Error FormatError(Napi::Env env, const char* format, ...) { 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) { Napi::Object Database::Init(Napi::Env env, Napi::Object exports) {
exports["databaseOpen"] = Napi::Function::New(env, &Database::Open); 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["databaseClose"] = Napi::Function::New(env, &Database::Close);
exports["databaseExec"] = Napi::Function::New(env, &Database::Exec); 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; return exports;
} }
@ -115,6 +285,9 @@ Database::~Database() {
return; return;
} }
delete wal_hook_wrap_;
wal_hook_wrap_ = nullptr;
int r = sqlite3_close(handle_); int r = sqlite3_close(handle_);
if (r != SQLITE_OK) { if (r != SQLITE_OK) {
fprintf(stderr, "Cleanup: sqlite3_close failure\n"); 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->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); fts5_api* fts5 = db->GetFTS5API(env);
if (fts5 == nullptr) { if (fts5 == nullptr) {
return Napi::Value(); return Napi::Value();
} }
SignalTokenizerModule* icu = new SignalTokenizerModule(); 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); &SignalTokenizerModule::Destroy);
if (r != SQLITE_OK) { if (r != SQLITE_OK) {
delete icu; delete icu;
return db->ThrowSqliteError(env, r); return db->ThrowSqliteError(env, r);
} }
return db->self_ref_.Value(); return Napi::Value();
} }
Napi::Value Database::Close(const Napi::CallbackInfo& info) { Napi::Value Database::Close(const Napi::CallbackInfo& info) {
@ -194,6 +379,9 @@ Napi::Value Database::Close(const Napi::CallbackInfo& info) {
} }
db->statements_.clear(); db->statements_.clear();
delete db->wal_hook_wrap_;
db->wal_hook_wrap_ = nullptr;
int r = sqlite3_close(db->handle_); int r = sqlite3_close(db->handle_);
if (r != SQLITE_OK) { if (r != SQLITE_OK) {
return db->ThrowSqliteError(env, r); return db->ThrowSqliteError(env, r);
@ -227,19 +415,99 @@ Napi::Value Database::Exec(const Napi::CallbackInfo& info) {
return Napi::Value(); 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) { Napi::Value Database::ThrowSqliteError(Napi::Env env, int error) {
assert(handle_ != nullptr); assert(handle_ != nullptr);
const char* msg = sqlite3_errmsg(handle_); const char* msg = sqlite3_errmsg(handle_);
int offset = sqlite3_error_offset(handle_); int offset = sqlite3_error_offset(handle_);
int extended = sqlite3_extended_errcode(handle_); int extended = sqlite3_extended_errcode(handle_);
if (offset == -1) {
NAPI_THROW(FormatError(env, "sqlite error(%d): %s", extended, msg), #define EXTENDED_STR(NAME) \
Napi::Value()); case NAME: \
} else { extended_name = #NAME; \
NAPI_THROW(FormatError(env, "sqlite error(%d): %s, offset: %d", extended, break;
msg, offset),
Napi::Value()); 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) { 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["statementClose"] = Napi::Function::New(env, &Statement::Close);
exports["statementRun"] = Napi::Function::New(env, &Statement::Run); exports["statementRun"] = Napi::Function::New(env, &Statement::Run);
exports["statementStep"] = Napi::Function::New(env, &Statement::Step); exports["statementStep"] = Napi::Function::New(env, &Statement::Step);
exports["statementScanStats"] =
Napi::Function::New(env, &Statement::ScanStats);
return exports; return exports;
} }
@ -329,12 +599,14 @@ Napi::Value Statement::New(const Napi::CallbackInfo& info) {
auto is_persistent = info[2].As<Napi::Boolean>(); auto is_persistent = info[2].As<Napi::Boolean>();
auto is_pluck = info[3].As<Napi::Boolean>(); auto is_pluck = info[3].As<Napi::Boolean>();
auto is_bigint = info[4].As<Napi::Boolean>(); auto is_bigint = info[4].As<Napi::Boolean>();
auto param_names = info[5].As<Napi::Array>();
assert(db_external.IsExternal()); assert(db_external.IsExternal());
assert(query.IsString()); assert(query.IsString());
assert(is_persistent.IsBoolean()); assert(is_persistent.IsBoolean());
assert(is_pluck.IsBoolean()); assert(is_pluck.IsBoolean());
assert(is_bigint.IsBoolean()); assert(is_bigint.IsBoolean());
assert(param_names.IsArray());
auto db = db_external.Data(); 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, auto stmt = new Statement(db, db_external, handle, is_persistent, is_pluck,
is_bigint); 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( return Napi::External<Statement>::New(
env, stmt, [](Napi::Env env, Statement* stmt) { delete stmt; }); 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 env = info.Env();
auto stmt = FromExternal(info[0]); auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
int r = sqlite3_finalize(stmt->handle_); int r = sqlite3_finalize(stmt->handle_);
if (r != SQLITE_OK) { if (r != SQLITE_OK) {
@ -399,6 +686,10 @@ Napi::Value Statement::Run(const Napi::CallbackInfo& info) {
auto env = info.Env(); auto env = info.Env();
auto stmt = FromExternal(info[0]); auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
auto params = info[1]; auto params = info[1];
auto result = info[2].As<Napi::Array>(); auto result = info[2].As<Napi::Array>();
@ -437,6 +728,10 @@ Napi::Value Statement::Step(const Napi::CallbackInfo& info) {
auto env = info.Env(); auto env = info.Env();
auto stmt = FromExternal(info[0]); auto stmt = FromExternal(info[0]);
if (stmt == nullptr) {
return Napi::Value();
}
auto params = info[1]; auto params = info[1];
auto cache = info[2]; auto cache = info[2];
auto is_get = info[3].As<Napi::Boolean>(); 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_); int r = sqlite3_step(stmt->handle_);
AutoResetStatement auto_reset(stmt, is_get.Value());
// No more rows // No more rows
if (r == SQLITE_DONE) { if (r == SQLITE_DONE) {
stmt->Reset(); auto_reset.Reset();
return Napi::Value(); return Napi::Value();
} }
AutoResetStatement _(stmt, is_get.Value());
if (r != SQLITE_ROW) { if (r != SQLITE_ROW) {
auto_reset.Reset();
return stmt->db_->ThrowSqliteError(env, r); 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 // In pluck mode - return the value of the first column
if (stmt->is_pluck_) { if (stmt->is_pluck_) {
if (column_count != 1) { if (column_count != 1) {
auto_reset.Reset();
NAPI_THROW(Napi::Error::New(env, "Invalid column count for pluck"), NAPI_THROW(Napi::Error::New(env, "Invalid column count for pluck"),
Napi::Value()); Napi::Value());
} }
@ -515,6 +813,108 @@ Napi::Value Statement::Step(const Napi::CallbackInfo& info) {
return result; 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) { bool Statement::BindParams(Napi::Env env, Napi::Value params) {
int key_count = sqlite3_bind_parameter_count(handle_); 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++) { for (int i = 1; i <= list_len; i++) {
auto name = sqlite3_bind_parameter_name(handle_, 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]); auto error = BindParam(env, i, list[i - 1]);
if (error != nullptr) { if (error != nullptr) {
NAPI_THROW( if (name == nullptr) {
FormatError(env, "Failed to bind param %d, error %s", i, error), NAPI_THROW(
false); FormatError(env, "Failed to bind param %d, error %s", i, error),
} false);
} } else {
} else { NAPI_THROW(FormatError(env, "Failed to bind param %s, error %s",
auto obj = params.As<Napi::Object>(); name + 1, error),
false);
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);
} }
} }
} }
@ -689,6 +1071,11 @@ Napi::Value Statement::GetColumnValue(Napi::Env env, int column) {
return Napi::Value(); return Napi::Value();
} }
void AutoResetStatement::Reset() {
stmt_->Reset();
enabled_ = false;
}
AutoResetStatement::~AutoResetStatement() { AutoResetStatement::~AutoResetStatement() {
if (enabled_) { if (enabled_) {
stmt_->Reset(); stmt_->Reset();
@ -700,6 +1087,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
Database::Init(env, exports); Database::Init(env, exports);
Statement::Init(env, exports); Statement::Init(env, exports);
exports["setLogger"] = Napi::Function::New(env, &SetLogger);
exports["signalTokenize"] = Napi::Function::New(env, &SignalTokenize); exports["signalTokenize"] = Napi::Function::New(env, &SignalTokenize);
return exports; return exports;
} }

View File

@ -9,6 +9,7 @@
#include "sqlite3.h" #include "sqlite3.h"
class Statement; class Statement;
class WalHookWrap;
class Database { class Database {
public: public:
@ -27,8 +28,11 @@ class Database {
static Database* FromExternal(const Napi::Value value); static Database* FromExternal(const Napi::Value value);
static Napi::Value Open(const Napi::CallbackInfo& info); 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 Close(const Napi::CallbackInfo& info);
static Napi::Value Exec(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); fts5_api* GetFTS5API(Napi::Env env);
@ -43,6 +47,8 @@ class Database {
// All currently open statements for this database. Used to close all open // All currently open statements for this database. Used to close all open
// statements when closing the database. // statements when closing the database.
std::list<Statement*> statements_; std::list<Statement*> statements_;
WalHookWrap* wal_hook_wrap_ = nullptr;
}; };
class AutoResetStatement { class AutoResetStatement {
@ -52,6 +58,9 @@ class AutoResetStatement {
~AutoResetStatement(); ~AutoResetStatement();
// Force reset statement now and clear `enabled_`
void Reset();
private: private:
Statement* stmt_; Statement* stmt_;
bool enabled_; bool enabled_;
@ -89,7 +98,7 @@ class Statement {
if (std::isspace(ch) || ch == ';') { if (std::isspace(ch) || ch == ';') {
p = p.substr(1); p = p.substr(1);
} else if (p.rfind("--", 0) == 0) { } else if (p.rfind("--", 0) == 0) {
// Line comment: "--" // Line comment: "--"
p = p.substr(2); p = p.substr(2);
@ -127,6 +136,7 @@ class Statement {
static Napi::Value Close(const Napi::CallbackInfo& info); static Napi::Value Close(const Napi::CallbackInfo& info);
static Napi::Value Run(const Napi::CallbackInfo& info); static Napi::Value Run(const Napi::CallbackInfo& info);
static Napi::Value Step(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); 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 { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; 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'; import Database from '../lib/index.js';
@ -65,3 +65,80 @@ test.each([[false], [true]])('ciphertext=%j', (ciphertext) => {
expect(row).toEqual({ name: 'Adam', value: 'Sandler' }); 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 = [ const rows = [
{ {
@ -49,6 +49,16 @@ test('db.close', () => {
db = new Database(); 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', () => { test('statement.close', () => {
const stmt = db.prepare('SELECT 1'); const stmt = db.prepare('SELECT 1');
stmt.close(); 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', () => { describe('list parameters', () => {
test('correct count', () => { test('correct count', () => {
expect(db.prepare('SELECT * FROM t WHERE a > ?').get([2])).toEqual(rows[2]); expect(db.prepare('SELECT * FROM t WHERE a > ?').get([2])).toEqual(rows[2]);
@ -187,12 +212,16 @@ describe('list parameters', () => {
test('object parameters', () => { test('object parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > ?'); 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', () => { test('against named parameters', () => {
const stmt = db.prepare('SELECT * FROM t WHERE a > $a'); 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'); const stmt = db.prepare('SELECT * FROM t WHERE a > $a');
expect(() => stmt.get()).toThrowError('Expected 1 parameters, got 0'); 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', () => { describe('tail', () => {
@ -380,6 +402,37 @@ test('bigint mode', () => {
).toEqual(n); ).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', () => { test('signalTokenize', () => {
expect(db.signalTokenize('a b c')).toEqual(['a', 'b', 'c']); 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');
});
});