Skip to content

bun:sqlite: report direct changes in run().changes, not the total_changes delta - #34916

Closed
robobun wants to merge 1 commit into
mainfrom
claude/06fb70ff/sqlite-changes-direct-only
Closed

robobun wants to merge 1 commit into
mainfrom
claude/06fb70ff/sqlite-changes-direct-only

Conversation

@robobun

@robobun robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

What

Statement#run().changes and Database#run().changes were computed as the sqlite3_total_changes() delta around the statement, so every row a trigger writes and every child row an ON DELETE CASCADE removes was added to the count. The standard optimistic-concurrency check if (res.changes !== 1) throw miscounts on any schema with triggers or cascades.

Repro

import { Database } from "bun:sqlite";
const db = new Database(":memory:");
db.exec("create table p(x); create table log(y);");
db.exec("create trigger tp after insert on p begin insert into log values (new.x); insert into log values (new.x*2); end;");
console.log(db.prepare("insert into p values (5)").run().changes);        // before: 3   after: 1
console.log(db.prepare("insert into p values (?),(?)").run(6,7).changes); // before: 6   after: 2
db.exec("pragma foreign_keys=on; create table par(id integer primary key);");
db.exec("create table ch(id integer primary key, pid references par(id) on delete cascade);");
db.exec("insert into par values (1); insert into ch values (10,1),(11,1),(12,1);");
console.log(db.prepare("delete from par where id=1").run().changes);      // before: 4   after: 1

Fix

  • Statement#run(): read sqlite3_changes64() after stepping, same as better-sqlite3, node:sqlite (including Bun's own implementation in NodeSqlite.cpp), and Python sqlite3.
  • Database#run() (multi-statement): sum sqlite3_changes64() per statement, gated on sqlite3_total_changes() movement so a SELECT or DDL statement doesn't re-add the previous statement's count.

Behavior change

Statement#run() on a read-only statement (e.g. SELECT) now returns the direct-change count from the most recently completed INSERT/UPDATE/DELETE on the connection, rather than 0. This matches better-sqlite3 and node:sqlite; .run() is documented for statements that do not return rows. One existing test assertion was updated accordingly.

Verification

  • New test fails on main (received 3, expected 1) and passes with this change.
  • bun bd test test/js/bun/sqlite/: 101 pass
  • bun bd test test/js/node/sqlite/: 115 pass, 4 skip

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/sqlite/sqlite.test.js

…nges delta

Statement#run() and Database#run() computed .changes as the
sqlite3_total_changes() delta across the statement, which includes rows
written by triggers and foreign-key cascades. Use sqlite3_changes64()
instead so .changes reflects only the rows directly modified by the
statement, matching better-sqlite3, node:sqlite, and Python sqlite3.

For multi-statement Database#run(), sum sqlite3_changes64() per
statement, gated on total_changes movement so non-modifying statements
don't re-add the previous statement's count.
@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with the trigger/cascade script (received 3/6/4, expected 1/2/1). Fix applied in JSSQLStatement.cpp; bun bd test test/js/bun/sqlite/ passes (101/101). Waiting on CI.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e28204c4-1a17-44bf-80c2-9201751a06f8

📥 Commits

Reviewing files that changed from the base of the PR and between e550f2c and b9a73ff.

📒 Files selected for processing (2)
  • src/jsc/bindings/sqlite/JSSQLStatement.cpp
  • test/js/bun/sqlite/sqlite.test.js

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:56 AM PT - Jul 21st, 2026

✅ @robobun, your commit b9a73ffe1f070ae1c89071a113b49b4e91477dd1 passed in Build #76813! 🎉


🧪   To try this PR locally:

bunx bun-pr 34916

That installs a local version of the PR into your bun-34916 executable, so you can run:

bun-34916 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs. The fix is correct and matches node:sqlite/better-sqlite3, but it changes user-visible semantics — Statement#run() on a read-only statement now returns the previous write's changes instead of 0 (an existing test assertion was updated). Worth a human sign-off on that behavior change.

What was reviewed:

  • jsSQLStatementExecuteStatementFunctionRun now reads sqlite3_changes64(db) — identical to NodeSqlite.cpp:2795 and safe on macOS 12 via the lazy_sqlite3_changes64 fallback.
  • Multi-statement Database#run() gates the per-statement changes64 sum on total_changes movement so a trailing SELECT/DDL doesn't re-add the prior count — covered by the new test.
  • New test's sanity checks (log=14, ch=0) confirm triggers/cascades actually fired, so the assertion isn't vacuous.
Extended reasoning...

Overview

Two functions in src/jsc/bindings/sqlite/JSSQLStatement.cpp are changed: jsSQLStatementExecuteFunction (Database#run, multi-statement) and jsSQLStatementExecuteStatementFunctionRun (Statement#run). Both previously computed .changes as sqlite3_total_changes() after − before, which includes trigger and ON DELETE CASCADE rows. Now Statement#run reads sqlite3_changes64() directly (matching NodeSqlite.cpp, better-sqlite3, and Python's sqlite3), and Database#run sums sqlite3_changes64() per statement, gated on total_changes moving so read-only/DDL statements don't double-count. A new test in test/js/bun/sqlite/sqlite.test.js covers triggers, multi-row inserts, multi-statement run(), and FK cascades; one existing assertion (stmt2.run() on a SELECT returning changes: 0) was updated to changes: 1.

Security risks

None. This only changes how a per-connection change counter is read after stepping a statement. No new user-controlled data flows into SQLite, no new allocation, no new FFI surface.

Level of scrutiny

Medium. The core fix (use sqlite3_changes64 for direct changes) is the textbook approach and mirrors Bun's own node:sqlite implementation line-for-line. However, it alters a documented public return value: Statement#run().changes on a SELECT is no longer 0 but the connection's most recent direct-change count. The PR description acknowledges this and cites better-sqlite3/node:sqlite as precedent, and .run() is documented for non-returning statements — but it's still a semantic change to a Bun-native API that a maintainer should confirm is acceptable.

Other factors

  • sqlite3_changes64 availability on old macOS system SQLite: lazy_sqlite3.h:508-512 already installs a 32-bit fallback when the symbol is absent, so no null-fn-pointer crash on macOS 12.
  • Type width: previous code used int − int (32-bit), new code accumulates in sqlite3_int64 and casts to double for jsNumber — strictly wider than before.
  • Test quality: the new test asserts positive counts (1, 2, 1, 3, 1) and verifies via SELECT count(*) that the trigger and cascade actually ran, so it can't pass vacuously if the schema setup silently failed.
  • No prior human review comments on the PR.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-21 and it conflicts with main. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant