Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/studio-read-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@bunny.net/cli": minor
"@bunny.net/database-studio": minor
"@bunny.net/database-rest": minor
"@bunny.net/database-adapter": patch
"@bunny.net/database-openapi": patch
---

`bunny db studio` is now a read-only explorer: it mints a read-only token and the local server refuses writes. It adds a read-only SQL console with autocomplete and history, a database overview with views and triggers, `CREATE` statements on the Schema tab, and a grid with resizable columns and keyboard cell selection. The UI also gets a searchable table picker (⌘K) for its tabs, tabs that keep their own filters and paging, working NOT LIKE / IS NOT NULL / OR filters, row details that follow foreign keys and show referencing rows, a fuller schema view, and CSV/JSON export of the current page. Tables with 64-bit integers or BLOBs no longer fail to load, and composite primary keys are introspected correctly.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ jobs:
done
rm -rf packages/openapi-client/dist packages/stream-import/dist packages/stream-import-*/dist
- run: bun test
- name: Typecheck database studio client
run: bun run --filter @bunny.net/database-studio typecheck:client
- name: Build database studio
run: bun run --filter @bunny.net/database-studio build:client
- name: Build CLI binary
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,9 @@ Everything database-related goes through `@bunny.net/database-client`. `db shell
### Other database rules

- **Never leak error text through the REST surface.** `createRestHandler()` returns `{ message: "Internal error", code: "INTERNAL_ERROR" }` with a 500 and hands the real error to the optional `onError` hook, because a raw SQLite error would leak table names, column names, and file paths. `db studio` wires `onError` to its own logger, which is why failures print in the launching terminal rather than the browser.
- **Studio is read-only at every layer.** `db studio` asks `resolveCredentials()` for a `read-only` token, and `startStudio()` answers anything but `GET` under `/api` with a 405, because a token from `--token` or `.env` may still be full-access. The SQL console (`runReadOnlyQuery()` in `database-studio/src/query.ts`) allows only read keywords, rejects any statement containing a write keyword (catching `WITH … DELETE`), and allows only PRAGMAs that read. Adding writes means lifting each layer deliberately, not just one.
- **Everything studio runs must pass a read-only token.** libSQL servers classify each statement and refuse anything they call a write when the token is read-only, and their list is stricter than SQLite's: `PRAGMA foreign_key_list` and `PRAGMA user_version` count as writes, and `PRAGMA query_only = ON` is rejected for every token. Use the table-valued form (`SELECT * FROM pragma_foreign_key_list(?)`), which is a SELECT. `introspect.test.ts` fails if introspection issues anything else; local SQLite won't catch this, since it doesn't check scopes.
- **The SQL console shows SQL errors; nothing else does.** Its errors are about the query the person just typed, so `DatabaseError`s without an HTTP status go back to the browser. Transport and unexpected errors stay generic and go to the terminal, as with the REST surface.
- **Every CLI connection identifies itself** via `databaseUserAgent(command)` (`bunny-cli/<version> (db shell)` and so on). The CLI compiles the shell and studio from source into its binary, so those packages' own versions say nothing about the traffic. Standalone `bsql` keeps `bunny-database-shell/<version>`.
- **Full-scan dot-commands** (`.count`, `.size`, `.dump`) require confirmation via `confirmReadQuota()`, since reads count against the quota.
- **`db create` validates the name client-side** against `DB_NAME_MAX_LENGTH` (16) before any API call: longer names make the backend 500 instead of returning a validation error.
Expand Down
103 changes: 65 additions & 38 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,11 @@ bunny db shell --url libsql://01KCHBG8C5KSFGG0VRNFQ7EK7X-my-app.lite.bunnydb.net

#### `bunny db studio`

Open a read-only table viewer in your browser. Spins up a local server, generates a short-lived auth token if needed, and opens the studio UI.
Open a read-only database explorer in your browser. Spins up a local server, generates a short-lived read-only token if needed, and opens the studio UI. The local server refuses writes even when `--token` or `.env` supplies a full-access token.

The home page summarises the database (tables, views, indexes, triggers) with Browse, Structure, and SQL actions per table. Open tables as tabs across the top (<kbd>⌘K</kbd> / <kbd>Ctrl K</kbd> to search for one); each tab keeps its own filters, sort, and page. Sort by any column, drag a header edge to resize it, and select cells with the arrow keys (<kbd>⌘C</kbd> copies, <kbd>Enter</kbd> opens the row). Filter with AND/OR conditions (equals, contains, starts with, ranges, IN, LIKE, NULL checks). Click a row to see every field, follow foreign keys to the referenced row, or jump to rows in other tables that reference it. The Schema tab lists columns, defaults, constraints, indexes, incoming foreign keys, and the table's `CREATE` statements, triggers included. Export the current page as CSV or JSON.

The **SQL** tab is a query console with schema-aware autocomplete and query history. <kbd>⌘Enter</kbd> / <kbd>Ctrl Enter</kbd> runs the selection or the statement under the cursor. Only `SELECT`, `WITH`, `EXPLAIN`, `PRAGMA`, and `VALUES` run; studio rejects statements containing write keywords and PRAGMAs that set a value. With the token studio creates, the database also refuses writes itself. A token from `--token` or `.env` may allow writes, so the console says when studio's own check is the only guard. Results show up to 1,000 rows. Views open here too. Filters, sorting, and paging live in the URL, so Back works and links can be reopened while studio is running.

```bash
# Auto-detect database (link, .env, or interactive)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/db/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type Database = components["schemas"]["Database2"];
type RegionConfig = components["schemas"]["ListConfigAPIResponse"];
type GenerateTokenResponse =
components["schemas"]["GenerateTokenDatabaseV2Response"];
type TokenAuthorization =
export type TokenAuthorization =
components["schemas"]["GenerateTokenDatabaseV2Payload"]["authorization"];
type DBLiveStatus = components["schemas"]["DBLiveStatus"];

Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/db/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { clientOptions } from "@/core/client-options.ts";
import { UserError } from "@/core/errors.ts";
import { spinner } from "@/core/ui.ts";
import { readEnvValue } from "@/utils/env-file.ts";
import { generateToken, tokenExpiryFromNow } from "./api.ts";
import {
generateToken,
type TokenAuthorization,
tokenExpiryFromNow,
} from "./api.ts";
import { ENV_DATABASE_AUTH_TOKEN, ENV_DATABASE_URL } from "./constants.ts";
import { resolveDbId } from "./resolve-db.ts";

