diff --git a/.changeset/add-database-client.md b/.changeset/add-database-client.md new file mode 100644 index 00000000..941f7b83 --- /dev/null +++ b/.changeset/add-database-client.md @@ -0,0 +1,5 @@ +--- +"@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/.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/.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 5c969238..b3da9f22 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. 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. @@ -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, 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 +│ │ │ ├── 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,14 @@ 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`. + +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. ### CI diff --git a/README.md b/README.md index 89fa5315..162924fc 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/database-client/README.md b/packages/database-client/README.md new file mode 100644 index 00000000..0d36f063 --- /dev/null +++ b/packages/database-client/README.md @@ -0,0 +1,267 @@ +# @bunny.net/database-client + +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.** +> +> 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. + +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 + +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. + +### 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 --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 new file mode 100644 index 00000000..9819b1e0 --- /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 --env-file=.env --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..cfe4dca1 --- /dev/null +++ b/packages/database-client/src/client.test.ts @@ -0,0 +1,513 @@ +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 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 () => { + 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("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( + 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..7f5e9677 --- /dev/null +++ b/packages/database-client/src/client.ts @@ -0,0 +1,216 @@ +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) => { + // 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; + }); + 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 (!Object.hasOwn(row, column)) { + 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.test.ts b/packages/database-client/src/env.test.ts new file mode 100644 index 00000000..41dad2ca --- /dev/null +++ b/packages/database-client/src/env.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { readEnv } from "./env.ts"; + +const NAME = "BUNNY_READENV_TEST"; + +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("a permission throw reads as unset, not a crash", () => { + const g = globalThis as { process?: unknown }; + 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 new file mode 100644 index 00000000..bfdeb200 --- /dev/null +++ b/packages/database-client/src/env.ts @@ -0,0 +1,11 @@ +export const ENV_DATABASE_URL = "BUNNY_DATABASE_URL"; +export const ENV_DATABASE_AUTH_TOKEN = "BUNNY_DATABASE_AUTH_TOKEN"; + +// Reading can throw rather than return undefined (Deno without --allow-env); treat that as unset. +export function readEnv(name: string): string | undefined { + try { + return process.env[name] || undefined; + } catch { + return 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..0813d13c --- /dev/null +++ b/packages/database-client/src/protocol.test.ts @@ -0,0 +1,175 @@ +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("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\(\)/, + ); + }); +}); + +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..74ecfcad --- /dev/null +++ b/packages/database-client/src/protocol.ts @@ -0,0 +1,255 @@ +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); +const INT64_MAX = 2n ** 63n - 1n; +const INT64_MIN = -(2n ** 63n); + +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") { + 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( + `cannot bind non-finite number: ${value}`, + "ARGUMENT_INVALID", + ); + } + 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) + 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"] +}