Skip to main content
@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.
Server-side only. Never ship this to a browser or any other untrusted client.An auth token grants access to the whole database, and this client sends raw SQL. Put either one in client-side code and every visitor can read and write every table, whatever your UI happens to offer them. Why not the browser has the details.

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:
Pass them explicitly when you need to:
Runnable examples for Edge Scripting, Bun, Node, Hono, Next.js, Astro, and SvelteKit live in 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.
Pass a row type to have it flow through every execution of that statement. See Types.

db.sql`...`

A template literal that binds every interpolated value, so the shortest way to write a query is also the parameterized one:
Each ${...} 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:
Pass a single object for SQLite’s named forms, :name, @name, and $name:
Names may carry the sigil or leave it off, so { 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:
Statements do nothing until one of these is called, so 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.
You get one 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.
Keep that order: build the replacement under a temporary name, drop the original, then rename. Renaming the original out of the way first looks equivalent and breaks your other tables. With foreign keys off, SQLite rewrites every 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.
Anything you need to replay on another database or another machine belongs in migrations (bunny db migrations).

Errors

Everything throws DatabaseError:
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 as number 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:
Or type the statement once and let every execution of it inherit the shape:
A type argument on the executor still wins over the statement’s, so 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:
Building the client at module scope is fine. 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 uses fetch, 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:
That still hands over every row of every table, because the token authorizes the connection and not the query, and SQLite has no row-level security to fall back on.

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:
The browser calls your endpoint, and your endpoint decides what SQL runs.

Handling tokens

  • Keep tokens in environment variables and out of source. connect() reads BUNNY_DATABASE_URL and BUNNY_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 authToken instead.
  • Prefer short-lived tokens (bunny db tokens create --expiry 12h) and the narrowest authorization that works. If one does leak, bunny db tokens invalidate revokes every token for the database.
  • DatabaseError carries 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.
See Authorization for how tokens are issued and scoped, and bunny db for the full command reference.
Last modified on September 7, 2026