Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,16 @@ jobs:
echo "CXXFLAGS=${CXXFLAGS:-} -include ../src/gcc-preinclude.h" >> $GITHUB_ENV

- name: Build binaries
# tools/prebuild.mjs forwards --tag-libc on linux and drops it
# elsewhere: a libc-tagged binary in a darwin-*/win32-* prebuilds
# directory outranks the untagged one at load time (node-gyp-build
# reads libc as 'glibc' on every non-Alpine platform), so a stale
# copy would be preferred silently.
run: pnpm run prebuild --arch ${{ env.TARGET }} --tag-libc

- name: Verify prebuild layout
run: pnpm run check:prebuilds

- name: Print binary info
if: contains(matrix.os, 'ubuntu')
run: |
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/electron-asar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ jobs:
echo "CXXFLAGS=${CXXFLAGS:-} -include ../src/gcc-preinclude.h" >> $GITHUB_ENV
- name: Build the shipped prebuild
run: pnpm run prebuild --tag-libc
- name: Verify prebuild layout
run: pnpm run check:prebuilds
- name: Install xvfb
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Packaged-ASAR tests (unpacked + sealed-in-archive)
Expand Down
51 changes: 51 additions & 0 deletions MIGRATING-TO-V9.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ run to completion; its result is dropped. The signal listener is removed
when the call settles, so one long-lived signal does not accumulate
listeners.

The rejection is delivered when the signal fires, which is before the
interrupted statement has finished unwinding on its worker — so the
connection is not idle yet at that moment and a synchronous method called
there refuses with "database is busy: sync methods require a fully idle
database". `await db.wait()` in the catch block drains the teardown. A
cancellation token's own rejection (`token.cancel()`) arrives after the
unwind and needs no drain.

## User-defined functions, aggregates, window functions and collations (new)

`db.function()`, `db.aggregate()`, `db.collation()`, `db.removeFunction()`
Expand Down Expand Up @@ -344,6 +352,49 @@ fixes it (the original `node-gyp-build` error is preserved as
`err.cause`). node-webkit support and its build instructions were
removed. See [docs/electron.md](docs/electron.md).

## Within v9: 9.0 → 9.1 behaviour changes

Small, but they change what working 9.0 code observes:

- **`stmt.parameterNames` on a fully positional statement is now
`undefined`.** 9.0 returned an array of `null`s, one per `?`
parameter. A statement with no named parameters at all has no names to
report, so it reports none; a **mixed** statement still carries `null`
at each positional index, so indices stay aligned with
`parameterCount`:

```js
db.prepareSync("SELECT * FROM t WHERE a = ? AND b = ?").parameterNames;
// 9.0: [null, null] 9.1: undefined
db.prepareSync("SELECT * FROM t WHERE a = ? AND b = $b").parameterNames;
// [null, '$b'] in both
```

Code that iterated the array unconditionally needs a `?? []`.

- **`db.close()` refuses while a statement is unfinalized.** This is
unchanged behaviour, called out here because it is the first thing that
bites code ported from `node:sqlite` or `better-sqlite3`, where a
garbage-collected statement is finalized for you:

```js
const stmt = db.prepareSync("SELECT 1");
await db.close(); // SQLITE_BUSY: unable to close due to unfinalized statements
```

Finalize the statement (`await stmt.finalize()`, or `using stmt = ...`
for scope-bound disposal) before closing. The statement cache and the
`*Sync` fast paths manage their own statements, so this only concerns
statements you prepared explicitly. The `/compat` shim's
`DatabaseSync.close()` finalizes the statements it prepared for you, as
`node:sqlite` does — since 9.1, where it previously discarded the
resulting `SQLITE_BUSY` and left the connection open.

- **Query spans are delivered asynchronously**, and `sqlite3.flushQuerySpans()`
is the new drain point — see the observability section of the README.
9.0 code that read collected spans immediately after an awaited query
was seeing nothing; `unsubscribe()` now drains before it detaches.

## Minor notes

- `Date` still binds as epoch milliseconds (REAL) and reads back as a
Expand Down
105 changes: 94 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,26 @@ reaches every statement running on the connection. While a token exists,
each query pays one relaxed atomic load per `period` VM instructions
(default 1000) — within measurement noise in the benchmark suite.

One timing detail worth knowing: an `AbortSignal` rejection is delivered
as soon as the signal fires, which is *before* the interrupted statement
has finished unwinding on its worker. The connection is therefore not yet
idle at the moment the rejection is observed, so a synchronous method
called right there refuses with "database is busy: sync methods require a
fully idle database". `await db.wait()` (or any awaited query) drains the
teardown:

