@gtbuchanan/libsql-termux-shim
v0.1.1
Published
libsql native-binding stand-in for Termux/Android, implemented on node:sqlite, so libsql-dependent tools (e.g. promptfoo) run where libsql ships no prebuilt binding
Readme
@gtbuchanan/libsql-termux-shim
Stand-in for libsql's native
binding on Termux/Android, implemented on Node's built-in node:sqlite.
Why
libsql publishes prebuilt bindings for darwin, linux, and win32 only — there is
no @libsql/android-arm64. Anything that reaches libsql therefore fails at
startup on Termux. promptfoo is the motivating case: its
results database is mandatory and reached through drizzle, so it dies before
running a single eval.
Database migration failed: Cannot find module '@libsql/android-arm64'Node 24 ships SQLite in core, so the binding can be reimplemented in JavaScript rather than cross-compiled.
Install
Alias the missing target, gated to Android so every other platform keeps the real binding:
{
"optionalDependencies": {
"@libsql/android-arm64": "npm:@gtbuchanan/libsql-termux-shim@^0.1.0"
}
}Node resolves require("@libsql/android-arm64") from inside libsql by walking up
to the project's node_modules, so a root-level install is found even under
pnpm's isolated layout.
libsql itself declares an os allowlist that omits android, which makes pnpm
refuse to resolve the workspace before it ever reaches this shim. Ungate it in
.pnpmfile.cjs — in both hooks, since readPackage ungates resolution while
afterAllResolved drops the field pnpm records in the lockfile and rechecks on
every later install:
const readPackage = (pkg) => {
if (pkg.name !== 'libsql') return pkg;
const { os, ...ungated } = pkg;
return ungated;
};
/* Lockfile keys are `<name>@<version>`, and the name may itself be scoped. */
const nameOf = (id) => id.slice(0, id.lastIndexOf('@'));
const afterAllResolved = (lockFile) => {
for (const [id, pkg] of Object.entries(lockFile.packages ?? {})) {
if (nameOf(id) === 'libsql') delete pkg.os;
}
return lockFile;
};
module.exports = { hooks: { afterAllResolved, readPackage } };Scope
Local databases only. Embedded replicas (syncUrl, sync(), syncUntil()) need
libsql's replication protocol, which has no node:sqlite equivalent, so those
entry points throw rather than silently misbehave.
Extension loading throws for a different reason: libsql permits it per call, but
node:sqlite decides it when the connection is constructed. Opting every database
in to allowExtension so an unused call could work would trade real capability
for hypothetical fidelity, so the shim reports the gap instead.
How it works
libsql's JS wrapper calls each native function as fn.call(handle, ...), where
the handle is whatever databaseOpen or databasePrepareSync returned. Both
sides belong to the binding, so plain objects serve as handles.
node:sqlite's DatabaseSync/StatementSync are synchronous like libsql's
*Sync natives, and run() already returns libsql's
{ changes, lastInsertRowid } shape, so most of the mapping is direct.
Three places where it isn't, all load-bearing:
raw(true)must throw for a statement returning no columns.@libsql/clientcalls it inside atry/catchpurely to detect whether a statement yields rows, routingBEGIN/COMMIT/INSERTtorun()when it throws. Succeeding sendsBEGINdown the query path, and the client then fails the batch withTRANSACTION_CLOSED.- Transaction state is tracked from the SQL.
node:sqliteexposes no autocommit flag. Both entry points matter: libsql's owntransaction()issuesBEGINthroughexec(), while@libsql/clientissues it throughprepare().run(). - Rows are rebuilt as plain objects.
node:sqlitereturns null-prototype rows where the real binding returns ordinary ones, a difference that otherwise leaks to anything inspecting a row's prototype.
