Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
cbe7350605 fix(sqlite): detect schema changes from external processes
SQLite prepared statements cache column names for performance. Previously,
when an external process modified the database schema (e.g., renaming a
column), the cached column names were not updated because the internal
version counter only tracked changes made by the same Bun process.

This fix uses SQLite's `SQLITE_STMTSTATUS_REPREPARE` counter to detect
when SQLite has auto-reprepared a statement due to schema changes. When
this counter changes between executions, the column names are refreshed.

Closes #1332

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 01:02:40 +00:00
2 changed files with 96 additions and 7 deletions

View File

@@ -459,11 +459,23 @@ public:
bool need_update() { return version_db->version.load() != version; }
void update_version() { version = version_db->version.load(); }
// Check if SQLite auto-reprepared the statement due to external schema changes
bool check_reprepare()
{
int currentReprepare = sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_REPREPARE, 0);
if (currentReprepare != lastReprepareCount) {
lastReprepareCount = currentReprepare;
return true;
}
return false;
}
~JSSQLStatement();
sqlite3_stmt* stmt;
VersionSqlite3* version_db;
uint64_t version = 0;
int lastReprepareCount = 0;
// Tracks which columns are valid in the current result set. Used to handle duplicate column names.
// The bit at index i is set if the column at index i is valid.
WTF::BitVector validColumns;
@@ -2068,7 +2080,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionIterate, (JSC::JS
castedThis->version_db->version++;
}
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -2120,7 +2132,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionAll, (JSC::JSGlob
castedThis->version_db->version++;
}
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -2205,7 +2217,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionGet, (JSC::JSGlob
castedThis->version_db->version++;
}
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
RETURN_IF_EXCEPTION(scope, {});
}
@@ -2261,7 +2273,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRows, (JSC::JSGlo
castedThis->version_db->version++;
}
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
if (scope.exception()) [[unlikely]] {
@@ -2348,7 +2360,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRawRows, (JSC::JS
castedThis->version_db->version++;
}
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
if (scope.exception()) [[unlikely]] {
sqlite3_reset(stmt);
@@ -2446,7 +2458,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteStatementFunctionRun, (JSC::JSGlob
castedThis->version_db->version++;
}
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
if (scope.exception()) [[unlikely]] {
sqlite3_reset(stmt);
@@ -2506,7 +2518,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsSqlStatementGetColumnNames, (JSGlobalObject * lexical
auto scope = DECLARE_THROW_SCOPE(vm);
CHECK_THIS
if (!castedThis->hasExecuted || castedThis->need_update()) {
if (!castedThis->hasExecuted || castedThis->need_update() || castedThis->check_reprepare()) {
initializeColumnNames(lexicalGlobalObject, castedThis);
RETURN_IF_EXCEPTION(scope, {});
}

View File

@@ -0,0 +1,77 @@
// https://github.com/oven-sh/bun/issues/1332
// SQLite schema cache not invalidated for external changes
import { Database } from "bun:sqlite";
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("schema changes by external process are detected", async () => {
using dir = tempDir("sqlite-schema-cache", {});
const dbPath = join(String(dir), "test.sqlite");
// Create database and initial schema
const db = new Database(dbPath);
db.run("pragma journal_mode = wal");
db.run("CREATE TABLE foo (id INTEGER PRIMARY KEY AUTOINCREMENT, greeting TEXT)");
db.run("INSERT INTO foo (greeting) VALUES (?)", "Hello");
// Create a prepared statement and execute it - this caches the column names
const query = db.query("SELECT * FROM foo");
const result1 = query.get();
expect(result1).toEqual({ id: 1, greeting: "Hello" });
// Run another process to rename the column
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const { Database } = require("bun:sqlite");
const db = new Database(${JSON.stringify(dbPath)});
db.run("ALTER TABLE foo RENAME COLUMN greeting TO greeting2");
db.close();
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
if (exitCode !== 0) {
console.error("External process failed:", stderr);
}
expect(exitCode).toBe(0);
// Execute the same prepared statement again - should detect schema change
const result2 = query.get();
// The result should now have the new column name "greeting2" instead of "greeting"
expect(result2).toHaveProperty("greeting2");
expect(result2).not.toHaveProperty("greeting");
expect(result2).toEqual({ id: 1, greeting2: "Hello" });
db.close();
});
test("schema changes by same connection are detected", () => {
const db = new Database(":memory:");
db.run("CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)");
db.run("INSERT INTO foo (name) VALUES (?)", "Alice");
const query = db.query("SELECT * FROM foo");
const result1 = query.get();
expect(result1).toEqual({ id: 1, name: "Alice" });
// Rename column in same connection
db.run("ALTER TABLE foo RENAME COLUMN name TO username");
// The query should pick up the new column name
const result2 = query.get();
expect(result2).toHaveProperty("username");
expect(result2).not.toHaveProperty("name");
expect(result2).toEqual({ id: 1, username: "Alice" });
db.close();
});