@bunny.net/database-client is a small SQL client for Bunny Database. It has no dependencies, and fetch is the only runtime API it touches, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node.
Bunny Database is currently in Public
Preview. Features and APIs
may evolve during this period.
Install
Quickstart
connect() reads BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN from the environment. Edge Scripting already sets both, bunny db quickstart --lang typescript prints them for you, and bunny db create --token --save-env writes them straight to .env:
packages/database-client/examples.
API
connect(config?)
Returns a Database. Every option is optional.
The client rewrites a
libsql:// URL to https://. It rejects credentials in the URL, both user:pass@ and an authToken query parameter, so pass authToken instead. Dropping a token silently would leave you debugging an unexplained 401.
Every request carries User-Agent: bunny-database-client. The client matches header names case-insensitively, so your own User-Agent replaces that default and only one of the two goes out.
Without timeout a request waits as long as the runtime allows, which on an edge function means a hung fetch can burn the whole invocation. timeout and signal compose: whichever fires first aborts the request.
db.prepare(sql)
Returns a Statement. Statements are immutable, so you can keep one around and bind it as often as you like.
db.sql`...`
A template literal that binds every interpolated value, so the shortest way to write a query is also the parameterized one:
${...} becomes a ? placeholder, and the client binds the value without ever splicing it into the SQL string. It returns a Statement, so everything under Executing applies unchanged.
Values follow the same rules as bind(), with one exception: an interpolated object throws. Inside a template it is nearly always a mistake, so named parameters go through bind().
Pass a row type the same way as prepare(), as db.sql<User>`...` .
SQLite parameterizes values and nothing else, so a table or column name that has to vary belongs in the SQL string you build with prepare().
statement.bind(...values)
Binds parameters and returns a new statement. Accepts null, boolean, number, bigint, string, and Uint8Array.
Pass values in order for ? placeholders:
:name, @name, and $name:
{ id: 1 } and { ":id": 1 } both bind :id. One statement uses one style, and mixing positional values with an object in the same bind() call throws. The client will not pick a winner for you.
undefined throws. A mistyped property such as bind(user.nmae) surfaces at the call site, and nothing writes NULL on your behalf. Pass null when you mean NULL.
Any other value throws, because SQLite has nowhere to put it. Date gets its own message pointing at .toISOString() and .getTime(). The client will not choose for you, since each one puts something different in the column.
The client sends an integer number as INTEGER for as long as it fits exactly, up to 2^53. Past that every double is a whole number, so the client sends it as REAL and SQLite stores the value as is. Pass a bigint when you need an exact integer that large. Bigints must fit SQLite’s signed 64-bit range.
Executing
Four ways to run a statement:run() returns rows plus write metadata:
runRaw() returns the same metadata with rows as positional arrays. Reach for it when a result may contain two columns of the same name, since object rows keep only the last one:
prepare() and bind() are safe to pass around.
db.batch(statements, options?)
Runs every statement in one transaction and one round trip. All of them commit or none do.
Result per statement you passed, in order. batchRaw() does the same with positional rows. If any statement fails the transaction rolls back and batch() throws that statement’s error, with error.batchIndex set to the position of the statement that failed.
The batch is the transaction, so the client rejects a statement of your own that starts with BEGIN, COMMIT, END, or ROLLBACK before anything reaches the server. Savepoints are fine.
{ mode: "immediate" } opens the transaction with BEGIN IMMEDIATE, which takes the write lock up front. SQLite’s default, deferred, takes it at the first write instead, so a batch that reads and then writes can fail with SQLITE_BUSY if another writer got in between. Use immediate for batches you know will write. exclusive is also accepted.
batch() infers each result’s row type from its statement, so a prepare<User>(...) statement comes back as Result<User> even next to untyped ones. See Types.
{ foreignKeys: false } brackets the transaction with PRAGMA foreign_keys=off and =on. Schema changes need it: SQLite’s table rebuild procedure and several ALTER TABLE forms require enforcement genuinely off, and deferring it to commit is not enough. bunny db migrations apply runs this way.
REFERENCES users to follow the rename, so those references end up pointing at the table you are about to drop.
db.exec(sql)
Runs a multi-statement script. It takes no parameters and returns no rows, so it is mostly for setting up a schema.
bunny db migrations).
Errors
Everything throwsDatabaseError:
Failures that happen before any SQL runs are wrapped too, so a single
catch (error) { if (error instanceof DatabaseError) ... } covers the whole surface. A stray TypeError never reaches your handler:
For these three,
error.cause holds the runtime’s original error.
Types
Integers come back asnumber for as long as they fit exactly, and as bigint beyond Number.MAX_SAFE_INTEGER. The client never rounds a value to make it fit.
SQLite has no boolean type.
bind(true) stores 1, and reads back as 1.
Rows are typed as Record<string, SqlValue> by default. Pass your own shape to skip the cast:
all<Row>() on a Statement<User> gives you rows back untyped.
That type is an assertion. Nothing validates the rows against it at runtime, so it is only ever as accurate as your SQL.
Edge Scripting
Edge Scripting runs Deno, so you can import straight from npm. A standalone script serves requests through the Edge Scripting SDK:connect() opens no socket and does no I/O, so there is nothing to warm up or tear down per request.
An Edge Script is also the right place to hold a database token. The code and its environment stay on bunny.net’s edge, and the browser only ever sees the response you chose to return.
See Edge Scripting for step-by-step instructions on connecting your script to Bunny Database.
Security
Why not the browser
The client only usesfetch, so it would happily run in a browser. It still should not go there.
A database auth token authorizes the connection, so anything holding it can run whatever SQL that token allows against any table. In client-side code the token shows up in the network tab, in the JS bundle, in localStorage, and to any injected script. Once it leaks, a visitor can do everything you can, DROP TABLE included.
bunny db tokens create defaults to full access with no expiry, so the token you are most likely to have lying around is the worst one to lose.
Read-only tokens narrow the damage without fixing it:
What to do instead
Keep the token on your server and expose only the queries you want to allow. Usually that means an Edge Script sitting in front of the database:Handling tokens
- Keep tokens in environment variables and out of source.
connect()readsBUNNY_DATABASE_URLandBUNNY_DATABASE_AUTH_TOKEN, so a token never has to appear in code at all. - The client rejects credentials in the connection URL, because URLs end up in logs, referrers, and error reports. Pass
authTokeninstead. - Prefer short-lived tokens (
bunny db tokens create --expiry 12h) and the narrowest authorization that works. If one does leak,bunny db tokens invalidaterevokes every token for the database. DatabaseErrorcarries the server’s message and SQLite code, so passing one straight back to a client can leak schema details. Log it and return something generic.
bunny db for the full command reference.