```js
try {
await db.all(longRunningSql, { signal });
} catch (err) {
await db.wait(); // the interrupted statement has finished unwinding
db.allSync("SELECT 1"); // now the sync path is available again
}
```

A cancellation token's own rejection (`token.cancel()` with no signal)
arrives with the statement already unwound, so it needs no drain.

A JavaScript callback form exists for progress reporting —
`db.progress(10000, () => shouldStop)` calls the callback every 10,000
VM instructions and aborts the statement when it returns truthy — but
Expand Down Expand Up @@ -637,24 +657,44 @@ const both = sqlite3.concatChangeset(a, b); // a then b
### Rebasing (9.1): the fork-free sync loop

Apply with `{ rebase: true }` to harvest a **rebase buffer** — the
record of which conflicting changes were omitted or replaced — then
rebase later local changesets against it before applying them remotely.
record of which conflicting changes this database omitted or replaced —
then rebase the changesets you recorded *before* that apply, so they
land upstream without anyone resolving the same conflicts twice.
Together with `session.diff()` (a changeset of the differences between
an attached database's table and this one, without recording anything)
this is a complete offline-first sync toolkit on stock SQLite; no other
JS driver has rebasing (rusqlite is the only binding anywhere):

```js
// one round of sync: apply the server's changes, remember the conflicts
const rebase = await serverDb.applyChangeset(serverChangeset, {
conflict: "omit",
// This database is at S0 and records its own work (S0 → S1):
const session = db.session({ table: "t" });
await db.run("UPDATE t SET v = ? WHERE id = ?", "mine", 1);
const local = await session.changeset();
await session.close();

// A changeset based on S0 arrives from the peer. Apply it *here*,
// resolving conflicts, and keep the record of those resolutions:
const rebase = await db.applyChangeset(incoming, {
conflict: "omit", // this database's row wins
rebase: true, // resolves the rebase buffer (null if no conflicts)
});
// later: rebase the client's new work against those resolutions and push
const rebased = sqlite3.rebaseChangeset(clientChangeset, rebase);
await serverDb.applyChangeset(rebased);

// Rebasing rewrites the local changeset's old values to the ones the
// peer holds, so pushing it needs no conflict handling at all:
await peer.applyChangeset(sqlite3.rebaseChangeset(local, rebase));
```

The matching rule, because it is easy to get backwards: the rebaser
finds a buffer entry **by primary key** and rewrites the change's
`old.*` values to the values the buffer carries (for an omitted remote
UPDATE, the values that remote left in place). It does not check when
the change was recorded — so a changeset recorded *after* the apply is
rewritten just the same, and its old values then describe a state the
peer has already moved past. **One buffer belongs to the changesets
recorded before its apply**; later work gets its own round of the loop.
The direction matters too: the buffer must come from the apply performed
on the database whose changeset you are rebasing.

`session.diff('t', 'other')` records the changes that transform the
attached database `other`'s table into this connection's — the
"what changed between these two databases" primitive for verification
Expand Down Expand Up @@ -780,6 +820,10 @@ catch (err) { err.offset; } // 9

// per-statement integer mode
const stmt = db.prepareSync(sql, { integerMode: "bigint" });

// connection-free namespace helpers
sqlite3.complete("SELECT 1;"); // true — a complete statement (REPL input)
sqlite3.compileOptions(); // ['ENABLE_FTS5', 'ENABLE_SESSION', …]
```

**JavaScript virtual tables** — generator-computed, read-only, working
Expand Down Expand Up @@ -807,6 +851,24 @@ generator as `undefined`; one the query constrained is also reported as
that column's value, so `SELECT count FROM sequence(5)` works without the
generator echoing it.

A parameter is a real (hidden) column, and `sequence(5)` is exactly
`WHERE count = 5` — **SQLite re-checks that predicate against every row
the generator produces**, rather than trusting the generator to have
applied it. So a row either leaves the parameter's column NULL — filled
with the argument, as above — or echoes the argument **as it was
received**: parameter columns carry no affinity (like every other column
here), so an echoed `String(5)` is the text `'5'`, which does not equal
the integer `5`, and the row is filtered out. A row reporting anything
else in that column contradicts the `WHERE` clause the argument came from
and is filtered out too; put unrelated output in its own column. The
generator is still free to pre-filter for speed, and should: a generator
that ignores a constraint it cannot satisfy again produces an endless
scan (correct, but unbounded — `LIMIT`, or a cancellation token on the
async path, is the stop). A row shorter than `columns` pads with NULL, so
a mis-ordered `yield` shows up as NULLs rather than an error, and a
throwing generator fails the query with a message naming the table and the
thrown value attached as `err.cause`.

`db.values(array)` exposes any JS array as a queryable table — the
rusqlite `rarray()` ergonomics no JS driver had: `JOIN` against
in-memory data instead of building IN-lists. `drop()` the handle when you
Expand Down Expand Up @@ -846,6 +908,26 @@ const unsubscribe = sqlite3.subscribeQueries(({ sql, durationMs }) => {
});
```

Spans are delivered **asynchronously**: SQLite reports a statement's
timing on the thread that ran it, and the span crosses to the JS thread
through a queue drained on a later event-loop turn — so right after
`await db.all(sql)` the span for that query has usually not arrived yet.
`sqlite3.flushQuerySpans()` delivers everything pending, synchronously,
which is what a test or a shutdown flush wants; `unsubscribe()` drains
first as well, so nothing recorded before it is lost.

```js
const spans = [];
const stop = sqlite3.subscribeQueries((s) => spans.push(s.sql));
await db.all("SELECT 1");
sqlite3.flushQuerySpans(); // spans === ['SELECT 1']
stop();
```

Spans are per module instance, so `pool()` traffic is invisible here: a
pool's queries run in workers, each with its own copy of this module and
its own channels ([docs/concurrency.md](docs/concurrency.md#the-pool)).

**node:sqlite drop-in** — code written against the built-in module can
switch without rewriting:

Expand All @@ -860,9 +942,10 @@ The shim maps onto the sync fast path (re-entrant UDFs included) and
exposes the full async surface through `db.native`. The places a
synchronous form cannot exist keep this package's async signatures and
are listed at the top of [lib/compat.js](lib/compat.js): sessions,
`serialize`, and `close()`, which starts the close rather than completing
it (`await using`, or `await db.native.close()`, when a caller must know
the file is free — deleting or reopening it on Windows).
`serialize`, and `close()`, which finalizes the statements it prepared
(as `node:sqlite` does) and then *starts* the close rather than
completing it (`await using`, or `await db.native.close()`, when a caller
must know the file is free — deleting or reopening it on Windows).
`StatementSync.iterate()` materialises its rows, because the sync path has
no mid-cursor suspension.

Expand Down
54 changes: 54 additions & 0 deletions docs/concurrency.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,60 @@ Error's own properties.
transaction finishes or rolls back), closes every connection and waits
for every worker's exit. `await using pool` works.

**`file:` URI filenames** work in the pool as they do on a single
connection: the workers set `OPEN_URI` when the filename starts with
`file:`, so `mode`, `immutable` and `cache` are honoured (before 9.1 the
URI reached SQLite as a literal path and every worker failed with
`SQLITE_CANTOPEN`). Two consequences worth knowing:

```js
// Read-only over a shipped database — WAL is a write, so the pool's WAL
// default is dropped for a read-only URI rather than failing the open.
const ro = await sqlite3.pool("file:/data/vdb.sqlite?mode=ro", { readers: 2 });
// Asking for WAL explicitly on such a URI is refused up front:
await sqlite3.pool("file:/data/vdb.sqlite?mode=ro", { walMode: true }); // TypeError
```

An in-memory URI (`file::memory:`, `mode=memory`) is refused with
`readers > 0` for the same reason `:memory:` is — each worker would get
its own empty database — unless the URI carries `cache=shared`. WAL is
turned off by itself for an in-memory pool (there is no journal to
switch) and for a read-only URI, so both forms open under the default
options.

**`cache=shared` locks per table, not per file.** It is the only way pool
workers can share an in-memory database, and its concurrency model is not
WAL's: while the writer's transaction is open, a reader touching the same
table gets `SQLITE_LOCKED_SHAREDCACHE` — and the busy timeout does not
cover it, because SQLite never calls the busy handler for a shared-cache
table lock (`sqlite3_unlock_notify` is the mechanism there, and it needs a
compile-time option this build does not carry). The pool therefore
**retries a read** that hits a table lock, spending the connection's
`busyTimeout` budget on it, so a read dispatched mid-transaction returns
the committed data instead of failing. Three consequences:

- `busyTimeout: 0` keeps the fail-fast behaviour — the read rejects with
`SQLITE_LOCKED_SHAREDCACHE` immediately.
- A read outlives its normal latency when it collides with a long
transaction; it is waiting, not working. Cancelling it (`{ signal }`)
stops the retries.
- Writes and `exec` are **not** retried: re-running a script, or a
statement that may already have landed, is not safe. A shared-cache pool
under concurrent writes should keep transactions short.

For a shared database that needs real reader/writer concurrency, use a
file in WAL mode — that is what the pool is shaped for. `cache=shared` is
for sharing an in-memory database across the pool's workers at all.

**What the pool does not do: spans.** Each worker loads its own instance
of this module, so `sqlite3.subscribeQueries()` on the main thread sees
nothing from `pool.read()`, `pool.write()` or `pool.exec()` — those spans
are published on the worker's own `diagnostics_channel` (and its
`sqlite.db.query` mirror), where no main-thread subscriber is listening.
`flushQuerySpans()` cannot reach them either. If pool traffic must be
traced, time it at the call site, or use a dedicated worker you control
(the path handoff above) and subscribe inside it.

## Terminating a worker

`worker.terminate()` while a query is in flight used to abort the whole
Expand Down
35 changes: 33 additions & 2 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ which looks in `prebuilds/<platform>/` first, then falls back to a
`build/Release/` build. (For `--tag-libc` builds the directory stays
`linux-<arch>` and the libc is carried by the file suffix.)

### The libc tag belongs to linux only

`node-gyp-build` resolves the running libc as `'glibc'` on **every
non-Alpine platform, macOS and Windows included**, and prefers a
tag-matching file over an untagged one. So a
`prebuilds/darwin-arm64/@appthreat+sqlite3.glibc.node` matches the
platform *and* the libc, outranks the `@appthreat+sqlite3.node` beside it,
and is loaded in preference to it — even when it comes from an entirely
different revision. That failure is silent: the addon loads, and only the
APIs added since are missing.

Three things prevent it:

- `pnpm run prebuild` goes through `tools/prebuild.mjs`, which forwards
`--tag-libc` on linux and drops it everywhere else. CI passes the flag
for every target; the platform rule lives in the wrapper so a local
build cannot recreate the trap either.
- `pnpm run check:prebuilds` (a CI step after every prebuild) fails when a
`darwin-*`/`win32-*` directory contains a `*.glibc.node`/`*.musl.node`,
or when a binary's object format contradicts its directory (a Mach-O
file in `linux-x64/`).
- The loader refuses a binding whose `NATIVE_INTERFACE_VERSION` is not the
one `lib/` expects, naming the file it loaded and the
`rm -rf prebuilds build && pnpm run rebuild` remedy. The two constants
live in `src/node_sqlite3.cc` and `lib/sqlite3-binding.js` and are
bumped together whenever `lib/` starts using a new native export.

