From c238a154b05be86364bc666c9e45e4b3e28f8c73 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 13 Aug 2026 18:34:54 +0100 Subject: [PATCH 1/4] feat(database): add @bunny.net/database-client, a fetch-only SQL client --- .changeset/add-database-client.md | 5 + AGENTS.md | 26 +- README.md | 23 +- bun.lock | 28 +- .../cli/src/commands/sites/deploy.test.ts | 4 +- packages/database-client/README.md | 265 ++++++++++ packages/database-client/examples/smoke.ts | 187 +++++++ packages/database-client/package.json | 29 ++ packages/database-client/src/client.test.ts | 486 ++++++++++++++++++ packages/database-client/src/client.ts | 215 ++++++++ packages/database-client/src/env.ts | 22 + packages/database-client/src/errors.ts | 45 ++ packages/database-client/src/index.ts | 11 + packages/database-client/src/protocol.test.ts | 147 ++++++ packages/database-client/src/protocol.ts | 239 +++++++++ packages/database-client/tsconfig.build.json | 13 + packages/database-client/tsconfig.json | 4 + 17 files changed, 1723 insertions(+), 26 deletions(-) create mode 100644 .changeset/add-database-client.md create mode 100644 packages/database-client/README.md create mode 100644 packages/database-client/examples/smoke.ts create mode 100644 packages/database-client/package.json create mode 100644 packages/database-client/src/client.test.ts create mode 100644 packages/database-client/src/client.ts create mode 100644 packages/database-client/src/env.ts create mode 100644 packages/database-client/src/errors.ts create mode 100644 packages/database-client/src/index.ts create mode 100644 packages/database-client/src/protocol.test.ts create mode 100644 packages/database-client/src/protocol.ts create mode 100644 packages/database-client/tsconfig.build.json create mode 100644 packages/database-client/tsconfig.json diff --git a/.changeset/add-database-client.md b/.changeset/add-database-client.md new file mode 100644 index 00000000..561afc3e --- /dev/null +++ b/.changeset/add-database-client.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/database-client": minor +--- + +Add `@bunny.net/database-client`, a zero-dependency server-side SQL client for Bunny Database that runs on Edge Scripting, Bun, and Node. diff --git a/AGENTS.md b/AGENTS.md index 1a47702c..e985c79a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,10 +69,11 @@ Bun replaces the entire Node.js toolchain. There are no separate tools for trans ## Project Structure -This is a Bun workspace monorepo with six packages: +This is a Bun workspace monorepo with seven packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. - **`@bunny.net/config`** (`packages/config/`) — Shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools. +- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. D1-shaped surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. - **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies. @@ -137,6 +138,21 @@ bunny-cli/ │ │ ├── index.d.ts # Ambient globals: ARecord/AaaaRecord/CnameRecord/TxtRecord/PullZoneRecord/Server, Monitoring/GeoDatabase/GeoDistance/RoutingEngine, DnsRequest/DnsQuery/GeoLocation │ │ └── README.md │ │ +│ ├── database-client/ # @bunny.net/database-client package (SQL client for app code) +│ │ ├── package.json # exports/main/types point at dist/ for npm consumers +│ │ ├── tsconfig.json +│ │ ├── tsconfig.build.json # Emits dist/ (JS + .d.ts); paths:{} so nothing resolves from source +│ │ ├── examples/ +│ │ │ └── smoke.ts # Live end-to-end run on Bun and Deno; creates and drops its own tables +│ │ └── src/ +│ │ ├── index.ts # Barrel export: connect, Database, Statement, DatabaseError, types +│ │ ├── client.ts # Database + Statement; batch() wraps steps in BEGIN/COMMIT/ROLLBACK +│ │ ├── protocol.ts # Hrana wire types, value codecs, normalizeUrl(), /v2/pipeline transport +│ │ ├── errors.ts # DatabaseError (code + status) +│ │ ├── env.ts # BUNNY_DATABASE_URL / _AUTH_TOKEN names, cross-runtime readEnv() +│ │ ├── client.test.ts # Client behaviour against a fake fetch (no network) +│ │ └── protocol.test.ts # URL normalization and value codec tests +│ │ │ ├── database-shell/ # @bunny.net/database-shell package │ │ ├── package.json # bin: { "bsql": "./src/cli.ts" } │ │ ├── tsconfig.json @@ -475,7 +491,7 @@ bunny-cli/ ### Conventions -- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. +- **Monorepo with Bun workspaces.** `packages/openapi-client/` is the standalone API client SDK; `packages/config/` provides shared Zod schemas, types, and API conversion functions for `bunny.jsonc`; `packages/database-client/` is the standalone SQL client for application code; `packages/database-shell/` is the standalone SQL shell engine; `packages/sandbox/` is the standalone sandbox SDK (provisioning + SSH transport); `packages/cli/` is the CLI. - **API clients use `ClientOptions`** — an options object with `apiKey`, `baseUrl`, `verbose`, `userAgent`, and `onDebug`. The CLI provides a `clientOptions(config, verbose)` helper to build this from `ResolvedConfig`. - **One command per file.** Each file in `commands/` exports a single command or namespace. - **Commands are grouped by domain** in subdirectories (`config/`, `db/`, `scripts/`). @@ -937,6 +953,12 @@ Two differences from openapi-client: - Sandbox depends on `@bunny.net/openapi-client` with `workspace:*`, so the `publish-sandbox` job in `release.yml` uses `bun publish` (not `npm publish`) — bun rewrites `workspace:*` to the local package version in the published tarball; npm would ship the unresolvable `workspace:*` spec verbatim. `bun publish` authenticates via the `NPM_CONFIG_TOKEN` env var. - Its `tsconfig.build.json` overrides `paths` to `{}` so openapi-client resolves via its package `exports` (`dist/`) instead of source — otherwise openapi-client's sources would enter the program and violate `rootDir`. The publish job therefore builds openapi-client before building sandbox. +### Publishing `@bunny.net/database-client` + +`@bunny.net/database-client` follows the same compiled-library pattern as `@bunny.net/sandbox`, and is the simplest case of it: zero dependencies, so `npm publish` works (there is no `workspace:*` spec for bun to rewrite), and no declaration transformer, so emitted `.d.ts` keep their `.ts` import specifiers for TypeScript to resolve against the sibling `.d.ts`. Its `tsconfig.build.json` sets `include: ["src"]` so `examples/` stays out of the program and does not violate `rootDir`. + +Nothing in the repo imports it: the CLI talks to databases through `@bunny.net/database-shell`, and this package exists for user application code. It therefore has no root `tsconfig.json` `paths` entry, and its tests run against its own source directly. + `@bunny.net/config` is a private workspace package (not published); the CLI consumes it from source via the workspace symlink. ### CI diff --git a/README.md b/README.md index 07b94c86..66f18c68 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,18 @@ Monorepo for the [bunny.net](https://bunny.net) CLI and supporting packages. ## Packages -| Package | Name | Description | -| ------------------------------------------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------- | -| [`packages/cli/`](packages/cli/) | `@bunny.net/cli` | Command-line interface for bunny.net | -| [`packages/openapi-client/`](packages/openapi-client/) | `@bunny.net/openapi-client` | Standalone, type-safe OpenAPI client for bunny.net | -| [`packages/sandbox/`](packages/sandbox/) | `@bunny.net/sandbox` | Standalone sandbox SDK over Magic Containers and SSH | -| [`packages/config/`](packages/config/) | `@bunny.net/config` | Shared Zod schemas, types, and JSON Schema for `bunny.jsonc` (app + sites) | -| [`packages/database-shell/`](packages/database-shell/) | `@bunny.net/database-shell` | Standalone interactive SQL shell for libSQL databases | -| [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | -| [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | -| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | -| [`packages/scriptable-dns-types/`](packages/scriptable-dns-types/) | `@bunny.net/scriptable-dns-types` | Ambient TypeScript types for the Scriptable DNS runtime | +| Package | Name | Description | +| ------------------------------------------------------------------------ | ------------------------------------ | ----------------------------------------------------------------------------------- | +| [`packages/cli/`](packages/cli/) | `@bunny.net/cli` | Command-line interface for bunny.net | +| [`packages/openapi-client/`](packages/openapi-client/) | `@bunny.net/openapi-client` | Standalone, type-safe OpenAPI client for bunny.net | +| [`packages/sandbox/`](packages/sandbox/) | `@bunny.net/sandbox` | Standalone sandbox SDK over Magic Containers and SSH | +| [`packages/config/`](packages/config/) | `@bunny.net/config` | Shared Zod schemas, types, and JSON Schema for `bunny.jsonc` (app + sites) | +| [`packages/database-client/`](packages/database-client/) | `@bunny.net/database-client` | Standalone `fetch`-only SQL client for server-side code (Edge Scripting, Bun, Node) | +| [`packages/database-shell/`](packages/database-shell/) | `@bunny.net/database-shell` | Standalone interactive SQL shell for libSQL databases | +| [`packages/database-openapi/`](packages/database-openapi/) | `@bunny.net/database-openapi` | Generate OpenAPI 3.0 specs from a database schema | +| [`packages/database-rest/`](packages/database-rest/) | `@bunny.net/database-rest` | PostgREST-like REST API handler (database-agnostic) | +| [`packages/database-adapter-libsql/`](packages/database-adapter-libsql/) | `@bunny.net/database-adapter-libsql` | Bunny Database adapter for database-rest | +| [`packages/scriptable-dns-types/`](packages/scriptable-dns-types/) | `@bunny.net/scriptable-dns-types` | Ambient TypeScript types for the Scriptable DNS runtime | See each package's README for usage and API documentation. diff --git a/bun.lock b/bun.lock index f46f9e9d..ab8fecbf 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "bun-ny-cli", @@ -17,7 +16,7 @@ }, "packages/cli": { "name": "@bunny.net/cli", - "version": "0.9.1", + "version": "0.13.0", "bin": { "bunny": "./bin/bunny.cjs", }, @@ -50,27 +49,27 @@ }, "packages/cli-darwin-arm64": { "name": "@bunny.net/cli-darwin-arm64", - "version": "0.9.1", + "version": "0.13.0", }, "packages/cli-darwin-x64": { "name": "@bunny.net/cli-darwin-x64", - "version": "0.9.1", + "version": "0.13.0", }, "packages/cli-linux-arm64": { "name": "@bunny.net/cli-linux-arm64", - "version": "0.9.1", + "version": "0.13.0", }, "packages/cli-linux-x64": { "name": "@bunny.net/cli-linux-x64", - "version": "0.9.1", + "version": "0.13.0", }, "packages/cli-windows-x64": { "name": "@bunny.net/cli-windows-x64", - "version": "0.9.1", + "version": "0.13.0", }, "packages/config": { "name": "@bunny.net/config", - "version": "0.1.2", + "version": "0.1.4", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "zod": "^4.3.6", @@ -85,6 +84,13 @@ "@libsql/client": "^0.17.0", }, }, + "packages/database-client": { + "name": "@bunny.net/database-client", + "version": "0.0.0", + "devDependencies": { + "typescript": "^5", + }, + }, "packages/database-openapi": { "name": "@bunny.net/database-openapi", "version": "0.2.0", @@ -175,7 +181,7 @@ }, "packages/openapi-client": { "name": "@bunny.net/openapi-client", - "version": "0.1.2", + "version": "0.2.0", "dependencies": { "openapi-fetch": "^0.17.0", }, @@ -186,7 +192,7 @@ }, "packages/sandbox": { "name": "@bunny.net/sandbox", - "version": "0.3.0", + "version": "0.3.3", "dependencies": { "@bunny.net/openapi-client": "workspace:*", "@types/ssh2": "^1.15.0", @@ -276,6 +282,8 @@ "@bunny.net/database-adapter-libsql": ["@bunny.net/database-adapter-libsql@workspace:packages/database-adapter-libsql"], + "@bunny.net/database-client": ["@bunny.net/database-client@workspace:packages/database-client"], + "@bunny.net/database-openapi": ["@bunny.net/database-openapi@workspace:packages/database-openapi"], "@bunny.net/database-rest": ["@bunny.net/database-rest@workspace:packages/database-rest"], diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts index 20df8fa5..cfefd1d4 100644 --- a/packages/cli/src/commands/sites/deploy.test.ts +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -44,9 +44,7 @@ test("deployUrls holds the preview URL as pending until the wildcard certificate ); const pending = deployUrls(site, "abc123", { previewSecure: false }); expect(pending.preview).toBeUndefined(); - expect(pending.previewPending).toBe( - "https://dpl-abc123.preview.example.com", - ); + expect(pending.previewPending).toBe("https://dpl-abc123.preview.example.com"); // Zone unreadable: fall back to https rather than downgrading a working preview. expect(deployUrls(site, "abc123", {}).preview).toBe( "https://dpl-abc123.preview.example.com", diff --git a/packages/database-client/README.md b/packages/database-client/README.md new file mode 100644 index 00000000..7e74952d --- /dev/null +++ b/packages/database-client/README.md @@ -0,0 +1,265 @@ +# @bunny.net/database-client + +A small SQL client for [Bunny Database](https://bunny.net). It uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node. No dependencies. + +> [!WARNING] +> **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](#why-not-the-browser) has the details. + +## Install + +```bash +bun add @bunny.net/database-client +``` + +## Quick start + +`connect()` reads `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` from the environment, which is what `bunny db link` and Edge Scripting already set: + +```ts +import { connect } from "@bunny.net/database-client"; + +const db = connect(); + +const user = await db.prepare("SELECT * FROM users WHERE id = ?").bind(1).first(); +``` + +Pass them explicitly when you need to: + +```ts +const db = connect({ + url: "libsql://your-db.lite.bunnydb.net", + authToken: "your-token", +}); +``` + +## API + +### `connect(config?)` + +Returns a `Database`. Every option is optional. + +| Option | Type | Default | Description | +| ----------- | ------------------------ | --------------------------- | ----------------------------------------------------- | +| `url` | `string` | `BUNNY_DATABASE_URL` | `libsql://`, `https://`, or `http://` connection URL. | +| `authToken` | `string` | `BUNNY_DATABASE_AUTH_TOKEN` | Sent as `Authorization: Bearer `. | +| `fetch` | `typeof fetch` | global `fetch` | Override for testing, tracing, or a custom agent. | +| `headers` | `Record` | none | Extra headers on every request. | +| `signal` | `AbortSignal` | none | Applied to every request. | + +The client rewrites a `libsql://` URL to `https://`. It rejects credentials in the URL, so pass `authToken` instead. + +### `db.prepare(sql)` + +Returns a `Statement`. Statements are immutable, so you can keep one around and bind it as often as you like. + +```ts +const byId = db.prepare("SELECT * FROM users WHERE id = ?"); + +const alice = await byId.bind(1).first(); +const bob = await byId.bind(2).first(); +``` + +### `statement.bind(...values)` + +Binds positional `?` parameters and returns a new statement. Accepts `null`, `boolean`, `number`, `bigint`, `string`, and `Uint8Array`. + +Anything else throws instead of being quietly converted, because SQLite has nowhere to put it. `Date` gets its own message suggesting `.toISOString()` or `.getTime()`, since guessing which one you meant would change what ends up in the column. + +### Executing + +Four ways to run a statement: + +```ts +const rows = await db.prepare("SELECT id, name FROM users").all(); +// [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] + +const row = await db.prepare("SELECT * FROM users WHERE id = ?").bind(1).first(); +// { id: 1, name: "Alice" } or null + +const name = await db.prepare("SELECT name FROM users WHERE id = ?").bind(1).first("name"); +// "Alice" or null + +const rows = await db.prepare("SELECT id, name FROM users").raw(); +// [[1, "Alice"], [2, "Bob"]] +``` + +`run()` returns rows plus write metadata: + +```ts +const result = await db + .prepare("INSERT INTO users (name) VALUES (?) RETURNING id") + .bind("Carol") + .run(); + +// { +// rows: [{ id: 3 }], +// columns: ["id"], +// rowsAffected: 1, +// lastInsertRowid: 3, +// } +``` + +Statements do nothing until one of these is called, so `prepare()` and `bind()` are safe to pass around. + +### `db.batch(statements)` + +Runs every statement in one transaction and one round trip. All of them commit or none do. + +```ts +const [inserted, count] = await db.batch([ + db.prepare("INSERT INTO users (name) VALUES (?)").bind("Dan"), + db.prepare("SELECT COUNT(*) AS c FROM users"), +]); +``` + +You get one `Result` per statement you passed, in order. If any statement fails the transaction rolls back and `batch()` throws that statement's error. + +### `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. + +```ts +await db.exec(` + CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS users_name ON users (name); +`); +``` + +For anything you need to run more than once, reach for migrations (`bunny db migrations`) instead. + +### Errors + +Everything throws `DatabaseError`: + +```ts +import { DatabaseError } from "@bunny.net/database-client"; + +try { + await db.prepare("INSERT INTO users (email) VALUES (?)").bind("dupe@example.com").run(); +} catch (error) { + if (error instanceof DatabaseError && error.code === "SQLITE_CONSTRAINT") { + // handle the duplicate + } +} +``` + +| Property | Description | +| --------- | ------------------------------------------------------------------------------------ | +| `message` | The server's message, or a description of the local validation failure. | +| `code` | SQLite code (`SQLITE_CONSTRAINT`), or a client code (`UNAUTHORIZED`, `URL_MISSING`). | +| `status` | HTTP status, when the failure came from the transport rather than from SQL. | + +## Types + +Integers come back as `number` while they fit exactly, and as `bigint` beyond `Number.MAX_SAFE_INTEGER`. The client never rounds a value to make it fit. + +| SQLite | JavaScript | +| ------- | ------------------------------- | +| NULL | `null` | +| INTEGER | `number`, or `bigint` past 2^53 | +| REAL | `number` | +| TEXT | `string` | +| BLOB | `Uint8Array` | + +SQLite has no boolean type. `bind(true)` stores `1`, and reads back as `1`. + +Rows are typed as `Record` by default. Pass your own shape to skip the cast: + +```ts +interface User { + id: number; + name: string; +} + +const users = await db.prepare("SELECT id, name FROM users").all(); +``` + +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: + +```ts +import * as BunnySDK from "https://esm.sh/@bunny.net/edgescript-sdk@0.12.0"; +import { connect } from "npm:@bunny.net/database-client"; + +const db = connect(); + +BunnySDK.net.http.serve(async (request: Request): Promise => { + const users = await db.prepare("SELECT id, name FROM users LIMIT 10").all(); + return Response.json(users); +}); +``` + +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's edge, and the browser only ever sees the response you chose to return. + +## 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, which means 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. + +Worth knowing: `bunny db tokens create` defaults to full access with no expiry, so the token you are most likely to have lying around is also the worst one to lose. + +Read-only tokens narrow the damage without fixing it: + +```bash +bunny db tokens create --read-only --expiry 30d +``` + +That still hands over every row of every table, because the token authorizes the connection rather than 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 actually want to allow. Usually that means an Edge Script sitting in front of the database: + +```ts +// Edge Script: the token stays here, the browser gets only this shape. +import * as BunnySDK from "https://esm.sh/@bunny.net/edgescript-sdk@0.12.0"; +import { connect } from "npm:@bunny.net/database-client"; + +const db = connect(); + +BunnySDK.net.http.serve(async (request: Request): Promise => { + const url = new URL(request.url); + const author = url.searchParams.get("author"); + if (!author) return new Response("author required", { status: 400 }); + + // Parameterized, and scoped to the columns and rows the caller may see. + const posts = await db + .prepare("SELECT id, title FROM posts WHERE author = ? AND published = 1 LIMIT 50") + .bind(author) + .all(); + + return Response.json(posts); +}); +``` + +The browser calls your endpoint, and your endpoint decides what SQL runs. If you would rather not hand-write endpoints + +### Handling tokens + +- Keep tokens in environment variables rather than in source. `connect()` reads `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN`, so a token never has to appear in code at all. +- Credentials in the connection URL are rejected, 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. + +## Development + +```bash +bun test +``` + +`examples/smoke.ts` runs the client against a real database on both runtimes. It creates and drops its own tables, and leaves nothing behind: + +```bash +bun run examples/smoke.ts +deno run --allow-net --allow-env examples/smoke.ts +``` diff --git a/packages/database-client/examples/smoke.ts b/packages/database-client/examples/smoke.ts new file mode 100644 index 00000000..afed2715 --- /dev/null +++ b/packages/database-client/examples/smoke.ts @@ -0,0 +1,187 @@ +/** + * Exercises the client against a live database on both Bun and Deno. It creates and drops its own tables. + * + * Needs BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN in the environment: + * bun run examples/smoke.ts + * deno run --allow-net --allow-env examples/smoke.ts + */ +import { connect, DatabaseError, ENV_DATABASE_URL } from "../src/index.ts"; + +// No arguments: url and token come from the environment. +const db = connect(); + +const url = process.env[ENV_DATABASE_URL] as string; +console.log(`endpoint: ${new URL(url).host}`); + +function check(label: string, ok: boolean, detail?: unknown) { + console.log( + `${ok ? "PASS" : "FAIL"} ${label}${detail === undefined ? "" : ` ${JSON.stringify(detail, (_k, v) => (typeof v === "bigint" ? `${v}n` : v))}`}`, + ); +} + +// 1. plain select with binding +const one = await db.prepare("SELECT ? AS a, ? AS b").bind(1, "two").first(); +check("bind + first", one?.a === 1 && one?.b === "two", one); + +// 2. first(column) +const col = await db.prepare("SELECT 42 AS answer").first("answer"); +check("first(column)", col === 42, col); + +// 3. raw +const raw = await db.prepare("SELECT 1, 'x'").raw(); +check("raw", Array.isArray(raw[0]) && raw[0]?.[1] === "x", raw); + +// 4. run returns rows plus metadata +const direct = await db.prepare("SELECT 7 AS n").run(); +check("run returns rows and columns", direct.rows[0]?.n === 7, direct.columns); + +// 5. int64 precision beyond 2^53 +const big = await db + .prepare("SELECT 9007199254740993 AS big, 5 AS small") + .first(); +check( + "int64 widens to bigint, small stays number", + typeof big?.big === "bigint" && typeof big?.small === "number", + big, +); + +// 6. blob round trip +const blob = await db + .prepare("SELECT ? AS b") + .bind(new Uint8Array([1, 2, 255])) + .first(); +const bytes = blob?.b as Uint8Array; +check("blob round trip", bytes instanceof Uint8Array && bytes[2] === 255, [ + ...bytes, +]); + +// 7. null + float +const mixed = await db + .prepare("SELECT NULL AS n, 1.5 AS f, ? AS bool") + .bind(true) + .first(); +check( + "null / float / boolean", + mixed?.n === null && mixed?.f === 1.5 && mixed?.bool === 1, + mixed, +); + +// 8. batch is atomic and returns per-statement results +const batch = await db.batch([ + db.prepare("CREATE TABLE __probe (id INTEGER PRIMARY KEY, name TEXT)"), + db.prepare("INSERT INTO __probe (name) VALUES (?)").bind("alice"), + db + .prepare("INSERT INTO __probe (name) VALUES (?) RETURNING id, name") + .bind("bob"), + db.prepare("SELECT COUNT(*) AS c FROM __probe"), + db.prepare("DROP TABLE __probe"), +]); +check("batch results align with statements", batch.length === 5, { + insertRowid: batch[1]?.lastInsertRowid, + rowsAffected: batch[1]?.rowsAffected, + returning: batch[2]?.rows, + count: batch[3]?.rows, +}); + +// 9. batch rolls back on failure +try { + await db.batch([ + db.prepare("CREATE TABLE __probe2 (id INTEGER PRIMARY KEY)"), + db.prepare("INSERT INTO __probe2 (id) VALUES (1)"), + db.prepare("INSERT INTO __probe2 (id) VALUES (1)"), + ]); + check("batch rollback", false, "expected a constraint error"); +} catch (error) { + const code = error instanceof DatabaseError ? error.code : undefined; + const survived = await db + .prepare("SELECT name FROM sqlite_master WHERE name = '__probe2'") + .all(); + check("batch rolls back on failure", survived.length === 0, { + code, + message: (error as Error).message, + }); +} + +// 10. exec runs a multi-statement script +await db.exec( + "CREATE TABLE __probe3 (id INTEGER); INSERT INTO __probe3 VALUES (1),(2); DROP TABLE __probe3;", +); +check("exec multi-statement script", true); + +// 11. SQL errors surface with a code +try { + await db.prepare("SELECT * FROM nope_does_not_exist").all(); + check("sql error", false); +} catch (error) { + const e = error as DatabaseError; + check("sql error carries code", e instanceof DatabaseError && !!e.code, { + code: e.code, + message: e.message, + }); +} + +// 12. bad auth surfaces as UNAUTHORIZED +try { + await connect({ url, authToken: "not-a-real-token" }) + .prepare("SELECT 1") + .all(); + check("auth error", false); +} catch (error) { + const e = error as DatabaseError; + check("bad token -> UNAUTHORIZED", e.code === "UNAUTHORIZED", { + status: e.status, + message: e.message, + }); +} + +// 13. server rejects TEMP tables outright +try { + await db.exec("CREATE TEMP TABLE __t (id INTEGER)"); + check( + "CREATE TEMP TABLE rejected by server", + false, + "expected SQL_PARSE_ERROR", + ); +} catch (error) { + const e = error as DatabaseError; + check( + "CREATE TEMP TABLE rejected by server", + e.code === "SQL_PARSE_ERROR", + e.message, + ); +} + +// 14. statelessness: an uncommitted transaction is discarded when the request ends +await db.exec("BEGIN; CREATE TABLE __probe4 (id INTEGER);"); +const leaked = await db + .prepare("SELECT name FROM sqlite_master WHERE name = ?") + .bind("__probe4") + .all(); +check( + "uncommitted work does not leak across requests", + leaked.length === 0, + leaked, +); + +// 15. rejects a URL carrying credentials +try { + connect({ url: "libsql://user:pass@db.lite.bunnydb.net" }); + check("reject credentials in URL", false); +} catch (error) { + check( + "reject credentials in URL", + (error as DatabaseError).code === "URL_INVALID", + ); +} + +// 16. rejects unbindable values +try { + db.prepare("SELECT ?").bind(new Date()); + check("reject Date bind", false); +} catch (error) { + check( + "reject Date bind with guidance", + (error as DatabaseError).code === "ARGUMENT_INVALID", + (error as Error).message, + ); +} diff --git a/packages/database-client/package.json b/packages/database-client/package.json new file mode 100644 index 00000000..2e650a20 --- /dev/null +++ b/packages/database-client/package.json @@ -0,0 +1,29 @@ +{ + "name": "@bunny.net/database-client", + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "test": "bun test", + "typecheck": "bun run tsc --noEmit" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "devDependencies": { + "typescript": "^5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts new file mode 100644 index 00000000..9423723b --- /dev/null +++ b/packages/database-client/src/client.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, test } from "bun:test"; +import { connect } from "./client.ts"; +import { ENV_DATABASE_AUTH_TOKEN, ENV_DATABASE_URL } from "./env.ts"; +import { DatabaseError } from "./errors.ts"; + +const URL_ = "libsql://db.lite.bunnydb.net"; + +interface Capture { + url: string; + headers: Record; + body: { + baton: string | null; + requests: { + type: string; + stmt?: { sql: string; args: unknown[]; want_rows: boolean }; + batch?: { steps: { stmt: { sql: string }; condition?: unknown }[] }; + sql?: string; + }[]; + }; +} + +/** A fetch stand-in that records the request and replays canned pipeline results. */ +function fakeFetch(results: unknown[], captures: Capture[] = []) { + const impl = (async (input: string, init?: RequestInit) => { + captures.push({ + url: String(input), + headers: (init?.headers ?? {}) as Record, + body: JSON.parse(String(init?.body)), + }); + return new Response( + JSON.stringify({ baton: null, base_url: null, results }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }) as unknown as typeof fetch; + return { fetch: impl, captures }; +} + +function okExecute( + cols: string[], + rows: unknown[][], + extra: { + affected_row_count?: number; + last_insert_rowid?: string | null; + } = {}, +) { + return { + type: "ok", + response: { + type: "execute", + result: { + cols: cols.map((name) => ({ name, decltype: null })), + rows: rows.map((row) => + row.map((value) => + value === null + ? { type: "null" } + : typeof value === "number" + ? { type: "integer", value: String(value) } + : { type: "text", value: String(value) }, + ), + ), + affected_row_count: extra.affected_row_count ?? 0, + last_insert_rowid: extra.last_insert_rowid ?? null, + }, + }, + }; +} + +describe("transport", () => { + test("posts one self-contained pipeline to /v2/pipeline", async () => { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + const db = connect({ url: URL_, authToken: "tok", fetch: fake.fetch }); + + await db.prepare("SELECT 1 AS a").all(); + + const capture = fake.captures[0] as Capture; + expect(capture.url).toBe("https://db.lite.bunnydb.net/v2/pipeline"); + expect(capture.headers.authorization).toBe("Bearer tok"); + expect(capture.headers["content-type"]).toBe("application/json"); + expect(capture.body.baton).toBeNull(); + }); + + test("closes the server-side session in the same request", async () => { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + await db.prepare("SELECT 1 AS a").all(); + + const types = (fake.captures[0] as Capture).body.requests.map( + (r) => r.type, + ); + expect(types).toEqual(["execute", "close"]); + }); + + test("omits the auth header when no token is configured", async () => { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT 1 AS a") + .all(); + expect((fake.captures[0] as Capture).headers.authorization).toBeUndefined(); + }); + + test("caller headers ride along", async () => { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + const db = connect({ + url: URL_, + fetch: fake.fetch, + headers: { "x-trace": "abc" }, + }); + await db.prepare("SELECT 1 AS a").all(); + expect((fake.captures[0] as Capture).headers["x-trace"]).toBe("abc"); + }); +}); + +describe("statement", () => { + test("binds arguments positionally in wire form", async () => { + const fake = fakeFetch([okExecute(["id"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + await db + .prepare("SELECT * FROM t WHERE a = ? AND b = ?") + .bind(1, "x") + .all(); + + const stmt = (fake.captures[0] as Capture).body.requests[0]?.stmt; + expect(stmt?.args).toEqual([ + { type: "integer", value: "1" }, + { type: "text", value: "x" }, + ]); + }); + + test("bind returns a new statement and leaves the original unbound", async () => { + const fake = fakeFetch([okExecute(["id"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + const base = db.prepare("SELECT ?"); + + const bound = base.bind(5); + + expect(bound).not.toBe(base); + expect(base.wire.args).toEqual([]); + expect(bound.wire.args).toEqual([{ type: "integer", value: "5" }]); + }); + + test("all returns rows as objects", async () => { + const fake = fakeFetch([ + okExecute( + ["id", "name"], + [ + [1, "a"], + [2, "b"], + ], + ), + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + expect(await db.prepare("SELECT id, name FROM t").all()).toEqual([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]); + }); + + test("first returns only the first row", async () => { + const fake = fakeFetch([okExecute(["id"], [[1], [2]])]); + const row = await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT id") + .first(); + expect(row).toEqual({ id: 1 }); + }); + + test("first is null when there are no rows", async () => { + const fake = fakeFetch([okExecute(["id"], [])]); + const row = await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT id") + .first(); + expect(row).toBeNull(); + }); + + test("first(column) pulls a single value out", async () => { + const fake = fakeFetch([okExecute(["c"], [[42]])]); + expect( + await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT c") + .first("c"), + ).toBe(42); + }); + + test("first(column) on an empty result is null, not an error", async () => { + const fake = fakeFetch([okExecute(["c"], [])]); + expect( + await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT c") + .first("c"), + ).toBeNull(); + }); + + test("first(column) names the available columns when the column is absent", async () => { + const fake = fakeFetch([okExecute(["a", "b"], [[1, 2]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + await expect(db.prepare("SELECT a, b").first("nope")).rejects.toThrow( + /got a, b/, + ); + }); + + test("raw returns positional arrays", async () => { + const fake = fakeFetch([okExecute(["id", "name"], [[1, "a"]])]); + expect( + await connect({ url: URL_, fetch: fake.fetch }).prepare("SELECT *").raw(), + ).toEqual([[1, "a"]]); + }); + + test("run exposes write metadata alongside rows", async () => { + const fake = fakeFetch([ + okExecute(["id"], [[9]], { + affected_row_count: 1, + last_insert_rowid: "9", + }), + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const result = await db + .prepare("INSERT INTO t VALUES (?) RETURNING id") + .bind("a") + .run(); + + expect(result).toEqual({ + rows: [{ id: 9 }], + columns: ["id"], + rowsAffected: 1, + lastInsertRowid: 9, + }); + }); + + test("a statement is inert until one of its execute methods is called", async () => { + const fake = fakeFetch([okExecute(["id"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + db.prepare("DELETE FROM users").bind(); + + expect(fake.captures).toHaveLength(0); + }); + + test("names unaliased columns rather than dropping them", async () => { + const fake = fakeFetch([ + { + type: "ok", + response: { + type: "execute", + result: { + cols: [{ name: null, decltype: null }], + rows: [[{ type: "integer", value: "1" }]], + affected_row_count: 0, + last_insert_rowid: null, + }, + }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + expect(await db.prepare("SELECT 1").all()).toEqual([{ column1: 1 }]); + }); +}); + +describe("batch", () => { + function okBatch(count: number) { + const step = { + cols: [], + rows: [], + affected_row_count: 1, + last_insert_rowid: null, + }; + return { + type: "ok", + response: { + type: "batch", + result: { + step_results: Array.from({ length: count + 3 }, () => step), + step_errors: Array.from({ length: count + 3 }, () => null), + }, + }, + }; + } + + test("wraps the statements in a transaction with a rollback fallback", async () => { + const fake = fakeFetch([okBatch(2)]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + await db.batch([ + db.prepare("INSERT INTO t VALUES (1)"), + db.prepare("INSERT INTO t VALUES (2)"), + ]); + + const steps = + (fake.captures[0] as Capture).body.requests[0]?.batch?.steps ?? []; + expect(steps.map((s) => s.stmt.sql)).toEqual([ + "BEGIN", + "INSERT INTO t VALUES (1)", + "INSERT INTO t VALUES (2)", + "COMMIT", + "ROLLBACK", + ]); + expect(steps[1]?.condition).toEqual({ type: "ok", step: 0 }); + expect(steps[3]?.condition).toEqual({ type: "ok", step: 2 }); + expect(steps[4]?.condition).toEqual({ + type: "not", + cond: { type: "ok", step: 3 }, + }); + }); + + test("returns one result per caller statement, not per wire step", async () => { + const fake = fakeFetch([okBatch(2)]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const results = await db.batch([ + db.prepare("INSERT INTO t VALUES (1)"), + db.prepare("SELECT 1"), + ]); + + expect(results).toHaveLength(2); + expect(results[0]?.rowsAffected).toBe(1); + }); + + test("an empty batch is a no-op that sends nothing", async () => { + const fake = fakeFetch([]); + const db = connect({ url: URL_, fetch: fake.fetch }); + expect(await db.batch([])).toEqual([]); + expect(fake.captures).toHaveLength(0); + }); + + test("surfaces the failing step's error", async () => { + const fake = fakeFetch([ + { + type: "ok", + response: { + type: "batch", + result: { + step_results: [null, null, null, null], + step_errors: [ + null, + { + message: "UNIQUE constraint failed", + code: "SQLITE_CONSTRAINT", + }, + null, + null, + ], + }, + }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const error = (await db + .batch([db.prepare("INSERT INTO t VALUES (1)")]) + .catch((e) => e)) as DatabaseError; + + expect(error).toBeInstanceOf(DatabaseError); + expect(error.code).toBe("SQLITE_CONSTRAINT"); + }); +}); + +describe("exec", () => { + test("sends the script as a single sequence request", async () => { + const fake = fakeFetch([{ type: "ok", response: { type: "sequence" } }]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + await db.exec("CREATE TABLE a (id INT); CREATE TABLE b (id INT);"); + + const request = (fake.captures[0] as Capture).body.requests[0]; + expect(request?.type).toBe("sequence"); + expect(request?.sql).toBe( + "CREATE TABLE a (id INT); CREATE TABLE b (id INT);", + ); + }); +}); + +describe("errors", () => { + test("a failed step throws with the server's code", async () => { + const fake = fakeFetch([ + { + type: "error", + error: { message: "no such table: t", code: "SQLITE_UNKNOWN" }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const error = (await db + .prepare("SELECT 1") + .all() + .catch((e) => e)) as DatabaseError; + + expect(error).toBeInstanceOf(DatabaseError); + expect(error.code).toBe("SQLITE_UNKNOWN"); + expect(error.message).toBe("no such table: t"); + }); + + test("a 401 becomes UNAUTHORIZED and points at the token", async () => { + const impl = (async () => + new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + })) as unknown as typeof fetch; + const db = connect({ url: URL_, fetch: impl }); + + const error = (await db + .prepare("SELECT 1") + .all() + .catch((e) => e)) as DatabaseError; + + expect(error.code).toBe("UNAUTHORIZED"); + expect(error.status).toBe(401); + expect(error.message).toContain(ENV_DATABASE_AUTH_TOKEN); + }); + + test("a non-JSON error body still yields a usable message", async () => { + const impl = (async () => + new Response("upstream is down", { + status: 502, + })) as unknown as typeof fetch; + const db = connect({ url: URL_, fetch: impl }); + + const error = (await db + .prepare("SELECT 1") + .all() + .catch((e) => e)) as DatabaseError; + + expect(error.status).toBe(502); + expect(error.message).toBe("upstream is down"); + }); +}); + +describe("connect", () => { + test("falls back to the environment for url and token", async () => { + const previousUrl = process.env[ENV_DATABASE_URL]; + const previousToken = process.env[ENV_DATABASE_AUTH_TOKEN]; + process.env[ENV_DATABASE_URL] = URL_; + process.env[ENV_DATABASE_AUTH_TOKEN] = "from-env"; + + try { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + await connect({ fetch: fake.fetch }).prepare("SELECT 1 AS a").all(); + + const capture = fake.captures[0] as Capture; + expect(capture.url).toBe("https://db.lite.bunnydb.net/v2/pipeline"); + expect(capture.headers.authorization).toBe("Bearer from-env"); + } finally { + if (previousUrl === undefined) delete process.env[ENV_DATABASE_URL]; + else process.env[ENV_DATABASE_URL] = previousUrl; + if (previousToken === undefined) + delete process.env[ENV_DATABASE_AUTH_TOKEN]; + else process.env[ENV_DATABASE_AUTH_TOKEN] = previousToken; + } + }); + + test("an explicit url wins over the environment", async () => { + const previousUrl = process.env[ENV_DATABASE_URL]; + process.env[ENV_DATABASE_URL] = "libsql://from-env.lite.bunnydb.net"; + + try { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + await connect({ + url: "libsql://explicit.lite.bunnydb.net", + fetch: fake.fetch, + }) + .prepare("SELECT 1 AS a") + .all(); + expect((fake.captures[0] as Capture).url).toBe( + "https://explicit.lite.bunnydb.net/v2/pipeline", + ); + } finally { + if (previousUrl === undefined) delete process.env[ENV_DATABASE_URL]; + else process.env[ENV_DATABASE_URL] = previousUrl; + } + }); + + test("names the environment variable when there is no url at all", () => { + const previousUrl = process.env[ENV_DATABASE_URL]; + delete process.env[ENV_DATABASE_URL]; + + try { + expect(() => connect()).toThrow(ENV_DATABASE_URL); + } finally { + if (previousUrl !== undefined) + process.env[ENV_DATABASE_URL] = previousUrl; + } + }); +}); diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts new file mode 100644 index 00000000..f3d36cce --- /dev/null +++ b/packages/database-client/src/client.ts @@ -0,0 +1,215 @@ +import { ENV_DATABASE_AUTH_TOKEN, ENV_DATABASE_URL, readEnv } from "./env.ts"; +import { DatabaseError } from "./errors.ts"; +import { + createTransport, + decodeValue, + encodeValue, + type SqlValue, + type Transport, + type TransportConfig, + unwrap, + type WireBatchResult, + type WireStmtResult, + type WireValue, +} from "./protocol.ts"; + +export type Row = Record; + +/** Full result of one statement. */ +export interface Result { + rows: T[]; + columns: string[]; + rowsAffected: number; + lastInsertRowid: number | bigint | null; +} + +export interface Config extends TransportConfig { + /** Abort signal applied to every request unless a per-call signal is given. */ + signal?: AbortSignal; +} + +interface StatementInternals { + sql: string; + args: WireValue[]; + transport: Transport; + signal?: AbortSignal; +} + +function toResult(wire: WireStmtResult): Result { + const columns = wire.cols.map( + (col, index) => col.name ?? `column${index + 1}`, + ); + const rows = wire.rows.map((row) => { + const out: Row = {}; + for (let i = 0; i < columns.length; i++) + out[columns[i] as string] = decodeValue(row[i] as WireValue); + return out as T; + }); + return { + rows, + columns, + rowsAffected: wire.affected_row_count, + lastInsertRowid: + wire.last_insert_rowid === null + ? null + : (decodeValue({ type: "integer", value: wire.last_insert_rowid }) as + | number + | bigint), + }; +} + +/** A SQL statement plus its bound arguments. Immutable and reusable. */ +export class Statement { + readonly #internals: StatementInternals; + + constructor(internals: StatementInternals) { + this.#internals = internals; + } + + /** Return a copy of this statement with `values` bound to its `?` placeholders. */ + bind(...values: unknown[]): Statement { + return new Statement({ ...this.#internals, args: values.map(encodeValue) }); + } + + /** Execute and return every row as an object. */ + async all(): Promise { + return (await this.run()).rows; + } + + /** Execute and return the first row, or the value of one column of it. */ + async first(): Promise; + async first(column: string): Promise; + async first(column?: string): Promise { + const result = await this.run(); + const row = result.rows[0]; + if (!row) return null; + if (column === undefined) return row as T; + if (!(column in row)) { + throw new DatabaseError( + `column "${column}" is not in the result; got ${result.columns.join(", ")}`, + "COLUMN_NOT_FOUND", + ); + } + return row[column] as SqlValue; + } + + /** Execute and return rows as positional arrays, skipping object construction. */ + async raw(): Promise { + const wire = await this.#execute(); + return wire.rows.map((row) => row.map(decodeValue)); + } + + /** Execute and return rows together with write metadata. */ + async run(): Promise> { + return toResult(await this.#execute()); + } + + /** @internal exposed so `batch()` can read the wire form. */ + get wire(): { sql: string; args: WireValue[]; want_rows: boolean } { + return { + sql: this.#internals.sql, + args: this.#internals.args, + want_rows: true, + }; + } + + async #execute(): Promise { + const { transport, signal } = this.#internals; + const results = await transport.send( + [{ type: "execute", stmt: this.wire }], + signal, + ); + return unwrap(results[0]); + } +} + +/** A connection to a bunny.net database. Stateless: each call is one HTTPS request. */ +export class Database { + readonly #transport: Transport; + readonly #signal?: AbortSignal; + + constructor(config: Config) { + if (!config.url) throw new DatabaseError("url is required", "URL_INVALID"); + this.#transport = createTransport(config); + this.#signal = config.signal; + } + + /** Create a statement from SQL. Bind arguments with `.bind()`. */ + prepare(sql: string): Statement { + return new Statement({ + sql, + args: [], + transport: this.#transport, + signal: this.#signal, + }); + } + + /** Run every statement in one transaction. All succeed or none are applied. */ + async batch(statements: Statement[]): Promise[]> { + if (statements.length === 0) return []; + + const control = (sql: string, condition?: unknown) => ({ + stmt: { sql, args: [], want_rows: false }, + ...(condition ? { condition } : {}), + }); + + const steps = [ + control("BEGIN"), + ...statements.map((statement, index) => ({ + stmt: statement.wire, + condition: { type: "ok", step: index }, + })), + control("COMMIT", { type: "ok", step: statements.length }), + control("ROLLBACK", { + type: "not", + cond: { type: "ok", step: statements.length + 1 }, + }), + ]; + + const results = await this.#transport.send( + [{ type: "batch", batch: { steps } }], + this.#signal, + ); + const batch = unwrap(results[0]); + + const failure = batch.step_errors.find((error) => error !== null); + if (failure) throw DatabaseError.fromWire(failure); + + return statements.map((_, index) => { + const step = batch.step_results[index + 1]; + if (!step) throw new DatabaseError("batch step returned no result"); + return toResult(step); + }); + } + + /** Run a multi-statement SQL script. No parameters, no rows returned. */ + async exec(sql: string): Promise { + const results = await this.#transport.send( + [{ type: "sequence", sql }], + this.#signal, + ); + unwrap(results[0]); + } +} + +/** + * Connect to a bunny.net database. + * + * `url` and `authToken` fall back to `BUNNY_DATABASE_URL` and + * `BUNNY_DATABASE_AUTH_TOKEN`, so `connect()` with no arguments is enough + * wherever the CLI or Edge Scripting has already put them in the environment. + */ +export function connect(config: Partial = {}): Database { + const url = config.url ?? readEnv(ENV_DATABASE_URL); + if (!url) { + throw new DatabaseError( + `no database URL: pass { url } or set ${ENV_DATABASE_URL}`, + "URL_MISSING", + ); + } + return new Database({ + ...config, + url, + authToken: config.authToken ?? readEnv(ENV_DATABASE_AUTH_TOKEN), + }); +} diff --git a/packages/database-client/src/env.ts b/packages/database-client/src/env.ts new file mode 100644 index 00000000..9d3c386b --- /dev/null +++ b/packages/database-client/src/env.ts @@ -0,0 +1,22 @@ +export const ENV_DATABASE_URL = "BUNNY_DATABASE_URL"; +export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN"; + +export function readEnv(name: string): string | undefined { + const deno = ( + globalThis as { Deno?: { env?: { get(key: string): string | undefined } } } + ).Deno; + if (deno?.env?.get) { + try { + const value = deno.env.get(name); + if (value) return value; + } catch { + // No env permission. + // Fall through to process.env, then to the caller's error. + } + } + + const proc = ( + globalThis as { process?: { env?: Record } } + ).process; + return proc?.env?.[name] || undefined; +} diff --git a/packages/database-client/src/errors.ts b/packages/database-client/src/errors.ts new file mode 100644 index 00000000..244c0ebf --- /dev/null +++ b/packages/database-client/src/errors.ts @@ -0,0 +1,45 @@ +import { ENV_DATABASE_AUTH_TOKEN } from "./env.ts"; + +export class DatabaseError extends Error { + override readonly name = "DatabaseError"; + + /** SQLite/hrana error code (e.g. `SQLITE_CONSTRAINT_UNIQUE`). */ + readonly code?: string; + + /** HTTP status when the failure came from the transport rather than SQL. */ + readonly status?: number; + + constructor(message: string, code?: string, status?: number) { + super(message); + this.code = code; + this.status = status; + } + + static fromWire( + error: { message: string; code?: string | null } | undefined, + ): DatabaseError { + return new DatabaseError( + error?.message ?? "unknown database error", + error?.code ?? undefined, + ); + } + + static fromHttp(status: number, body: string): DatabaseError { + let message = `database request failed with HTTP ${status}`; + try { + const parsed = JSON.parse(body) as { error?: string; message?: string }; + if (parsed.error || parsed.message) + message = String(parsed.error ?? parsed.message); + } catch { + if (body.trim()) message = body.trim().slice(0, 300); + } + if (status === 401 || status === 403) { + return new DatabaseError( + `${message} (check the auth token, or set ${ENV_DATABASE_AUTH_TOKEN})`, + "UNAUTHORIZED", + status, + ); + } + return new DatabaseError(message, undefined, status); + } +} diff --git a/packages/database-client/src/index.ts b/packages/database-client/src/index.ts new file mode 100644 index 00000000..9dcb258a --- /dev/null +++ b/packages/database-client/src/index.ts @@ -0,0 +1,11 @@ +export { + type Config, + connect, + Database, + type Result, + type Row, + Statement, +} from "./client.ts"; +export { ENV_DATABASE_AUTH_TOKEN, ENV_DATABASE_URL } from "./env.ts"; +export { DatabaseError } from "./errors.ts"; +export type { SqlValue } from "./protocol.ts"; diff --git a/packages/database-client/src/protocol.test.ts b/packages/database-client/src/protocol.test.ts new file mode 100644 index 00000000..b9a41a10 --- /dev/null +++ b/packages/database-client/src/protocol.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { DatabaseError } from "./errors.ts"; +import { decodeValue, encodeValue, normalizeUrl } from "./protocol.ts"; + +describe("normalizeUrl", () => { + test("maps libsql: to https:", () => { + expect(normalizeUrl("libsql://db.lite.bunnydb.net")).toBe( + "https://db.lite.bunnydb.net", + ); + }); + + test("strips the trailing slash the API returns", () => { + expect(normalizeUrl("libsql://db.lite.bunnydb.net/")).toBe( + "https://db.lite.bunnydb.net", + ); + }); + + test("maps websocket schemes onto their HTTP equivalents", () => { + expect(normalizeUrl("wss://db.lite.bunnydb.net")).toBe( + "https://db.lite.bunnydb.net", + ); + expect(normalizeUrl("ws://127.0.0.1:8080")).toBe("http://127.0.0.1:8080"); + }); + + test("passes https: and http: through", () => { + expect(normalizeUrl("https://db.lite.bunnydb.net")).toBe( + "https://db.lite.bunnydb.net", + ); + expect(normalizeUrl("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080"); + }); + + test("keeps a non-default port", () => { + expect(normalizeUrl("libsql://db.lite.bunnydb.net:8443")).toBe( + "https://db.lite.bunnydb.net:8443", + ); + }); + + test("honours the libsql tls=0 downgrade", () => { + expect(normalizeUrl("libsql://127.0.0.1:8080?tls=0")).toBe( + "http://127.0.0.1:8080", + ); + }); + + test("drops query and fragment", () => { + expect( + normalizeUrl("libsql://db.lite.bunnydb.net?authToken=leaked#frag"), + ).toBe("https://db.lite.bunnydb.net"); + }); + + test("rejects credentials embedded in the URL", () => { + expect(() => + normalizeUrl("libsql://user:pass@db.lite.bunnydb.net"), + ).toThrow(/must not contain credentials/); + }); + + test("rejects unknown schemes and non-URLs", () => { + expect(() => normalizeUrl("file:///tmp/local.db")).toThrow( + /unsupported URL scheme/, + ); + expect(() => normalizeUrl("db.lite.bunnydb.net")).toThrow( + /invalid database URL/, + ); + }); +}); + +describe("encodeValue", () => { + test("encodes SQLite's storage classes", () => { + expect(encodeValue(null)).toEqual({ type: "null" }); + expect(encodeValue(undefined)).toEqual({ type: "null" }); + expect(encodeValue("hi")).toEqual({ type: "text", value: "hi" }); + expect(encodeValue(7)).toEqual({ type: "integer", value: "7" }); + expect(encodeValue(1.5)).toEqual({ type: "float", value: 1.5 }); + expect(encodeValue(10n)).toEqual({ type: "integer", value: "10" }); + }); + + test("encodes booleans as SQLite's 0 and 1", () => { + expect(encodeValue(true)).toEqual({ type: "integer", value: "1" }); + expect(encodeValue(false)).toEqual({ type: "integer", value: "0" }); + }); + + test("encodes byte arrays as base64 blobs", () => { + expect(encodeValue(new Uint8Array([1, 2, 255]))).toEqual({ + type: "blob", + base64: "AQL/", + }); + }); + + test("rejects values SQLite cannot store", () => { + expect(() => encodeValue(Number.NaN)).toThrow(/non-finite/); + expect(() => encodeValue(Number.POSITIVE_INFINITY)).toThrow(/non-finite/); + expect(() => encodeValue({ a: 1 })).toThrow( + /cannot bind value of type object/, + ); + }); + + test("points Date binds at an explicit conversion instead of guessing one", () => { + expect(() => encodeValue(new Date(0))).toThrow( + /toISOString\(\) or date.getTime\(\)/, + ); + }); +}); + +describe("decodeValue", () => { + test("decodes the storage classes", () => { + expect(decodeValue({ type: "null" })).toBeNull(); + expect(decodeValue({ type: "text", value: "hi" })).toBe("hi"); + expect(decodeValue({ type: "float", value: 1.5 })).toBe(1.5); + }); + + test("keeps integers as numbers while they are exactly representable", () => { + expect(decodeValue({ type: "integer", value: "7" })).toBe(7); + expect(decodeValue({ type: "integer", value: "-7" })).toBe(-7); + expect( + decodeValue({ type: "integer", value: String(Number.MAX_SAFE_INTEGER) }), + ).toBe(Number.MAX_SAFE_INTEGER); + }); + + test("widens to bigint only where a number would lose precision", () => { + expect(decodeValue({ type: "integer", value: "9007199254740993" })).toBe( + 9007199254740993n, + ); + expect(decodeValue({ type: "integer", value: "-9007199254740993" })).toBe( + -9007199254740993n, + ); + }); + + test("round-trips blobs, including unpadded base64", () => { + expect(decodeValue({ type: "blob", base64: "AQL/" })).toEqual( + new Uint8Array([1, 2, 255]), + ); + expect(decodeValue({ type: "blob", base64: "AQI" })).toEqual( + new Uint8Array([1, 2]), + ); + }); + + test("round-trips a blob through encode and decode", () => { + const bytes = new Uint8Array([0, 1, 127, 128, 255]); + const encoded = encodeValue(bytes); + expect(decodeValue(encoded)).toEqual(bytes); + }); + + test("fails loudly on a value type it does not know", () => { + expect(() => decodeValue({ type: "quantum" } as never)).toThrow( + DatabaseError, + ); + }); +}); diff --git a/packages/database-client/src/protocol.ts b/packages/database-client/src/protocol.ts new file mode 100644 index 00000000..8df5fc1d --- /dev/null +++ b/packages/database-client/src/protocol.ts @@ -0,0 +1,239 @@ +import { DatabaseError } from "./errors.ts"; + +export type SqlValue = null | string | number | bigint | boolean | Uint8Array; + +export type WireValue = + | { type: "null" } + | { type: "integer"; value: string } + | { type: "float"; value: number } + | { type: "text"; value: string } + | { type: "blob"; base64: string }; + +export interface WireColumn { + name: string | null; + decltype: string | null; +} + +export interface WireStmtResult { + cols: WireColumn[]; + rows: WireValue[][]; + affected_row_count: number; + last_insert_rowid: string | null; +} + +export interface WireBatchResult { + step_results: (WireStmtResult | null)[]; + step_errors: (WireError | null)[]; +} + +export interface WireError { + message: string; + code?: string | null; +} + +interface WireStmt { + sql: string; + args: WireValue[]; + want_rows: boolean; +} + +type WireRequest = + | { type: "execute"; stmt: WireStmt } + | { + type: "batch"; + batch: { steps: { stmt: WireStmt; condition?: unknown }[] }; + } + | { type: "sequence"; sql: string } + | { type: "close" }; + +interface WireResponse { + baton: string | null; + base_url: string | null; + results: { + type: "ok" | "error"; + response?: { type: string; result?: WireStmtResult | WireBatchResult }; + error?: WireError; + }[]; +} + +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); +const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + return btoa(binary); +} + +function base64ToBytes(base64: string): Uint8Array { + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function encodeValue(value: unknown): WireValue { + if (value === null || value === undefined) return { type: "null" }; + if (typeof value === "boolean") { + return { type: "integer", value: value ? "1" : "0" }; + } + if (typeof value === "bigint") + return { type: "integer", value: value.toString() }; + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new DatabaseError( + `cannot bind non-finite number: ${value}`, + "ARGUMENT_INVALID", + ); + } + return Number.isInteger(value) + ? { type: "integer", value: value.toString() } + : { type: "float", value }; + } + if (typeof value === "string") return { type: "text", value }; + if (value instanceof Uint8Array) + return { type: "blob", base64: bytesToBase64(value) }; + if (value instanceof ArrayBuffer) { + return { type: "blob", base64: bytesToBase64(new Uint8Array(value)) }; + } + if (value instanceof Date) { + throw new DatabaseError( + "cannot bind a Date; pass date.toISOString() or date.getTime() instead", + "ARGUMENT_INVALID", + ); + } + throw new DatabaseError( + `cannot bind value of type ${typeof value}; expected null, boolean, number, bigint, string, or Uint8Array`, + "ARGUMENT_INVALID", + ); +} + +export function decodeValue(value: WireValue): SqlValue { + switch (value.type) { + case "null": + return null; + case "text": + return value.value; + case "float": + return value.value; + case "blob": + return base64ToBytes(value.base64); + case "integer": { + const big = BigInt(value.value); + return big > MAX_SAFE || big < MIN_SAFE ? big : Number(big); + } + default: + throw new DatabaseError( + `unsupported value type from server: ${(value as { type: string }).type}`, + ); + } +} + +const SCHEME_MAP: Record = { + libsql: "https", + wss: "https", + https: "https", + ws: "http", + http: "http", +}; + +export function normalizeUrl(url: string): string { + const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(url); + if (!match) { + throw new DatabaseError( + `invalid database URL "${url}"; expected a libsql:// or https:// URL`, + "URL_INVALID", + ); + } + const scheme = (match[1] as string).toLowerCase(); + const mapped = SCHEME_MAP[scheme]; + if (!mapped) { + throw new DatabaseError( + `unsupported URL scheme "${scheme}:"; expected libsql:, https:, or http:`, + "URL_SCHEME_NOT_SUPPORTED", + ); + } + + let parsed: URL; + try { + parsed = new URL(`${mapped}://${url.slice(match[0].length)}`); + } catch { + throw new DatabaseError(`invalid database URL "${url}"`, "URL_INVALID"); + } + if (parsed.username || parsed.password) { + throw new DatabaseError( + "database URL must not contain credentials; pass authToken instead", + "URL_INVALID", + ); + } + if (scheme === "libsql" && parsed.searchParams.get("tls") === "0") { + parsed.protocol = "http:"; + } + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); +} + +export interface Transport { + send( + requests: WireRequest[], + signal?: AbortSignal, + ): Promise; +} + +export interface TransportConfig { + url: string; + authToken?: string; + fetch?: typeof fetch; + headers?: Record; +} + +/** Build a stateless transport: every call is one self-contained POST to /v2/pipeline. */ +export function createTransport(config: TransportConfig): Transport { + // v2 is the widest-supported pipeline path and every request we send is v2-capable. + const endpoint = `${normalizeUrl(config.url)}/v2/pipeline`; + const doFetch = config.fetch ?? fetch; + const headers: Record = { + "content-type": "application/json", + ...config.headers, + }; + if (config.authToken) headers.authorization = `Bearer ${config.authToken}`; + + return { + async send(requests, signal) { + const response = await doFetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + baton: null, + requests: [...requests, { type: "close" }], + }), + signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw DatabaseError.fromHttp(response.status, body); + } + + const payload = (await response.json()) as WireResponse; + return payload.results; + }, + }; +} + +/** Unwrap a pipeline result slot, throwing the server error if the step failed. */ +export function unwrap( + result: WireResponse["results"][number] | undefined, +): T { + if (!result) + throw new DatabaseError("server returned no result for a request"); + if (result.type === "error" || result.error) { + throw DatabaseError.fromWire(result.error); + } + return result.response?.result as T; +} diff --git a/packages/database-client/tsconfig.build.json b/packages/database-client/tsconfig.build.json new file mode 100644 index 00000000..5ccb64e1 --- /dev/null +++ b/packages/database-client/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "rewriteRelativeImportExtensions": true, + "outDir": "dist", + "rootDir": "src", + "paths": {} + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/database-client/tsconfig.json b/packages/database-client/tsconfig.json new file mode 100644 index 00000000..596e2cf7 --- /dev/null +++ b/packages/database-client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} From b8062be0d4828fb0cf77e6b274b566ccc27c4d5f Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 17 Aug 2026 10:33:15 +0100 Subject: [PATCH 2/4] address PR review feedback and wire into the release flow --- .changeset/add-database-client.md | 2 +- .github/workflows/release.yml | 39 +++++++++++++++++++ AGENTS.md | 2 + packages/database-client/README.md | 4 +- packages/database-client/examples/smoke.ts | 5 ++- packages/database-client/src/client.test.ts | 37 +++++++++++++++--- packages/database-client/src/client.ts | 5 ++- packages/database-client/src/protocol.test.ts | 28 +++++++++++++ packages/database-client/src/protocol.ts | 24 ++++++++++-- 9 files changed, 131 insertions(+), 15 deletions(-) diff --git a/.changeset/add-database-client.md b/.changeset/add-database-client.md index 561afc3e..941f7b83 100644 --- a/.changeset/add-database-client.md +++ b/.changeset/add-database-client.md @@ -1,5 +1,5 @@ --- -"@bunny.net/database-client": minor +"@bunny.net/database-client": patch --- Add `@bunny.net/database-client`, a zero-dependency server-side SQL client for Bunny Database that runs on Edge Scripting, Bun, and Node. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d3ca3e1..8c8be977 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ jobs: if: needs.changesets.outputs.hasChangesets == 'false' outputs: cli-version: ${{ steps.check-cli.outputs.version }} + database-client-version: ${{ steps.check-database-client.outputs.version }} database-shell-version: ${{ steps.check-database-shell.outputs.version }} openapi-client-version: ${{ steps.check-openapi-client.outputs.version }} sandbox-version: ${{ steps.check-sandbox.outputs.version }} @@ -87,6 +88,17 @@ jobs: else echo "No version change: $VERSION" fi + - name: Check database-client version + id: check-database-client + run: | + VERSION=$(node -p "require('./packages/database-client/package.json').version") + PUBLISHED=$(npm view @bunny.net/database-client version 2>/dev/null || echo "0.0.0") + if [ "$VERSION" != "$PUBLISHED" ]; then + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "New version detected: $VERSION (published: $PUBLISHED)" + else + echo "No version change: $VERSION" + fi - name: Check database-shell version id: check-database-shell run: | @@ -355,6 +367,33 @@ jobs: npm-artifacts/bunny-darwin-x64-baseline/bunny-darwin-x64-baseline fail_on_unmatched_files: true + publish-database-client: + name: Publish database-client + runs-on: ubuntu-latest + needs: version + if: needs.version.outputs.database-client-version + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.11" + - run: bun install + + - name: Build package + run: bun run --filter @bunny.net/database-client build + + - uses: actions/setup-node@v5 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Publish @bunny.net/database-client + run: | + cd packages/database-client + npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + publish-database-shell: name: Publish database-shell runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index e985c79a..10142086 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -957,6 +957,8 @@ Two differences from openapi-client: `@bunny.net/database-client` follows the same compiled-library pattern as `@bunny.net/sandbox`, and is the simplest case of it: zero dependencies, so `npm publish` works (there is no `workspace:*` spec for bun to rewrite), and no declaration transformer, so emitted `.d.ts` keep their `.ts` import specifiers for TypeScript to resolve against the sibling `.d.ts`. Its `tsconfig.build.json` sets `include: ["src"]` so `examples/` stays out of the program and does not violate `rootDir`. +The `publish-database-client` job in `release.yml` (gated on a version bump detected via `npm view`, like the other independently versioned packages) builds with `bun run --filter @bunny.net/database-client build`, then runs `cd packages/database-client && npm publish`. The package versions independently of the CLI; it is not part of any `fixed` group in `.changeset/config.json`. + Nothing in the repo imports it: the CLI talks to databases through `@bunny.net/database-shell`, and this package exists for user application code. It therefore has no root `tsconfig.json` `paths` entry, and its tests run against its own source directly. `@bunny.net/config` is a private workspace package (not published); the CLI consumes it from source via the workspace symlink. diff --git a/packages/database-client/README.md b/packages/database-client/README.md index 7e74952d..6b1ffcf1 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -67,6 +67,8 @@ Binds positional `?` parameters and returns a new statement. Accepts `null`, `bo Anything else throws instead of being quietly converted, because SQLite has nowhere to put it. `Date` gets its own message suggesting `.toISOString()` or `.getTime()`, since guessing which one you meant would change what ends up in the column. +Integer `number`s past 2^53 are rejected rather than rounded: by the time the client sees one it has already lost precision, so pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range. + ### Executing Four ways to run a statement: @@ -261,5 +263,5 @@ bun test ```bash bun run examples/smoke.ts -deno run --allow-net --allow-env examples/smoke.ts +deno run --env-file=.env --allow-net --allow-env examples/smoke.ts ``` diff --git a/packages/database-client/examples/smoke.ts b/packages/database-client/examples/smoke.ts index afed2715..5fa627bc 100644 --- a/packages/database-client/examples/smoke.ts +++ b/packages/database-client/examples/smoke.ts @@ -3,14 +3,15 @@ * * Needs BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN in the environment: * bun run examples/smoke.ts - * deno run --allow-net --allow-env examples/smoke.ts + * deno run --env-file=.env --allow-net --allow-env examples/smoke.ts */ +import { readEnv } from "../src/env.ts"; import { connect, DatabaseError, ENV_DATABASE_URL } from "../src/index.ts"; // No arguments: url and token come from the environment. const db = connect(); -const url = process.env[ENV_DATABASE_URL] as string; +const url = readEnv(ENV_DATABASE_URL) as string; console.log(`endpoint: ${new URL(url).host}`); function check(label: string, ok: boolean, detail?: unknown) { diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts index 9423723b..cfe4dca1 100644 --- a/packages/database-client/src/client.test.ts +++ b/packages/database-client/src/client.test.ts @@ -95,11 +95,21 @@ describe("transport", () => { }); test("omits the auth header when no token is configured", async () => { - const fake = fakeFetch([okExecute(["a"], [[1]])]); - await connect({ url: URL_, fetch: fake.fetch }) - .prepare("SELECT 1 AS a") - .all(); - expect((fake.captures[0] as Capture).headers.authorization).toBeUndefined(); + const previousToken = process.env[ENV_DATABASE_AUTH_TOKEN]; + delete process.env[ENV_DATABASE_AUTH_TOKEN]; + + try { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT 1 AS a") + .all(); + expect( + (fake.captures[0] as Capture).headers.authorization, + ).toBeUndefined(); + } finally { + if (previousToken !== undefined) + process.env[ENV_DATABASE_AUTH_TOKEN] = previousToken; + } }); test("caller headers ride along", async () => { @@ -203,6 +213,23 @@ describe("statement", () => { ); }); + test("first(column) does not fall back to inherited object members", async () => { + const fake = fakeFetch([okExecute(["a"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + await expect(db.prepare("SELECT a").first("toString")).rejects.toThrow( + /not in the result/, + ); + }); + + test("a column named __proto__ survives as a real row field", async () => { + const fake = fakeFetch([okExecute(["__proto__", "n"], [["evil", 7]])]); + const row = await connect({ url: URL_, fetch: fake.fetch }) + .prepare("SELECT '__proto__', n") + .first(); + expect(row?.["__proto__"]).toBe("evil"); + expect(row?.n).toBe(7); + }); + test("raw returns positional arrays", async () => { const fake = fakeFetch([okExecute(["id", "name"], [[1, "a"]])]); expect( diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts index f3d36cce..7f5e9677 100644 --- a/packages/database-client/src/client.ts +++ b/packages/database-client/src/client.ts @@ -40,7 +40,8 @@ function toResult(wire: WireStmtResult): Result { (col, index) => col.name ?? `column${index + 1}`, ); const rows = wire.rows.map((row) => { - const out: Row = {}; + // Null prototype so a column named __proto__ (or constructor, toString, ...) is a plain own property. + const out: Row = Object.create(null); for (let i = 0; i < columns.length; i++) out[columns[i] as string] = decodeValue(row[i] as WireValue); return out as T; @@ -84,7 +85,7 @@ export class Statement { const row = result.rows[0]; if (!row) return null; if (column === undefined) return row as T; - if (!(column in row)) { + if (!Object.hasOwn(row, column)) { throw new DatabaseError( `column "${column}" is not in the result; got ${result.columns.join(", ")}`, "COLUMN_NOT_FOUND", diff --git a/packages/database-client/src/protocol.test.ts b/packages/database-client/src/protocol.test.ts index b9a41a10..0813d13c 100644 --- a/packages/database-client/src/protocol.test.ts +++ b/packages/database-client/src/protocol.test.ts @@ -93,6 +93,34 @@ describe("encodeValue", () => { ); }); + test("rejects integer numbers past 2^53 instead of silently rounding them", () => { + expect(() => encodeValue(Number.MAX_SAFE_INTEGER + 2)).toThrow( + /pass a bigint/, + ); + expect(() => encodeValue(Number.MIN_SAFE_INTEGER - 2)).toThrow( + /pass a bigint/, + ); + expect(encodeValue(Number.MAX_SAFE_INTEGER)).toEqual({ + type: "integer", + value: "9007199254740991", + }); + }); + + test("accepts the int64 bounds as bigint but rejects beyond them", () => { + expect(encodeValue(2n ** 63n - 1n)).toEqual({ + type: "integer", + value: "9223372036854775807", + }); + expect(encodeValue(-(2n ** 63n))).toEqual({ + type: "integer", + value: "-9223372036854775808", + }); + expect(() => encodeValue(2n ** 63n)).toThrow(/64-bit integer range/); + expect(() => encodeValue(-(2n ** 63n) - 1n)).toThrow( + /64-bit integer range/, + ); + }); + test("points Date binds at an explicit conversion instead of guessing one", () => { expect(() => encodeValue(new Date(0))).toThrow( /toISOString\(\) or date.getTime\(\)/, diff --git a/packages/database-client/src/protocol.ts b/packages/database-client/src/protocol.ts index 8df5fc1d..74ecfcad 100644 --- a/packages/database-client/src/protocol.ts +++ b/packages/database-client/src/protocol.ts @@ -58,6 +58,8 @@ interface WireResponse { const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); const MIN_SAFE = BigInt(Number.MIN_SAFE_INTEGER); +const INT64_MAX = 2n ** 63n - 1n; +const INT64_MIN = -(2n ** 63n); function bytesToBase64(bytes: Uint8Array): string { let binary = ""; @@ -81,8 +83,15 @@ export function encodeValue(value: unknown): WireValue { if (typeof value === "boolean") { return { type: "integer", value: value ? "1" : "0" }; } - if (typeof value === "bigint") + if (typeof value === "bigint") { + if (value > INT64_MAX || value < INT64_MIN) { + throw new DatabaseError( + `cannot bind bigint ${value}; outside SQLite's 64-bit integer range`, + "ARGUMENT_INVALID", + ); + } return { type: "integer", value: value.toString() }; + } if (typeof value === "number") { if (!Number.isFinite(value)) { throw new DatabaseError( @@ -90,9 +99,16 @@ export function encodeValue(value: unknown): WireValue { "ARGUMENT_INVALID", ); } - return Number.isInteger(value) - ? { type: "integer", value: value.toString() } - : { type: "float", value }; + if (Number.isInteger(value)) { + if (!Number.isSafeInteger(value)) { + throw new DatabaseError( + `cannot bind unsafe integer ${value}; numbers past 2^53 have already lost precision, pass a bigint instead`, + "ARGUMENT_INVALID", + ); + } + return { type: "integer", value: value.toString() }; + } + return { type: "float", value }; } if (typeof value === "string") return { type: "text", value }; if (value instanceof Uint8Array) From e0dd6c86dce99649b46b08190b4bb4f8ab8e79b6 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Mon, 17 Aug 2026 13:46:07 +0100 Subject: [PATCH 3/4] fix(database-client): treat unreadable env vars as unset under Deno without --allow-env (#155) * docs(database-client): tidy README and comments, drop the D1 shorthand - remove the truncated sentence at the end of the security section - fold the intro's dangling dependency claim into the sentence - explain the unsafe-integer rejection in active voice - collapse a stacked comment in env.ts to one line - describe the API surface in AGENTS.md without the D1 comparison * fix(database-client): treat unreadable env vars as unset under Deno without --allow-env Deno 2's node-compat process.env throws NotCapable on read just like Deno.env.get, but only the latter was guarded, so readEnv crashed instead of falling through to connect()'s clearer missing-URL error. One module-scope sniff type and one try now cover both globals, and env.test.ts locks in the degrade-to-unset behavior with throwing stubs. --- .changeset/database-env-read.md | 5 ++ AGENTS.md | 2 +- packages/database-client/README.md | 6 +- packages/database-client/src/env.test.ts | 88 ++++++++++++++++++++++++ packages/database-client/src/env.ts | 26 +++---- 5 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 .changeset/database-env-read.md create mode 100644 packages/database-client/src/env.test.ts diff --git a/.changeset/database-env-read.md b/.changeset/database-env-read.md new file mode 100644 index 00000000..2f0b2903 --- /dev/null +++ b/.changeset/database-env-read.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/database-client": patch +--- + +Treat unreadable environment variables as unset instead of crashing when Deno runs without --allow-env diff --git a/AGENTS.md b/AGENTS.md index 447f6095..40a30da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ This is a Bun workspace monorepo with seven packages: - **`@bunny.net/openapi-client`** (`packages/openapi-client/`) — Standalone, type-safe OpenAPI client for bunny.net, generated from OpenAPI specs. Zero CLI dependencies. Publishable to npm. - **`@bunny.net/config`** (`packages/config/`) — Shared `bunny.jsonc` schemas (Zod), inferred types, JSON Schema generation, and API conversion functions. The root `BunnyConfigSchema` has optional `app` (Magic Containers) and `sites` (static sites) blocks; `BunnyAppConfigSchema` narrows it to require `app`. Used by the CLI and potentially other tools. -- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. D1-shaped surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm. +- **`@bunny.net/database-client`** (`packages/database-client/`) — Standalone SQL client for Bunny Database, for application code rather than the CLI. Speaks hrana-over-HTTP (`POST /v2/pipeline`) using only `fetch`, so it runs unchanged on Edge Scripting (Deno), Bun, and Node. Zero dependencies. **Server-side only, and documented as such:** an auth token is a bearer credential for the whole database and the client sends raw SQL, so it must never reach a browser or other untrusted client. Do not describe it as browser-compatible even though `fetch`-only code would technically run there; the correct pattern is an Edge Script (or `database-rest` behind an auth check) that holds the token and exposes only intended queries. Prepared-statement surface: `connect()`, `prepare().bind()`, `.all()`/`.first()`/`.raw()`/`.run()`, plus `batch()` (one transaction, one round trip) and `exec()` (multi-statement script). Deliberately stateless: no baton tracking, no connection pool, no interactive transactions, no cursor streaming. Publishable to npm. - **`@bunny.net/database-shell`** (`packages/database-shell/`) — Standalone interactive SQL shell for libSQL databases. Framework-agnostic REPL, dot-commands, formatting, masking, and history. Also usable as a standalone CLI (binary: `bsql`). - **`@bunny.net/scriptable-dns-types`** (`packages/scriptable-dns-types/`): Ambient TypeScript declarations for the Scriptable DNS runtime globals (`ARecord`, `Monitoring`, `RoutingEngine`, etc.). Types-only, no runtime code: the DNS runtime can't `import`, so these power editor autocomplete and an optional typecheck step. Scaffolded into projects by `bunny dns scripts init`; intended to also feed the dashboard editor. Publishable to npm. - **`@bunny.net/sandbox`** (`packages/sandbox/`) — Standalone sandbox SDK. Code-first DX (`Sandbox.create`, `writeFiles`, `runCommand`, `exposePort`, `setEnv`/`getEnv`/`unsetEnv`, `listFiles`/`deleteFile`/`rename`/`exists`) over Magic Containers provisioning plus an `ssh2` SSH/SFTP transport. Blocking `runCommand` accepts `timeout` (rejects with `CommandTimeoutError` carrying partial output), `signal` for cancellation, and `onStdout`/`onStderr` callbacks for live output. Env vars can be baked in at `create` (persisted), passed per-command via `runCommand({ env })` (temporary), or persisted after creation via `setEnv`. The handle implements `Symbol.dispose`/`Symbol.asyncDispose` so `using`/`await using` release the SSH connection (without deleting the sandbox). Zero CLI dependencies. diff --git a/packages/database-client/README.md b/packages/database-client/README.md index 6b1ffcf1..0d36f063 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -1,6 +1,6 @@ # @bunny.net/database-client -A small SQL client for [Bunny Database](https://bunny.net). It uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node. No dependencies. +A small SQL client for [Bunny Database](https://bunny.net). It has no dependencies and uses `fetch` and nothing else, so the same code runs on Bunny Edge Scripting (Deno), Bun, and Node. > [!WARNING] > **Server-side only. Never ship this to a browser or any other untrusted client.** @@ -67,7 +67,7 @@ Binds positional `?` parameters and returns a new statement. Accepts `null`, `bo Anything else throws instead of being quietly converted, because SQLite has nowhere to put it. `Date` gets its own message suggesting `.toISOString()` or `.getTime()`, since guessing which one you meant would change what ends up in the column. -Integer `number`s past 2^53 are rejected rather than rounded: by the time the client sees one it has already lost precision, so pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range. +Integer `number`s past 2^53 also throw: JavaScript has already lost the precision by the time the client sees the value, so storing it would quietly write the wrong number. Pass a `bigint` for values that large. Bigints must fit SQLite's signed 64-bit range. ### Executing @@ -244,7 +244,7 @@ BunnySDK.net.http.serve(async (request: Request): Promise => { }); ``` -The browser calls your endpoint, and your endpoint decides what SQL runs. If you would rather not hand-write endpoints +The browser calls your endpoint, and your endpoint decides what SQL runs. ### Handling tokens diff --git a/packages/database-client/src/env.test.ts b/packages/database-client/src/env.test.ts new file mode 100644 index 00000000..25cc2366 --- /dev/null +++ b/packages/database-client/src/env.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { readEnv } from "./env.ts"; + +const NAME = "BUNNY_READENV_TEST"; + +type GlobalWithSniffs = { + Deno?: { env?: { get(key: string): string | undefined } }; + process?: unknown; +}; +const g = globalThis as GlobalWithSniffs; + +describe("readEnv", () => { + test("reads from process.env", () => { + process.env[NAME] = "from-process"; + try { + expect(readEnv(NAME)).toBe("from-process"); + } finally { + delete process.env[NAME]; + } + }); + + test("missing and empty string both read as unset", () => { + delete process.env[NAME]; + expect(readEnv(NAME)).toBeUndefined(); + process.env[NAME] = ""; + try { + expect(readEnv(NAME)).toBeUndefined(); + } finally { + delete process.env[NAME]; + } + }); + + test("prefers Deno.env.get when a Deno global is present", () => { + process.env[NAME] = "from-process"; + g.Deno = { env: { get: () => "from-deno" } }; + try { + expect(readEnv(NAME)).toBe("from-deno"); + } finally { + delete g.Deno; + delete process.env[NAME]; + } + }); + + test("an empty Deno value falls through to process.env", () => { + process.env[NAME] = "from-process"; + g.Deno = { env: { get: () => "" } }; + try { + expect(readEnv(NAME)).toBe("from-process"); + } finally { + delete g.Deno; + delete process.env[NAME]; + } + }); + + test("a permission throw from Deno.env.get reads as unset, not a crash", () => { + g.Deno = { + env: { + get: () => { + throw new Error("NotCapable: Requires env access"); + }, + }, + }; + try { + expect(readEnv(NAME)).toBeUndefined(); + } finally { + delete g.Deno; + } + }); + + test("a permission throw from process.env reads as unset, not a crash", () => { + const realProcess = g.process; + g.process = { + env: new Proxy( + {}, + { + get() { + throw new Error("NotCapable: Requires env access"); + }, + }, + ), + }; + try { + expect(readEnv(NAME)).toBeUndefined(); + } finally { + g.process = realProcess; + } + }); +}); diff --git a/packages/database-client/src/env.ts b/packages/database-client/src/env.ts index 9d3c386b..69f7c25a 100644 --- a/packages/database-client/src/env.ts +++ b/packages/database-client/src/env.ts @@ -1,22 +1,16 @@ export const ENV_DATABASE_URL = "BUNNY_DATABASE_URL"; export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN"; +// Runtime sniff: either global may be absent (wrong runtime) or throw on read (Deno without --allow-env). +const g = globalThis as { + Deno?: { env?: { get(key: string): string | undefined } }; + process?: { env?: Record }; +}; + export function readEnv(name: string): string | undefined { - const deno = ( - globalThis as { Deno?: { env?: { get(key: string): string | undefined } } } - ).Deno; - if (deno?.env?.get) { - try { - const value = deno.env.get(name); - if (value) return value; - } catch { - // No env permission. - // Fall through to process.env, then to the caller's error. - } + try { + return g.Deno?.env?.get(name) || g.process?.env?.[name] || undefined; + } catch { + return undefined; } - - const proc = ( - globalThis as { process?: { env?: Record } } - ).process; - return proc?.env?.[name] || undefined; } From 9790c82e8868e118cb397263434206de573f169d Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 18 Aug 2026 10:47:18 +0100 Subject: [PATCH 4/4] refactor(database-client): read env through process.env only Every runtime this client targets exposes process.env, Deno included, so the Deno.env.get branch and the globalThis runtime sniff were carrying no weight. readEnv() now reads process.env directly, and the examples use process.env instead of importing readEnv. The permission tolerance from #155 stays: reading can throw rather than return undefined when Deno runs without --allow-env, so readEnv still catches and reports the variable as unset. Verified that connect() with no arguments under `deno run --allow-net` still raises URL_MISSING with its usual message rather than a NotCapable crash. --allow-env is still required for Deno to read process.env, so the example's invocation line is unchanged. Behaviour is unchanged for consumers, so the existing changeset still covers it. Live smoke passes on Bun and Deno. --- AGENTS.md | 2 +- packages/database-client/examples/smoke.ts | 3 +- packages/database-client/src/env.test.ts | 46 +--------------------- packages/database-client/src/env.ts | 9 +---- 4 files changed, 6 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 40a30da8..b3da9f22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,7 +149,7 @@ bunny-cli/ │ │ ├── client.ts # Database + Statement; batch() wraps steps in BEGIN/COMMIT/ROLLBACK │ │ ├── protocol.ts # Hrana wire types, value codecs, normalizeUrl(), /v2/pipeline transport │ │ ├── errors.ts # DatabaseError (code + status) -│ │ ├── env.ts # BUNNY_DATABASE_URL / _AUTH_TOKEN names, cross-runtime readEnv() +│ │ ├── env.ts # BUNNY_DATABASE_URL / _AUTH_TOKEN names, readEnv() over process.env (unreadable reads as unset) │ │ ├── client.test.ts # Client behaviour against a fake fetch (no network) │ │ └── protocol.test.ts # URL normalization and value codec tests │ │ diff --git a/packages/database-client/examples/smoke.ts b/packages/database-client/examples/smoke.ts index 5fa627bc..9819b1e0 100644 --- a/packages/database-client/examples/smoke.ts +++ b/packages/database-client/examples/smoke.ts @@ -5,13 +5,12 @@ * bun run examples/smoke.ts * deno run --env-file=.env --allow-net --allow-env examples/smoke.ts */ -import { readEnv } from "../src/env.ts"; import { connect, DatabaseError, ENV_DATABASE_URL } from "../src/index.ts"; // No arguments: url and token come from the environment. const db = connect(); -const url = readEnv(ENV_DATABASE_URL) as string; +const url = process.env[ENV_DATABASE_URL] as string; console.log(`endpoint: ${new URL(url).host}`); function check(label: string, ok: boolean, detail?: unknown) { diff --git a/packages/database-client/src/env.test.ts b/packages/database-client/src/env.test.ts index 25cc2366..41dad2ca 100644 --- a/packages/database-client/src/env.test.ts +++ b/packages/database-client/src/env.test.ts @@ -3,12 +3,6 @@ import { readEnv } from "./env.ts"; const NAME = "BUNNY_READENV_TEST"; -type GlobalWithSniffs = { - Deno?: { env?: { get(key: string): string | undefined } }; - process?: unknown; -}; -const g = globalThis as GlobalWithSniffs; - describe("readEnv", () => { test("reads from process.env", () => { process.env[NAME] = "from-process"; @@ -30,44 +24,8 @@ describe("readEnv", () => { } }); - test("prefers Deno.env.get when a Deno global is present", () => { - process.env[NAME] = "from-process"; - g.Deno = { env: { get: () => "from-deno" } }; - try { - expect(readEnv(NAME)).toBe("from-deno"); - } finally { - delete g.Deno; - delete process.env[NAME]; - } - }); - - test("an empty Deno value falls through to process.env", () => { - process.env[NAME] = "from-process"; - g.Deno = { env: { get: () => "" } }; - try { - expect(readEnv(NAME)).toBe("from-process"); - } finally { - delete g.Deno; - delete process.env[NAME]; - } - }); - - test("a permission throw from Deno.env.get reads as unset, not a crash", () => { - g.Deno = { - env: { - get: () => { - throw new Error("NotCapable: Requires env access"); - }, - }, - }; - try { - expect(readEnv(NAME)).toBeUndefined(); - } finally { - delete g.Deno; - } - }); - - test("a permission throw from process.env reads as unset, not a crash", () => { + test("a permission throw reads as unset, not a crash", () => { + const g = globalThis as { process?: unknown }; const realProcess = g.process; g.process = { env: new Proxy( diff --git a/packages/database-client/src/env.ts b/packages/database-client/src/env.ts index 69f7c25a..bfdeb200 100644 --- a/packages/database-client/src/env.ts +++ b/packages/database-client/src/env.ts @@ -1,15 +1,10 @@ export const ENV_DATABASE_URL = "BUNNY_DATABASE_URL"; export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN"; -// Runtime sniff: either global may be absent (wrong runtime) or throw on read (Deno without --allow-env). -const g = globalThis as { - Deno?: { env?: { get(key: string): string | undefined } }; - process?: { env?: Record }; -}; - +// Reading can throw rather than return undefined (Deno without --allow-env); treat that as unset. export function readEnv(name: string): string | undefined { try { - return g.Deno?.env?.get(name) || g.process?.env?.[name] || undefined; + return process.env[name] || undefined; } catch { return undefined; }