Expand All @@ -29,6 +33,8 @@ export interface ResolveCredentialsOptions {
profile: string;
apiKey?: string;
verbose?: boolean;
/** Scope of a token created for this run. Defaults to `full-access`; tokens from flags or `.env` are used as given. */
authorization?: TokenAuthorization;
}

/** Schemes that encrypt in transit. `libsql:` resolves to `https:`/`wss:` unless it opts out with `?tls=0`. */
Expand Down Expand Up @@ -181,7 +187,7 @@ export async function resolveCredentials(
const mintToken = () => {
spin.text = "Generating token...";
return generateToken(apiClient, databaseId, {
authorization: "full-access",
authorization: opts.authorization ?? "full-access",
expiresAt: tokenExpiryFromNow(),
});
};
Expand Down
35 changes: 21 additions & 14 deletions packages/cli/src/commands/db/studio.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { defineCommand } from "@/core/define-command.ts";
import { logger } from "@/core/logger.ts";
import { confirm } from "@/core/ui.ts";
import { confirm, openBrowser } from "@/core/ui.ts";
import {
ARG_DATABASE_ID,
databaseUserAgent,
TOKEN_TTL_MINUTES,
} from "./constants.ts";
import { resolveCredentials } from "./credentials.ts";
import { databaseTarget, resolveCredentials } from "./credentials.ts";

const COMMAND = `studio [${ARG_DATABASE_ID}]`;
const DESCRIPTION = "Open a visual database explorer in your browser.";
const DESCRIPTION = "Open a read-only database explorer in your browser.";

const ARG_PORT = "port";
const ARG_URL = "url";
Expand Down Expand Up @@ -87,10 +87,11 @@ export const dbStudioCommand = defineCommand<{
}) => {
logger.warn("db studio will:");
logger.log(
` - Create a full-access token scoped to this database that expires in ${TOKEN_TTL_MINUTES} minutes.`,
` - Create a read-only token scoped to this database that expires in ${TOKEN_TTL_MINUTES} minutes,`,
);
logger.log(" unless --token or .env already provides one.");
logger.log(
" - Load that token into a browser tab running on http://localhost.",
" - Serve a read-only explorer on http://localhost that only this browser session can use.",
);
logger.log(
" - Power the UI with additional reads (table previews, row counts, pagination),",
Expand All @@ -107,14 +108,17 @@ export const dbStudioCommand = defineCommand<{
const { connect } = await import("@bunny.net/database-client");
const { startStudio } = await import("@bunny.net/database-studio");

const { url, token } = await resolveCredentials({
url: urlArg,
token: tokenArg,
databaseId: databaseIdArg,
profile,
apiKey,
verbose,
});
const { url, token, databaseId, tokenGenerated } = await resolveCredentials(
{
url: urlArg,
token: tokenArg,
databaseId: databaseIdArg,
profile,
apiKey,
verbose,
authorization: "read-only",
},
);

const client = connect({
url,
Expand All @@ -125,8 +129,11 @@ export const dbStudioCommand = defineCommand<{
logger.log("");
await startStudio({
client,
label: databaseTarget(url, databaseId).label,
// Only a token created here is known to be read-only; one from --token or .env may be full-access.
tokenReadOnly: tokenGenerated,
port: port ?? 4488,
open: !noOpen,
open: noOpen ? undefined : openBrowser,
dev,
logger: {
log: (msg: string) => logger.log(msg),
Expand Down
6 changes: 5 additions & 1 deletion packages/database-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@ export type { AdapterClient, AdapterStatement } from "./client.ts";
export type { CreateExecutorOptions } from "./executor.ts";
export { createExecutor } from "./executor.ts";
export type { IntrospectOptions } from "./introspect.ts";
export { DEFAULT_EXCLUDE_PATTERNS, introspect } from "./introspect.ts";
export {
DEFAULT_EXCLUDE_PATTERNS,
introspect,
shouldInclude,
} from "./introspect.ts";
36 changes: 36 additions & 0 deletions packages/database-adapter/src/introspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ beforeAll(async () => {
title TEXT NOT NULL,
user_id INTEGER REFERENCES users(id)
);
CREATE TABLE post_tags (
tag_id INTEGER NOT NULL,
post_id INTEGER NOT NULL,
PRIMARY KEY (post_id, tag_id)
);
CREATE TABLE "__drizzle_migrations" (
id INTEGER PRIMARY KEY,
hash TEXT NOT NULL
Expand Down Expand Up @@ -121,6 +126,37 @@ describe("introspect", () => {
expect(posts.foreignKeys[0]?.referencesTable).toBe("users");
});

test("reports every column of a composite primary key, in key order", async () => {
const schema = await introspect({ client });
const postTags = schema.tables.post_tags!;

expect(postTags.primaryKey).toEqual(["post_id", "tag_id"]);
expect(postTags.columns.every((c) => c.primaryKey)).toBe(true);
});

test("only runs statements a read-only libSQL token may run", async () => {
// libSQL servers check each statement's kind against the token's scope.
// These PRAGMAs are the ones they classify as reads; anything else (even
// `PRAGMA foreign_key_list`) needs write access and would break studio,
// which connects with a read-only token.
const READ_PRAGMAS =
/^PRAGMA (table_info|table_xinfo|table_list|index_list|index_info|index_xinfo)\b/i;
const seen: string[] = [];
const recording: AdapterClient = {
prepare: (sql) => {
seen.push(sql.trim());
return client.prepare(sql);
},
};

await introspect({ client: recording });

const notRead = seen.filter(
(sql) => !/^SELECT\b/i.test(sql) && !READ_PRAGMAS.test(sql),
);
expect(notRead).toEqual([]);
});

test("uses custom version", async () => {
const schema = await introspect({ client, version: "2.0.0" });
expect(schema.version).toBe("2.0.0");
Expand Down
31 changes: 21 additions & 10 deletions packages/database-adapter/src/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,32 +50,43 @@ const getTables = async (client: AdapterClient): Promise<string[]> => {
return result.rows.map((row) => row.name as string);
};

/** Columns in declaration order, plus the primary key in key order (PRAGMA `pk` is the 1-based position, 0 when not part of the key). */
const getColumns = async (
client: AdapterClient,
tableName: string,
): Promise<ColumnDefinition[]> => {
): Promise<{ columns: ColumnDefinition[]; primaryKey: string[] }> => {
const result = await query(
client,
`PRAGMA table_info("${tableName.replace(/"/g, '""')}")`,
);

return result.rows.map((row) => ({
const columns = result.rows.map((row) => ({
name: row.name as string,
type: mapColumnType(row.type as string),
nullable: row.notnull === 0,
primaryKey: row.pk === 1,
primaryKey: Number(row.pk) > 0,
defaultValue: row.dflt_value as string | number | null,
}));
const primaryKey = result.rows
.filter((row) => Number(row.pk) > 0)
.sort((a, b) => Number(a.pk) - Number(b.pk))
.map((row) => row.name as string);

return { columns, primaryKey };
};

const getForeignKeys = async (
client: AdapterClient,
tableName: string,
): Promise<ForeignKey[]> => {
const result = await query(
client,
`PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`,
);
// The SELECT form, not `PRAGMA foreign_key_list(...)`: libSQL servers
// classify that pragma as a write, so a read-only token can't run it.
const result = await client
.prepare(
'SELECT "from", "table", "to" FROM pragma_foreign_key_list(?) ORDER BY id, seq',
)
.bind(tableName)
.run();

return result.rows.map((row) => ({
column: row.from as string,
Expand Down Expand Up @@ -111,10 +122,9 @@ const introspectTable = async (
client: AdapterClient,
tableName: string,
): Promise<TableDefinition> => {
const columns = await getColumns(client, tableName);
const { columns, primaryKey } = await getColumns(client, tableName);
const foreignKeys = await getForeignKeys(client, tableName);
const indexes = await getIndexes(client, tableName);
const primaryKey = columns.filter((c) => c.primaryKey).map((c) => c.name);

// Unique columns: single-column unique indexes, excluding the PK
const pkSet = new Set(primaryKey);
Expand Down Expand Up @@ -152,7 +162,8 @@ const matchesPattern = (name: string, pattern: string): boolean => {
return name === pattern;
};

const shouldInclude = (
/** Whether introspection keeps a table or view named `name`, by the same rules as `exclude`/`include`. */
export const shouldInclude = (
name: string,
exclude: string[],
include?: string[],
Expand Down
7 changes: 6 additions & 1 deletion packages/database-openapi/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,12 @@ const tableToInsertSchema = (table: TableDefinition): SchemaObject => {
const required: string[] = [];

for (const column of table.columns) {
if (column.primaryKey && column.type === "INTEGER") {
// Only a sole INTEGER PRIMARY KEY aliases the rowid and is assigned by SQLite.
if (
column.primaryKey &&
column.type === "INTEGER" &&
table.primaryKey.length === 1
) {
continue;
}
properties[column.name] = columnTypeToSchema(column);
Expand Down
8 changes: 8 additions & 0 deletions packages/database-rest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,16 @@ GET /users?age=gte.18
GET /users?name=like.John%25
GET /users?role=in.(admin,moderator)
GET /users?deleted_at=is.null
GET /users?deleted_at=not.is.null
GET /users?name=not.like.John%25
GET /users?or=(role.eq.admin,age.gte.18)
GET /users?or=(name.eq."Smith, John",name.is.null)
```

**Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is`, `in`

Prefix any operator with `not.` to negate it. Separate parameters are combined with `AND`; an `or=(...)` parameter matches rows where any of its conditions hold. Wrap a value in double quotes when it contains commas or parentheses. A filter that doesn't parse (an unknown operator, a malformed `or` group) returns `400` rather than being dropped, since ignoring it would match more rows. Values are sent as numbers only when written exactly as a number prints (`18`, `-2.5`); anything else, such as `007` or `1e3`, or any quoted value inside `or=(...)`, is compared as text.

#### Order

```
Expand Down Expand Up @@ -297,6 +303,8 @@ Returns `404` if the row doesn't exist or if the column isn't a unique index. Co
}
```

Integers outside JavaScript's safe range (±2^53) are sent as decimal strings, and `BLOB` values as base64 strings.

**Error:**

```json
Expand Down
Loading
Loading