## pnpm 10+ and the blocked install script

pnpm 10 and later refuse to run a dependency's lifecycle scripts unless the
Expand Down Expand Up @@ -154,7 +181,10 @@ Error: No native build was found for platform=linux arch=arm64 runtime=node ...
`pnpm run rebuild`.
3. **Stale `prebuilds/` while iterating on C++**: `node-gyp-build` prefers
`prebuilds/` over `build/`, so your `pnpm run rebuild` output is being
shadowed. Delete `prebuilds/` while iterating.
shadowed. Delete `prebuilds/` while iterating. Since 9.1 a mismatch
between a resolved binary and `lib/` throws at import ("loaded a native
binding that does not match this JavaScript"), naming the file — rather
than presenting as methods missing from the namespace.
4. **Runtime below the Node-API floor** (Node < 22, Electron < 35): the
binding loader refuses with an error naming the floors rather than
crashing. On Electron the default prebuild needs no rebuild at all; only
Expand All @@ -171,7 +201,8 @@ This repo is developed with **pnpm >= 11** (pinned exactly in
pnpm install # strictDepBuilds is on; frozen form: pnpm install --frozen-lockfile
pnpm run rebuild # node-gyp rebuild — always `pnpm run rebuild`
pnpm run test
pnpm run prebuild # prebuildify --napi --strip
pnpm run prebuild # tools/prebuild.mjs → prebuildify --napi --strip
pnpm run check:prebuilds # refuses a prebuilds/ layout that loads the wrong file
pnpm pack # tarball includes prebuilds/ — smoke-test it in a scratch project
```

Expand Down
13 changes: 11 additions & 2 deletions lib/compat.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,17 @@ export declare class DatabaseSync {
): Promise<void>;
/** Serializes the database (async divergence). */
serialize(): Promise<Uint8Array>;
/** Closes the connection (asynchronously under the hood). */
/**
* Closes the connection, finalizing the statements prepared through
* it (as node:sqlite does). The close itself is queued: the
* connection refuses further work at once, the handle is released a
* turn later. A close that still fails is reported on the underlying
* connection's 'error' event.
*/
close(): void;
/** `await using` support. */
/**
* `await using` support: finalizes outstanding statements, then waits
* for the close to complete.
*/
[Symbol.asyncDispose](): Promise<void>;
}
Loading
Loading