Panic after enabling embedded replica when using named placeholders
app-1 | thread '<unnamed>' panicked at src/statement.rs:340:62:
app-1 | called `Option::unwrap()` on a `None` value
app-1 | stack backtrace:
app-1 | 0: 0x7f4b06c5b439 - <unknown>
app-1 | 1: 0x7f4b06920400 - <unknown>
app-1 | 2: 0x7f4b06c30e12 - <unknown>
app-1 | 3: 0x7f4b06c5cd9e - <unknown>
app-1 | 4: 0x7f4b06c5c520 - <unknown>
app-1 | 5: 0x7f4b06c5d6e7 - <unknown>
app-1 | 6: 0x7f4b06c5d0b8 - <unknown>
app-1 | 7: 0x7f4b06c5d046 - <unknown>
app-1 | 8: 0x7f4b06c5d033 - <unknown>
app-1 | 9: 0x7f4b06894cb4 - <unknown>
app-1 | 10: 0x7f4b06894e82 - <unknown>
app-1 | 11: 0x7f4b06895245 - <unknown>
app-1 | 12: 0x7f4b069156f0 - <unknown>
app-1 | 13: 0x7f4b068dfa2b - <unknown>
app-1 | 14: 0xc51049 - _ZN6v8impl12_GLOBAL__N_123FunctionCallbackWrapper6InvokeERKN2v820FunctionCallbackInfoINS2_5ValueEEE
app-1 | 15: 0xf57eaf - _ZN2v88internal25FunctionCallbackArguments4CallENS0_15CallHandlerInfoE
app-1 | 16: 0xf5871d - _ZN2v88internal12_GLOBAL__N_119HandleApiCallHelperILb0EEENS0_11MaybeHandleINS0_6ObjectEEEPNS0_7IsolateENS0_6HandleINS0_10HeapObjectEEENS8_INS0_20FunctionTemplateInfoEEENS8_IS4_EEPmi
app-1 | 17: 0xf58be5 - _ZN2v88internal21Builtin_HandleApiCallEiPmPNS0_7IsolateE
app-1 | 18: 0x1963df6 - Builtins_CEntry_Return1_ArgvOnStack_BuiltinExit
const dbClient = libsqlClient.createClient({
url: conf.EVENT_DB_URL,
syncUrl: conf.EVENT_DB_SYNC_URL,
authToken: conf.EVENT_DB_SYNC_TOKEN,
});
Everything works fine when the client has no syncUrl and uses EVENT_DB_SYNC_URL for the url.
Using @libsql/client 0.7
I was able to narrow this issue down. It seems to occur when I use named placeholders.
This works:
const result = await client.execute({
sql: "SELECT * FROM users WHERE id = ?",
args: [1],
});
This panics:
const result = await client.execute({
sql: "SELECT * FROM users WHERE id = $id",
args: {id: 23},
});
I've tried :@$ as per the docs. All lead to a panic.
I just learned about the same problem here. Also narrowed down to this issue.
I also tried using the npm libsql package (which is compatible with better-sqlite3 api), and also have the exact same issue.
As I rely heavily in named arguments, I've created a helper to transform from named to positional arguments. Hope it helps anyone who comes to this issue while it's not fixed:
function convertToPositionalParams({sql, args}) {
const paramNames = Object.keys(params);
const positionalParams = [];
const paramMap = {};
// Replace named parameters in the SQL with positional parameters (?)
const transformedSql = sql.replace(/\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (_, paramName) => {
if (paramNames.includes(paramName)) {
if (!(paramName in paramMap)) {
positionalParams.push(params[paramName]);
paramMap[paramName] = positionalParams.length; // Track the index of each parameter
}
return '?';
} else {
throw new Error(`Parameter ${paramName} not found in params object`);
}
});
return {
sql: transformedSql,
args: positionalParams
};
}
In this case, it's using $ as a biding character. But you can change the code to use @ or :as well inside the regex.
Thanks @vfssantos for the workaround — it definitely works well when you're using named parameters and know the binding character (e.g., : or $) in advance.
To make this more flexible and general-purpose, I’ve been using a slightly extended approach that automatically detects the binding character and rewrites the SQL with positional parameters (?). This helps when integrating with tools that provide named parameter objects (e.g., query builders or abstractions) and avoids having to commit to a specific binding style.
Here’s a simplified version of the logic I’ve added to the client:
const originalExecute = client.execute.bind(client);
client.execute = (statement) => {
if (typeof statement === 'object' && typeof statement.args === 'object') {
statement = convertToPositionalParams(statement);
}
return originalExecute(statement);
};
const guessBindingCharacter = (sql, args) => {
for (const bindingChar of ['$', '@', ':']) {
if (Object.keys(args).every(arg => sql.includes(bindingChar + arg))) {
return bindingChar;
}
}
throw new Error('Could not identify binding character');
};
const convertToPositionalParams = ({ sql, args }) => {
const positionalParams = [];
const bindingCharacter = guessBindingCharacter(sql, args);
const matcher = new RegExp(`\\${bindingCharacter}([a-zA-Z_][a-zA-Z0-9_]*)`, 'g');
// Replace named parameters in the SQL with positional parameters (?)
const transformedSql = sql.replace(matcher, (_, paramName) => {
if (paramName in args) {
positionalParams.push(args[paramName]);
return '?';
}
throw new Error(`Parameter ${paramName} not found in params object`);
});
return {
sql: transformedSql,
args: positionalParams
};
};